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
4 changes: 4 additions & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,10 @@ See the [Goose extensions docs](https://block.github.io/goose/docs/getting-start

Goose [recipes](https://block.github.io/goose/docs/guides/recipes/recipe-reference/) are reusable YAML workflow files. **Commands** map to top-level recipes at `.goose/recipes/<name>.yaml` (project) and `~/.config/goose/recipes/<name>.yaml` (global); the command body becomes the recipe `prompt`, `title` defaults to the file name and `description` to the rulesync `description` (falling back to `title`), `version` defaults to `1.0.0`, and any other recipe field round-trips through the rulesync `goose` section of a command.

A recipe on disk is not invocable as `/name` on its own: Goose resolves slash commands from the `slash_commands` list in the user config (`~/.config/goose/config.yaml`), whose entries are `{ command, recipe_path }` pairs. In **global mode** Rulesync therefore registers every generated recipe there. `recipe_path` is written as an **absolute** path, because Goose resolves it with a bare `PathBuf::from(...)` on this code path (the tilde expansion used by `goose run --recipe` does not apply, so a `~/…` registration would never resolve), and the command name is lowercased, because Goose lowercases the typed command and compares it against the stored value verbatim. There is no project-level registration surface upstream, so project-scope recipes must still be run with `goose run --recipe`.

The list is co-owned: entries whose `recipe_path` points outside `~/.config/goose/recipes/` — and sub-recipes under `recipes/subagents/` — are carried over untouched, while **every** entry pointing directly into that directory is Rulesync-owned and recomputed on each `--global` generate. That retracts a deleted command's registration and drops the key once nothing is registered, but it also means a slash command you registered yourself (via Goose's own UI or `goose recipe`) for a recipe living in that directory is removed on the next generate — keep such recipes elsewhere, or author them in `.rulesync/commands/`. Command names must be unique, contain no spaces, and must not shadow a built-in command such as `/recipe`, `/compact`, or `/help`; Rulesync does not check the built-in names for you. See the [slash-command mapping in the Goose source](https://github.com/block/goose/blob/main/crates/goose/src/slash_commands/recipe_slash_command.rs).

**Subagents** map to Goose's [custom agents](https://block.github.io/goose/docs/guides/context-engineering/custom-agents/) (v1.34.0+): Markdown files with `name` (required) / `description` / `model` frontmatter whose body is the agent instructions, invocable via `@name` or delegation. They are emitted to the goose-specific discovery dirs `.goose/agents/<name>.md` (project) and `~/.config/goose/agents/<name>.md` (global), so the output cannot collide with a future shared `.agents/agents/` target; `model` and unknown future fields round-trip through the rulesync `goose` subagent section. Earlier rulesync versions emitted subagents as sub-recipe YAML under `.goose/recipes/subagents/` — a location Goose's agent discovery never scans, so those files were inert; they are no longer generated (stale outputs stay gitignored but are not cleaned up automatically).

### Vibe-specific: stdio `cwd` and MCP `[auth]` block
Expand Down
91 changes: 90 additions & 1 deletion src/features/commands/commands-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { afterEach, beforeEach, describe, expect, it, MockedFunction, vi } from
import { RULESYNC_COMMANDS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js";
import { createMockLogger } from "../../test-utils/mock-logger.js";
import { setupTestDirectory } from "../../test-utils/test-directories.js";
import { ensureDir, findFilesByGlobs, writeFileContent } from "../../utils/file.js";
import {
ensureDir,
findFilesByGlobs,
readFileContent,
writeFileContent,
} from "../../utils/file.js";
import { ClaudecodeCommand } from "./claudecode-command.js";
import { ClineCommand } from "./cline-command.js";
import { CommandsProcessor, CommandsProcessorToolTarget } from "./commands-processor.js";
Expand Down Expand Up @@ -1499,3 +1504,87 @@ describe("CommandsProcessor secondary import roots", () => {
]);
});
});

describe("CommandsProcessor Goose slash-command retraction", () => {
let testDir: string;
let cleanup: () => Promise<void>;

beforeEach(async () => {
({ testDir, cleanup } = await setupTestDirectory());
vi.spyOn(process, "cwd").mockReturnValue(testDir);
});

afterEach(async () => {
await cleanup();
vi.restoreAllMocks();
});

const writeConfig = async (): Promise<string> => {
const configPath = join(testDir, ".config", "goose", "config.yaml");
await writeFileContent(
configPath,
[
"GOOSE_PROVIDER: openai",
"slash_commands:",
" - command: removed",
` recipe_path: ${join(testDir, ".config", "goose", "recipes", "removed.yaml")}`,
" - command: mine",
" recipe_path: ~/my-recipes/mine.yaml",
"",
].join("\n"),
);
return configPath;
};

it("drops the managed registrations when no recipe is generated any more", async () => {
const configPath = await writeConfig();
const processor = new CommandsProcessor({
outputRoot: testDir,
toolTarget: "goose",
global: true,
logger: createMockLogger(),
});

await processor.removeOrphanAiFiles([], []);

expect(await readFileContent(configPath)).toContain("my-recipes/mine.yaml");
expect(await readFileContent(configPath)).not.toContain("removed.yaml");
});

it("does not rewrite a config that carries no managed registration", async () => {
const configPath = join(testDir, ".config", "goose", "config.yaml");
const before = [
"# my provider",
"GOOSE_PROVIDER: openai # inline",
"slash_commands:",
" - command: mine",
" recipe_path: ~/my-recipes/mine.yaml",
"",
].join("\n");
await writeFileContent(configPath, before);
const processor = new CommandsProcessor({
outputRoot: testDir,
toolTarget: "goose",
global: true,
logger: createMockLogger(),
});

await processor.removeOrphanAiFiles([], []);

expect(await readFileContent(configPath)).toBe(before);
});

it("leaves the config untouched in project scope", async () => {
const configPath = await writeConfig();
const before = await readFileContent(configPath);
const processor = new CommandsProcessor({
outputRoot: testDir,
toolTarget: "goose",
logger: createMockLogger(),
});

await processor.removeOrphanAiFiles([], []);

expect(await readFileContent(configPath)).toBe(before);
});
});
35 changes: 34 additions & 1 deletion src/features/commands/commands-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { basename, dirname, join, relative } from "node:path";

import { z } from "zod/mini";

import { GOOSE_GLOBAL_DIR, GOOSE_MCP_FILE_NAME } from "../../constants/goose-paths.js";
import {
HERMESAGENT_CONFIG_FILE_PATH,
HERMESAGENT_RULESYNC_COMMANDS_PLUGIN_OWNERSHIP_PATH,
Expand Down Expand Up @@ -36,7 +37,11 @@ import { CopilotCommand } from "./copilot-command.js";
import { CursorCommand } from "./cursor-command.js";
import { DevinCommand } from "./devin-command.js";
import { FactorydroidCommand } from "./factorydroid-command.js";
import { GooseCommand } from "./goose-command.js";
import {
getGooseSlashCommandsConfigContent,
GooseCommand,
hasManagedGooseSlashCommands,
} from "./goose-command.js";
import { GrokcliCommand } from "./grokcli-command.js";
import {
getDisabledHermesCommandsPluginConfigContent,
Expand Down Expand Up @@ -919,6 +924,8 @@ export class CommandsProcessor extends FeatureProcessor {
!generatedFiles.some((file) => file.getFilePath() === ownershipPath);
let changedCount = await super.removeOrphanAiFiles(existingFiles, generatedFiles);

changedCount += await this.retractGooseSlashCommands(generatedFiles);

if (!shouldDisableHermesCommandsPlugin) return changedCount;
const configPath = join(
this.outputRoot,
Expand All @@ -944,6 +951,32 @@ export class CommandsProcessor extends FeatureProcessor {
return changedCount;
}

/**
* Drop the `slash_commands` registrations when no Goose recipe is generated
* any more. `GooseCommand.getAuxiliaryFiles` handles every other case, but it
* is not reached when the whole feature has no source files left (`--delete`
* removes the recipes there), which would strand `/name` on a deleted recipe.
*/
private async retractGooseSlashCommands(generatedFiles: AiFile[]): Promise<number> {
if (this.toolTarget !== "goose" || !this.global) return 0;
if (generatedFiles.some((file) => file instanceof GooseCommand)) return 0;

const configPath = join(this.outputRoot, GOOSE_GLOBAL_DIR, GOOSE_MCP_FILE_NAME);
const currentContent = await readFileContentOrNull(configPath);
// Rewriting a config that holds no managed registration would reformat the
// user's file (dropping their comments) for no gain.
if (currentContent === null || !hasManagedGooseSlashCommands(currentContent)) return 0;
const nextContent = getGooseSlashCommandsConfigContent({ currentContent, entries: [] });
if (nextContent === currentContent) return 0;

if (this.dryRun) {
this.logger.info(`[DRY RUN] Would write: ${configPath}`);
} else {
await writeFileContent(configPath, nextContent);
}
return 1;
}

/**
* Implementation of abstract method from FeatureProcessor
* Return the tool targets that this processor supports
Expand Down
179 changes: 179 additions & 0 deletions src/features/commands/goose-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { RULESYNC_COMMANDS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js";
import { setupTestDirectory } from "../../test-utils/test-directories.js";
import type { ToolFile } from "../../types/tool-file.js";
import { writeFileContent } from "../../utils/file.js";
import { GooseCommand } from "./goose-command.js";
import { RulesyncCommand } from "./rulesync-command.js";

const configOf = (files: ToolFile[]): Record<string, unknown> =>
load(files[0]!.getFileContent()) as Record<string, unknown>;

const buildRulesyncCommand = (overrides?: {
body?: string;
description?: string;
Expand Down Expand Up @@ -203,4 +207,179 @@ describe("GooseCommand", () => {
expect(command.validate().success).toBe(true);
});
});

describe("getExtraSharedWritePaths", () => {
it("declares the user config only in global mode", () => {
expect(GooseCommand.getExtraSharedWritePaths({ global: true })).toEqual([
{ relativeDirPath: join(".config", "goose"), relativeFilePath: "config.yaml" },
]);
expect(GooseCommand.getExtraSharedWritePaths({ global: false })).toEqual([]);
});
});

describe("getAuxiliaryFiles", () => {
const globalCommand = (relativeFilePath: string): GooseCommand =>
GooseCommand.fromRulesyncCommand({
outputRoot: testDir,
rulesyncCommand: buildRulesyncCommand({ relativeFilePath }),
global: true,
});

const writeConfig = async (content: string): Promise<void> => {
await writeFileContent(join(testDir, ".config", "goose", "config.yaml"), content);
};

it("registers each generated recipe as a slash command", async () => {
const files = await GooseCommand.getAuxiliaryFiles({
toolCommands: [globalCommand("deploy.md"), globalCommand("review.md")],
outputRoot: testDir,
global: true,
});

expect(files).toHaveLength(1);
expect(files[0]!.getRelativePathFromCwd()).toBe(join(".config", "goose", "config.yaml"));
expect(configOf(files)).toEqual({
slash_commands: [
{
command: "deploy",
recipe_path: join(testDir, ".config", "goose", "recipes", "deploy.yaml"),
},
{
command: "review",
recipe_path: join(testDir, ".config", "goose", "recipes", "review.yaml"),
},
],
});
});

it("does not touch the user config in project scope", async () => {
expect(
await GooseCommand.getAuxiliaryFiles({
toolCommands: [globalCommand("deploy.md")],
outputRoot: testDir,
global: false,
}),
).toEqual([]);
});

it("keeps unrelated settings and slash commands pointing outside the managed directory", async () => {
await writeConfig(
[
"GOOSE_PROVIDER: openai",
"slash_commands:",
" - command: standup",
" recipe_path: ~/my-recipes/standup.yaml",
" - command: helper",
" recipe_path: ~/.config/goose/recipes/subagents/helper.yaml",
"",
].join("\n"),
);

const files = await GooseCommand.getAuxiliaryFiles({
toolCommands: [globalCommand("deploy.md")],
outputRoot: testDir,
global: true,
});

expect(configOf(files)).toEqual({
GOOSE_PROVIDER: "openai",
slash_commands: [
{ command: "standup", recipe_path: "~/my-recipes/standup.yaml" },
{ command: "helper", recipe_path: "~/.config/goose/recipes/subagents/helper.yaml" },
{
command: "deploy",
recipe_path: join(testDir, ".config", "goose", "recipes", "deploy.yaml"),
},
],
});
});

it("lowercases the command name, which Goose compares verbatim", async () => {
const files = await GooseCommand.getAuxiliaryFiles({
toolCommands: [globalCommand("Deploy.md")],
outputRoot: testDir,
global: true,
});

expect(configOf(files)).toEqual({
slash_commands: [
{
command: "deploy",
recipe_path: join(testDir, ".config", "goose", "recipes", "Deploy.yaml"),
},
],
});
});

it("replaces a stale registration of a recipe that is no longer generated", async () => {
await writeConfig(
[
"slash_commands:",
" - command: removed",
` recipe_path: ${join(testDir, ".config", "goose", "recipes", "removed.yaml")}`,
" - command: legacy",
// Written by an earlier rulesync version as a `~`-relative path.
" recipe_path: ~/.config/goose/recipes/legacy.yaml",
"",
].join("\n"),
);

const files = await GooseCommand.getAuxiliaryFiles({
toolCommands: [globalCommand("deploy.md")],
outputRoot: testDir,
global: true,
});

expect(configOf(files)).toEqual({
slash_commands: [
{
command: "deploy",
recipe_path: join(testDir, ".config", "goose", "recipes", "deploy.yaml"),
},
],
});
});

it("retracts the whole key when the last generated command is gone", async () => {
await writeConfig(
[
"GOOSE_PROVIDER: openai",
"slash_commands:",
" - command: removed",
" recipe_path: ~/.config/goose/recipes/removed.yaml",
"",
].join("\n"),
);

const files = await GooseCommand.getAuxiliaryFiles({
toolCommands: [],
outputRoot: testDir,
global: true,
});

expect(files).toHaveLength(1);
expect(configOf(files)).toEqual({ GOOSE_PROVIDER: "openai" });
});

it("does not create a config file when there is nothing to register or retract", async () => {
expect(
await GooseCommand.getAuxiliaryFiles({
toolCommands: [],
outputRoot: testDir,
global: true,
}),
).toEqual([]);
});

it("is never a deletion candidate", async () => {
expect(
await GooseCommand.getAuxiliaryFiles({
toolCommands: [globalCommand("deploy.md")],
outputRoot: testDir,
global: true,
forDeletion: true,
}),
).toEqual([]);
});
});
});
Loading
Loading