Skip to content

Commit 02db29b

Browse files
cm-dyoshikawaclaude
andcommitted
feat(goose): map commands to recipes and subagents to sub-recipes
Goose recipes are reusable YAML workflow files discovered in .goose/recipes/ (project) and ~/.config/goose/recipes/ (global). Add two adapters: - GooseCommand: commands -> top-level recipes (.goose/recipes/<name>.yaml). The command body becomes the recipe `prompt`. - GooseSubagent: subagents -> sub-recipes (.goose/recipes/subagents/<name>.yaml), referenced from a parent recipe's `sub_recipes` by relative path. The subagent body becomes the recipe `instructions`. Recipes require version/title/description plus instructions|prompt; title defaults to the file name and description to the rulesync description (falling back to title), version to 1.0.0. Any other recipe field (parameters, extensions, sub_recipes, …) round-trips through the rulesync `goose` section. Subagents live in the `subagents/` subdirectory so the command-recipe and subagent-recipe file sets stay disjoint (the command importer scans .goose/recipes/*.yaml non-recursively), preventing import/orphan-deletion cross-over. Register goose in the commands and subagents processors (both project + global), add gitignore entries, README/docs matrix updates, and unit + E2E happy-path coverage. Refs #1823 (Goose recipes/sub-recipes; skills still tracked by #1115) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent db39505 commit 02db29b

18 files changed

Lines changed: 871 additions & 4 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,8 @@ rulesync.local.jsonc
285285
**/.goosehints
286286
**/.goose/
287287
**/.gooseignore
288+
**/.goose/recipes/
289+
**/.goose/recipes/subagents/
288290
**/.agents/plugins/
289291
**/.github/copilot-instructions.md
290292
**/.github/instructions/

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ The tables below show whether each tool supports a given feature (✅ = supporte
8080
| Claude Code |||||||||
8181
| Codex CLI || |||||||
8282
| Gemini CLI ⚠️ |||||||||
83-
| Goose |||| | | || |
83+
| Goose |||| | | || |
8484
| GitHub Copilot || |||||| |
8585
| GitHub Copilot CLI || || |||| |
8686
| Cursor |||||||||

docs/reference/file-formats.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,15 @@ env_vars = ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"]
571571

572572
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/).
573573

574+
### Goose-specific: commands and subagents as recipes
575+
576+
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:
577+
578+
- **commands → top-level recipes** at `.goose/recipes/<name>.yaml` (project) and `~/.config/goose/recipes/<name>.yaml` (global). The command body becomes the recipe `prompt`.
579+
- **subagents → sub-recipes** at `.goose/recipes/subagents/<name>.yaml` (project) and `~/.config/goose/recipes/subagents/<name>.yaml` (global), referenced from a parent recipe's `sub_recipes` list by relative `path`. The subagent body becomes the recipe `instructions`.
580+
581+
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.
582+
574583
## `.rulesync/.aiignore` or `.rulesyncignore`
575584

576585
Rulesync supports a single ignore list that can live in either location below:

docs/reference/supported-tools.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod
1212
| Gemini CLI ⚠️ | geminicli | ✅ 🌏 || ✅ 🌏 | ✅ 🌏 || ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
1313
| GitHub Copilot | copilot | ✅ 🌏 | |||||| |
1414
| GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
15-
| Goose | goose | ✅ 🌏 || 🌏 | | | | ✅ 🌏 | |
15+
| Goose | goose | ✅ 🌏 || 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | |
1616
| Cursor | cursor ||| ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
1717
| deepagents-cli | deepagents | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | 🌏 | |
1818
| Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |

skills/rulesync/file-formats.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,15 @@ env_vars = ["OPENAI_API_KEY", "OPENROUTER_API_KEY", "GEMINI_API_KEY"]
571571

572572
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/).
573573

574+
### Goose-specific: commands and subagents as recipes
575+
576+
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:
577+
578+
- **commands → top-level recipes** at `.goose/recipes/<name>.yaml` (project) and `~/.config/goose/recipes/<name>.yaml` (global). The command body becomes the recipe `prompt`.
579+
- **subagents → sub-recipes** at `.goose/recipes/subagents/<name>.yaml` (project) and `~/.config/goose/recipes/subagents/<name>.yaml` (global), referenced from a parent recipe's `sub_recipes` list by relative `path`. The subagent body becomes the recipe `instructions`.
580+
581+
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.
582+
574583
## `.rulesync/.aiignore` or `.rulesyncignore`
575584

576585
Rulesync supports a single ignore list that can live in either location below:

skills/rulesync/supported-tools.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod
1212
| Gemini CLI ⚠️ | geminicli | ✅ 🌏 || ✅ 🌏 | ✅ 🌏 || ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
1313
| GitHub Copilot | copilot | ✅ 🌏 | |||||| |
1414
| GitHub Copilot CLI | copilotcli | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
15-
| Goose | goose | ✅ 🌏 || 🌏 | | | | ✅ 🌏 | |
15+
| Goose | goose | ✅ 🌏 || 🌏 | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | |
1616
| Cursor | cursor ||| ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
1717
| deepagents-cli | deepagents | ✅ 🌏 | | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | 🌏 | |
1818
| Factory Droid | factorydroid | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |

src/cli/commands/gitignore-entries.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,12 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray<GitignoreEntryTag> = [
235235
{ target: "goose", feature: "rules", entry: "**/.goosehints" },
236236
{ target: "goose", feature: "rules", entry: "**/.goose/" },
237237
{ target: "goose", feature: "ignore", entry: "**/.gooseignore" },
238+
// Goose recipes: commands map to top-level recipes (`.goose/recipes/*.yaml`)
239+
// and subagents to sub-recipes (`.goose/recipes/subagents/*.yaml`). These are
240+
// already covered by the broad `**/.goose/` rules entry above, but tagging
241+
// them per-feature keeps coverage correct under feature-filtered gitignore.
242+
{ target: "goose", feature: "commands", entry: "**/.goose/recipes/" },
243+
{ target: "goose", feature: "subagents", entry: "**/.goose/recipes/subagents/" },
238244
// Goose lifecycle hooks plugin (.agents/plugins/<name>/hooks/hooks.json)
239245
{ target: "goose", feature: "hooks", entry: "**/.agents/plugins/" },
240246
// Goose reads MCP "extensions" only from the global user config

src/constants/goose-paths.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,18 @@ export const GOOSE_IGNORE_FILE_NAME = ".gooseignore";
77
export const GOOSE_MCP_FILE_NAME = "config.yaml";
88
export const GOOSE_HOOKS_DIR_PATH = join(".agents", "plugins", "rulesync", "hooks");
99
export const GOOSE_HOOKS_FILE_NAME = "hooks.json";
10+
11+
// Recipes are reusable YAML workflow files. Goose discovers project recipes in
12+
// `./.goose/recipes/` and global recipes in `~/.config/goose/recipes/`.
13+
// rulesync maps commands → top-level recipes (here) and subagents → sub-recipe
14+
// files under the `subagents/` subdirectory (referenced from a parent recipe via
15+
// a relative `path`). Keeping subagents in a subdirectory makes the command and
16+
// subagent file sets disjoint so import/orphan-deletion never cross over.
17+
// @see https://block.github.io/goose/docs/guides/recipes/recipe-reference/
18+
export const GOOSE_RECIPES_DIR_PATH = join(GOOSE_DIR, "recipes");
19+
export const GOOSE_GLOBAL_RECIPES_DIR_PATH = join(GOOSE_GLOBAL_DIR, "recipes");
20+
export const GOOSE_RECIPES_SUBAGENTS_DIR_PATH = join(GOOSE_RECIPES_DIR_PATH, "subagents");
21+
export const GOOSE_GLOBAL_RECIPES_SUBAGENTS_DIR_PATH = join(
22+
GOOSE_GLOBAL_RECIPES_DIR_PATH,
23+
"subagents",
24+
);

src/e2e/e2e-commands.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ describe("E2E: commands", () => {
3232
{ target: "pi", outputPath: join(".pi", "prompts", "review-pr.md") },
3333
{ target: "devin", outputPath: join(".devin", "workflows", "review-pr.md") },
3434
{ target: "factorydroid", outputPath: join(".factory", "commands", "review-pr.md") },
35+
{ target: "goose", outputPath: join(".goose", "recipes", "review-pr.yaml") },
3536
])("should generate $target commands", async ({ target, outputPath }) => {
3637
const testDir = getTestDir();
3738

@@ -100,6 +101,7 @@ Check the PR diff and provide feedback.
100101
{ target: "pi", orphanPath: join(".pi", "prompts", "orphan.md") },
101102
{ target: "devin", orphanPath: join(".devin", "workflows", "orphan.md") },
102103
{ target: "factorydroid", orphanPath: join(".factory", "commands", "orphan.md") },
104+
{ target: "goose", orphanPath: join(".goose", "recipes", "orphan.yaml") },
103105
])(
104106
"should fail in check mode when delete would remove an orphan $target command file",
105107
async ({ target, orphanPath }) => {
@@ -160,6 +162,25 @@ describe("E2E: commands (import)", () => {
160162
);
161163
expect(importedContent).toContain("Review the PR diff and provide feedback.");
162164
});
165+
166+
it("should import goose commands (recipe YAML)", async () => {
167+
const testDir = getTestDir();
168+
169+
const recipeContent = [
170+
"version: 1.0.0",
171+
"title: review-pr",
172+
"description: Review a pull request",
173+
"prompt: Review the PR diff and provide feedback.",
174+
].join("\n");
175+
await writeFileContent(join(testDir, ".goose", "recipes", "review-pr.yaml"), recipeContent);
176+
177+
await runImport({ target: "goose", features: "commands" });
178+
179+
const importedContent = await readFileContent(
180+
join(testDir, RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "review-pr.md"),
181+
);
182+
expect(importedContent).toContain("Review the PR diff and provide feedback.");
183+
});
163184
});
164185

165186
describe("E2E: commands (global mode)", () => {
@@ -189,6 +210,7 @@ describe("E2E: commands (global mode)", () => {
189210
outputPath: join(".codeium", "windsurf", "global_workflows", "review-pr.md"),
190211
},
191212
{ target: "factorydroid", outputPath: join(".factory", "commands", "review-pr.md") },
213+
{ target: "goose", outputPath: join(".config", "goose", "recipes", "review-pr.yaml") },
192214
])("should generate $target commands in home directory", async ({ target, outputPath }) => {
193215
const projectDir = getProjectDir();
194216
const homeDir = getHomeDir();

src/e2e/e2e-subagents.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ describe("E2E: subagents", () => {
7171
target: "vibe",
7272
outputPath: join(".vibe", "agents", "planner.toml"),
7373
},
74+
{
75+
target: "goose",
76+
outputPath: join(".goose", "recipes", "subagents", "planner.yaml"),
77+
},
7478
])("should generate $target subagents", async ({ target, outputPath }) => {
7579
const testDir = getTestDir();
7680

@@ -222,6 +226,7 @@ You are a subagent-only helper.
222226
{ target: "factorydroid", orphanPath: join(".factory", "droids", "orphan.md") },
223227
{ target: "cline", orphanPath: join(".cline", "agents", "orphan.yaml") },
224228
{ target: "vibe", orphanPath: join(".vibe", "agents", "orphan.toml") },
229+
{ target: "goose", orphanPath: join(".goose", "recipes", "subagents", "orphan.yaml") },
225230
])(
226231
"should fail in check mode when delete would remove an orphan $target subagent file",
227232
async ({ target, orphanPath }) => {
@@ -306,6 +311,28 @@ Break down tasks into steps.
306311
expect(importedContent).toContain("planner");
307312
});
308313

314+
it("should import goose subagents (sub-recipe YAML)", async () => {
315+
const testDir = getTestDir();
316+
317+
const recipeContent = [
318+
"version: 1.0.0",
319+
"title: planner",
320+
"description: Plans tasks",
321+
"instructions: Break down tasks into steps.",
322+
].join("\n");
323+
await writeFileContent(
324+
join(testDir, ".goose", "recipes", "subagents", "planner.yaml"),
325+
recipeContent,
326+
);
327+
328+
await runImport({ target: "goose", features: "subagents" });
329+
330+
const importedContent = await readFileContent(
331+
join(testDir, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, "planner.md"),
332+
);
333+
expect(importedContent).toContain("planner");
334+
});
335+
309336
it("should import kiro subagents (JSON format)", async () => {
310337
const testDir = getTestDir();
311338

@@ -366,6 +393,10 @@ describe("E2E: subagents (global mode)", () => {
366393
outputPath: join(".deepagents", "deepagents", "agents", "planner", "AGENTS.md"),
367394
},
368395
{ target: "vibe", outputPath: join(".vibe", "agents", "planner.toml") },
396+
{
397+
target: "goose",
398+
outputPath: join(".config", "goose", "recipes", "subagents", "planner.yaml"),
399+
},
369400
])("should generate $target subagents in home directory", async ({ target, outputPath }) => {
370401
const projectDir = getProjectDir();
371402
const homeDir = getHomeDir();

0 commit comments

Comments
 (0)