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
33 changes: 6 additions & 27 deletions src/lib/agents.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { parseFrontmatter } from './frontmatter.js'
import {
extractBoolean,
extractNonEmptyString,
extractNumber,
extractString,
isAgentMode,
isToolsMap,
normalizePermission,
Expand Down Expand Up @@ -53,31 +57,6 @@ export function findAgentsInDir(dir: string, maxDepth = 2): AgentInfo[] {
}))
}

function extractString(
data: Record<string, unknown>,
key: string,
fallback = '',
): string {
const value = data[key]
return typeof value === 'string' ? value : fallback
}

function extractNumber(
data: Record<string, unknown>,
key: string,
): number | undefined {
const value = data[key]
return typeof value === 'number' ? value : undefined
}

function extractBoolean(
data: Record<string, unknown>,
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<Record<string, unknown>>(content)
Expand All @@ -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),
}
Expand Down
44 changes: 30 additions & 14 deletions src/lib/commands.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<Record<string, unknown>>(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'),
}
}
11 changes: 9 additions & 2 deletions src/lib/config-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
46 changes: 46 additions & 0 deletions src/lib/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
key: string,
fallback = '',
): string {
const value = data[key]
return typeof value === 'string' ? value : fallback
}

export function extractNonEmptyString(
data: Record<string, unknown>,
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<string, unknown>,
key: string,
): number | undefined {
const value = data[key]
return typeof value === 'number' ? value : undefined
}

export function extractBoolean(
data: Record<string, unknown>,
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
}
146 changes: 146 additions & 0 deletions tests/unit/commands.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading