diff --git a/src/Cli.ts b/src/Cli.ts index f7a2f82..b30575c 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -242,16 +242,19 @@ export function create( return cli } const mountedRootDef = toRootDefinition.get(nameOrCli) - if (mountedRootDef) { + const sub = nameOrCli as Cli + const subCommands = toCommands.get(sub)! + // Root-only CLI with no subcommands → mount as leaf command + if (mountedRootDef && subCommands.size === 0) { commands.set(nameOrCli.name, mountedRootDef) return cli } - const sub = nameOrCli as Cli - const subCommands = toCommands.get(sub)! const subOutputPolicy = toOutputPolicy.get(sub) const subMiddlewares = toMiddlewares.get(sub) commands.set(sub.name, { _group: true, + // Root + subcommands → mount as group with default handler + ...(mountedRootDef ? { default: mountedRootDef } : undefined), description: sub.description, commands: subCommands, ...(subOutputPolicy ? { outputPolicy: subOutputPolicy } : undefined), @@ -952,9 +955,12 @@ async function serveImpl( const isRootCmd = resolved.path === name const commandName = isRootCmd ? name : `${name} ${resolved.path}` const helpSubcommands = - isRootCmd && options.rootCommand && commands.size > 0 - ? collectHelpCommands(commands) - : undefined + // Group with default command → show sibling subcommands + resolved.commands && resolved.commands.size > 0 + ? collectHelpCommands(resolved.commands) + : isRootCmd && options.rootCommand && commands.size > 0 + ? collectHelpCommands(commands) + : undefined writeln( Help.formatCommand(commandName, { alias: cmd.alias as Record | undefined, @@ -1825,6 +1831,7 @@ function resolveCommand( ): | { command: CommandDefinition + commands?: Map | undefined middlewares: MiddlewareHandler[] outputPolicy?: OutputPolicy | undefined path: string @@ -1870,16 +1877,41 @@ function resolveCommand( if (entry.outputPolicy) inheritedOutputPolicy = entry.outputPolicy if (entry.middlewares) collectedMiddlewares.push(...entry.middlewares) const next = remaining[0] - if (!next) + if (!next) { + // Group has a default command → dispatch to it + if (entry.default) { + const outputPolicy = entry.default.outputPolicy ?? inheritedOutputPolicy + return { + command: entry.default, + commands: entry.commands, + middlewares: collectedMiddlewares, + path: path.join(' '), + rest: remaining, + ...(outputPolicy ? { outputPolicy } : undefined), + } + } return { help: true, path: path.join(' '), description: entry.description, commands: entry.commands, } + } const child = entry.commands.get(next) if (!child) { + // Group has a default command → pass unknown tokens as rest args + if (entry.default) { + const outputPolicy = entry.default.outputPolicy ?? inheritedOutputPolicy + return { + command: entry.default, + commands: entry.commands, + middlewares: collectedMiddlewares, + path: path.join(' '), + rest: remaining, + ...(outputPolicy ? { outputPolicy } : undefined), + } + } return { error: next, path: path.join(' '), @@ -2264,6 +2296,7 @@ type FetchHandler = (req: Request) => Response | Promise /** @internal A command group's internal storage. */ type InternalGroup = { _group: true + default?: CommandDefinition | undefined description?: string | undefined middlewares?: MiddlewareHandler[] | undefined outputPolicy?: OutputPolicy | undefined @@ -2613,6 +2646,11 @@ function collectIndexCommands( for (const [name, entry] of commands) { const path = [...prefix, name] if (isGroup(entry)) { + if (entry.default) { + const cmd: (typeof result)[number] = { name: path.join(' ') } + if (entry.default.description) cmd.description = entry.default.description + result.push(cmd) + } result.push(...collectIndexCommands(entry.commands, path)) } else { const cmd: (typeof result)[number] = { name: path.join(' ') } @@ -2651,6 +2689,20 @@ function collectCommands( if (entry.description) cmd.description = entry.description result.push(cmd) } else if (isGroup(entry)) { + if (entry.default) { + const cmd: (typeof result)[number] = { name: path.join(' ') } + if (entry.default.description) cmd.description = entry.default.description + const inputSchema = buildInputSchema(entry.default.args, entry.default.env, entry.default.options) + const outputSchema = entry.default.output ? Schema.toJsonSchema(entry.default.output) : undefined + if (inputSchema || outputSchema) { + cmd.schema = {} + if (inputSchema?.args) cmd.schema.args = inputSchema.args + if (inputSchema?.env) cmd.schema.env = inputSchema.env + if (inputSchema?.options) cmd.schema.options = inputSchema.options + if (outputSchema) cmd.schema.output = outputSchema + } + result.push(cmd) + } result.push(...collectCommands(entry.commands, path)) } else { const cmd: (typeof result)[number] = { name: path.join(' ') } @@ -2696,6 +2748,16 @@ function collectSkillCommands( result.push(cmd) } else if (isGroup(entry)) { if (entry.description) groups.set(path.join(' '), entry.description) + if (entry.default) { + const cmd: Skill.CommandInfo = { name: path.join(' ') } + if (entry.default.description) cmd.description = entry.default.description + if (entry.default.args) cmd.args = entry.default.args + if (entry.default.env) cmd.env = entry.default.env + if (entry.default.hint) cmd.hint = entry.default.hint + if (entry.default.options) cmd.options = entry.default.options + if (entry.default.output) cmd.output = entry.default.output + result.push(cmd) + } result.push(...collectSkillCommands(entry.commands, path, groups)) } else { const cmd: Skill.CommandInfo = { name: path.join(' ') } diff --git a/src/Completions.test.ts b/src/Completions.test.ts index 9d5f5f2..f330d6f 100644 --- a/src/Completions.test.ts +++ b/src/Completions.test.ts @@ -181,6 +181,34 @@ describe('complete', () => { const build = candidates.find((c) => c.value === 'build') expect(build?.noSpace).toBeUndefined() }) + + test('suggests subcommands for group with default', () => { + const lint = Cli.create('lint', { + description: 'Run linter', + options: z.object({ fix: z.boolean().default(false).describe('Auto-fix') }), + run: () => ({ linted: true }), + }) + lint.command('rules', { description: 'List rules', run: () => ({}) }) + const cli = Cli.create('app', { description: 'App' }).command(lint) + const commands = Cli.toCommands.get(cli)! + + const candidates = Completions.complete(commands, undefined, ['app', 'lint', ''], 2) + expect(candidates.map((c) => c.value)).toContain('rules') + }) + + test('suggests options from group default command', () => { + const lint = Cli.create('lint', { + description: 'Run linter', + options: z.object({ fix: z.boolean().default(false).describe('Auto-fix') }), + run: () => ({ linted: true }), + }) + lint.command('rules', { description: 'List rules', run: () => ({}) }) + const cli = Cli.create('app', { description: 'App' }).command(lint) + const commands = Cli.toCommands.get(cli)! + + const candidates = Completions.complete(commands, undefined, ['app', 'lint', '--'], 2) + expect(candidates.map((c) => c.value)).toContain('--fix') + }) }) describe('format', () => { diff --git a/src/Completions.ts b/src/Completions.ts index 9d654b3..b355be2 100644 --- a/src/Completions.ts +++ b/src/Completions.ts @@ -18,6 +18,7 @@ type CommandEntry = { alias?: Record | undefined args?: z.ZodObject | undefined commands?: Map | undefined + default?: CommandEntry | undefined description?: string | undefined options?: z.ZodObject | undefined } @@ -64,7 +65,7 @@ export function complete( const entry = scope.commands.get(token) if (!entry) continue if (entry._group && entry.commands) { - scope = { commands: entry.commands } + scope = { commands: entry.commands, leaf: entry.default } } else { scope = { commands: new Map(), leaf: entry } break diff --git a/src/Mcp.test.ts b/src/Mcp.test.ts index 5d5c9c2..dbd01f7 100644 --- a/src/Mcp.test.ts +++ b/src/Mcp.test.ts @@ -42,6 +42,29 @@ function createTestCommands() { ]), }) + commands.set('lint', { + _group: true, + description: 'Lint commands', + default: { + description: 'Run linter', + args: z.object({ path: z.string().optional().describe('Path to lint') }), + run(c: any) { + return { linted: true, path: c.args.path ?? '.' } + }, + }, + commands: new Map([ + [ + 'fix', + { + description: 'Auto-fix lint issues', + run() { + return { fixed: true } + }, + }, + ], + ]), + }) + commands.set('fail', { description: 'Always fails', run(c: any) { @@ -109,7 +132,7 @@ describe('Mcp', () => { { id: 2, method: 'tools/list', params: {} }, ]) const names = res.result.tools.map((t: any) => t.name).sort() - expect(names).toEqual(['echo', 'fail', 'greet_hello', 'ping', 'stream']) + expect(names).toEqual(['echo', 'fail', 'greet_hello', 'lint', 'lint_fix', 'ping', 'stream']) const echoTool = res.result.tools.find((t: any) => t.name === 'echo') expect(echoTool.description).toBe('Echo a message') @@ -161,6 +184,30 @@ describe('Mcp', () => { expect(res.result.content).toEqual([{ type: 'text', text: '{"greeting":"hello world"}' }]) }) + test('tools/call with group default command', async () => { + const [, res] = await mcpSession(createTestCommands(), [ + { id: 1, method: 'initialize', params: initParams }, + { + id: 2, + method: 'tools/call', + params: { name: 'lint', arguments: { path: 'src/' } }, + }, + ]) + expect(res.result.content).toEqual([{ type: 'text', text: '{"linted":true,"path":"src/"}' }]) + }) + + test('tools/call with group default subcommand', async () => { + const [, res] = await mcpSession(createTestCommands(), [ + { id: 1, method: 'initialize', params: initParams }, + { + id: 2, + method: 'tools/call', + params: { name: 'lint_fix', arguments: {} }, + }, + ]) + expect(res.result.content).toEqual([{ type: 'text', text: '{"fixed":true}' }]) + }) + test('tools/call unknown tool returns error', async () => { const [, res] = await mcpSession(createTestCommands(), [ { id: 1, method: 'initialize', params: initParams }, diff --git a/src/Mcp.ts b/src/Mcp.ts index 4fbfa36..68cbf67 100644 --- a/src/Mcp.ts +++ b/src/Mcp.ts @@ -173,6 +173,18 @@ export function collectTools( ...parentMiddlewares, ...((entry.middlewares as MiddlewareHandler[] | undefined) ?? []), ] + if (entry.default) { + result.push({ + name: path.join('_'), + description: entry.default.description, + inputSchema: buildToolSchema(entry.default.args, entry.default.options), + ...(entry.default.output + ? { outputSchema: Schema.toJsonSchema(entry.default.output) as Record } + : undefined), + command: entry.default, + ...(groupMw.length > 0 ? { middlewares: groupMw } : undefined), + }) + } result.push(...collectTools(entry.commands, path, groupMw)) } else { result.push({ diff --git a/src/Skillgen.test.ts b/src/Skillgen.test.ts index 49ae419..db3144a 100644 --- a/src/Skillgen.test.ts +++ b/src/Skillgen.test.ts @@ -57,6 +57,23 @@ test('collects group descriptions', async () => { expect(content).toContain('admin reset') }) +test('group with default command includes both default and subcommands', async () => { + const lint = Cli.create('lint', { + description: 'Run linter', + run: () => ({ linted: true }), + }) + lint.command('fix', { description: 'Auto-fix', run: () => ({}) }) + const cli = Cli.create('app', { description: 'My app' }).command(lint) + vi.mocked(importCli).mockResolvedValue(cli) + + const files = await generate('fake-input', tmp, 1) + const content = files.map((f) => readFileSync(f, 'utf-8')).join('\n') + expect(content).toContain('lint') + expect(content).toContain('Run linter') + expect(content).toContain('lint fix') + expect(content).toContain('Auto-fix') +}) + test('includes args, options, and examples in output', async () => { const cli = Cli.create('tool', { description: 'A tool', diff --git a/src/Skillgen.ts b/src/Skillgen.ts index 5427a8b..e14aee2 100644 --- a/src/Skillgen.ts +++ b/src/Skillgen.ts @@ -42,6 +42,16 @@ function collectEntries( const path = [...prefix, name] if ('_group' in entry && entry._group) { if (entry.description) groups.set(path.join(' '), entry.description) + if (entry.default) { + const cmd: Skill.CommandInfo = { name: path.join(' ') } + if (entry.default.description) cmd.description = entry.default.description + if (entry.default.args) cmd.args = entry.default.args + if (entry.default.env) cmd.env = entry.default.env + if (entry.default.hint) cmd.hint = entry.default.hint + if (entry.default.options) cmd.options = entry.default.options + if (entry.default.output) cmd.output = entry.default.output + result.push(cmd) + } result.push(...collectEntries(entry.commands, path, groups)) } else { const cmd: Skill.CommandInfo = { name: path.join(' ') } diff --git a/src/SyncSkills.test.ts b/src/SyncSkills.test.ts index 28541cc..6b8700b 100644 --- a/src/SyncSkills.test.ts +++ b/src/SyncSkills.test.ts @@ -102,6 +102,33 @@ test('readHash returns undefined when no hash exists', () => { rmSync(tmp, { recursive: true, force: true }) }) +test('group with default command generates skills for both default and subcommands', async () => { + const tmp = join(tmpdir(), `clac-default-test-${Date.now()}`) + mkdirSync(tmp, { recursive: true }) + + const lint = Cli.create('lint', { description: 'Run linter', run: () => ({}) }) + lint.command('fix', { description: 'Auto-fix', run: () => ({}) }) + const cli = Cli.create('test', { description: 'Test CLI' }).command(lint) + + const commands = Cli.toCommands.get(cli)! + const installDir = join(tmp, 'install') + mkdirSync(join(installDir, '.agents', 'skills'), { recursive: true }) + + const result = await SyncSkills.sync('test', commands, { + description: 'Test CLI', + global: false, + cwd: installDir, + }) + + const allContent = result.paths + .map((p) => readFileSync(join(p, 'SKILL.md'), 'utf8')) + .join('\n') + expect(allContent).toContain('lint') + expect(allContent).toContain('lint fix') + + rmSync(tmp, { recursive: true, force: true }) +}) + test('installed SKILL.md contains frontmatter', async () => { const tmp = join(tmpdir(), `clac-content-test-${Date.now()}`) mkdirSync(tmp, { recursive: true }) diff --git a/src/SyncSkills.ts b/src/SyncSkills.ts index bf44b16..dff6565 100644 --- a/src/SyncSkills.ts +++ b/src/SyncSkills.ts @@ -124,6 +124,16 @@ function collectEntries( const entryPath = [...prefix, name] if ('_group' in entry && entry._group) { if (entry.description) groups.set(entryPath.join(' '), entry.description) + if (entry.default) { + const cmd: Skill.CommandInfo = { name: entryPath.join(' ') } + if (entry.default.description) cmd.description = entry.default.description + if (entry.default.args) cmd.args = entry.default.args + if (entry.default.env) cmd.env = entry.default.env + if (entry.default.hint) cmd.hint = entry.default.hint + if (entry.default.options) cmd.options = entry.default.options + if (entry.default.output) cmd.output = entry.default.output + result.push(cmd) + } result.push(...collectEntries(entry.commands, entryPath, groups)) } else { const cmd: Skill.CommandInfo = { name: entryPath.join(' ') } diff --git a/src/Typegen.test.ts b/src/Typegen.test.ts index e6402c0..4e7c36d 100644 --- a/src/Typegen.test.ts +++ b/src/Typegen.test.ts @@ -202,4 +202,30 @@ describe('fromCli', () => { " `) }) + + test('group with default command includes both default and subcommands', () => { + const cli = Cli.create('test') + const lint = Cli.create('lint', { + args: z.object({ path: z.string().optional() }), + options: z.object({ fix: z.boolean().optional() }), + run: () => ({}), + }) + lint.command('rules', { + options: z.object({ enabled: z.boolean().optional() }), + run: () => ({}), + }) + cli.command(lint) + + expect(Typegen.fromCli(cli)).toMatchInlineSnapshot(` + "declare module 'incur' { + interface Register { + commands: { + 'lint': { args: { path?: string }; options: { fix?: boolean } } + 'lint rules': { args: {}; options: { enabled?: boolean } } + } + } + } + " + `) + }) }) diff --git a/src/Typegen.ts b/src/Typegen.ts index 2bed6a8..632fef0 100644 --- a/src/Typegen.ts +++ b/src/Typegen.ts @@ -36,7 +36,10 @@ function collectEntries( const result: ReturnType = [] for (const [name, entry] of commands) { const path = [...prefix, name] - if ('_group' in entry && entry._group) result.push(...collectEntries(entry.commands, path)) + if ('_group' in entry && entry._group) { + if (entry.default) result.push({ name: path.join(' '), args: entry.default.args, options: entry.default.options }) + result.push(...collectEntries(entry.commands, path)) + } else result.push({ name: path.join(' '), args: entry.args, options: entry.options }) } return result.sort((a, b) => a.name.localeCompare(b.name)) diff --git a/src/e2e.test.ts b/src/e2e.test.ts index b8a6a84..9eb7b36 100644 --- a/src/e2e.test.ts +++ b/src/e2e.test.ts @@ -1768,6 +1768,86 @@ describe('root command with subcommands', () => { }) }) +describe('mounted group with default command', () => { + function createApp() { + const cli = Cli.create('app', { description: 'My app' }) + + const lint = Cli.create('lint', { + description: 'Run linter', + args: z.object({ path: z.string().optional().describe('Path to lint') }), + run(c) { + return { linted: true, path: c.args.path ?? '.' } + }, + }) + lint.command('fix', { + description: 'Auto-fix lint issues', + run: () => ({ fixed: true }), + }) + lint.command('rules', { + description: 'List lint rules', + run: () => ({ rules: ['no-unused-vars', 'semi'] }), + }) + + cli.command(lint) + cli.command('ping', { run: () => ({ pong: true }) }) + return cli + } + + test('bare group invocation dispatches to default command', async () => { + const { output } = await serve(createApp(), ['lint']) + expect(output).toMatchInlineSnapshot(` + "linted: true + path: . + " + `) + }) + + test('default command receives positional args', async () => { + const { output } = await serve(createApp(), ['lint', 'src/']) + expect(output).toMatchInlineSnapshot(` + "linted: true + path: src/ + " + `) + }) + + test('subcommand takes precedence over default', async () => { + const { output } = await serve(createApp(), ['lint', 'fix']) + expect(output).toMatchInlineSnapshot(` + "fixed: true + " + `) + }) + + test('--help shows default command info and subcommands', async () => { + const { output } = await serve(createApp(), ['lint', '--help']) + expect(output).toContain('Run linter') + expect(output).toContain('fix') + expect(output).toContain('Auto-fix lint issues') + expect(output).toContain('rules') + expect(output).toContain('List lint rules') + }) + + test('sibling commands still work', async () => { + const { output } = await serve(createApp(), ['ping']) + expect(output).toMatchInlineSnapshot(` + "pong: true + " + `) + }) + + test('group without default still shows help', async () => { + const cli = Cli.create('app', { description: 'My app' }) + const sub = Cli.create('db', { description: 'Database commands' }) + sub.command('migrate', { run: () => ({ migrated: true }) }) + cli.command(sub) + + const { output, exitCode } = await serve(cli, ['db']) + expect(output).toContain('db') + expect(output).toContain('migrate') + }) +}) + describe('edge cases', () => { test('command with only options (no args)', async () => { const { output } = await serve(createApp(), [ diff --git a/src/internal/configSchema.test.ts b/src/internal/configSchema.test.ts index a23efd7..2cec066 100644 --- a/src/internal/configSchema.test.ts +++ b/src/internal/configSchema.test.ts @@ -135,6 +135,33 @@ describe('fromCli', () => { `) }) + test('group with default command includes default options in schema', () => { + const lint = Cli.create('lint', { + options: z.object({ fix: z.boolean().default(false) }), + run: () => ({}), + }) + lint.command('rules', { + options: z.object({ enabled: z.boolean().default(true) }), + run: () => ({}), + }) + + const cli = Cli.create('test') + cli.command(lint) + + const schema = ConfigSchema.fromCli(cli) + const lintNode = (schema as any).properties.commands.properties.lint + // Group-level options from the default command + expect(lintNode.properties.options.properties.fix).toEqual({ + default: false, + type: 'boolean', + }) + // Subcommand options nested under commands + expect(lintNode.properties.commands.properties.rules.properties.options.properties.enabled).toEqual({ + default: true, + type: 'boolean', + }) + }) + test('returns schema with only $schema for cli with no commands', () => { const cli = Cli.create('test') const schema = ConfigSchema.fromCli(cli) diff --git a/src/internal/configSchema.ts b/src/internal/configSchema.ts index 163ac1e..4b25d48 100644 --- a/src/internal/configSchema.ts +++ b/src/internal/configSchema.ts @@ -48,7 +48,7 @@ function buildNode( const commandProps: Record = {} for (const [name, entry] of commands) { if ('_group' in entry && entry._group) { - commandProps[name] = buildNode(entry.commands, undefined) + commandProps[name] = buildNode(entry.commands, entry.default?.options) } else if (!('_fetch' in entry)) { const cmd = entry as { options?: z.ZodObject } commandProps[name] = buildNode(new Map(), cmd.options)