feat(commands): add handoffs support for speckit compatibility (#410)
* feat(commands): add handoffs support for speckit compatibility - Upgrade frontmatter parser to use js-yaml for complex YAML support - Add HandoffDefinition interface for speckit-style workflow transitions - Update CommandFrontmatter and CommandDefinition to include handoffs - Add comprehensive tests for backward compatibility and complex YAML - Fix type parameters in auto-slash-command and slashcommand tools Closes #407 * fix(frontmatter): use JSON_SCHEMA for security and add extra fields tolerance tests - Use JSON_SCHEMA in yaml.load() to prevent code execution via YAML tags - Add tests to verify extra fields in frontmatter don't cause failures - Address Greptile security review comment --------- Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
This commit is contained in:
@@ -78,6 +78,7 @@ $ARGUMENTS
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
subtask: data.subtask,
|
||||
argumentHint: data["argument-hint"],
|
||||
handoffs: data.handoffs,
|
||||
}
|
||||
|
||||
commands.push({
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
export type CommandScope = "user" | "project" | "opencode" | "opencode-project"
|
||||
|
||||
/**
|
||||
* Handoff definition for command workflows.
|
||||
* Based on speckit's handoff pattern for multi-agent orchestration.
|
||||
* @see https://github.com/github/spec-kit
|
||||
*/
|
||||
export interface HandoffDefinition {
|
||||
/** Human-readable label for the handoff action */
|
||||
label: string
|
||||
/** Target agent/command identifier (e.g., "speckit.tasks") */
|
||||
agent: string
|
||||
/** Pre-filled prompt text for the handoff */
|
||||
prompt: string
|
||||
/** If true, automatically executes after command completion; if false, shows as suggestion */
|
||||
send?: boolean
|
||||
}
|
||||
|
||||
export interface CommandDefinition {
|
||||
name: string
|
||||
description?: string
|
||||
@@ -8,6 +24,8 @@ export interface CommandDefinition {
|
||||
model?: string
|
||||
subtask?: boolean
|
||||
argumentHint?: string
|
||||
/** Handoff definitions for workflow transitions */
|
||||
handoffs?: HandoffDefinition[]
|
||||
}
|
||||
|
||||
export interface CommandFrontmatter {
|
||||
@@ -16,6 +34,8 @@ export interface CommandFrontmatter {
|
||||
agent?: string
|
||||
model?: string
|
||||
subtask?: boolean
|
||||
/** Handoff definitions for workflow transitions */
|
||||
handoffs?: HandoffDefinition[]
|
||||
}
|
||||
|
||||
export interface LoadedCommand {
|
||||
|
||||
@@ -134,7 +134,7 @@ Skill with env vars.
|
||||
})
|
||||
|
||||
it("handles malformed YAML gracefully", async () => {
|
||||
// #given
|
||||
// #given - malformed YAML causes entire frontmatter to fail parsing
|
||||
const skillContent = `---
|
||||
name: bad-yaml
|
||||
mcp: [this is not valid yaml for mcp
|
||||
@@ -150,9 +150,9 @@ Skill body.
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "bad-yaml")
|
||||
// #then - when YAML fails, skill uses directory name as fallback
|
||||
const skill = skills.find(s => s.name === "bad-yaml-skill")
|
||||
|
||||
// #then - should still load skill but without MCP config
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.mcpConfig).toBeUndefined()
|
||||
} finally {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
sanitizeModelField,
|
||||
getClaudeConfigDir,
|
||||
} from "../../shared"
|
||||
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
import type { ParsedSlashCommand } from "./types"
|
||||
@@ -49,7 +50,7 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type"
|
||||
|
||||
try {
|
||||
const content = readFileSync(commandPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter(content)
|
||||
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
|
||||
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const metadata: CommandMetadata = {
|
||||
|
||||
262
src/shared/frontmatter.test.ts
Normal file
262
src/shared/frontmatter.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { parseFrontmatter } from "./frontmatter"
|
||||
|
||||
describe("parseFrontmatter", () => {
|
||||
// #region backward compatibility
|
||||
test("parses simple key-value frontmatter", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
description: Test command
|
||||
agent: build
|
||||
---
|
||||
Body content`
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter(content)
|
||||
|
||||
// #then
|
||||
expect(result.data.description).toBe("Test command")
|
||||
expect(result.data.agent).toBe("build")
|
||||
expect(result.body).toBe("Body content")
|
||||
})
|
||||
|
||||
test("parses boolean values", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
subtask: true
|
||||
enabled: false
|
||||
---
|
||||
Body`
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter<{ subtask: boolean; enabled: boolean }>(content)
|
||||
|
||||
// #then
|
||||
expect(result.data.subtask).toBe(true)
|
||||
expect(result.data.enabled).toBe(false)
|
||||
})
|
||||
// #endregion
|
||||
|
||||
// #region complex YAML (handoffs support)
|
||||
test("parses complex array frontmatter (speckit handoffs)", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
description: Execute planning workflow
|
||||
handoffs:
|
||||
- label: Create Tasks
|
||||
agent: speckit.tasks
|
||||
prompt: Break the plan into tasks
|
||||
send: true
|
||||
- label: Create Checklist
|
||||
agent: speckit.checklist
|
||||
prompt: Create a checklist
|
||||
---
|
||||
Workflow instructions`
|
||||
|
||||
interface TestMeta {
|
||||
description: string
|
||||
handoffs: Array<{ label: string; agent: string; prompt: string; send?: boolean }>
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter<TestMeta>(content)
|
||||
|
||||
// #then
|
||||
expect(result.data.description).toBe("Execute planning workflow")
|
||||
expect(result.data.handoffs).toHaveLength(2)
|
||||
expect(result.data.handoffs[0].label).toBe("Create Tasks")
|
||||
expect(result.data.handoffs[0].agent).toBe("speckit.tasks")
|
||||
expect(result.data.handoffs[0].send).toBe(true)
|
||||
expect(result.data.handoffs[1].agent).toBe("speckit.checklist")
|
||||
expect(result.data.handoffs[1].send).toBeUndefined()
|
||||
})
|
||||
|
||||
test("parses nested objects in frontmatter", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
name: test
|
||||
config:
|
||||
timeout: 5000
|
||||
retry: true
|
||||
options:
|
||||
verbose: false
|
||||
---
|
||||
Content`
|
||||
|
||||
interface TestMeta {
|
||||
name: string
|
||||
config: {
|
||||
timeout: number
|
||||
retry: boolean
|
||||
options: { verbose: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter<TestMeta>(content)
|
||||
|
||||
// #then
|
||||
expect(result.data.name).toBe("test")
|
||||
expect(result.data.config.timeout).toBe(5000)
|
||||
expect(result.data.config.retry).toBe(true)
|
||||
expect(result.data.config.options.verbose).toBe(false)
|
||||
})
|
||||
// #endregion
|
||||
|
||||
// #region edge cases
|
||||
test("handles content without frontmatter", () => {
|
||||
// #given
|
||||
const content = "Just body content"
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter(content)
|
||||
|
||||
// #then
|
||||
expect(result.data).toEqual({})
|
||||
expect(result.body).toBe("Just body content")
|
||||
})
|
||||
|
||||
test("handles empty frontmatter", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
---
|
||||
Body`
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter(content)
|
||||
|
||||
// #then
|
||||
expect(result.data).toEqual({})
|
||||
expect(result.body).toBe("Body")
|
||||
})
|
||||
|
||||
test("handles invalid YAML gracefully", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
invalid: yaml: syntax: here
|
||||
bad indentation
|
||||
---
|
||||
Body`
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter(content)
|
||||
|
||||
// #then - should not throw, return empty data
|
||||
expect(result.data).toEqual({})
|
||||
expect(result.body).toBe("Body")
|
||||
})
|
||||
|
||||
test("handles frontmatter with only whitespace", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
|
||||
---
|
||||
Body with whitespace-only frontmatter`
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter(content)
|
||||
|
||||
// #then
|
||||
expect(result.data).toEqual({})
|
||||
expect(result.body).toBe("Body with whitespace-only frontmatter")
|
||||
})
|
||||
// #endregion
|
||||
|
||||
// #region mixed content
|
||||
test("preserves multiline body content", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
title: Test
|
||||
---
|
||||
Line 1
|
||||
Line 2
|
||||
|
||||
Line 4 after blank`
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter<{ title: string }>(content)
|
||||
|
||||
// #then
|
||||
expect(result.data.title).toBe("Test")
|
||||
expect(result.body).toBe("Line 1\nLine 2\n\nLine 4 after blank")
|
||||
})
|
||||
|
||||
test("handles CRLF line endings", () => {
|
||||
// #given
|
||||
const content = "---\r\ndescription: Test\r\n---\r\nBody"
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter<{ description: string }>(content)
|
||||
|
||||
// #then
|
||||
expect(result.data.description).toBe("Test")
|
||||
expect(result.body).toBe("Body")
|
||||
})
|
||||
// #endregion
|
||||
|
||||
// #region extra fields tolerance
|
||||
test("allows extra fields beyond typed interface", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
description: Test command
|
||||
agent: build
|
||||
extra_field: should not fail
|
||||
another_extra:
|
||||
nested: value
|
||||
array:
|
||||
- item1
|
||||
- item2
|
||||
custom_boolean: true
|
||||
custom_number: 42
|
||||
---
|
||||
Body content`
|
||||
|
||||
interface MinimalMeta {
|
||||
description: string
|
||||
agent: string
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter<MinimalMeta>(content)
|
||||
|
||||
// #then
|
||||
expect(result.data.description).toBe("Test command")
|
||||
expect(result.data.agent).toBe("build")
|
||||
expect(result.body).toBe("Body content")
|
||||
// @ts-expect-error - accessing extra field not in MinimalMeta
|
||||
expect(result.data.extra_field).toBe("should not fail")
|
||||
// @ts-expect-error - accessing extra field not in MinimalMeta
|
||||
expect(result.data.another_extra).toEqual({ nested: "value", array: ["item1", "item2"] })
|
||||
// @ts-expect-error - accessing extra field not in MinimalMeta
|
||||
expect(result.data.custom_boolean).toBe(true)
|
||||
// @ts-expect-error - accessing extra field not in MinimalMeta
|
||||
expect(result.data.custom_number).toBe(42)
|
||||
})
|
||||
|
||||
test("extra fields do not interfere with expected fields", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
description: Original description
|
||||
unknown_field: extra value
|
||||
handoffs:
|
||||
- label: Task 1
|
||||
agent: test.agent
|
||||
---
|
||||
Content`
|
||||
|
||||
interface HandoffMeta {
|
||||
description: string
|
||||
handoffs: Array<{ label: string; agent: string }>
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter<HandoffMeta>(content)
|
||||
|
||||
// #then
|
||||
expect(result.data.description).toBe("Original description")
|
||||
expect(result.data.handoffs).toHaveLength(1)
|
||||
expect(result.data.handoffs[0].label).toBe("Task 1")
|
||||
expect(result.data.handoffs[0].agent).toBe("test.agent")
|
||||
})
|
||||
// #endregion
|
||||
})
|
||||
@@ -1,12 +1,14 @@
|
||||
export interface FrontmatterResult<T = Record<string, string>> {
|
||||
import yaml from "js-yaml"
|
||||
|
||||
export interface FrontmatterResult<T = Record<string, unknown>> {
|
||||
data: T
|
||||
body: string
|
||||
}
|
||||
|
||||
export function parseFrontmatter<T = Record<string, string>>(
|
||||
export function parseFrontmatter<T = Record<string, unknown>>(
|
||||
content: string
|
||||
): FrontmatterResult<T> {
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n?---\r?\n([\s\S]*)$/
|
||||
const match = content.match(frontmatterRegex)
|
||||
|
||||
if (!match) {
|
||||
@@ -16,19 +18,12 @@ export function parseFrontmatter<T = Record<string, string>>(
|
||||
const yamlContent = match[1]
|
||||
const body = match[2]
|
||||
|
||||
const data: Record<string, string | boolean> = {}
|
||||
for (const line of yamlContent.split("\n")) {
|
||||
const colonIndex = line.indexOf(":")
|
||||
if (colonIndex !== -1) {
|
||||
const key = line.slice(0, colonIndex).trim()
|
||||
let value: string | boolean = line.slice(colonIndex + 1).trim()
|
||||
|
||||
if (value === "true") value = true
|
||||
else if (value === "false") value = false
|
||||
|
||||
data[key] = value
|
||||
}
|
||||
try {
|
||||
// Use JSON_SCHEMA for security - prevents code execution via YAML tags
|
||||
const parsed = yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA })
|
||||
const data = (parsed ?? {}) as T
|
||||
return { data, body }
|
||||
} catch {
|
||||
return { data: {} as T, body }
|
||||
}
|
||||
|
||||
return { data: data as T, body }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { join, basename, dirname } from "path"
|
||||
import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, sanitizeModelField } from "../../shared"
|
||||
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
@@ -23,7 +24,7 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): Comm
|
||||
|
||||
try {
|
||||
const content = readFileSync(commandPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter(content)
|
||||
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
|
||||
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const metadata: CommandMetadata = {
|
||||
|
||||
Reference in New Issue
Block a user