Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions packages/claude-code-plugin/docs/migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,20 @@ Keywords also support localized variants: Korean (`계획`/`실행`/`평가`/`

If you prefer slash commands, use the namespaced form:

| Legacy (bare) | Namespaced (new) | Status |
| -------------- | -------------------------- | ---------------- |
| `/plan` | `/codingbuddy:plan` | migration target |
| `/act` | `/codingbuddy:act` | migration target |
| `/eval` | `/codingbuddy:eval` | migration target |
| `/auto` | `/codingbuddy:auto` | migration target |
| `/buddy` | `/codingbuddy:buddy` | migration target |
| `/checklist` | `/codingbuddy:checklist` | migration target |
| Legacy (bare) | Namespaced (canonical) | Status |
| -------------- | -------------------------- | -------- |
| `/plan` | `/codingbuddy:plan` | complete |
| `/act` | `/codingbuddy:act` | complete |
| `/eval` | `/codingbuddy:eval` | complete |
| `/auto` | `/codingbuddy:auto` | complete |
| `/buddy` | `/codingbuddy:buddy` | complete |
| `/checklist` | `/codingbuddy:checklist` | complete |

## Timeline

1. **Now**: Both bare and namespaced commands work. Keywords are the recommended entry point.
2. **Transition**: Bare commands are deprecated but functional. New commands use `codingbuddy:*` only.
3. **Future**: Once Claude Code fully supports `plugin:command` namespace resolution, bare aliases will be removed.
1. **Complete**: All commands are namespaced as `codingbuddy:*`. Claude Code resolves them via `plugin.json` name + `commands/` filenames. Keywords remain the recommended entry point.
2. **Bare aliases**: Legacy bare commands (`/plan`, `/act`, etc.) continue to work. New commands use `codingbuddy:*` only.
3. **Future**: Once Claude Code drops bare-alias resolution, legacy bare filenames may be removed.

## What Changed and Why

Expand Down
113 changes: 111 additions & 2 deletions packages/claude-code-plugin/scripts/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import { describe, it, expect, beforeAll } from 'vitest';
// Import utilities that are testable in isolation
import { getErrorMessage, type BuildMode } from '../src/utils';
import { buildNamespaceManifest, type NamespaceManifest } from './build';
import { PLUGIN_NAMESPACE, NAMESPACE_SEPARATOR, LEGACY_ALLOWLIST } from './validate-commands';
import {
PLUGIN_NAMESPACE,
NAMESPACE_SEPARATOR,
KNOWN_BARE_COMMANDS,
validateCommands,
} from './validate-commands';

describe('build script orchestration', () => {
// ============================================================================
Expand Down Expand Up @@ -157,7 +162,7 @@ describe('build script orchestration', () => {

it('includes all current command files', () => {
const bareNames = manifest.commands.map(c => c.bare);
for (const cmd of LEGACY_ALLOWLIST) {
for (const cmd of KNOWN_BARE_COMMANDS) {
expect(bareNames).toContain(cmd);
}
});
Expand Down Expand Up @@ -269,3 +274,107 @@ describe('packaging integration', () => {
expect(manifest.commands.length).toBeGreaterThanOrEqual(6);
});
});

// ============================================================================
// End-to-End Namespace Consumption — proves the runtime path is consistent
// ============================================================================
describe('end-to-end namespace consumption', () => {
const pluginRoot = path.resolve(__dirname, '..');
const commandsDir = path.join(pluginRoot, 'commands');
const manifestPath = path.join(pluginRoot, 'namespace-manifest.json');
const pluginJsonPath = path.join(pluginRoot, '.claude-plugin', 'plugin.json');
const pkgPath = path.join(pluginRoot, 'package.json');

/**
* Claude Code resolves plugin commands as `{plugin.json.name}:{command-filename}`.
* This test suite proves the full chain:
* plugin.json.name → commands/*.md filenames → namespace-manifest.json → validator
* are all consistent and produce the expected `codingbuddy:*` namespace.
*/

// --- Artifact existence ---

it('all required artifacts exist on disk', () => {
expect(fs.existsSync(pluginJsonPath)).toBe(true);
expect(fs.existsSync(commandsDir)).toBe(true);
expect(fs.existsSync(manifestPath)).toBe(true);
expect(fs.existsSync(pkgPath)).toBe(true);
});

// --- plugin.json → namespace ---

it('plugin.json name establishes the namespace prefix', () => {
const pluginJson = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8'));
expect(pluginJson.name).toBe(PLUGIN_NAMESPACE);
// Claude Code resolves: /{plugin.name}:{command}
expect(`${pluginJson.name}${NAMESPACE_SEPARATOR}`).toBe(`${PLUGIN_NAMESPACE}:`);
});

// --- commands/ ↔ manifest bidirectional consistency ---

it('every commands/*.md file has a corresponding manifest entry', () => {
const files: string[] = fs.readdirSync(commandsDir).filter((f: string) => f.endsWith('.md'));
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const manifestBareNames: string[] = manifest.commands.map((c: { bare: string }) => c.bare);

for (const file of files) {
const bare = path.basename(file, '.md');
expect(manifestBareNames).toContain(bare);
}
});

it('every manifest entry has a corresponding commands/*.md file', () => {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
for (const cmd of manifest.commands) {
const filePath = path.join(pluginRoot, cmd.file);
expect(fs.existsSync(filePath)).toBe(true);
}
});

it('manifest count matches commands/ file count exactly', () => {
const files: string[] = fs.readdirSync(commandsDir).filter((f: string) => f.endsWith('.md'));
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
expect(manifest.commands.length).toBe(files.length);
});

// --- Namespaced form correctness ---

it('manifest namespaced fields match plugin.json name + bare name', () => {
const pluginJson = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8'));
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));

for (const cmd of manifest.commands) {
const expected = `${pluginJson.name}${NAMESPACE_SEPARATOR}${cmd.bare}`;
expect(cmd.namespaced).toBe(expected);
}
});

// --- Validator agreement ---

it('validator passes for all commands in the manifest', () => {
const result = validateCommands(commandsDir);
expect(result.valid).toBe(true);
expect(result.collisions).toHaveLength(0);
expect(result.namespaceViolations).toHaveLength(0);
});

// --- Package distribution ---

it('package.json files array ships all namespace artifacts', () => {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
expect(pkg.files).toContain('namespace-manifest.json');
expect(pkg.files).toContain('commands/');
expect(pkg.files).toContain('.claude-plugin');
});

// --- Cross-artifact namespace coherence ---

it('plugin.json name, manifest pluginName, and PLUGIN_NAMESPACE constant all agree', () => {
const pluginJson = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8'));
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));

expect(pluginJson.name).toBe(PLUGIN_NAMESPACE);
expect(manifest.pluginName).toBe(PLUGIN_NAMESPACE);
expect(manifest.namespace).toBe(`${PLUGIN_NAMESPACE}${NAMESPACE_SEPARATOR}`);
});
});
19 changes: 12 additions & 7 deletions packages/claude-code-plugin/scripts/validate-commands.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as path from 'path';

import {
RESERVED_COMMANDS,
KNOWN_BARE_COMMANDS,
LEGACY_ALLOWLIST,
extractCommandsFromDirectory,
getBaseCommandName,
Expand Down Expand Up @@ -71,22 +72,26 @@ describe('reserved command denylist', () => {
});

// ============================================================================
// Legacy Allowlist
// Known Bare Commands
// ============================================================================

describe('legacy allowlist', () => {
it('contains current bare commands', () => {
describe('known bare commands', () => {
it('contains current bare command filenames', () => {
const expected = ['plan', 'act', 'eval', 'auto', 'buddy', 'checklist'];
for (const cmd of expected) {
expect(LEGACY_ALLOWLIST.has(cmd)).toBe(true);
expect(KNOWN_BARE_COMMANDS.has(cmd)).toBe(true);
}
});

it('does not overlap with reserved commands', () => {
for (const cmd of LEGACY_ALLOWLIST) {
for (const cmd of KNOWN_BARE_COMMANDS) {
expect(RESERVED_COMMANDS.has(cmd)).toBe(false);
}
});

it('LEGACY_ALLOWLIST alias points to the same set', () => {
expect(LEGACY_ALLOWLIST).toBe(KNOWN_BARE_COMMANDS);
});
});

// ============================================================================
Expand Down Expand Up @@ -218,7 +223,7 @@ describe('validateCommands', () => {
});

it('passes with current legacy commands', () => {
for (const cmd of LEGACY_ALLOWLIST) {
for (const cmd of KNOWN_BARE_COMMANDS) {
fs.writeFileSync(path.join(tmpDir, `${cmd}.md`), `# ${cmd}`);
}

Expand All @@ -236,7 +241,7 @@ describe('validateCommands', () => {
expect(result.collisions).toContain('help');
});

it('fails when a bare command is not in legacy allowlist', () => {
it('fails when a bare command is not in known bare commands', () => {
fs.writeFileSync(path.join(tmpDir, 'my-new-command.md'), '# New');

const result = validateCommands(tmpDir);
Expand Down
26 changes: 17 additions & 9 deletions packages/claude-code-plugin/scripts/validate-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,20 @@ export const RESERVED_COMMANDS: ReadonlySet<string> = new Set([
]);

// ============================================================================
// Legacy Allowlist
// Known Bare Commands
// ============================================================================

/**
* Known legacy bare commands that are intentionally kept without namespace prefix.
* These existed before the codingbuddy:* namespace convention.
* Bare command filenames shipped in commands/.
*
* Claude Code resolves these as `{plugin.json.name}:{filename}` at runtime,
* so the files are stored without a namespace prefix.
* New commands MUST use the codingbuddy:* namespace.
*
* @deprecated Use `KNOWN_BARE_COMMANDS`. The `LEGACY_ALLOWLIST` alias is
* retained only for backward compatibility with existing test imports.
*/
export const LEGACY_ALLOWLIST: ReadonlySet<string> = new Set([
export const KNOWN_BARE_COMMANDS: ReadonlySet<string> = new Set([
'plan',
'act',
'eval',
Expand All @@ -101,6 +106,9 @@ export const LEGACY_ALLOWLIST: ReadonlySet<string> = new Set([
'checklist',
]);

/** @deprecated Alias — prefer {@link KNOWN_BARE_COMMANDS}. */
export const LEGACY_ALLOWLIST = KNOWN_BARE_COMMANDS;

// ============================================================================
// Plugin Namespace
// ============================================================================
Expand Down Expand Up @@ -182,7 +190,7 @@ export function isReservedCommand(command: string): boolean {
*
* Rules:
* 1. No command may collide with a reserved Claude Code built-in
* 2. Bare (non-namespaced) commands must be in the legacy allowlist
* 2. Bare (non-namespaced) filenames must be in KNOWN_BARE_COMMANDS
* 3. New commands must use the codingbuddy:* namespace
*/
export function validateCommands(commandsDir: string): ValidationResult {
Expand All @@ -205,8 +213,8 @@ export function validateCommands(commandsDir: string): ValidationResult {
result.valid = false;
}

// Check namespace compliance: bare commands must be in legacy allowlist
if (!isNamespaced(cmd) && !LEGACY_ALLOWLIST.has(cmd)) {
// Check namespace compliance: bare filenames must be in KNOWN_BARE_COMMANDS
if (!isNamespaced(cmd) && !KNOWN_BARE_COMMANDS.has(cmd)) {
result.namespaceViolations.push(cmd);
result.valid = false;
}
Expand All @@ -233,7 +241,7 @@ function main(): void {

console.log(`Commands found: ${result.commands.join(', ') || '(none)'}`);
console.log(`Reserved denylist size: ${RESERVED_COMMANDS.size}`);
console.log(`Legacy allowlist: ${[...LEGACY_ALLOWLIST].join(', ')}`);
console.log(`Known bare commands: ${[...KNOWN_BARE_COMMANDS].join(', ')}`);
console.log('');

if (result.collisions.length > 0) {
Expand All @@ -248,7 +256,7 @@ function main(): void {
console.error('❌ NAMESPACE VIOLATION:');
for (const cmd of result.namespaceViolations) {
console.error(
` "${cmd}" is a bare command not in the legacy allowlist. Use "${PLUGIN_NAMESPACE}:${cmd}" instead.`,
` "${cmd}" is not a known bare command. Use "${PLUGIN_NAMESPACE}:${cmd}" instead.`,
);
}
console.error('');
Expand Down
Loading