@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 +12 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +10 -1
- package/dist/lib/agents.js +42 -10
- package/dist/lib/commands.js +29 -0
- package/dist/lib/convert.d.ts +11 -0
- package/dist/lib/convert.js +22 -0
- package/dist/lib/doctor-diff.js +17 -1
- package/dist/lib/goose-commands.d.ts +41 -0
- package/dist/lib/goose-commands.js +176 -0
- package/dist/lib/hooks.js +134 -0
- package/dist/lib/permissions.d.ts +15 -0
- package/dist/lib/permissions.js +99 -0
- package/dist/lib/plugins.d.ts +20 -0
- package/dist/lib/plugins.js +118 -0
- package/dist/lib/resources/permissions.js +2 -0
- package/dist/lib/session/active.d.ts +8 -1
- package/dist/lib/session/active.js +1 -0
- package/dist/lib/session/parse.js +12 -0
- package/dist/lib/session/state.d.ts +33 -0
- package/dist/lib/session/state.js +40 -0
- package/dist/lib/staleness/detectors/permissions.js +23 -0
- package/dist/lib/staleness/detectors/subagents.js +36 -0
- package/dist/lib/staleness/writers/commands.js +7 -0
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/staleness/writers/subagents.js +22 -3
- package/dist/lib/subagents.d.ts +32 -0
- package/dist/lib/subagents.js +238 -0
- package/package.json +1 -1
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
|
package/dist/commands/exec.js
CHANGED
|
@@ -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
|
package/dist/lib/agents.js
CHANGED
|
@@ -291,7 +291,15 @@ export const AGENTS = {
|
|
|
291
291
|
format: 'markdown',
|
|
292
292
|
variableSyntax: '$ARGUMENTS',
|
|
293
293
|
supportsHooks: true,
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
688
|
+
commands: true,
|
|
657
689
|
plugins: false,
|
|
658
|
-
subagents:
|
|
690
|
+
subagents: true,
|
|
659
691
|
rules: { file: 'AGENTS.md' },
|
|
660
692
|
workflows: false,
|
|
661
693
|
memory: false,
|
package/dist/lib/commands.js
CHANGED
|
@@ -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 })) {
|
package/dist/lib/convert.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/convert.js
CHANGED
|
@@ -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*"([^"]+)"/);
|
package/dist/lib/doctor-diff.js
CHANGED
|
@@ -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
|
-
:
|
|
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
|
+
}
|