From 99711dacc1f470b9e13e3d22860404e619bfa1f3 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Fri, 2 Jan 2026 15:11:14 +0900 Subject: [PATCH] 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 --- .../claude-code-command-loader/loader.ts | 1 + .../claude-code-command-loader/types.ts | 20 ++ .../opencode-skill-loader/loader.test.ts | 6 +- src/hooks/auto-slash-command/executor.ts | 3 +- src/shared/frontmatter.test.ts | 262 ++++++++++++++++++ src/shared/frontmatter.ts | 29 +- src/tools/slashcommand/tools.ts | 3 +- 7 files changed, 302 insertions(+), 22 deletions(-) create mode 100644 src/shared/frontmatter.test.ts diff --git a/src/features/claude-code-command-loader/loader.ts b/src/features/claude-code-command-loader/loader.ts index 2294825..31677db 100644 --- a/src/features/claude-code-command-loader/loader.ts +++ b/src/features/claude-code-command-loader/loader.ts @@ -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({ diff --git a/src/features/claude-code-command-loader/types.ts b/src/features/claude-code-command-loader/types.ts index 55b9b42..f32a8bb 100644 --- a/src/features/claude-code-command-loader/types.ts +++ b/src/features/claude-code-command-loader/types.ts @@ -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 { diff --git a/src/features/opencode-skill-loader/loader.test.ts b/src/features/opencode-skill-loader/loader.test.ts index 35b5909..b415957 100644 --- a/src/features/opencode-skill-loader/loader.test.ts +++ b/src/features/opencode-skill-loader/loader.test.ts @@ -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 { diff --git a/src/hooks/auto-slash-command/executor.ts b/src/hooks/auto-slash-command/executor.ts index 2d4582f..01dde50 100644 --- a/src/hooks/auto-slash-command/executor.ts +++ b/src/hooks/auto-slash-command/executor.ts @@ -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(content) const isOpencodeSource = scope === "opencode" || scope === "opencode-project" const metadata: CommandMetadata = { diff --git a/src/shared/frontmatter.test.ts b/src/shared/frontmatter.test.ts new file mode 100644 index 0000000..9150db3 --- /dev/null +++ b/src/shared/frontmatter.test.ts @@ -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(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(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(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(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 +}) diff --git a/src/shared/frontmatter.ts b/src/shared/frontmatter.ts index f0bfbbe..674b03d 100644 --- a/src/shared/frontmatter.ts +++ b/src/shared/frontmatter.ts @@ -1,12 +1,14 @@ -export interface FrontmatterResult> { +import yaml from "js-yaml" + +export interface FrontmatterResult> { data: T body: string } -export function parseFrontmatter>( +export function parseFrontmatter>( content: string ): FrontmatterResult { - 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>( const yamlContent = match[1] const body = match[2] - const data: Record = {} - 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 } } diff --git a/src/tools/slashcommand/tools.ts b/src/tools/slashcommand/tools.ts index d99c14b..2092eb1 100644 --- a/src/tools/slashcommand/tools.ts +++ b/src/tools/slashcommand/tools.ts @@ -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(content) const isOpencodeSource = scope === "opencode" || scope === "opencode-project" const metadata: CommandMetadata = {