diff --git a/src/lib/agents.ts b/src/lib/agents.ts index 086a0e99..841f4cc1 100644 --- a/src/lib/agents.ts +++ b/src/lib/agents.ts @@ -1,5 +1,9 @@ import { parseFrontmatter } from './frontmatter.js' import { + extractBoolean, + extractNonEmptyString, + extractNumber, + extractString, isAgentMode, isToolsMap, normalizePermission, @@ -53,31 +57,6 @@ export function findAgentsInDir(dir: string, maxDepth = 2): AgentInfo[] { })) } -function extractString( - data: Record, - key: string, - fallback = '', -): string { - const value = data[key] - return typeof value === 'string' ? value : fallback -} - -function extractNumber( - data: Record, - key: string, -): number | undefined { - const value = data[key] - return typeof value === 'number' ? value : undefined -} - -function extractBoolean( - data: Record, - key: string, -): boolean | undefined { - const value = data[key] - return typeof value === 'boolean' ? value : undefined -} - export function extractAgentFrontmatter(content: string): AgentFrontmatter { const { data, parseError, body } = parseFrontmatter>(content) @@ -90,13 +69,13 @@ export function extractAgentFrontmatter(content: string): AgentFrontmatter { name: extractString(data, 'name'), description: extractString(data, 'description'), prompt: body.trim(), - model: extractString(data, 'model') || undefined, + model: extractNonEmptyString(data, 'model'), temperature: extractNumber(data, 'temperature'), top_p: extractNumber(data, 'top_p'), tools: isToolsMap(data.tools) ? data.tools : undefined, disable: extractBoolean(data, 'disable'), mode: isAgentMode(data.mode) ? data.mode : undefined, - color: extractString(data, 'color') || undefined, + color: extractNonEmptyString(data, 'color'), maxSteps: extractNumber(data, 'maxSteps'), permission: normalizePermission(data.permission), } diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 347cfde8..ba600f92 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1,10 +1,21 @@ import { parseFrontmatter } from './frontmatter.js' +import { + extractBoolean, + extractNonEmptyString, + extractString, +} from './validation.js' import { walkDir } from './walk-dir.js' export interface CommandFrontmatter { name: string description: string argumentHint: string + /** Agent ID to use for this command */ + agent?: string + /** Model override for this command */ + model?: string + /** Whether this command should run as a subtask */ + subtask?: boolean } export interface CommandInfo { @@ -33,23 +44,28 @@ export function findCommandsInDir(dir: string, maxDepth = 2): CommandInfo[] { } export function extractCommandFrontmatter(content: string): CommandFrontmatter { - const { data, parseError } = parseFrontmatter<{ - name?: string - description?: string - 'argument-hint'?: string - }>(content) + const { data, parseError } = + parseFrontmatter>(content) - const argumentHintRaw = - !parseError && typeof data['argument-hint'] === 'string' - ? data['argument-hint'] - : '' + if (parseError) { + return { + name: '', + description: '', + argumentHint: '', + agent: undefined, + model: undefined, + subtask: undefined, + } + } + + const argumentHintRaw = extractString(data, 'argument-hint') return { - name: !parseError && typeof data.name === 'string' ? data.name : '', - description: - !parseError && typeof data.description === 'string' - ? data.description - : '', + name: extractString(data, 'name'), + description: extractString(data, 'description'), argumentHint: argumentHintRaw.replace(/^["']|["']$/g, ''), + agent: extractNonEmptyString(data, 'agent'), + model: extractNonEmptyString(data, 'model'), + subtask: extractBoolean(data, 'subtask'), } } diff --git a/src/lib/config-handler.ts b/src/lib/config-handler.ts index 2dd51fa8..9c3d21a2 100644 --- a/src/lib/config-handler.ts +++ b/src/lib/config-handler.ts @@ -70,15 +70,22 @@ function loadCommandAsConfig(commandInfo: { const converted = convertFileWithCache(commandInfo.file, 'command', { source: 'bundled', }) - const { name, description } = extractCommandFrontmatter(converted) + const { name, description, agent, model, subtask } = + extractCommandFrontmatter(converted) const { body } = parseFrontmatter(converted) const cleanName = commandInfo.name.replace(/^\//, '') - return { + const config: CommandConfig = { template: body.trim(), description: description || `${name || cleanName} command`, } + + if (agent !== undefined) config.agent = agent + if (model !== undefined) config.model = model + if (subtask !== undefined) config.subtask = subtask + + return config } catch { return null } diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 16af0d87..e9927d46 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -106,3 +106,49 @@ export function normalizePermission( external_directory, ) } + +/** + * Shared frontmatter extraction helpers. + * Centralized to ensure consistent behavior across agents, commands, and skills. + */ + +export function extractString( + data: Record, + key: string, + fallback = '', +): string { + const value = data[key] + return typeof value === 'string' ? value : fallback +} + +export function extractNonEmptyString( + data: Record, + key: string, +): string | undefined { + const value = data[key] + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed !== '' ? trimmed : undefined +} + +export function extractNumber( + data: Record, + key: string, +): number | undefined { + const value = data[key] + return typeof value === 'number' ? value : undefined +} + +export function extractBoolean( + data: Record, + key: string, +): boolean | undefined { + const value = data[key] + if (typeof value === 'boolean') return value + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase() + if (normalized === 'true') return true + if (normalized === 'false') return false + } + return undefined +} diff --git a/tests/unit/commands.test.ts b/tests/unit/commands.test.ts new file mode 100644 index 00000000..172efb93 --- /dev/null +++ b/tests/unit/commands.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'bun:test' +import { extractCommandFrontmatter } from '../../src/lib/commands.ts' + +describe('extractCommandFrontmatter', () => { + test('extracts agent field when present', () => { + const content = `--- +name: test-cmd +agent: my-agent +--- +Template content` + const result = extractCommandFrontmatter(content) + expect(result.agent).toBe('my-agent') + }) + + test('extracts model field when present', () => { + const content = `--- +name: test-cmd +model: gpt-4 +--- +Template content` + const result = extractCommandFrontmatter(content) + expect(result.model).toBe('gpt-4') + }) + + test('extracts subtask: true when present', () => { + const content = `--- +name: test-cmd +subtask: true +--- +Template content` + const result = extractCommandFrontmatter(content) + expect(result.subtask).toBe(true) + }) + + test('extracts subtask: false when present', () => { + const content = `--- +name: test-cmd +subtask: false +--- +Template content` + const result = extractCommandFrontmatter(content) + expect(result.subtask).toBe(false) + }) + + test('extracts subtask from string values', () => { + const content = `--- +name: test-cmd +subtask: "true" +--- +Template content` + const result = extractCommandFrontmatter(content) + expect(result.subtask).toBe(true) + }) + + test('returns undefined for missing optional fields', () => { + const content = `--- +name: test-cmd +--- +Template content` + const result = extractCommandFrontmatter(content) + expect(result.agent).toBeUndefined() + expect(result.model).toBeUndefined() + expect(result.subtask).toBeUndefined() + }) + + test('empty strings become undefined for agent and model', () => { + const content = `--- +name: test-cmd +agent: "" +model: '' +--- +Template content` + const result = extractCommandFrontmatter(content) + expect(result.agent).toBeUndefined() + expect(result.model).toBeUndefined() + }) + + test('whitespace-only agent and model are ignored', () => { + const content = `--- +name: test-cmd +agent: " " +model: "\t" +--- +Template content` + const result = extractCommandFrontmatter(content) + expect(result.agent).toBeUndefined() + expect(result.model).toBeUndefined() + }) + + test('existing fields still work correctly', () => { + const content = `--- +name: test-cmd +description: A test command +argument-hint: "[file]" +--- +Template content` + const result = extractCommandFrontmatter(content) + expect(result.name).toBe('test-cmd') + expect(result.description).toBe('A test command') + expect(result.argumentHint).toBe('[file]') + }) + + test('handles missing frontmatter gracefully', () => { + const content = '# No frontmatter here' + const result = extractCommandFrontmatter(content) + expect(result.name).toBe('') + expect(result.description).toBe('') + expect(result.argumentHint).toBe('') + expect(result.agent).toBeUndefined() + expect(result.model).toBeUndefined() + expect(result.subtask).toBeUndefined() + }) + + test('returns undefined for invalid subtask string values', () => { + const content = `--- +name: test-cmd +subtask: "yes" +--- +Template content` + const result = extractCommandFrontmatter(content) + expect(result.subtask).toBeUndefined() + }) + + test('returns undefined for subtask with numeric value', () => { + const content = `--- +name: test-cmd +subtask: 1 +--- +Template content` + const result = extractCommandFrontmatter(content) + expect(result.subtask).toBeUndefined() + }) + + test('handles malformed frontmatter gracefully', () => { + const content = `--- +name: test + bad: indentation +--- +Content` + const result = extractCommandFrontmatter(content) + expect(result.name).toBe('') + expect(result.agent).toBeUndefined() + expect(result.model).toBeUndefined() + expect(result.subtask).toBeUndefined() + }) +}) diff --git a/tests/unit/config-handler.test.ts b/tests/unit/config-handler.test.ts index 2f739b49..4b8b760f 100644 --- a/tests/unit/config-handler.test.ts +++ b/tests/unit/config-handler.test.ts @@ -362,5 +362,109 @@ Command template for ${name}.`, expect(agent?.tools).toEqual({ bash: true, read: false }) expect(agent?.permission).toEqual({ edit: 'ask' }) }) + + test('includes agent field in command config', async () => { + fs.writeFileSync( + path.join(bundledDir, 'commands', 'routed.md'), + `--- +name: routed +description: Command with agent +agent: oracle +--- +Use oracle for this task.`, + ) + + const handler = createConfigHandler({ + directory: projectDir, + bundledSkillsDir: path.join(bundledDir, 'skills'), + bundledAgentsDir: path.join(bundledDir, 'agents'), + bundledCommandsDir: path.join(bundledDir, 'commands'), + }) + + const config: Config = {} + await handler(config) + + expect(config.command?.routed?.agent).toBe('oracle') + }) + + test('includes model field in command config', async () => { + fs.writeFileSync( + path.join(bundledDir, 'commands', 'modeled.md'), + `--- +name: modeled +description: Command with model +model: gpt-4 +--- +Use gpt-4 for this task.`, + ) + + const handler = createConfigHandler({ + directory: projectDir, + bundledSkillsDir: path.join(bundledDir, 'skills'), + bundledAgentsDir: path.join(bundledDir, 'agents'), + bundledCommandsDir: path.join(bundledDir, 'commands'), + }) + + const config: Config = {} + await handler(config) + + expect(config.command?.modeled?.model).toBe('openai/gpt-4') + }) + + test('includes subtask field in command config', async () => { + fs.writeFileSync( + path.join(bundledDir, 'commands', 'subtasked.md'), + `--- +name: subtasked +description: Command as subtask +subtask: true +--- +Run as subtask.`, + ) + + const handler = createConfigHandler({ + directory: projectDir, + bundledSkillsDir: path.join(bundledDir, 'skills'), + bundledAgentsDir: path.join(bundledDir, 'agents'), + bundledCommandsDir: path.join(bundledDir, 'commands'), + }) + + const config: Config = {} + await handler(config) + + expect(config.command?.subtasked?.subtask).toBe(true) + }) + + test('extracts all command frontmatter fields into config', async () => { + fs.writeFileSync( + path.join(bundledDir, 'commands', 'full-command.md'), + `--- +name: full-command +description: A full command +agent: oracle +model: gpt-4 +subtask: true +--- +Full command template.`, + ) + + const handler = createConfigHandler({ + directory: projectDir, + bundledSkillsDir: path.join(bundledDir, 'skills'), + bundledAgentsDir: path.join(bundledDir, 'agents'), + bundledCommandsDir: path.join(bundledDir, 'commands'), + }) + + const config: Config = {} + await handler(config) + + const command = config.command?.['full-command'] + expect(command).toBeDefined() + expect(command?.description).toBe('A full command') + expect(command?.agent).toBe('oracle') + expect(command?.model).toBe('openai/gpt-4') + expect(command?.subtask).toBe(true) + expect(command?.template).toContain('Full command template') + }) }) })