diff --git a/.gitignore b/.gitignore index 42d38af33..1e4ea9ac1 100644 --- a/.gitignore +++ b/.gitignore @@ -285,6 +285,8 @@ rulesync.local.jsonc **/.goosehints **/.goose/ **/.gooseignore +**/.goose/recipes/ +**/.goose/recipes/subagents/ **/.agents/plugins/ **/.github/copilot-instructions.md **/.github/instructions/ diff --git a/README.md b/README.md index e9d91341e..a126fe7ab 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ The tables below show whether each tool supports a given feature (✅ = supporte | Claude Code | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Codex CLI | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Gemini CLI ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Goose | ✅ | ✅ | ✅ | | | | ✅ | | +| Goose | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | | | GitHub Copilot | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | | | GitHub Copilot CLI | ✅ | | ✅ | | ✅ | ✅ | ✅ | | | Cursor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 6bce1d69c..ee58ee4c9 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -571,6 +571,15 @@ env_vars = ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"] Goose configures MCP servers as **extensions** in the shared user config `~/.config/goose/config.yaml`, which is **global only** (Goose has no project-scoped MCP location), so they are generated with `--global`. The schema is non-standard, so Rulesync maps canonical MCP fields to Goose's: `command` → `cmd` (an array `command` folds its tail into `args`), `env` → `envs`, `url`/`httpUrl` → `uri`, and `disabled: true` → `enabled: false`. The `type` is derived — `command` ⇒ `stdio`, a remote `url` ⇒ `streamable_http` (or `sse` when the canonical `type` is `sse`). Each extension also carries its own `name`. Generation merges the `extensions:` block into the existing `config.yaml`, preserving other Goose settings (model, provider, ...), and the file is never deleted. See the [Goose extensions docs](https://block.github.io/goose/docs/getting-started/using-extensions/). +### Goose-specific: commands and subagents as recipes + +Goose [recipes](https://block.github.io/goose/docs/guides/recipes/recipe-reference/) are reusable YAML workflow files (`version`, `title`, `description`, plus at least one of `instructions` / `prompt`, and optional `extensions`, `parameters`, `sub_recipes`, …). Rulesync maps: + +- **commands → top-level recipes** at `.goose/recipes/.yaml` (project) and `~/.config/goose/recipes/.yaml` (global). The command body becomes the recipe `prompt`. +- **subagents → sub-recipes** at `.goose/recipes/subagents/.yaml` (project) and `~/.config/goose/recipes/subagents/.yaml` (global), referenced from a parent recipe's `sub_recipes` list by relative `path`. The subagent body becomes the recipe `instructions`. + +Subagents live in the `subagents/` subdirectory so the command-recipe and subagent-recipe file sets stay disjoint (import and orphan deletion never cross over). `title` defaults to the file name and `description` to the rulesync `description` (falling back to `title`) since recipes require both; `version` defaults to `1.0.0`. Any other recipe field round-trips through the rulesync `goose` section of a command/subagent. + ## `.rulesync/.aiignore` or `.rulesyncignore` Rulesync supports a single ignore list that can live in either location below: diff --git a/docs/reference/supported-tools.md b/docs/reference/supported-tools.md index 680ca39c3..392e821f5 100644 --- a/docs/reference/supported-tools.md +++ b/docs/reference/supported-tools.md @@ -12,7 +12,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Gemini CLI ⚠️ | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ | ✅ | ✅ | | | GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | -| Goose | goose | ✅ 🌏 | ✅ | 🌏 | | | | ✅ 🌏 | | +| Goose | goose | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | | | Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | deepagents-cli | deepagents | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | 🌏 | | | Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 6bce1d69c..ee58ee4c9 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -571,6 +571,15 @@ env_vars = ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"] Goose configures MCP servers as **extensions** in the shared user config `~/.config/goose/config.yaml`, which is **global only** (Goose has no project-scoped MCP location), so they are generated with `--global`. The schema is non-standard, so Rulesync maps canonical MCP fields to Goose's: `command` → `cmd` (an array `command` folds its tail into `args`), `env` → `envs`, `url`/`httpUrl` → `uri`, and `disabled: true` → `enabled: false`. The `type` is derived — `command` ⇒ `stdio`, a remote `url` ⇒ `streamable_http` (or `sse` when the canonical `type` is `sse`). Each extension also carries its own `name`. Generation merges the `extensions:` block into the existing `config.yaml`, preserving other Goose settings (model, provider, ...), and the file is never deleted. See the [Goose extensions docs](https://block.github.io/goose/docs/getting-started/using-extensions/). +### Goose-specific: commands and subagents as recipes + +Goose [recipes](https://block.github.io/goose/docs/guides/recipes/recipe-reference/) are reusable YAML workflow files (`version`, `title`, `description`, plus at least one of `instructions` / `prompt`, and optional `extensions`, `parameters`, `sub_recipes`, …). Rulesync maps: + +- **commands → top-level recipes** at `.goose/recipes/.yaml` (project) and `~/.config/goose/recipes/.yaml` (global). The command body becomes the recipe `prompt`. +- **subagents → sub-recipes** at `.goose/recipes/subagents/.yaml` (project) and `~/.config/goose/recipes/subagents/.yaml` (global), referenced from a parent recipe's `sub_recipes` list by relative `path`. The subagent body becomes the recipe `instructions`. + +Subagents live in the `subagents/` subdirectory so the command-recipe and subagent-recipe file sets stay disjoint (import and orphan deletion never cross over). `title` defaults to the file name and `description` to the rulesync `description` (falling back to `title`) since recipes require both; `version` defaults to `1.0.0`. Any other recipe field round-trips through the rulesync `goose` section of a command/subagent. + ## `.rulesync/.aiignore` or `.rulesyncignore` Rulesync supports a single ignore list that can live in either location below: diff --git a/skills/rulesync/supported-tools.md b/skills/rulesync/supported-tools.md index 680ca39c3..392e821f5 100644 --- a/skills/rulesync/supported-tools.md +++ b/skills/rulesync/supported-tools.md @@ -12,7 +12,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod | Gemini CLI ⚠️ | geminicli | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | GitHub Copilot | copilot | ✅ 🌏 | | ✅ | ✅ | ✅ | ✅ | ✅ | | | GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | -| Goose | goose | ✅ 🌏 | ✅ | 🌏 | | | | ✅ 🌏 | | +| Goose | goose | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | | | Cursor | cursor | ✅ | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | | deepagents-cli | deepagents | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | 🌏 | | | Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index b5cbc0568..2ee1f3f38 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -235,6 +235,12 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ { target: "goose", feature: "rules", entry: "**/.goosehints" }, { target: "goose", feature: "rules", entry: "**/.goose/" }, { target: "goose", feature: "ignore", entry: "**/.gooseignore" }, + // Goose recipes: commands map to top-level recipes (`.goose/recipes/*.yaml`) + // and subagents to sub-recipes (`.goose/recipes/subagents/*.yaml`). These are + // already covered by the broad `**/.goose/` rules entry above, but tagging + // them per-feature keeps coverage correct under feature-filtered gitignore. + { target: "goose", feature: "commands", entry: "**/.goose/recipes/" }, + { target: "goose", feature: "subagents", entry: "**/.goose/recipes/subagents/" }, // Goose lifecycle hooks plugin (.agents/plugins//hooks/hooks.json) { target: "goose", feature: "hooks", entry: "**/.agents/plugins/" }, // Goose reads MCP "extensions" only from the global user config diff --git a/src/constants/goose-paths.ts b/src/constants/goose-paths.ts index 82cace244..b94d5e9ec 100644 --- a/src/constants/goose-paths.ts +++ b/src/constants/goose-paths.ts @@ -7,3 +7,18 @@ export const GOOSE_IGNORE_FILE_NAME = ".gooseignore"; export const GOOSE_MCP_FILE_NAME = "config.yaml"; export const GOOSE_HOOKS_DIR_PATH = join(".agents", "plugins", "rulesync", "hooks"); export const GOOSE_HOOKS_FILE_NAME = "hooks.json"; + +// Recipes are reusable YAML workflow files. Goose discovers project recipes in +// `./.goose/recipes/` and global recipes in `~/.config/goose/recipes/`. +// rulesync maps commands → top-level recipes (here) and subagents → sub-recipe +// files under the `subagents/` subdirectory (referenced from a parent recipe via +// a relative `path`). Keeping subagents in a subdirectory makes the command and +// subagent file sets disjoint so import/orphan-deletion never cross over. +// @see https://block.github.io/goose/docs/guides/recipes/recipe-reference/ +export const GOOSE_RECIPES_DIR_PATH = join(GOOSE_DIR, "recipes"); +export const GOOSE_GLOBAL_RECIPES_DIR_PATH = join(GOOSE_GLOBAL_DIR, "recipes"); +export const GOOSE_RECIPES_SUBAGENTS_DIR_PATH = join(GOOSE_RECIPES_DIR_PATH, "subagents"); +export const GOOSE_GLOBAL_RECIPES_SUBAGENTS_DIR_PATH = join( + GOOSE_GLOBAL_RECIPES_DIR_PATH, + "subagents", +); diff --git a/src/e2e/e2e-commands.spec.ts b/src/e2e/e2e-commands.spec.ts index 9807826fd..ed8623896 100644 --- a/src/e2e/e2e-commands.spec.ts +++ b/src/e2e/e2e-commands.spec.ts @@ -32,6 +32,7 @@ describe("E2E: commands", () => { { target: "pi", outputPath: join(".pi", "prompts", "review-pr.md") }, { target: "devin", outputPath: join(".devin", "workflows", "review-pr.md") }, { target: "factorydroid", outputPath: join(".factory", "commands", "review-pr.md") }, + { target: "goose", outputPath: join(".goose", "recipes", "review-pr.yaml") }, ])("should generate $target commands", async ({ target, outputPath }) => { const testDir = getTestDir(); @@ -100,6 +101,7 @@ Check the PR diff and provide feedback. { target: "pi", orphanPath: join(".pi", "prompts", "orphan.md") }, { target: "devin", orphanPath: join(".devin", "workflows", "orphan.md") }, { target: "factorydroid", orphanPath: join(".factory", "commands", "orphan.md") }, + { target: "goose", orphanPath: join(".goose", "recipes", "orphan.yaml") }, ])( "should fail in check mode when delete would remove an orphan $target command file", async ({ target, orphanPath }) => { @@ -160,6 +162,25 @@ describe("E2E: commands (import)", () => { ); expect(importedContent).toContain("Review the PR diff and provide feedback."); }); + + it("should import goose commands (recipe YAML)", async () => { + const testDir = getTestDir(); + + const recipeContent = [ + "version: 1.0.0", + "title: review-pr", + "description: Review a pull request", + "prompt: Review the PR diff and provide feedback.", + ].join("\n"); + await writeFileContent(join(testDir, ".goose", "recipes", "review-pr.yaml"), recipeContent); + + await runImport({ target: "goose", features: "commands" }); + + const importedContent = await readFileContent( + join(testDir, RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "review-pr.md"), + ); + expect(importedContent).toContain("Review the PR diff and provide feedback."); + }); }); describe("E2E: commands (global mode)", () => { @@ -189,6 +210,7 @@ describe("E2E: commands (global mode)", () => { outputPath: join(".codeium", "windsurf", "global_workflows", "review-pr.md"), }, { target: "factorydroid", outputPath: join(".factory", "commands", "review-pr.md") }, + { target: "goose", outputPath: join(".config", "goose", "recipes", "review-pr.yaml") }, ])("should generate $target commands in home directory", async ({ target, outputPath }) => { const projectDir = getProjectDir(); const homeDir = getHomeDir(); diff --git a/src/e2e/e2e-subagents.spec.ts b/src/e2e/e2e-subagents.spec.ts index 766fce738..dad2e19bc 100644 --- a/src/e2e/e2e-subagents.spec.ts +++ b/src/e2e/e2e-subagents.spec.ts @@ -71,6 +71,10 @@ describe("E2E: subagents", () => { target: "vibe", outputPath: join(".vibe", "agents", "planner.toml"), }, + { + target: "goose", + outputPath: join(".goose", "recipes", "subagents", "planner.yaml"), + }, ])("should generate $target subagents", async ({ target, outputPath }) => { const testDir = getTestDir(); @@ -222,6 +226,7 @@ You are a subagent-only helper. { target: "factorydroid", orphanPath: join(".factory", "droids", "orphan.md") }, { target: "cline", orphanPath: join(".cline", "agents", "orphan.yaml") }, { target: "vibe", orphanPath: join(".vibe", "agents", "orphan.toml") }, + { target: "goose", orphanPath: join(".goose", "recipes", "subagents", "orphan.yaml") }, ])( "should fail in check mode when delete would remove an orphan $target subagent file", async ({ target, orphanPath }) => { @@ -306,6 +311,28 @@ Break down tasks into steps. expect(importedContent).toContain("planner"); }); + it("should import goose subagents (sub-recipe YAML)", async () => { + const testDir = getTestDir(); + + const recipeContent = [ + "version: 1.0.0", + "title: planner", + "description: Plans tasks", + "instructions: Break down tasks into steps.", + ].join("\n"); + await writeFileContent( + join(testDir, ".goose", "recipes", "subagents", "planner.yaml"), + recipeContent, + ); + + await runImport({ target: "goose", features: "subagents" }); + + const importedContent = await readFileContent( + join(testDir, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, "planner.md"), + ); + expect(importedContent).toContain("planner"); + }); + it("should import kiro subagents (JSON format)", async () => { const testDir = getTestDir(); @@ -366,6 +393,10 @@ describe("E2E: subagents (global mode)", () => { outputPath: join(".deepagents", "deepagents", "agents", "planner", "AGENTS.md"), }, { target: "vibe", outputPath: join(".vibe", "agents", "planner.toml") }, + { + target: "goose", + outputPath: join(".config", "goose", "recipes", "subagents", "planner.yaml"), + }, ])("should generate $target subagents in home directory", async ({ target, outputPath }) => { const projectDir = getProjectDir(); const homeDir = getHomeDir(); diff --git a/src/features/commands/commands-processor.test.ts b/src/features/commands/commands-processor.test.ts index 3551d3a1b..c8ceecc5f 100644 --- a/src/features/commands/commands-processor.test.ts +++ b/src/features/commands/commands-processor.test.ts @@ -1144,6 +1144,7 @@ describe("CommandsProcessor", () => { "cursor", "factorydroid", "geminicli", + "goose", "junie", "kilo", "kiro", @@ -1171,6 +1172,7 @@ describe("CommandsProcessor", () => { "cursor", "factorydroid", "geminicli", + "goose", "junie", "kilo", "kiro", @@ -1197,6 +1199,7 @@ describe("CommandsProcessor", () => { "cursor", "factorydroid", "geminicli", + "goose", "junie", "codexcli", "kilo", diff --git a/src/features/commands/commands-processor.ts b/src/features/commands/commands-processor.ts index 5aa62fe92..2b95eae00 100644 --- a/src/features/commands/commands-processor.ts +++ b/src/features/commands/commands-processor.ts @@ -21,6 +21,7 @@ import { CursorCommand } from "./cursor-command.js"; import { DevinCommand } from "./devin-command.js"; import { FactorydroidCommand } from "./factorydroid-command.js"; import { GeminiCliCommand } from "./geminicli-command.js"; +import { GooseCommand } from "./goose-command.js"; import { JunieCommand } from "./junie-command.js"; import { KiloCommand } from "./kilo-command.js"; import { KiroCommand } from "./kiro-command.js"; @@ -51,7 +52,7 @@ type ToolCommandFactory = { }; meta: { /** File extension for the command file */ - extension: "md" | "toml" | "prompt.md"; + extension: "md" | "toml" | "prompt.md" | "yaml"; /** Whether the tool supports project-level commands */ supportsProject: boolean; /** Whether the tool supports global (user-level) commands */ @@ -80,6 +81,7 @@ const commandsProcessorToolTargetTuple = [ "cursor", "factorydroid", "geminicli", + "goose", "junie", "kilo", "kiro", @@ -259,6 +261,22 @@ export const toolCommandFactories = new Map; + relativeFilePath?: string; +}): RulesyncCommand => + new RulesyncCommand({ + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: overrides?.relativeFilePath ?? "deploy.md", + frontmatter: { + targets: ["goose"], + description: overrides?.description ?? "Deploy the app", + ...(overrides?.goose ? { goose: overrides.goose } : {}), + }, + body: overrides?.body ?? "Run the deploy steps.", + fileContent: "", + }); + +describe("GooseCommand", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("returns project recipes dir", () => { + expect(GooseCommand.getSettablePaths().relativeDirPath).toBe(join(".goose", "recipes")); + }); + + it("returns global recipes dir", () => { + expect(GooseCommand.getSettablePaths({ global: true }).relativeDirPath).toBe( + join(".config", "goose", "recipes"), + ); + }); + }); + + describe("fromRulesyncCommand", () => { + it("emits a valid recipe YAML with version/title/description/prompt", () => { + const command = GooseCommand.fromRulesyncCommand({ + rulesyncCommand: buildRulesyncCommand(), + }); + + expect(command.getRelativeFilePath()).toBe("deploy.yaml"); + const recipe = load(command.getFileContent()) as Record; + expect(recipe.version).toBe("1.0.0"); + expect(recipe.title).toBe("deploy"); + expect(recipe.description).toBe("Deploy the app"); + expect(recipe.prompt).toBe("Run the deploy steps."); + }); + + it("falls back to the title for description when none is provided", () => { + const command = GooseCommand.fromRulesyncCommand({ + rulesyncCommand: new RulesyncCommand({ + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "review.md", + frontmatter: { targets: ["goose"] }, + body: "Review the diff.", + fileContent: "", + }), + }); + + const recipe = load(command.getFileContent()) as Record; + expect(recipe.title).toBe("review"); + expect(recipe.description).toBe("review"); + }); + + it("layers extra recipe fields from the goose section", () => { + const command = GooseCommand.fromRulesyncCommand({ + rulesyncCommand: buildRulesyncCommand({ + goose: { + parameters: [ + { key: "env", input_type: "string", requirement: "required", description: "Target" }, + ], + }, + }), + }); + + const recipe = load(command.getFileContent()) as Record; + expect(recipe.parameters).toEqual([ + { key: "env", input_type: "string", requirement: "required", description: "Target" }, + ]); + }); + + it("does not line-fold a long prompt body", () => { + const longBody = `Run the deploy steps. ${"word ".repeat(60)}done.`; + const command = GooseCommand.fromRulesyncCommand({ + rulesyncCommand: buildRulesyncCommand({ body: longBody }), + }); + const recipe = load(command.getFileContent()) as Record; + expect(recipe.prompt).toBe(longBody); + }); + + it("writes to the global recipes dir when global is set", () => { + const command = GooseCommand.fromRulesyncCommand({ + rulesyncCommand: buildRulesyncCommand(), + global: true, + }); + expect(command.getRelativeDirPath()).toBe(join(".config", "goose", "recipes")); + }); + }); + + describe("toRulesyncCommand", () => { + it("round-trips the prompt as the body and keeps extras in the goose section", () => { + const yamlContent = [ + "version: 1.0.0", + "title: deploy", + "description: Deploy the app", + "prompt: Run the deploy steps.", + "activities:", + " - Build", + ].join("\n"); + + const command = new GooseCommand({ + relativeDirPath: join(".goose", "recipes"), + relativeFilePath: "deploy.yaml", + fileContent: yamlContent, + }); + + const rulesync = command.toRulesyncCommand(); + expect(rulesync.getRelativeFilePath()).toBe("deploy.md"); + expect(rulesync.getBody()).toBe("Run the deploy steps."); + const fm = rulesync.getFrontmatter(); + expect(fm.description).toBe("Deploy the app"); + expect(fm.goose).toMatchObject({ version: "1.0.0", title: "deploy", activities: ["Build"] }); + expect((fm.goose as Record).prompt).toBeUndefined(); + }); + + it("uses instructions as the body without duplicating it into the goose section", () => { + const yamlContent = [ + "version: 1.0.0", + "title: deploy", + "description: Deploy the app", + "instructions: Run the deploy steps.", + ].join("\n"); + + const command = new GooseCommand({ + relativeDirPath: join(".goose", "recipes"), + relativeFilePath: "deploy.yaml", + fileContent: yamlContent, + }); + + const rulesync = command.toRulesyncCommand(); + expect(rulesync.getBody()).toBe("Run the deploy steps."); + // The body field must not be echoed back into the goose section. + expect( + (rulesync.getFrontmatter().goose as Record)?.instructions, + ).toBeUndefined(); + }); + }); + + describe("fromFile", () => { + it("loads a recipe file from disk", async () => { + const yamlContent = "version: 1.0.0\ntitle: deploy\ndescription: Deploy\nprompt: Go\n"; + await writeFileContent(join(testDir, ".goose", "recipes", "deploy.yaml"), yamlContent); + + const command = await GooseCommand.fromFile({ + outputRoot: testDir, + relativeFilePath: "deploy.yaml", + }); + + expect(command).toBeInstanceOf(GooseCommand); + expect(command.getBody()).toBe("Go"); + }); + }); + + describe("isTargetedByRulesyncCommand", () => { + it("returns true for goose and wildcard targets", () => { + expect(GooseCommand.isTargetedByRulesyncCommand(buildRulesyncCommand())).toBe(true); + expect( + GooseCommand.isTargetedByRulesyncCommand( + buildRulesyncCommand({ goose: undefined as never }), + ), + ).toBe(true); + }); + }); + + describe("validate", () => { + it("succeeds on a parseable recipe", () => { + const command = new GooseCommand({ + relativeDirPath: join(".goose", "recipes"), + relativeFilePath: "deploy.yaml", + fileContent: "version: 1.0.0\ntitle: t\ndescription: d\nprompt: p\n", + }); + expect(command.validate().success).toBe(true); + }); + }); +}); diff --git a/src/features/commands/goose-command.ts b/src/features/commands/goose-command.ts new file mode 100644 index 000000000..1fda47d8a --- /dev/null +++ b/src/features/commands/goose-command.ts @@ -0,0 +1,229 @@ +import { basename, join } from "node:path"; + +import { dump, load } from "js-yaml"; +import { z } from "zod/mini"; + +import { + GOOSE_GLOBAL_RECIPES_DIR_PATH, + GOOSE_RECIPES_DIR_PATH, +} from "../../constants/goose-paths.js"; +import type { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { formatError } from "../../utils/error.js"; +import { readFileContent } from "../../utils/file.js"; +import { stringifyFrontmatter } from "../../utils/frontmatter.js"; +import { RulesyncCommand, RulesyncCommandFrontmatter } from "./rulesync-command.js"; +import { + ToolCommand, + ToolCommandForDeletionParams, + ToolCommandFromFileParams, + ToolCommandFromRulesyncCommandParams, + ToolCommandSettablePaths, +} from "./tool-command.js"; + +const RECIPE_VERSION = "1.0.0"; + +/** + * Goose recipe files are reusable YAML workflow documents. A recipe requires + * `version`, `title`, and `description`, plus at least one of `instructions` / + * `prompt`; it may also carry `extensions`, `parameters`, `sub_recipes`, + * `settings`, `activities`, `author`, `response`, and `retry`. rulesync maps a + * command to a top-level recipe whose `prompt` is the command body; all other + * recipe fields round-trip through the rulesync `goose` command section. + * + * The whole file is a YAML mapping (not frontmatter + markdown body), so the + * class stores the parsed recipe object rather than a frontmatter/body split. + * + * @see https://block.github.io/goose/docs/guides/recipes/recipe-reference/ + */ +export const GooseCommandRecipeSchema = z.looseObject({ + version: z.optional(z.string()), + title: z.optional(z.string()), + description: z.optional(z.string()), + instructions: z.optional(z.string()), + prompt: z.optional(z.string()), +}); + +export type GooseCommandRecipe = z.infer; + +export class GooseCommand extends ToolCommand { + private readonly recipe: GooseCommandRecipe; + + constructor(params: AiFileParams) { + super(params); + // When validation is disabled (e.g. forDeletion with placeholder content), + // never throw on malformed YAML — fall back to an empty recipe. + if (params.validate === false) { + try { + this.recipe = this.parseRecipeContent(this.fileContent); + } catch { + this.recipe = {}; + } + } else { + this.recipe = this.parseRecipeContent(this.fileContent); + } + } + + static getSettablePaths({ global = false }: { global?: boolean } = {}): ToolCommandSettablePaths { + return { + relativeDirPath: global ? GOOSE_GLOBAL_RECIPES_DIR_PATH : GOOSE_RECIPES_DIR_PATH, + }; + } + + private parseRecipeContent(content: string): GooseCommandRecipe { + const where = join(this.relativeDirPath, this.relativeFilePath); + let parsed: unknown; + try { + parsed = load(content); + } catch (error) { + throw new Error(`Failed to parse Goose recipe (${where}): ${formatError(error)}`, { + cause: error, + }); + } + // An empty file parses to undefined/null; treat it as an empty recipe. + const candidate = parsed === undefined || parsed === null ? {} : parsed; + const result = GooseCommandRecipeSchema.safeParse(candidate); + if (!result.success) { + throw new Error(`Invalid Goose recipe in ${where}: ${formatError(result.error)}`); + } + return result.data; + } + + getBody(): string { + return this.recipe.prompt ?? this.recipe.instructions ?? ""; + } + + getFrontmatter(): GooseCommandRecipe { + return this.recipe; + } + + toRulesyncCommand(): RulesyncCommand { + // The body source (`prompt`, falling back to `instructions`) becomes the + // rulesync body; everything else is preserved in the goose section. Both + // body fields are excluded from the section so the body is never duplicated + // back into the recipe on regeneration. + const { + prompt: _prompt, + instructions: _instructions, + description, + ...restFields + } = this.recipe; + + const rulesyncFrontmatter: RulesyncCommandFrontmatter = { + targets: ["goose"], + description, + ...(Object.keys(restFields).length > 0 && { goose: restFields }), + }; + + const body = this.getBody(); + const fileContent = stringifyFrontmatter(body, rulesyncFrontmatter); + + return new RulesyncCommand({ + outputRoot: process.cwd(), + frontmatter: rulesyncFrontmatter, + body, + relativeDirPath: RulesyncCommand.getSettablePaths().relativeDirPath, + relativeFilePath: this.relativeFilePath.replace(/\.ya?ml$/, ".md"), + fileContent, + validate: true, + }); + } + + static fromRulesyncCommand({ + outputRoot = process.cwd(), + rulesyncCommand, + validate = true, + global = false, + }: ToolCommandFromRulesyncCommandParams): GooseCommand { + const rulesyncFrontmatter = rulesyncCommand.getFrontmatter(); + const gooseFields: Record = { ...rulesyncFrontmatter.goose }; + + const relativeFilePath = rulesyncCommand.getRelativeFilePath().replace(/\.md$/, ".yaml"); + // Recipes require a non-empty title and description. Derive sensible + // defaults from the command name / description when the user has not set + // them explicitly via the goose section. + const derivedTitle = basename(relativeFilePath).replace(/\.ya?ml$/, ""); + const title = typeof gooseFields.title === "string" ? gooseFields.title : derivedTitle; + const description = + typeof gooseFields.description === "string" + ? gooseFields.description + : (rulesyncFrontmatter.description ?? title); + const version = typeof gooseFields.version === "string" ? gooseFields.version : RECIPE_VERSION; + const prompt = + typeof gooseFields.prompt === "string" ? gooseFields.prompt : rulesyncCommand.getBody(); + + // Build the recipe with the canonical key order first, then layer any + // remaining goose-section fields (parameters, extensions, sub_recipes, …). + const { title: _t, description: _d, version: _v, prompt: _p, ...extraFields } = gooseFields; + const recipe: Record = { + version, + title, + description, + prompt, + ...extraFields, + }; + + const paths = this.getSettablePaths({ global }); + + return new GooseCommand({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + fileContent: dump(recipe, { lineWidth: -1, noRefs: true }), + validate, + }); + } + + static async fromFile({ + outputRoot = process.cwd(), + relativeFilePath, + validate = true, + global = false, + }: ToolCommandFromFileParams): Promise { + const paths = this.getSettablePaths({ global }); + const filePath = join(outputRoot, paths.relativeDirPath, relativeFilePath); + const fileContent = await readFileContent(filePath); + + return new GooseCommand({ + outputRoot, + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + fileContent, + validate, + }); + } + + validate(): ValidationResult { + try { + this.parseRecipeContent(this.fileContent); + return { success: true, error: null }; + } catch (error) { + return { success: false, error: error instanceof Error ? error : new Error(String(error)) }; + } + } + + static isTargetedByRulesyncCommand(rulesyncCommand: RulesyncCommand): boolean { + return this.isTargetedByRulesyncCommandDefault({ + rulesyncCommand, + toolTarget: "goose", + }); + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolCommandForDeletionParams): GooseCommand { + // Minimal valid recipe YAML so the constructor's parser succeeds. + const placeholder = dump( + { version: RECIPE_VERSION, title: "", description: "", prompt: "" }, + { lineWidth: -1, noRefs: true }, + ); + return new GooseCommand({ + outputRoot, + relativeDirPath, + relativeFilePath, + fileContent: placeholder, + validate: false, + }); + } +} diff --git a/src/features/subagents/goose-subagent.test.ts b/src/features/subagents/goose-subagent.test.ts new file mode 100644 index 000000000..d4a820ac9 --- /dev/null +++ b/src/features/subagents/goose-subagent.test.ts @@ -0,0 +1,147 @@ +import { join } from "node:path"; + +import { load } from "js-yaml"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { setupTestDirectory } from "../../test-utils/test-directories.js"; +import { writeFileContent } from "../../utils/file.js"; +import { GooseSubagent } from "./goose-subagent.js"; +import { RulesyncSubagent } from "./rulesync-subagent.js"; + +const buildRulesyncSubagent = (overrides?: { + body?: string; + name?: string; + description?: string; + goose?: Record; +}): RulesyncSubagent => + new RulesyncSubagent({ + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: `${overrides?.name ?? "planner"}.md`, + frontmatter: { + targets: ["*"], + name: overrides?.name ?? "planner", + description: overrides?.description ?? "Plans tasks", + ...(overrides?.goose ? { goose: overrides.goose } : {}), + }, + body: overrides?.body ?? "Break down tasks into steps.", + }); + +describe("GooseSubagent", () => { + let testDir: string; + let cleanup: () => Promise; + + beforeEach(async () => { + ({ testDir, cleanup } = await setupTestDirectory()); + vi.spyOn(process, "cwd").mockReturnValue(testDir); + }); + + afterEach(async () => { + await cleanup(); + vi.restoreAllMocks(); + }); + + describe("getSettablePaths", () => { + it("returns the project recipes/subagents dir", () => { + expect(GooseSubagent.getSettablePaths().relativeDirPath).toBe( + join(".goose", "recipes", "subagents"), + ); + }); + + it("returns the global recipes/subagents dir", () => { + expect(GooseSubagent.getSettablePaths({ global: true }).relativeDirPath).toBe( + join(".config", "goose", "recipes", "subagents"), + ); + }); + }); + + describe("fromRulesyncSubagent", () => { + it("emits a valid recipe with instructions as the body field", () => { + const subagent = GooseSubagent.fromRulesyncSubagent({ + relativeDirPath: GooseSubagent.getSettablePaths().relativeDirPath, + rulesyncSubagent: buildRulesyncSubagent(), + }); + + expect(subagent.getRelativeFilePath()).toBe("planner.yaml"); + expect(subagent.getRelativeDirPath()).toBe(join(".goose", "recipes", "subagents")); + const recipe = load(subagent.getFileContent()) as Record; + expect(recipe.version).toBe("1.0.0"); + expect(recipe.title).toBe("planner"); + expect(recipe.description).toBe("Plans tasks"); + expect(recipe.instructions).toBe("Break down tasks into steps."); + }); + + it("writes to the global subagents dir when global is set", () => { + const subagent = GooseSubagent.fromRulesyncSubagent({ + relativeDirPath: GooseSubagent.getSettablePaths().relativeDirPath, + rulesyncSubagent: buildRulesyncSubagent(), + global: true, + }); + expect(subagent.getRelativeDirPath()).toBe(join(".config", "goose", "recipes", "subagents")); + }); + }); + + describe("toRulesyncSubagent", () => { + it("round-trips instructions as the body and name/description", () => { + const subagent = GooseSubagent.fromRulesyncSubagent({ + relativeDirPath: GooseSubagent.getSettablePaths().relativeDirPath, + rulesyncSubagent: buildRulesyncSubagent(), + }); + const rulesync = subagent.toRulesyncSubagent(); + expect(rulesync.getRelativeFilePath()).toBe("planner.md"); + expect(rulesync.getBody()).toBe("Break down tasks into steps."); + const fm = rulesync.getFrontmatter(); + expect(fm.name).toBe("planner"); + expect(fm.description).toBe("Plans tasks"); + }); + }); + + describe("toRulesyncSubagent (prompt fallback)", () => { + it("uses prompt as the body without duplicating it into the goose section", async () => { + const yamlContent = [ + "version: 1.0.0", + "title: planner", + "description: Plans tasks", + "prompt: Break down tasks into steps.", + ].join("\n"); + await writeFileContent( + join(testDir, ".goose", "recipes", "subagents", "planner.yaml"), + yamlContent, + ); + + const subagent = await GooseSubagent.fromFile({ + outputRoot: testDir, + relativeFilePath: "planner.yaml", + }); + const rulesync = subagent.toRulesyncSubagent(); + expect(rulesync.getBody()).toBe("Break down tasks into steps."); + const goose = rulesync.getFrontmatter().goose as Record | undefined; + expect(goose?.prompt).toBeUndefined(); + }); + }); + + describe("fromFile", () => { + it("loads a sub-recipe file from disk", async () => { + const yamlContent = + "version: 1.0.0\ntitle: planner\ndescription: Plans\ninstructions: Steps\n"; + await writeFileContent( + join(testDir, ".goose", "recipes", "subagents", "planner.yaml"), + yamlContent, + ); + + const subagent = await GooseSubagent.fromFile({ + outputRoot: testDir, + relativeFilePath: "planner.yaml", + }); + + expect(subagent).toBeInstanceOf(GooseSubagent); + expect(subagent.getBody()).toBe("Steps"); + }); + }); + + describe("isTargetedByRulesyncSubagent", () => { + it("returns true for wildcard and goose targets", () => { + expect(GooseSubagent.isTargetedByRulesyncSubagent(buildRulesyncSubagent())).toBe(true); + }); + }); +}); diff --git a/src/features/subagents/goose-subagent.ts b/src/features/subagents/goose-subagent.ts new file mode 100644 index 000000000..697591b45 --- /dev/null +++ b/src/features/subagents/goose-subagent.ts @@ -0,0 +1,244 @@ +import { basename, join } from "node:path"; + +import { dump, load } from "js-yaml"; +import { z } from "zod/mini"; + +import { + GOOSE_GLOBAL_RECIPES_SUBAGENTS_DIR_PATH, + GOOSE_RECIPES_SUBAGENTS_DIR_PATH, +} from "../../constants/goose-paths.js"; +import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js"; +import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; +import { formatError } from "../../utils/error.js"; +import { readFileContent } from "../../utils/file.js"; +import { RulesyncSubagent, RulesyncSubagentFrontmatter } from "./rulesync-subagent.js"; +import { + ToolSubagent, + ToolSubagentForDeletionParams, + ToolSubagentFromFileParams, + ToolSubagentFromRulesyncSubagentParams, + ToolSubagentSettablePaths, +} from "./tool-subagent.js"; + +const RECIPE_VERSION = "1.0.0"; + +/** + * Goose sub-recipes are ordinary recipe files referenced from a parent recipe's + * `sub_recipes` list to run a specialized task. rulesync maps a subagent to such + * a recipe whose `instructions` is the subagent body, written under + * `.goose/recipes/subagents/` (project) and `~/.config/goose/recipes/subagents/` + * (global). Keeping them in a subdirectory makes the command-recipe and + * subagent-recipe file sets disjoint so import/orphan-deletion never overlap. + * + * The whole file is a YAML recipe mapping. Beyond the canonical + * `version`/`title`/`description`/`instructions`, any extra recipe field + * (`parameters`, `extensions`, `sub_recipes`, …) round-trips through the + * rulesync `goose` subagent section. + * + * @see https://block.github.io/goose/docs/guides/recipes/sub-recipes/ + */ +export const GooseSubagentRecipeSchema = z.looseObject({ + version: z.optional(z.string()), + title: z.optional(z.string()), + description: z.optional(z.string()), + instructions: z.optional(z.string()), + prompt: z.optional(z.string()), +}); + +export type GooseSubagentRecipe = z.infer; + +export type GooseSubagentParams = { + recipe: GooseSubagentRecipe; +} & AiFileParams; + +export class GooseSubagent extends ToolSubagent { + private readonly recipe: GooseSubagentRecipe; + + constructor({ recipe, ...rest }: GooseSubagentParams) { + if (rest.validate !== false) { + const result = GooseSubagentRecipeSchema.safeParse(recipe); + if (!result.success) { + throw new Error( + `Invalid Goose recipe in ${join(rest.relativeDirPath, rest.relativeFilePath)}: ${formatError(result.error)}`, + ); + } + } + super({ ...rest }); + this.recipe = recipe; + } + + static getSettablePaths({ + global = false, + }: { global?: boolean } = {}): ToolSubagentSettablePaths { + return { + relativeDirPath: global + ? GOOSE_GLOBAL_RECIPES_SUBAGENTS_DIR_PATH + : GOOSE_RECIPES_SUBAGENTS_DIR_PATH, + }; + } + + getBody(): string { + return this.recipe.instructions ?? this.recipe.prompt ?? ""; + } + + getRecipe(): GooseSubagentRecipe { + return this.recipe; + } + + toRulesyncSubagent(): RulesyncSubagent { + // Both body fields (`instructions`, falling back to `prompt`) are excluded + // from the goose section so the body is never duplicated back into the + // recipe on regeneration. + const { + instructions: _instructions, + prompt: _prompt, + title, + description, + ...restFields + } = this.recipe; + + const gooseSection: Record = { ...restFields }; + + const rulesyncFrontmatter: RulesyncSubagentFrontmatter = { + targets: ["*"] as const, + name: title ?? basename(this.getRelativeFilePath()).replace(/\.ya?ml$/, ""), + description: description ?? "", + ...(Object.keys(gooseSection).length > 0 && { goose: gooseSection }), + }; + + return new RulesyncSubagent({ + outputRoot: ".", + frontmatter: rulesyncFrontmatter, + body: this.getBody(), + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: this.getRelativeFilePath().replace(/\.ya?ml$/, ".md"), + validate: true, + }); + } + + static fromRulesyncSubagent({ + outputRoot = process.cwd(), + rulesyncSubagent, + validate = true, + global = false, + }: ToolSubagentFromRulesyncSubagentParams): GooseSubagent { + const rulesyncFrontmatter = rulesyncSubagent.getFrontmatter(); + const gooseSection: Record = { + ...this.filterToolSpecificSection(rulesyncFrontmatter.goose ?? {}, ["name", "description"]), + }; + + const relativeFilePath = rulesyncSubagent.getRelativeFilePath().replace(/\.md$/, ".yaml"); + const title = + typeof gooseSection.title === "string" + ? gooseSection.title + : rulesyncFrontmatter.name || basename(relativeFilePath).replace(/\.ya?ml$/, ""); + const description = + typeof gooseSection.description === "string" + ? gooseSection.description + : (rulesyncFrontmatter.description ?? title); + const version = + typeof gooseSection.version === "string" ? gooseSection.version : RECIPE_VERSION; + const instructions = + typeof gooseSection.instructions === "string" + ? gooseSection.instructions + : rulesyncSubagent.getBody(); + + const { + title: _t, + description: _d, + version: _v, + instructions: _i, + ...extraFields + } = gooseSection; + const recipe: GooseSubagentRecipe = { + version, + title, + description, + instructions, + ...extraFields, + }; + + const paths = this.getSettablePaths({ global }); + + return new GooseSubagent({ + outputRoot, + recipe, + relativeDirPath: paths.relativeDirPath, + relativeFilePath, + fileContent: dump(recipe, { lineWidth: -1, noRefs: true }), + validate, + global, + }); + } + + validate(): ValidationResult { + const result = GooseSubagentRecipeSchema.safeParse(this.recipe); + if (result.success) { + return { success: true, error: null }; + } + return { + success: false, + error: new Error( + `Invalid Goose recipe in ${join(this.relativeDirPath, this.relativeFilePath)}: ${formatError(result.error)}`, + ), + }; + } + + static isTargetedByRulesyncSubagent(rulesyncSubagent: RulesyncSubagent): boolean { + return this.isTargetedByRulesyncSubagentDefault({ + rulesyncSubagent, + toolTarget: "goose", + }); + } + + static async fromFile({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + validate = true, + global = false, + }: ToolSubagentFromFileParams): Promise { + const dirPath = relativeDirPath ?? this.getSettablePaths({ global }).relativeDirPath; + const filePath = join(outputRoot, dirPath, relativeFilePath); + const fileContent = await readFileContent(filePath); + + let parsed: unknown; + try { + parsed = load(fileContent); + } catch (error) { + throw new Error(`Failed to parse Goose recipe (${filePath}): ${formatError(error)}`, { + cause: error, + }); + } + const candidate = parsed === undefined || parsed === null ? {} : parsed; + const result = GooseSubagentRecipeSchema.safeParse(candidate); + if (!result.success) { + throw new Error(`Invalid Goose recipe in ${filePath}: ${formatError(result.error)}`); + } + + return new GooseSubagent({ + outputRoot, + recipe: result.data, + relativeDirPath: dirPath, + relativeFilePath, + fileContent, + validate, + global, + }); + } + + static forDeletion({ + outputRoot = process.cwd(), + relativeDirPath, + relativeFilePath, + }: ToolSubagentForDeletionParams): GooseSubagent { + return new GooseSubagent({ + outputRoot, + recipe: { version: RECIPE_VERSION, title: "", description: "", instructions: "" }, + relativeDirPath, + relativeFilePath, + fileContent: "", + validate: false, + }); + } +} diff --git a/src/features/subagents/subagents-processor.test.ts b/src/features/subagents/subagents-processor.test.ts index cbb5dcf6a..3c05f8117 100644 --- a/src/features/subagents/subagents-processor.test.ts +++ b/src/features/subagents/subagents-processor.test.ts @@ -1034,6 +1034,7 @@ Second global content`; "deepagents", "factorydroid", "geminicli", + "goose", "junie", "kilo", "opencode", @@ -1081,6 +1082,7 @@ Second global content`; "deepagents", "factorydroid", "geminicli", + "goose", "junie", "kilo", "kiro", diff --git a/src/features/subagents/subagents-processor.ts b/src/features/subagents/subagents-processor.ts index 529d3e5b2..8f413f422 100644 --- a/src/features/subagents/subagents-processor.ts +++ b/src/features/subagents/subagents-processor.ts @@ -20,6 +20,7 @@ import { CursorSubagent } from "./cursor-subagent.js"; import { DeepagentsSubagent } from "./deepagents-subagent.js"; import { FactorydroidSubagent } from "./factorydroid-subagent.js"; import { GeminiCliSubagent } from "./geminicli-subagent.js"; +import { GooseSubagent } from "./goose-subagent.js"; import { JunieSubagent } from "./junie-subagent.js"; import { KiloSubagent } from "./kilo-subagent.js"; import { KiroSubagent } from "./kiro-subagent.js"; @@ -78,6 +79,7 @@ const subagentsProcessorToolTargetTuple = [ "deepagents", "factorydroid", "geminicli", + "goose", "junie", "kiro", "opencode", @@ -204,6 +206,13 @@ export const toolSubagentFactories = new Map