@phnx-labs/agents-cli 1.20.61 → 1.20.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.20.62
6
+
7
+ - **Wire Goose commands support (RUSH-1572).** `agents` now syncs slash commands to Goose as recipe YAML files under `~/.config/goose/commands/<name>.yaml`, each registered in `~/.config/goose/config.yaml` under a `slash_commands: [{ command, recipe_path }]` array (Goose has no native slash-command file format — a slash command IS a recipe). The command recipes live in a dir distinct from the workflow recipes dir (`~/.config/goose/recipes/`) so the workflow detector never treats a command recipe as a workflow. Registration is a read-modify-write that preserves every other `config.yaml` key (`mcp_servers`, `extensions`, …) and other `slash_commands` entries, and removal soft-deletes the recipe + unregisters the entry. Flip Goose's `commands` capability and add `goose` branches to install/list/match/remove, the staleness commands writer, and doctor-diff, backed by a new `goose-commands.ts` module + a `markdownToGooseRecipe` converter. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/goose-commands.ts`, `apps/cli/src/lib/commands.ts`, `apps/cli/src/lib/convert.ts`, `apps/cli/src/lib/staleness/writers/commands.ts`, `apps/cli/src/lib/doctor-diff.ts`.
8
+ - **Fix: Hermes plugin sync no longer disables a plugin the user explicitly enabled.** The plugin install path (RUSH-1688) unconditionally forced `plugins.enabled` to the exec-surface trust verdict on every sync. An ordinary un-flagged background re-sync computes `enable=false` for a plugin with hooks/tools, so it stripped that plugin from the `~/.hermes/config.yaml` allowlist — clobbering a plugin the user deliberately enabled with `--allow-exec-surfaces`. The install path now enables only when trusted and never down-toggles (matching the marketplace flow's add-if-trusted semantics); removal still unregisters explicitly. Source: `apps/cli/src/lib/plugins.ts`.
9
+ - **Wire Goose subagents support (RUSH-1573).** `agents` now syncs subagents to Goose as recipe YAML files under `~/.config/goose/agents/<name>.yaml` — Goose has no dedicated subagent format, so a named subagent IS a recipe (goose auto-discovers `~/.config/goose/agents/` and delegates to them by name in autonomous mode). `transformSubagentForGoose` emits the same recipe schema agents-cli already uses for Goose workflow recipes (`version`/`title`/`description`/`instructions`/`prompt`, plus optional `settings.goose_model`). Flip Goose's `subagents` capability and wire the install/remove/list/orphan/version-remove branches plus the staleness subagents writer and detector. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/subagents.ts`, `apps/cli/src/lib/staleness/{writers,detectors}/subagents.ts`.
10
+
11
+ - **Wire ForgeCode commands and subagents support (RUSH-1689, RUSH-1690).** `agents` now syncs slash commands to ForgeCode as Markdown files under `~/.forge/commands/<name>.md` (previously ForgeCode had `commands: false` and received commands only as skills), and named subagents as Markdown-with-frontmatter definitions under `~/.forge/agents/<name>.md` (same `color`-less shape as Droid/Copilot/Cursor, so `transformSubagentForForge` aliases `transformSubagentForDroid`). Flip ForgeCode's `commands`/`subagents` capabilities, set `commandsDir`, add the subagent transform plus install/remove/list/orphan/version-remove branches, and register the subagents writer + detector. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/subagents.ts`, `apps/cli/src/lib/staleness/{writers,detectors}/subagents.ts`.
12
+ - **Wire allowlist (permissions) support for OpenClaw (RUSH-1570).** OpenClaw gates at TOOL granularity only, so permission sync maps just **blanket** (whole-tool) rules into `~/.openclaw/openclaw.json` `tools.alsoAllow` (allow) / `tools.deny` (deny): `bash → exec`, `read → read`, `write`/`edit → write`, `webfetch → web_fetch`, `websearch → web_search`. Sub-command/path/domain rules (`Bash(git:*)`, `Write(secrets/**)`, `WebFetch(domain:x)`) have no tool-level equivalent and are skipped — coarse-mapping a specific deny to a whole tool would wrongly gate every use of that tool. The absolute `tools.allow` list is never touched, and all other keys (`mcp`, `exec`, `agents`, …) are preserved on read-modify-write. Flip OpenClaw's `allowlist: true`, add `convertToOpenClawFormat` + the `openclaw` branch in `applyPermissionsToVersion`, register the config path and staleness detector. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/permissions.ts`, `apps/cli/src/lib/resources/permissions.ts`, `apps/cli/src/lib/staleness/detectors/permissions.ts`.
13
+ - **Wire subagents support for Cursor CLI (RUSH-1388).** cursor-agent loads custom subagents as Markdown with YAML frontmatter under `~/.cursor/agents/*.md` (project-scoped `.cursor/agents/` also supported natively), same shape as Claude/Droid/Copilot minus the `color` field, gated at `>= 2026.1.22` (cursor-agent's CalVer build tag for Cursor 2.4). Flip Cursor's `subagents`, add `transformSubagentForCursor` (alias of `transformSubagentForDroid`), and wire the install/remove, list, orphan-detection, writer, and detector paths. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/subagents.ts`, `apps/cli/src/lib/staleness/{writers,detectors}/subagents.ts`.
14
+ - **Wire hooks support for Hermes (RUSH-1687).** `agents` now registers central hooks into Hermes Agent's `~/.hermes/config.yaml` under a `hooks:` block (YAML, ≥ 0.11.0). The registrar read-modify-writes that shared config so sibling keys like `mcp_servers` survive, maps canonical events to Hermes' snake_case lifecycle names (`SessionStart→on_session_start`, `SessionEnd→on_session_end`, `PreToolUse→pre_tool_call`, `PostToolUse→post_tool_call`, `SubagentStop→subagent_stop`, `UserPromptSubmit→pre_llm_call`, `Stop→on_session_finalize`), and clamps each hook's timeout to 300s (default 60s). Managed entries are re-synced idempotently while user-authored hooks are preserved. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/hooks.ts`, `apps/cli/src/lib/staleness/writers/hooks.ts`.
15
+
5
16
  ## 1.20.61
6
17
 
7
18
  - **Detect OpenCode sign-in state so `agents view` stops mislabeling a logged-in install as "not signed in."** `getAccountInfo` had no `opencode` case, so it fell through to `signedIn: false` and every row printed "(not signed in — run opencode to log in)" even with a live login. It now reads OpenCode's `auth.json` (`$XDG_DATA_HOME/opencode/auth.json`, defaulting to `~/.local/share/opencode/auth.json` on every platform — `xdg-basedir` does not special-case macOS), validates each provider entry against its `oauth`/`api`/`wellknown` credential shape, and reports the account as signed in with the non-secret provider ids surfaced as the account label (e.g. `id:muse-spark`). Credential secrets (`access`/`refresh`/`key`/`token`) are only inspected for presence — never read into any display or JSON output. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/agents.test.ts`.
@@ -16,6 +27,7 @@
16
27
  - **Wire Gemini permissions/allowlist support (RUSH-1567).** Gemini permission groups now sync Bash allow/deny rules into `.gemini/settings.json` as `tools.core` / `tools.exclude` entries with per-command `ShellTool(...)` patterns; non-Bash canonical permissions remain unsupported by Gemini's native tool grammar and are skipped. Flip `allowlist: true`, register the permission writer/detector through the capability table, and replace the dormant legacy `tools.allowed` serializer. Source: `apps/cli/src/lib/agents.ts`, `apps/cli/src/lib/permissions.ts`, `apps/cli/src/lib/resources/permissions.ts`, `apps/cli/src/lib/staleness/detectors/permissions.ts`.
17
28
  - **Fix: `agents run <agent>@<version> --host <host>` now forwards the version pin and most run flags to the remote host.** Previously the `--host` branch stripped `@version` and ignored `--strategy`, `--effort`, `--add-dir`, `--json`, `--verbose`, `--timeout`, `--yes`, and `--acp`, so the remote host applied its own defaults. The local CLI now parses `agent@version` verbatim, normalizes `--strategy`/`--balanced`, makes `--add-dir` paths remote-portable, and forwards all of these flags to the remote `agents run` invocation. `--add-dir` portability uses the same `~`/`$HOME` re-rooting that `--cwd` already uses, so a Linux remote resolves home paths against its own `/home/<user>`. Source: `apps/cli/src/lib/hosts/dispatch.ts`, `apps/cli/src/commands/exec.ts`, `apps/cli/src/lib/hosts/dispatch.test.ts`.
18
29
  - **Fix: routine edits no longer rewrite the whole YAML file, so `~/.agents` stays clean and `agents repo pull` can sync routines across the fleet.** `writeJob` previously re-emitted the entire document via `yaml.stringify` on every mutation (pause/resume, `routines devices --set`, add), restyling untouched scalars — unquoting `schedule`, re-wrapping the folded `prompt` block — which left the git-backed user repo perpetually dirty. That made cross-device `agents repo pull` refuse ("uncommitted changes"), so a `devices:` pin set on one machine never reached the others and `Devices: all` routines kept firing on every box. A new `serializeJob` edits only the changed keys via the YAML Document API, preserving byte-for-byte formatting of untouched nodes; new/unparseable/non-mapping files fall back to canonical stringify. Source: `apps/cli/src/lib/routines.ts`, `apps/cli/src/lib/__tests__/routines.serialize.test.ts`.
30
+ - **`agents sessions --active --json` now carries live plan progress (RUSH-1380).** The state engine parses the latest `TodoWrite` off the transcript tail into `ActiveSession.todos` (`{ items: [{ content, status, activeForm? }], done, total, activeForm }`), and the live preview verb reads `Plan N/M: <current step>` instead of a bare "TodoWrite". This lets consumers (the Factory Floor) show an N/M pill + checklist for every session — including remote / device-dispatched agents that have no local tool-call stream. Source: `apps/cli/src/lib/session/state.ts` (`extractTodoProgress`, `inferActivity`), `apps/cli/src/lib/session/active.ts` (`ActiveSession.todos`, `applyState`), `apps/cli/src/lib/session/parse.ts` (`summarizeToolUse`).
19
31
 
20
32
  ## 1.20.59
21
33
 
package/dist/bin/agents CHANGED
Binary file
@@ -1081,8 +1081,17 @@ export function registerRunCommand(program) {
1081
1081
  if (nativeResume(agent)) {
1082
1082
  resumeNative = true;
1083
1083
  resumeSessionId = session.id;
1084
+ // Native `--resume` (claude/codex) resolves the transcript relative to the
1085
+ // working directory (projects/<cwd-hash>/). The session may have been started
1086
+ // in a different directory than we're standing in now — most importantly when a
1087
+ // routine daemon fires `agents run --resume` from its own cwd. Spawn from the
1088
+ // session's ORIGIN cwd so the resume actually finds it; otherwise the agent
1089
+ // exits "No conversation found with session ID". Honor an explicit --cwd only if
1090
+ // the caller passed one (they're overriding on purpose).
1091
+ if (!options.cwd && session.cwd)
1092
+ options.cwd = session.cwd;
1084
1093
  if (!options.quiet)
1085
- process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (native)${version ? ` @${version}` : ''}\n`));
1094
+ process.stderr.write(chalk.gray(`Resuming ${agent} ${session.shortId} (native)${version ? ` @${version}` : ''}${!options.cwd || options.cwd === session.cwd ? ` in ${session.cwd ?? cwd}` : ''}\n`));
1086
1095
  }
1087
1096
  else {
1088
1097
  // Tier-2: launch fresh with a /continue <id> first message; the agent
@@ -291,7 +291,15 @@ export const AGENTS = {
291
291
  format: 'markdown',
292
292
  variableSyntax: '$ARGUMENTS',
293
293
  supportsHooks: true,
294
- capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: true, skills: true, commands: true, plugins: true, subagents: false, rules: { file: '.cursorrules' }, workflows: false, memory: false, modes: ['edit', 'skip'] }, // allowlist: ~/.cursor/cli-config.json
294
+ // Subagents: `.cursor/agents/<name>.md` (project) or `~/.cursor/agents/<name>.md`
295
+ // (user), Markdown with YAML frontmatter (name, description, model, readonly,
296
+ // is_background — no `color`). Shipped in cursor-agent CLI 2026.01 (Cursor 2.4,
297
+ // 2026-01-22); cursor-agent uses CalVer build tags (e.g. 2025.11.25-<hash>), so
298
+ // gate at `>= 2026.1.22`. The `agents sync` path enforces this (versions.ts skips
299
+ // + warns for pre-2.4 installs); the direct `subagents add --agents cursor` path
300
+ // writes unconditionally, same as the other since-gated agents.
301
+ // See transformSubagentForCursor / https://cursor.com/docs/subagents.
302
+ capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: true, skills: true, commands: true, plugins: true, subagents: { since: '2026.1.22' }, rules: { file: '.cursorrules' }, workflows: false, memory: false, modes: ['edit', 'skip'] }, // allowlist: ~/.cursor/cli-config.json
295
303
  },
296
304
  opencode: {
297
305
  id: 'opencode',
@@ -329,7 +337,11 @@ export const AGENTS = {
329
337
  format: 'markdown',
330
338
  variableSyntax: '{{ARGUMENTS}}',
331
339
  supportsHooks: true,
332
- capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: false, skills: true, commands: false, plugins: true, subagents: true, rules: { file: 'workspace/AGENTS.md' }, workflows: false, memory: true, modes: ['plan', 'edit', 'skip'] },
340
+ // allowlist: maps blanket (whole-tool) rules to ~/.openclaw/openclaw.json
341
+ // tools.alsoAllow (allow) / tools.deny (deny). OpenClaw gates at tool
342
+ // granularity only, so sub-command/path/domain patterns are skipped.
343
+ // OpenClaw is self-updating (no pinned since), so `true` is correct.
344
+ capabilities: { hooks: true, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: true, skills: true, commands: false, plugins: true, subagents: true, rules: { file: 'workspace/AGENTS.md' }, workflows: false, memory: true, modes: ['plan', 'edit', 'skip'] },
333
345
  },
334
346
  copilot: {
335
347
  id: 'copilot',
@@ -421,7 +433,12 @@ export const AGENTS = {
421
433
  // Plugins: Open Plugins under ~/.agents/plugins/<name>/ (same layout as
422
434
  // agents-cli source). Version isolation copies into versionHome/.agents/plugins/.
423
435
  // Workflows sync as Goose recipe YAML; permissions sync to permission.yaml.
424
- capabilities: { hooks: { since: '1.34.0' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: true, skills: { since: '1.25.0' }, commands: false, plugins: true, subagents: false, rules: { file: 'AGENTS.md' }, workflows: true, memory: false, modes: ['edit'] },
436
+ // Commands: a Goose slash command is a recipe YAML under
437
+ // ~/.config/goose/commands/<name>.yaml, registered in ~/.config/goose/config.yaml
438
+ // under `slash_commands: [{ command, recipe_path }]` (see goose-commands.ts).
439
+ // Subagents: recipe YAML named agents under ~/.config/goose/agents/<name>.yaml
440
+ // (goose auto-discovers and delegates to them by name in autonomous mode).
441
+ capabilities: { hooks: { since: '1.34.0' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: true, skills: { since: '1.25.0' }, commands: true, plugins: true, subagents: true, rules: { file: 'AGENTS.md' }, workflows: true, memory: false, modes: ['edit'] },
425
442
  },
426
443
  // Google Antigravity CLI (`agy`) — official replacement for Gemini CLI as of IO 2026.
427
444
  // configDir nests inside `~/.gemini/` since agy shares the parent dir with the Gemini
@@ -610,16 +627,25 @@ export const AGENTS = {
610
627
  instructionsFile: 'MEMORY.md',
611
628
  format: 'markdown',
612
629
  variableSyntax: '$ARGUMENTS',
613
- supportsHooks: false,
630
+ supportsHooks: true,
631
+ // Plugins: Hermes loads plugins from a flat `~/.hermes/plugins/<name>/` dir
632
+ // with a `plugin.yaml` manifest; a plugin only loads once its name is in the
633
+ // `plugins.enabled` allowlist in `~/.hermes/config.yaml` (deny-list
634
+ // `plugins.disabled` wins). Not the Claude marketplace layout, so it installs
635
+ // via a flat-copy branch (mirrors goose) plus a YAML allowlist toggle.
636
+ // See https://hermes-agent.nousresearch.com/docs/user-guide/features/plugins
614
637
  capabilities: {
615
- hooks: false,
638
+ // Lifecycle hooks land in ~/.hermes/config.yaml under a `hooks:` block
639
+ // (YAML, shared with `mcp_servers`); gated to Hermes ≥ 0.11.0 which
640
+ // introduced the configurable hook runner.
641
+ hooks: { since: '0.11.0' },
616
642
  mcp: true,
617
643
  mcpHttp: true,
618
644
  mcpHeaders: false,
619
645
  allowlist: false,
620
646
  skills: true,
621
647
  commands: false,
622
- plugins: false,
648
+ plugins: true,
623
649
  subagents: false,
624
650
  rules: { file: 'MEMORY.md' },
625
651
  workflows: false,
@@ -638,14 +664,20 @@ export const AGENTS = {
638
664
  npmPackage: '',
639
665
  installScript: 'curl -fsSL https://forgecode.dev/cli | sh',
640
666
  configDir: path.join(HOME, '.forge'),
641
- commandsDir: '',
642
- commandsSubdir: '',
667
+ commandsDir: path.join(HOME, '.forge', 'commands'),
668
+ commandsSubdir: 'commands',
643
669
  skillsDir: path.join(HOME, '.forge', 'skills'),
644
670
  hooksDir: 'hooks',
645
671
  instructionsFile: 'AGENTS.md',
646
672
  format: 'markdown',
647
673
  variableSyntax: '$ARGUMENTS',
648
674
  supportsHooks: false,
675
+ // Commands: ForgeCode reads Markdown slash commands from `~/.forge/commands/<name>.md`
676
+ // (also the shared `~/.agents/commands/`); the filename is the command name.
677
+ // Subagents: named `.md` agent definitions with YAML frontmatter under
678
+ // `~/.forge/agents/<name>.md` — same Markdown+frontmatter shape as Droid/Copilot
679
+ // (no `color` field), so transformSubagentForForge aliases transformSubagentForDroid.
680
+ // See https://forgecode.dev/docs/commands/ and /docs/agent-definition-guide/.
649
681
  capabilities: {
650
682
  hooks: false,
651
683
  mcp: true,
@@ -653,9 +685,9 @@ export const AGENTS = {
653
685
  mcpHeaders: false,
654
686
  allowlist: false,
655
687
  skills: true,
656
- commands: false,
688
+ commands: true,
657
689
  plugins: false,
658
- subagents: false,
690
+ subagents: true,
659
691
  rules: { file: 'AGENTS.md' },
660
692
  workflows: false,
661
693
  memory: false,
@@ -15,6 +15,7 @@ import { markdownToToml } from './convert.js';
15
15
  import { getCommandsDir, getUserCommandsDir, getEnabledExtraRepos, getProjectAgentsDir, getSkillsDir, getTrashCommandsDir } from './state.js';
16
16
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions, resolveVersion } from './versions.js';
17
17
  import { commandSkillMatches, installCommandSkillToVersion, listCommandSkillsInVersion, removeCommandSkillFromVersion, shouldInstallCommandAsSkill, } from './command-skills.js';
18
+ import { installGooseCommandToVersion, listGooseCommandsInVersion, gooseCommandMatches, removeGooseCommandFromVersion, } from './goose-commands.js';
18
19
  function compareVersions(a, b) {
19
20
  const aParts = a.split('.').map((n) => parseInt(n, 10) || 0);
20
21
  const bParts = b.split('.').map((n) => parseInt(n, 10) || 0);
@@ -209,6 +210,19 @@ export function installCommand(sourcePath, agentId, commandName, method = 'symli
209
210
  const agent = AGENTS[agentId];
210
211
  ensureCommandsDir(agentId);
211
212
  const home = getEffectiveHome(agentId);
213
+ // Goose: a slash command is a recipe YAML registered in config.yaml, not a
214
+ // native command file under commandsSubdir.
215
+ if (agentId === 'goose') {
216
+ const result = installGooseCommandToVersion(home, commandName, sourcePath);
217
+ if (!result.success) {
218
+ return { path: '', method: 'copy', error: result.error, warnings: validation.warnings };
219
+ }
220
+ return {
221
+ path: path.join(home, '.config', 'goose', 'commands', `${commandName}.yaml`),
222
+ method: 'copy',
223
+ warnings: validation.warnings,
224
+ };
225
+ }
212
226
  const commandsDir = path.join(home, agentConfigDirName(agentId), agent.commandsSubdir);
213
227
  fs.mkdirSync(commandsDir, { recursive: true });
214
228
  const ext = agent.format === 'toml' ? '.toml' : '.md';
@@ -248,6 +262,9 @@ export function listCommandsInVersionHome(agent, version) {
248
262
  if (shouldInstallCommandAsSkill(agent, version)) {
249
263
  return listCommandSkillsInVersion(agentDir);
250
264
  }
265
+ if (agent === 'goose') {
266
+ return listGooseCommandsInVersion(versionHome);
267
+ }
251
268
  const dir = getVersionCommandsDir(agent, version);
252
269
  if (!fs.existsSync(dir))
253
270
  return [];
@@ -270,6 +287,9 @@ function versionCommandMatches(agent, version, commandName) {
270
287
  if (shouldInstallCommandAsSkill(agent, version)) {
271
288
  return commandSkillMatches(agentDir, commandName, sourcePath);
272
289
  }
290
+ if (agent === 'goose') {
291
+ return gooseCommandMatches(versionHome, commandName, sourcePath);
292
+ }
273
293
  const agentConfig = AGENTS[agent];
274
294
  const ext = agentConfig.format === 'toml' ? '.toml' : '.md';
275
295
  const installedPath = path.join(getVersionCommandsDir(agent, version), `${commandName}${ext}`);
@@ -357,6 +377,11 @@ export function installCommandToVersion(agent, version, commandName, method = 'c
357
377
  ...getEnabledExtraRepos().map((repo) => path.join(repo.dir, 'skills')),
358
378
  ]);
359
379
  }
380
+ // Goose: a slash command is a recipe YAML registered in config.yaml, not a
381
+ // native command file. Write the recipe + slash_commands entry.
382
+ if (agent === 'goose') {
383
+ return installGooseCommandToVersion(versionHome, commandName, sourcePath);
384
+ }
360
385
  const agentConfig = AGENTS[agent];
361
386
  const commandsDir = getVersionCommandsDir(agent, version);
362
387
  fs.mkdirSync(commandsDir, { recursive: true });
@@ -392,6 +417,10 @@ export function removeCommandFromVersion(agent, version, commandName) {
392
417
  if (shouldInstallCommandAsSkill(agent, version)) {
393
418
  return removeCommandSkillFromVersion(agentDir, commandName);
394
419
  }
420
+ if (agent === 'goose') {
421
+ const trashDir = path.join(getTrashCommandsDir(), agent, version, commandName);
422
+ return removeGooseCommandFromVersion(versionHome, commandName, trashDir);
423
+ }
395
424
  const ext = AGENTS[agent].format === 'toml' ? '.toml' : '.md';
396
425
  const targetPath = path.join(getVersionCommandsDir(agent, version), `${commandName}${ext}`);
397
426
  if (!fs.existsSync(targetPath) && !fs.lstatSync(targetPath, { throwIfNoEntry: false })) {
@@ -16,5 +16,16 @@ export declare function parseMarkdownFrontmatter(content: string): {
16
16
  };
17
17
  /** Convert a Markdown command file to Gemini's TOML format, translating $ARGUMENTS to {{args}}. */
18
18
  export declare function markdownToToml(skillName: string, markdown: string): string;
19
+ /**
20
+ * Convert a Markdown command file to a Goose recipe YAML object.
21
+ *
22
+ * Goose has no native slash-command file format — a slash command is a recipe
23
+ * (registered in `config.yaml` under `slash_commands`). The recipe schema matches
24
+ * the one agents-cli already emits for Goose workflow/subagent recipes:
25
+ * `version`, `title`, `description`, `instructions`, `prompt`. The Markdown body
26
+ * (with `$ARGUMENTS` preserved) becomes both `instructions` and `prompt`.
27
+ * Returns a plain object so the caller can `yaml.stringify` it.
28
+ */
29
+ export declare function markdownToGooseRecipe(commandName: string, markdown: string): Record<string, unknown>;
19
30
  /** Convert a Gemini TOML command file back to Markdown format, translating {{args}} to $ARGUMENTS. */
20
31
  export declare function tomlToMarkdown(toml: string): string;
@@ -40,6 +40,28 @@ export function markdownToToml(skillName, markdown) {
40
40
  ];
41
41
  return lines.join('\n');
42
42
  }
43
+ /**
44
+ * Convert a Markdown command file to a Goose recipe YAML object.
45
+ *
46
+ * Goose has no native slash-command file format — a slash command is a recipe
47
+ * (registered in `config.yaml` under `slash_commands`). The recipe schema matches
48
+ * the one agents-cli already emits for Goose workflow/subagent recipes:
49
+ * `version`, `title`, `description`, `instructions`, `prompt`. The Markdown body
50
+ * (with `$ARGUMENTS` preserved) becomes both `instructions` and `prompt`.
51
+ * Returns a plain object so the caller can `yaml.stringify` it.
52
+ */
53
+ export function markdownToGooseRecipe(commandName, markdown) {
54
+ const { frontmatter, body } = parseMarkdownFrontmatter(markdown);
55
+ const description = frontmatter.description || `Run ${commandName} command`;
56
+ const prompt = body.trim() || description;
57
+ return {
58
+ version: '1.0.0',
59
+ title: commandName,
60
+ description,
61
+ instructions: prompt,
62
+ prompt,
63
+ };
64
+ }
43
65
  /** Convert a Gemini TOML command file back to Markdown format, translating {{args}} to $ARGUMENTS. */
44
66
  export function tomlToMarkdown(toml) {
45
67
  const nameMatch = toml.match(/name\s*=\s*"([^"]+)"/);
@@ -30,6 +30,7 @@ import { markdownToToml } from './convert.js';
30
30
  import { resolveImports, supportsRulesImports } from './rules/compile.js';
31
31
  import { listCommandsInVersionHome, getVersionCommandsDir } from './commands.js';
32
32
  import { shouldInstallCommandAsSkill, commandSkillMatches, commandSkillName } from './command-skills.js';
33
+ import { gooseCommandMatches, gooseCommandsDir } from './goose-commands.js';
33
34
  import { supports } from './capabilities.js';
34
35
  import { listSkillsInVersionHome, getVersionSkillsDir } from './skills.js';
35
36
  import { listHooksInVersionHome, listHookEntriesFromDir } from './hooks.js';
@@ -141,6 +142,19 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
141
142
  });
142
143
  continue;
143
144
  }
145
+ if (agent === 'goose') {
146
+ // Compare against the installed Goose recipe YAML + slash_commands registration.
147
+ const matches = gooseCommandMatches(getVersionHomePath(agent, version), name, src.path);
148
+ rows.push({
149
+ kind: 'commands',
150
+ name,
151
+ status: matches ? 'ok' : 'diff',
152
+ source: src.layer,
153
+ sourcePath: src.path,
154
+ homePath: path.join(gooseCommandsDir(getVersionHomePath(agent, version)), `${name}.yaml`),
155
+ });
156
+ continue;
157
+ }
144
158
  const homePath = path.join(homeDir, `${name}${ext}`);
145
159
  const installedContent = readSafe(homePath);
146
160
  const sourceContent = readSafe(src.path);
@@ -164,7 +178,9 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
164
178
  continue;
165
179
  const extraHome = asSkill
166
180
  ? path.join(agentDir, 'skills', commandSkillName(name), 'SKILL.md')
167
- : path.join(homeDir, `${name}${ext}`);
181
+ : agent === 'goose'
182
+ ? path.join(gooseCommandsDir(getVersionHomePath(agent, version)), `${name}.yaml`)
183
+ : path.join(homeDir, `${name}${ext}`);
168
184
  rows.push({ kind: 'commands', name, status: 'extra', homePath: extraHome });
169
185
  }
170
186
  return rows.sort((a, b) => a.name.localeCompare(b.name));
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Goose slash-command install/remove/list/compare.
3
+ *
4
+ * Goose has no native slash-command file format — a slash command is a recipe
5
+ * YAML file registered in `~/.config/goose/config.yaml` under a `slash_commands`
6
+ * array: `[{ command: "<name>", recipe_path: "<abs path to recipe.yaml>" }]`.
7
+ * (See goose-docs.ai context-engineering/slash-commands.)
8
+ *
9
+ * agents-cli writes each command's recipe to `<versionHome>/.config/goose/commands/
10
+ * <name>.yaml` — a dir distinct from the workflow recipes dir
11
+ * (`.config/goose/recipes/`) so the workflow detector never treats a command
12
+ * recipe as a workflow — and registers/unregisters the `slash_commands` entry in
13
+ * `config.yaml` via a read-modify-write that preserves every other key
14
+ * (`mcp_servers`, `extensions`, …). Under agents-cli version isolation HOME is the
15
+ * version home, so both files live under it and the absolute `recipe_path`
16
+ * resolves correctly at goose runtime.
17
+ */
18
+ /** Directory holding Goose slash-command recipe YAML files in a version home. */
19
+ export declare function gooseCommandsDir(versionHome: string): string;
20
+ /** Path to the Goose config.yaml (holds the `slash_commands` registry) in a version home. */
21
+ export declare function gooseCommandConfigPath(versionHome: string): string;
22
+ /**
23
+ * Install a command into a Goose version home: write its recipe YAML and register
24
+ * the `slash_commands` entry in config.yaml.
25
+ */
26
+ export declare function installGooseCommandToVersion(versionHome: string, commandName: string, sourcePath: string): {
27
+ success: boolean;
28
+ error?: string;
29
+ };
30
+ /** List Goose command names (recipe files) installed in a version home. */
31
+ export declare function listGooseCommandsInVersion(versionHome: string): string[];
32
+ /** Whether an installed Goose command recipe matches the central Markdown source. */
33
+ export declare function gooseCommandMatches(versionHome: string, commandName: string, sourcePath: string): boolean;
34
+ /**
35
+ * Remove a Goose command from a version home: soft-delete the recipe to `trashDir`
36
+ * (when provided) and unregister its `slash_commands` entry.
37
+ */
38
+ export declare function removeGooseCommandFromVersion(versionHome: string, commandName: string, trashDir?: string): {
39
+ success: boolean;
40
+ error?: string;
41
+ };
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Goose slash-command install/remove/list/compare.
3
+ *
4
+ * Goose has no native slash-command file format — a slash command is a recipe
5
+ * YAML file registered in `~/.config/goose/config.yaml` under a `slash_commands`
6
+ * array: `[{ command: "<name>", recipe_path: "<abs path to recipe.yaml>" }]`.
7
+ * (See goose-docs.ai context-engineering/slash-commands.)
8
+ *
9
+ * agents-cli writes each command's recipe to `<versionHome>/.config/goose/commands/
10
+ * <name>.yaml` — a dir distinct from the workflow recipes dir
11
+ * (`.config/goose/recipes/`) so the workflow detector never treats a command
12
+ * recipe as a workflow — and registers/unregisters the `slash_commands` entry in
13
+ * `config.yaml` via a read-modify-write that preserves every other key
14
+ * (`mcp_servers`, `extensions`, …). Under agents-cli version isolation HOME is the
15
+ * version home, so both files live under it and the absolute `recipe_path`
16
+ * resolves correctly at goose runtime.
17
+ */
18
+ import * as fs from 'fs';
19
+ import * as path from 'path';
20
+ import * as yaml from 'yaml';
21
+ import { safeJoin } from './paths.js';
22
+ import { markdownToGooseRecipe } from './convert.js';
23
+ /** Directory holding Goose slash-command recipe YAML files in a version home. */
24
+ export function gooseCommandsDir(versionHome) {
25
+ return path.join(versionHome, '.config', 'goose', 'commands');
26
+ }
27
+ /** Path to the Goose config.yaml (holds the `slash_commands` registry) in a version home. */
28
+ export function gooseCommandConfigPath(versionHome) {
29
+ return path.join(versionHome, '.config', 'goose', 'config.yaml');
30
+ }
31
+ /**
32
+ * Read the goose config.yaml as a mutable object. Throws — rather than returning
33
+ * `{}` — when a NON-EMPTY file fails to parse or isn't a mapping, so the caller
34
+ * (which rewrites the whole file) never silently clobbers a real user config
35
+ * (`mcp_servers`, `GOOSE_MODEL`, `extensions`, …). A missing or genuinely empty
36
+ * file returns `{}`.
37
+ */
38
+ function readGooseConfig(configPath) {
39
+ if (!fs.existsSync(configPath))
40
+ return {};
41
+ const raw = fs.readFileSync(configPath, 'utf-8');
42
+ if (raw.trim() === '')
43
+ return {};
44
+ let parsed;
45
+ try {
46
+ parsed = yaml.parse(raw);
47
+ }
48
+ catch (err) {
49
+ throw new Error(`Refusing to rewrite goose config at ${configPath}: existing file is not valid YAML ` +
50
+ `(${err.message}). Fix or remove it, then re-sync.`);
51
+ }
52
+ if (parsed === null || parsed === undefined)
53
+ return {}; // comments/whitespace only
54
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
55
+ throw new Error(`Refusing to rewrite goose config at ${configPath}: expected a YAML mapping but found ` +
56
+ `${Array.isArray(parsed) ? 'a list' : typeof parsed}.`);
57
+ }
58
+ return parsed;
59
+ }
60
+ function readSlashCommands(config) {
61
+ const raw = config.slash_commands;
62
+ if (!Array.isArray(raw))
63
+ return [];
64
+ return raw.filter((e) => !!e && typeof e === 'object' && typeof e.command === 'string');
65
+ }
66
+ /**
67
+ * Register (or update) a `slash_commands` entry for `commandName` pointing at
68
+ * `recipePath`, preserving every other config key and other entries. Idempotent.
69
+ */
70
+ function registerSlashCommand(configPath, commandName, recipePath) {
71
+ const config = readGooseConfig(configPath);
72
+ const entries = readSlashCommands(config);
73
+ const existing = entries.find((e) => e.command === commandName);
74
+ if (existing && existing.recipe_path === recipePath)
75
+ return; // already current — no rewrite
76
+ const next = entries.filter((e) => e.command !== commandName);
77
+ next.push({ command: commandName, recipe_path: recipePath });
78
+ next.sort((a, b) => a.command.localeCompare(b.command));
79
+ config.slash_commands = next;
80
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
81
+ fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
82
+ }
83
+ /** Remove the `slash_commands` entry for `commandName`, preserving all other config. */
84
+ function unregisterSlashCommand(configPath, commandName) {
85
+ if (!fs.existsSync(configPath))
86
+ return;
87
+ const config = readGooseConfig(configPath);
88
+ const entries = readSlashCommands(config);
89
+ if (!entries.some((e) => e.command === commandName))
90
+ return; // nothing to do
91
+ const next = entries.filter((e) => e.command !== commandName);
92
+ if (next.length > 0) {
93
+ config.slash_commands = next;
94
+ }
95
+ else {
96
+ delete config.slash_commands;
97
+ }
98
+ fs.writeFileSync(configPath, yaml.stringify(config), 'utf-8');
99
+ }
100
+ /** Serialize a Goose command recipe from a central Markdown command file. */
101
+ function buildGooseCommandRecipe(commandName, sourcePath) {
102
+ const markdown = fs.readFileSync(sourcePath, 'utf-8');
103
+ return yaml.stringify(markdownToGooseRecipe(commandName, markdown));
104
+ }
105
+ /**
106
+ * Install a command into a Goose version home: write its recipe YAML and register
107
+ * the `slash_commands` entry in config.yaml.
108
+ */
109
+ export function installGooseCommandToVersion(versionHome, commandName, sourcePath) {
110
+ try {
111
+ const dir = gooseCommandsDir(versionHome);
112
+ fs.mkdirSync(dir, { recursive: true });
113
+ const recipePath = safeJoin(dir, `${commandName}.yaml`);
114
+ fs.writeFileSync(recipePath, buildGooseCommandRecipe(commandName, sourcePath), 'utf-8');
115
+ registerSlashCommand(gooseCommandConfigPath(versionHome), commandName, recipePath);
116
+ return { success: true };
117
+ }
118
+ catch (err) {
119
+ return { success: false, error: err.message };
120
+ }
121
+ }
122
+ /** List Goose command names (recipe files) installed in a version home. */
123
+ export function listGooseCommandsInVersion(versionHome) {
124
+ const dir = gooseCommandsDir(versionHome);
125
+ if (!fs.existsSync(dir))
126
+ return [];
127
+ return fs.readdirSync(dir)
128
+ .filter((f) => f.endsWith('.yaml'))
129
+ .map((f) => f.slice(0, -'.yaml'.length))
130
+ .sort();
131
+ }
132
+ /** Whether an installed Goose command recipe matches the central Markdown source. */
133
+ export function gooseCommandMatches(versionHome, commandName, sourcePath) {
134
+ const recipePath = safeJoin(gooseCommandsDir(versionHome), `${commandName}.yaml`);
135
+ if (!fs.existsSync(recipePath) || !fs.existsSync(sourcePath))
136
+ return false;
137
+ try {
138
+ // The slash_commands entry must also be registered for the command to be live.
139
+ // (An unparseable config.yaml surfaces here as "not a match" → a re-sync, which
140
+ // fails loudly rather than clobbering, instead of a crash during a read-only diff.)
141
+ const registered = readSlashCommands(readGooseConfig(gooseCommandConfigPath(versionHome)))
142
+ .some((e) => e.command === commandName);
143
+ if (!registered)
144
+ return false;
145
+ const installed = fs.readFileSync(recipePath, 'utf-8').trim();
146
+ const expected = buildGooseCommandRecipe(commandName, sourcePath).trim();
147
+ return installed === expected;
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ }
153
+ /**
154
+ * Remove a Goose command from a version home: soft-delete the recipe to `trashDir`
155
+ * (when provided) and unregister its `slash_commands` entry.
156
+ */
157
+ export function removeGooseCommandFromVersion(versionHome, commandName, trashDir) {
158
+ try {
159
+ const recipePath = safeJoin(gooseCommandsDir(versionHome), `${commandName}.yaml`);
160
+ if (fs.existsSync(recipePath)) {
161
+ if (trashDir) {
162
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
163
+ fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
164
+ fs.renameSync(recipePath, path.join(trashDir, `${commandName}.yaml.${stamp}`));
165
+ }
166
+ else {
167
+ fs.unlinkSync(recipePath);
168
+ }
169
+ }
170
+ unregisterSlashCommand(gooseCommandConfigPath(versionHome), commandName);
171
+ return { success: true };
172
+ }
173
+ catch (err) {
174
+ return { success: false, error: err.message };
175
+ }
176
+ }