Skip to content

Commit 099cef9

Browse files
authored
feat(commands): add agent routing and model override support (#42)
- Add agent, model, and subtask frontmatter fields to commands - Consolidate extraction helpers into validation module - Add test coverage for new command configuration options
1 parent 11494af commit 099cef9

6 files changed

Lines changed: 341 additions & 43 deletions

File tree

src/lib/agents.ts

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { parseFrontmatter } from './frontmatter.js'
22
import {
3+
extractBoolean,
4+
extractNonEmptyString,
5+
extractNumber,
6+
extractString,
37
isAgentMode,
48
isToolsMap,
59
normalizePermission,
@@ -53,31 +57,6 @@ export function findAgentsInDir(dir: string, maxDepth = 2): AgentInfo[] {
5357
}))
5458
}
5559

56-
function extractString(
57-
data: Record<string, unknown>,
58-
key: string,
59-
fallback = '',
60-
): string {
61-
const value = data[key]
62-
return typeof value === 'string' ? value : fallback
63-
}
64-
65-
function extractNumber(
66-
data: Record<string, unknown>,
67-
key: string,
68-
): number | undefined {
69-
const value = data[key]
70-
return typeof value === 'number' ? value : undefined
71-
}
72-
73-
function extractBoolean(
74-
data: Record<string, unknown>,
75-
key: string,
76-
): boolean | undefined {
77-
const value = data[key]
78-
return typeof value === 'boolean' ? value : undefined
79-
}
80-
8160
export function extractAgentFrontmatter(content: string): AgentFrontmatter {
8261
const { data, parseError, body } =
8362
parseFrontmatter<Record<string, unknown>>(content)
@@ -90,13 +69,13 @@ export function extractAgentFrontmatter(content: string): AgentFrontmatter {
9069
name: extractString(data, 'name'),
9170
description: extractString(data, 'description'),
9271
prompt: body.trim(),
93-
model: extractString(data, 'model') || undefined,
72+
model: extractNonEmptyString(data, 'model'),
9473
temperature: extractNumber(data, 'temperature'),
9574
top_p: extractNumber(data, 'top_p'),
9675
tools: isToolsMap(data.tools) ? data.tools : undefined,
9776
disable: extractBoolean(data, 'disable'),
9877
mode: isAgentMode(data.mode) ? data.mode : undefined,
99-
color: extractString(data, 'color') || undefined,
78+
color: extractNonEmptyString(data, 'color'),
10079
maxSteps: extractNumber(data, 'maxSteps'),
10180
permission: normalizePermission(data.permission),
10281
}

src/lib/commands.ts

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
import { parseFrontmatter } from './frontmatter.js'
2+
import {
3+
extractBoolean,
4+
extractNonEmptyString,
5+
extractString,
6+
} from './validation.js'
27
import { walkDir } from './walk-dir.js'
38

49
export interface CommandFrontmatter {
510
name: string
611
description: string
712
argumentHint: string
13+
/** Agent ID to use for this command */
14+
agent?: string
15+
/** Model override for this command */
16+
model?: string
17+
/** Whether this command should run as a subtask */
18+
subtask?: boolean
819
}
920

1021
export interface CommandInfo {
@@ -33,23 +44,28 @@ export function findCommandsInDir(dir: string, maxDepth = 2): CommandInfo[] {
3344
}
3445

3546
export function extractCommandFrontmatter(content: string): CommandFrontmatter {
36-
const { data, parseError } = parseFrontmatter<{
37-
name?: string
38-
description?: string
39-
'argument-hint'?: string
40-
}>(content)
47+
const { data, parseError } =
48+
parseFrontmatter<Record<string, unknown>>(content)
4149

42-
const argumentHintRaw =
43-
!parseError && typeof data['argument-hint'] === 'string'
44-
? data['argument-hint']
45-
: ''
50+
if (parseError) {
51+
return {
52+
name: '',
53+
description: '',
54+
argumentHint: '',
55+
agent: undefined,
56+
model: undefined,
57+
subtask: undefined,
58+
}
59+
}
60+
61+
const argumentHintRaw = extractString(data, 'argument-hint')
4662

4763
return {
48-
name: !parseError && typeof data.name === 'string' ? data.name : '',
49-
description:
50-
!parseError && typeof data.description === 'string'
51-
? data.description
52-
: '',
64+
name: extractString(data, 'name'),
65+
description: extractString(data, 'description'),
5366
argumentHint: argumentHintRaw.replace(/^["']|["']$/g, ''),
67+
agent: extractNonEmptyString(data, 'agent'),
68+
model: extractNonEmptyString(data, 'model'),
69+
subtask: extractBoolean(data, 'subtask'),
5470
}
5571
}

src/lib/config-handler.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,22 @@ function loadCommandAsConfig(commandInfo: {
7070
const converted = convertFileWithCache(commandInfo.file, 'command', {
7171
source: 'bundled',
7272
})
73-
const { name, description } = extractCommandFrontmatter(converted)
73+
const { name, description, agent, model, subtask } =
74+
extractCommandFrontmatter(converted)
7475
const { body } = parseFrontmatter(converted)
7576

7677
const cleanName = commandInfo.name.replace(/^\//, '')
7778

78-
return {
79+
const config: CommandConfig = {
7980
template: body.trim(),
8081
description: description || `${name || cleanName} command`,
8182
}
83+
84+
if (agent !== undefined) config.agent = agent
85+
if (model !== undefined) config.model = model
86+
if (subtask !== undefined) config.subtask = subtask
87+
88+
return config
8289
} catch {
8390
return null
8491
}

src/lib/validation.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,49 @@ export function normalizePermission(
106106
external_directory,
107107
)
108108
}
109+
110+
/**
111+
* Shared frontmatter extraction helpers.
112+
* Centralized to ensure consistent behavior across agents, commands, and skills.
113+
*/
114+
115+
export function extractString(
116+
data: Record<string, unknown>,
117+
key: string,
118+
fallback = '',
119+
): string {
120+
const value = data[key]
121+
return typeof value === 'string' ? value : fallback
122+
}
123+
124+
export function extractNonEmptyString(
125+
data: Record<string, unknown>,
126+
key: string,
127+
): string | undefined {
128+
const value = data[key]
129+
if (typeof value !== 'string') return undefined
130+
const trimmed = value.trim()
131+
return trimmed !== '' ? trimmed : undefined
132+
}
133+
134+
export function extractNumber(
135+
data: Record<string, unknown>,
136+
key: string,
137+
): number | undefined {
138+
const value = data[key]
139+
return typeof value === 'number' ? value : undefined
140+
}
141+
142+
export function extractBoolean(
143+
data: Record<string, unknown>,
144+
key: string,
145+
): boolean | undefined {
146+
const value = data[key]
147+
if (typeof value === 'boolean') return value
148+
if (typeof value === 'string') {
149+
const normalized = value.trim().toLowerCase()
150+
if (normalized === 'true') return true
151+
if (normalized === 'false') return false
152+
}
153+
return undefined
154+
}

tests/unit/commands.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { extractCommandFrontmatter } from '../../src/lib/commands.ts'
3+
4+
describe('extractCommandFrontmatter', () => {
5+
test('extracts agent field when present', () => {
6+
const content = `---
7+
name: test-cmd
8+
agent: my-agent
9+
---
10+
Template content`
11+
const result = extractCommandFrontmatter(content)
12+
expect(result.agent).toBe('my-agent')
13+
})
14+
15+
test('extracts model field when present', () => {
16+
const content = `---
17+
name: test-cmd
18+
model: gpt-4
19+
---
20+
Template content`
21+
const result = extractCommandFrontmatter(content)
22+
expect(result.model).toBe('gpt-4')
23+
})
24+
25+
test('extracts subtask: true when present', () => {
26+
const content = `---
27+
name: test-cmd
28+
subtask: true
29+
---
30+
Template content`
31+
const result = extractCommandFrontmatter(content)
32+
expect(result.subtask).toBe(true)
33+
})
34+
35+
test('extracts subtask: false when present', () => {
36+
const content = `---
37+
name: test-cmd
38+
subtask: false
39+
---
40+
Template content`
41+
const result = extractCommandFrontmatter(content)
42+
expect(result.subtask).toBe(false)
43+
})
44+
45+
test('extracts subtask from string values', () => {
46+
const content = `---
47+
name: test-cmd
48+
subtask: "true"
49+
---
50+
Template content`
51+
const result = extractCommandFrontmatter(content)
52+
expect(result.subtask).toBe(true)
53+
})
54+
55+
test('returns undefined for missing optional fields', () => {
56+
const content = `---
57+
name: test-cmd
58+
---
59+
Template content`
60+
const result = extractCommandFrontmatter(content)
61+
expect(result.agent).toBeUndefined()
62+
expect(result.model).toBeUndefined()
63+
expect(result.subtask).toBeUndefined()
64+
})
65+
66+
test('empty strings become undefined for agent and model', () => {
67+
const content = `---
68+
name: test-cmd
69+
agent: ""
70+
model: ''
71+
---
72+
Template content`
73+
const result = extractCommandFrontmatter(content)
74+
expect(result.agent).toBeUndefined()
75+
expect(result.model).toBeUndefined()
76+
})
77+
78+
test('whitespace-only agent and model are ignored', () => {
79+
const content = `---
80+
name: test-cmd
81+
agent: " "
82+
model: "\t"
83+
---
84+
Template content`
85+
const result = extractCommandFrontmatter(content)
86+
expect(result.agent).toBeUndefined()
87+
expect(result.model).toBeUndefined()
88+
})
89+
90+
test('existing fields still work correctly', () => {
91+
const content = `---
92+
name: test-cmd
93+
description: A test command
94+
argument-hint: "[file]"
95+
---
96+
Template content`
97+
const result = extractCommandFrontmatter(content)
98+
expect(result.name).toBe('test-cmd')
99+
expect(result.description).toBe('A test command')
100+
expect(result.argumentHint).toBe('[file]')
101+
})
102+
103+
test('handles missing frontmatter gracefully', () => {
104+
const content = '# No frontmatter here'
105+
const result = extractCommandFrontmatter(content)
106+
expect(result.name).toBe('')
107+
expect(result.description).toBe('')
108+
expect(result.argumentHint).toBe('')
109+
expect(result.agent).toBeUndefined()
110+
expect(result.model).toBeUndefined()
111+
expect(result.subtask).toBeUndefined()
112+
})
113+
114+
test('returns undefined for invalid subtask string values', () => {
115+
const content = `---
116+
name: test-cmd
117+
subtask: "yes"
118+
---
119+
Template content`
120+
const result = extractCommandFrontmatter(content)
121+
expect(result.subtask).toBeUndefined()
122+
})
123+
124+
test('returns undefined for subtask with numeric value', () => {
125+
const content = `---
126+
name: test-cmd
127+
subtask: 1
128+
---
129+
Template content`
130+
const result = extractCommandFrontmatter(content)
131+
expect(result.subtask).toBeUndefined()
132+
})
133+
134+
test('handles malformed frontmatter gracefully', () => {
135+
const content = `---
136+
name: test
137+
bad: indentation
138+
---
139+
Content`
140+
const result = extractCommandFrontmatter(content)
141+
expect(result.name).toBe('')
142+
expect(result.agent).toBeUndefined()
143+
expect(result.model).toBeUndefined()
144+
expect(result.subtask).toBeUndefined()
145+
})
146+
})

0 commit comments

Comments
 (0)