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:
Sisyphus
2026-01-02 15:11:14 +09:00
committed by GitHub
parent 6eaa96f421
commit 99711dacc1
7 changed files with 302 additions and 22 deletions

View File

@@ -78,6 +78,7 @@ $ARGUMENTS
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"), model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
subtask: data.subtask, subtask: data.subtask,
argumentHint: data["argument-hint"], argumentHint: data["argument-hint"],
handoffs: data.handoffs,
} }
commands.push({ commands.push({

View File

@@ -1,5 +1,21 @@
export type CommandScope = "user" | "project" | "opencode" | "opencode-project" 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 { export interface CommandDefinition {
name: string name: string
description?: string description?: string
@@ -8,6 +24,8 @@ export interface CommandDefinition {
model?: string model?: string
subtask?: boolean subtask?: boolean
argumentHint?: string argumentHint?: string
/** Handoff definitions for workflow transitions */
handoffs?: HandoffDefinition[]
} }
export interface CommandFrontmatter { export interface CommandFrontmatter {
@@ -16,6 +34,8 @@ export interface CommandFrontmatter {
agent?: string agent?: string
model?: string model?: string
subtask?: boolean subtask?: boolean
/** Handoff definitions for workflow transitions */
handoffs?: HandoffDefinition[]
} }
export interface LoadedCommand { export interface LoadedCommand {

View File

@@ -134,7 +134,7 @@ Skill with env vars.
}) })
it("handles malformed YAML gracefully", async () => { it("handles malformed YAML gracefully", async () => {
// #given // #given - malformed YAML causes entire frontmatter to fail parsing
const skillContent = `--- const skillContent = `---
name: bad-yaml name: bad-yaml
mcp: [this is not valid yaml for mcp mcp: [this is not valid yaml for mcp
@@ -150,9 +150,9 @@ Skill body.
try { try {
const skills = discoverSkills({ includeClaudeCodePaths: false }) 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).toBeDefined()
expect(skill?.mcpConfig).toBeUndefined() expect(skill?.mcpConfig).toBeUndefined()
} finally { } finally {

View File

@@ -8,6 +8,7 @@ import {
sanitizeModelField, sanitizeModelField,
getClaudeConfigDir, getClaudeConfigDir,
} from "../../shared" } from "../../shared"
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
import { isMarkdownFile } from "../../shared/file-utils" import { isMarkdownFile } from "../../shared/file-utils"
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader" import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
import type { ParsedSlashCommand } from "./types" import type { ParsedSlashCommand } from "./types"
@@ -49,7 +50,7 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type"
try { try {
const content = readFileSync(commandPath, "utf-8") 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 isOpencodeSource = scope === "opencode" || scope === "opencode-project"
const metadata: CommandMetadata = { const metadata: CommandMetadata = {

View 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
})

View File

@@ -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 data: T
body: string body: string
} }
export function parseFrontmatter<T = Record<string, string>>( export function parseFrontmatter<T = Record<string, unknown>>(
content: string content: string
): FrontmatterResult<T> { ): 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) const match = content.match(frontmatterRegex)
if (!match) { if (!match) {
@@ -16,19 +18,12 @@ export function parseFrontmatter<T = Record<string, string>>(
const yamlContent = match[1] const yamlContent = match[1]
const body = match[2] const body = match[2]
const data: Record<string, string | boolean> = {} try {
for (const line of yamlContent.split("\n")) { // Use JSON_SCHEMA for security - prevents code execution via YAML tags
const colonIndex = line.indexOf(":") const parsed = yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA })
if (colonIndex !== -1) { const data = (parsed ?? {}) as T
const key = line.slice(0, colonIndex).trim() return { data, body }
let value: string | boolean = line.slice(colonIndex + 1).trim() } catch {
return { data: {} as T, body }
if (value === "true") value = true
else if (value === "false") value = false
data[key] = value
} }
} }
return { data: data as T, body }
}

View File

@@ -2,6 +2,7 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import { existsSync, readdirSync, readFileSync } from "fs" import { existsSync, readdirSync, readFileSync } from "fs"
import { join, basename, dirname } from "path" import { join, basename, dirname } from "path"
import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, sanitizeModelField } from "../../shared" import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, sanitizeModelField } from "../../shared"
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
import { isMarkdownFile } from "../../shared/file-utils" import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared" import { getClaudeConfigDir } from "../../shared"
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader" import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
@@ -23,7 +24,7 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): Comm
try { try {
const content = readFileSync(commandPath, "utf-8") 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 isOpencodeSource = scope === "opencode" || scope === "opencode-project"
const metadata: CommandMetadata = { const metadata: CommandMetadata = {