Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,8 @@ rulesync.local.jsonc
**/.goosehints
**/.goose/
**/.gooseignore
**/.goose/recipes/
**/.goose/recipes/subagents/
**/.agents/plugins/
**/.github/copilot-instructions.md
**/.github/instructions/
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Expand Down
9 changes: 9 additions & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.yaml` (project) and `~/.config/goose/recipes/<name>.yaml` (global). The command body becomes the recipe `prompt`.
- **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`.

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:
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
Expand Down
9 changes: 9 additions & 0 deletions skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.yaml` (project) and `~/.config/goose/recipes/<name>.yaml` (global). The command body becomes the recipe `prompt`.
- **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`.

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:
Expand Down
2 changes: 1 addition & 1 deletion skills/rulesync/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
Expand Down
6 changes: 6 additions & 0 deletions src/cli/commands/gitignore-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,12 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray<GitignoreEntryTag> = [
{ 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/<name>/hooks/hooks.json)
{ target: "goose", feature: "hooks", entry: "**/.agents/plugins/" },
// Goose reads MCP "extensions" only from the global user config
Expand Down
15 changes: 15 additions & 0 deletions src/constants/goose-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
22 changes: 22 additions & 0 deletions src/e2e/e2e-commands.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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)", () => {
Expand Down Expand Up @@ -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();
Expand Down
31 changes: 31 additions & 0 deletions src/e2e/e2e-subagents.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions src/features/commands/commands-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1144,6 +1144,7 @@ describe("CommandsProcessor", () => {
"cursor",
"factorydroid",
"geminicli",
"goose",
"junie",
"kilo",
"kiro",
Expand Down Expand Up @@ -1171,6 +1172,7 @@ describe("CommandsProcessor", () => {
"cursor",
"factorydroid",
"geminicli",
"goose",
"junie",
"kilo",
"kiro",
Expand All @@ -1197,6 +1199,7 @@ describe("CommandsProcessor", () => {
"cursor",
"factorydroid",
"geminicli",
"goose",
"junie",
"codexcli",
"kilo",
Expand Down
20 changes: 19 additions & 1 deletion src/features/commands/commands-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -80,6 +81,7 @@ const commandsProcessorToolTargetTuple = [
"cursor",
"factorydroid",
"geminicli",
"goose",
"junie",
"kilo",
"kiro",
Expand Down Expand Up @@ -259,6 +261,22 @@ export const toolCommandFactories = new Map<CommandsProcessorToolTarget, ToolCom
},
},
],
[
"goose",
{
class: GooseCommand,
meta: {
extension: "yaml",
supportsProject: true,
supportsGlobal: true,
isSimulated: false,
// Non-recursive: project recipes live flat in `.goose/recipes/`, while
// subagent sub-recipes live in `.goose/recipes/subagents/` and must not
// be picked up by the command importer.
supportsSubdirectory: false,
},
},
],
[
"junie",
{
Expand Down
Loading