Skip to content
Open
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
76 changes: 69 additions & 7 deletions src/Cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<string, string> | undefined,
Expand Down Expand Up @@ -1825,6 +1831,7 @@ function resolveCommand(
):
| {
command: CommandDefinition<any, any, any>
commands?: Map<string, CommandEntry> | undefined
middlewares: MiddlewareHandler[]
outputPolicy?: OutputPolicy | undefined
path: string
Expand Down Expand Up @@ -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(' '),
Expand Down Expand Up @@ -2264,6 +2296,7 @@ type FetchHandler = (req: Request) => Response | Promise<Response>
/** @internal A command group's internal storage. */
type InternalGroup = {
_group: true
default?: CommandDefinition<any, any, any> | undefined
description?: string | undefined
middlewares?: MiddlewareHandler[] | undefined
outputPolicy?: OutputPolicy | undefined
Expand Down Expand Up @@ -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(' ') }
Expand Down Expand Up @@ -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(' ') }
Expand Down Expand Up @@ -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(' ') }
Expand Down
28 changes: 28 additions & 0 deletions src/Completions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/Completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type CommandEntry = {
alias?: Record<string, string | undefined> | undefined
args?: z.ZodObject<any> | undefined
commands?: Map<string, CommandEntry> | undefined
default?: CommandEntry | undefined
description?: string | undefined
options?: z.ZodObject<any> | undefined
}
Expand Down Expand Up @@ -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
Expand Down
49 changes: 48 additions & 1 deletion src/Mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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 },
Expand Down
12 changes: 12 additions & 0 deletions src/Mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }
: undefined),
command: entry.default,
...(groupMw.length > 0 ? { middlewares: groupMw } : undefined),
})
}
result.push(...collectTools(entry.commands, path, groupMw))
} else {
result.push({
Expand Down
17 changes: 17 additions & 0 deletions src/Skillgen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 10 additions & 0 deletions src/Skillgen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(' ') }
Expand Down
27 changes: 27 additions & 0 deletions src/SyncSkills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
10 changes: 10 additions & 0 deletions src/SyncSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(' ') }
Expand Down
Loading