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"),
|
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({
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
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
|
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 }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user