@phnx-labs/agents-cli 1.20.60 → 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.
Files changed (41) hide show
  1. package/CHANGELOG.md +18 -2
  2. package/README.md +1 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +10 -1
  5. package/dist/commands/routines.js +2 -0
  6. package/dist/lib/agents.js +129 -11
  7. package/dist/lib/commands.js +29 -0
  8. package/dist/lib/convert.d.ts +11 -0
  9. package/dist/lib/convert.js +22 -0
  10. package/dist/lib/doctor-diff.js +17 -1
  11. package/dist/lib/goose-commands.d.ts +41 -0
  12. package/dist/lib/goose-commands.js +176 -0
  13. package/dist/lib/hooks.js +134 -0
  14. package/dist/lib/permissions.d.ts +15 -0
  15. package/dist/lib/permissions.js +99 -0
  16. package/dist/lib/plugins.d.ts +20 -0
  17. package/dist/lib/plugins.js +118 -0
  18. package/dist/lib/resources/permissions.js +2 -0
  19. package/dist/lib/routines.d.ts +7 -0
  20. package/dist/lib/routines.js +18 -0
  21. package/dist/lib/runner.d.ts +7 -0
  22. package/dist/lib/runner.js +34 -6
  23. package/dist/lib/session/active.d.ts +8 -1
  24. package/dist/lib/session/active.js +1 -0
  25. package/dist/lib/session/parse.js +12 -0
  26. package/dist/lib/session/state.d.ts +33 -0
  27. package/dist/lib/session/state.js +40 -0
  28. package/dist/lib/staleness/detectors/permissions.js +23 -0
  29. package/dist/lib/staleness/detectors/subagents.js +36 -0
  30. package/dist/lib/staleness/detectors/workflows.js +29 -0
  31. package/dist/lib/staleness/writers/commands.js +7 -0
  32. package/dist/lib/staleness/writers/hooks.js +1 -1
  33. package/dist/lib/staleness/writers/subagents.js +22 -3
  34. package/dist/lib/subagents.d.ts +32 -0
  35. package/dist/lib/subagents.js +238 -0
  36. package/dist/lib/tmux/session.d.ts +13 -7
  37. package/dist/lib/tmux/session.js +23 -8
  38. package/dist/lib/versions.js +72 -0
  39. package/dist/lib/workflows.d.ts +9 -0
  40. package/dist/lib/workflows.js +84 -2
  41. package/package.json +1 -1
@@ -274,6 +274,27 @@ export function transformSubagentForDroid(subagentDir) {
274
274
  * See GitHub docs for custom agents.
275
275
  */
276
276
  export const transformSubagentForCopilot = transformSubagentForDroid;
277
+ /**
278
+ * Transform a subagent into a Cursor CLI custom subagent `.md` file.
279
+ *
280
+ * Cursor loads subagents from `.cursor/agents/*.md` (project) or
281
+ * `~/.cursor/agents/*.md` (user) — Markdown with YAML frontmatter
282
+ * (name, description, model; also readonly/is_background, which our
283
+ * frontmatter schema doesn't carry). Cursor has no `color` field, so this is
284
+ * an alias of transformSubagentForDroid, same as Copilot.
285
+ * See https://cursor.com/docs/subagents.
286
+ */
287
+ export const transformSubagentForCursor = transformSubagentForDroid;
288
+ /**
289
+ * Transform a subagent into a ForgeCode custom subagent `.md` file.
290
+ *
291
+ * ForgeCode loads named agents from `.forge/agents/*.md` (project) or
292
+ * `~/.forge/agents/*.md` (user) — Markdown with YAML frontmatter (id, title,
293
+ * description, tools, model, temperature) + a system-prompt body. ForgeCode has
294
+ * no `color` field, so this is an alias of transformSubagentForDroid, same as
295
+ * Copilot/Cursor. See https://forgecode.dev/docs/agent-definition-guide/.
296
+ */
297
+ export const transformSubagentForForge = transformSubagentForDroid;
277
298
  /**
278
299
  * Transform a subagent into Antigravity's custom-agent markdown shape.
279
300
  *
@@ -493,6 +514,45 @@ export function transformSubagentForKiro(subagentDir) {
493
514
  }
494
515
  return JSON.stringify(config, null, 2);
495
516
  }
517
+ /**
518
+ * Transform a subagent into a Goose recipe YAML file.
519
+ *
520
+ * Goose has no dedicated subagent file format — a named subagent IS a recipe.
521
+ * Goose resolves named agents from `~/.config/goose/agents/<name>.yaml` (global)
522
+ * and delegates to them by name in autonomous mode. The recipe schema mirrors the
523
+ * one agents-cli already emits for Goose workflow recipes (`writeGooseSubrecipe`):
524
+ * `version`, `title`, `description`, `instructions`, `prompt`, plus an optional
525
+ * `settings.goose_model`. See goose-docs.ai context-engineering/subagents.
526
+ */
527
+ export function transformSubagentForGoose(subagentDir) {
528
+ const agentMd = path.join(subagentDir, 'AGENT.md');
529
+ const frontmatter = parseSubagentFrontmatter(agentMd);
530
+ const body = getSubagentBody(agentMd);
531
+ if (!frontmatter) {
532
+ throw new Error(`Invalid AGENT.md in ${subagentDir}`);
533
+ }
534
+ const files = fs.readdirSync(subagentDir)
535
+ .filter(f => f.endsWith('.md') && f !== 'AGENT.md')
536
+ .sort();
537
+ let prompt = body || frontmatter.description || frontmatter.name;
538
+ for (const file of files) {
539
+ const content = fs.readFileSync(path.join(subagentDir, file), 'utf-8').trim();
540
+ const sectionName = file.replace('.md', '');
541
+ const title = sectionName.charAt(0).toUpperCase() + sectionName.slice(1).toLowerCase();
542
+ prompt += `\n\n## ${title}\n\n${content}`;
543
+ }
544
+ const recipe = {
545
+ version: '1.0.0',
546
+ title: frontmatter.name || path.basename(subagentDir),
547
+ description: frontmatter.description || frontmatter.name || path.basename(subagentDir),
548
+ instructions: prompt,
549
+ prompt,
550
+ };
551
+ if (frontmatter.model) {
552
+ recipe.settings = { goose_model: frontmatter.model };
553
+ }
554
+ return yaml.stringify(recipe);
555
+ }
496
556
  /**
497
557
  * Sync a subagent to an OpenClaw workspace
498
558
  * Copies full directory, renames AGENT.md to AGENTS.md
@@ -609,6 +669,51 @@ export function installSubagentToAgent(subagentDir, subagentName, agent, agentHo
609
669
  return { success: false, error: String(err) };
610
670
  }
611
671
  }
672
+ else if (agent === 'cursor') {
673
+ // Cursor: flattened .md custom subagent under ~/.cursor/agents/
674
+ const agentsDir = path.join(agentHome, '.cursor', 'agents');
675
+ if (!fs.existsSync(agentsDir)) {
676
+ fs.mkdirSync(agentsDir, { recursive: true });
677
+ }
678
+ try {
679
+ const transformed = transformSubagentForCursor(subagentDir);
680
+ fs.writeFileSync(safeJoin(agentsDir, `${subagentName}.md`), transformed);
681
+ return { success: true };
682
+ }
683
+ catch (err) {
684
+ return { success: false, error: String(err) };
685
+ }
686
+ }
687
+ else if (agent === 'forge') {
688
+ // ForgeCode: flattened .md custom subagent under ~/.forge/agents/
689
+ const agentsDir = path.join(agentHome, '.forge', 'agents');
690
+ if (!fs.existsSync(agentsDir)) {
691
+ fs.mkdirSync(agentsDir, { recursive: true });
692
+ }
693
+ try {
694
+ const transformed = transformSubagentForForge(subagentDir);
695
+ fs.writeFileSync(safeJoin(agentsDir, `${subagentName}.md`), transformed);
696
+ return { success: true };
697
+ }
698
+ catch (err) {
699
+ return { success: false, error: String(err) };
700
+ }
701
+ }
702
+ else if (agent === 'goose') {
703
+ // Goose: recipe YAML custom-agent file under ~/.config/goose/agents/
704
+ const agentsDir = path.join(agentHome, '.config', 'goose', 'agents');
705
+ if (!fs.existsSync(agentsDir)) {
706
+ fs.mkdirSync(agentsDir, { recursive: true });
707
+ }
708
+ try {
709
+ const transformed = transformSubagentForGoose(subagentDir);
710
+ fs.writeFileSync(safeJoin(agentsDir, `${subagentName}.yaml`), transformed);
711
+ return { success: true };
712
+ }
713
+ catch (err) {
714
+ return { success: false, error: String(err) };
715
+ }
716
+ }
612
717
  else {
613
718
  // Other agents don't support subagents yet
614
719
  return { success: false, error: `Agent '${agent}' does not support subagents` };
@@ -670,6 +775,27 @@ export function removeSubagentFromAgent(subagentName, agent, agentHome) {
670
775
  }
671
776
  return { success: true };
672
777
  }
778
+ else if (agent === 'cursor') {
779
+ const targetPath = safeJoin(path.join(agentHome, '.cursor', 'agents'), `${subagentName}.md`);
780
+ if (fs.existsSync(targetPath)) {
781
+ fs.unlinkSync(targetPath);
782
+ }
783
+ return { success: true };
784
+ }
785
+ else if (agent === 'forge') {
786
+ const targetPath = safeJoin(path.join(agentHome, '.forge', 'agents'), `${subagentName}.md`);
787
+ if (fs.existsSync(targetPath)) {
788
+ fs.unlinkSync(targetPath);
789
+ }
790
+ return { success: true };
791
+ }
792
+ else if (agent === 'goose') {
793
+ const targetPath = safeJoin(path.join(agentHome, '.config', 'goose', 'agents'), `${subagentName}.yaml`);
794
+ if (fs.existsSync(targetPath)) {
795
+ fs.unlinkSync(targetPath);
796
+ }
797
+ return { success: true };
798
+ }
673
799
  else {
674
800
  return { success: true }; // No-op for unsupported agents
675
801
  }
@@ -886,6 +1012,61 @@ export function listSubagentsForAgent(agentId, home) {
886
1012
  });
887
1013
  }
888
1014
  }
1015
+ else if (agentId === 'cursor') {
1016
+ // Cursor: flat `<name>.md` files under ~/.cursor/agents/
1017
+ const agentsDir = path.join(home, '.cursor', 'agents');
1018
+ if (!fs.existsSync(agentsDir))
1019
+ return subagents;
1020
+ for (const file of fs.readdirSync(agentsDir)) {
1021
+ if (!file.endsWith('.md'))
1022
+ continue;
1023
+ const filePath = path.join(agentsDir, file);
1024
+ if (!fs.statSync(filePath).isFile())
1025
+ continue;
1026
+ const name = file.replace(/\.md$/, '');
1027
+ const frontmatter = parseSubagentFrontmatter(filePath) ?? { name, description: '' };
1028
+ subagents.push({ name, path: filePath, files: [file], frontmatter });
1029
+ }
1030
+ }
1031
+ else if (agentId === 'forge') {
1032
+ // ForgeCode: flat `<name>.md` files under ~/.forge/agents/
1033
+ const agentsDir = path.join(home, '.forge', 'agents');
1034
+ if (!fs.existsSync(agentsDir))
1035
+ return subagents;
1036
+ for (const file of fs.readdirSync(agentsDir)) {
1037
+ if (!file.endsWith('.md'))
1038
+ continue;
1039
+ const filePath = path.join(agentsDir, file);
1040
+ if (!fs.statSync(filePath).isFile())
1041
+ continue;
1042
+ const name = file.replace(/\.md$/, '');
1043
+ const frontmatter = parseSubagentFrontmatter(filePath) ?? { name, description: '' };
1044
+ subagents.push({ name, path: filePath, files: [file], frontmatter });
1045
+ }
1046
+ }
1047
+ else if (agentId === 'goose') {
1048
+ // Goose: recipe YAML files under ~/.config/goose/agents/
1049
+ const agentsDir = path.join(home, '.config', 'goose', 'agents');
1050
+ if (!fs.existsSync(agentsDir))
1051
+ return subagents;
1052
+ for (const file of fs.readdirSync(agentsDir)) {
1053
+ if (!file.endsWith('.yaml'))
1054
+ continue;
1055
+ const filePath = path.join(agentsDir, file);
1056
+ if (!fs.statSync(filePath).isFile())
1057
+ continue;
1058
+ const name = file.replace(/\.yaml$/, '');
1059
+ let frontmatter;
1060
+ try {
1061
+ const recipe = yaml.parse(fs.readFileSync(filePath, 'utf-8'));
1062
+ frontmatter = { name: recipe?.title || name, description: recipe?.description || '' };
1063
+ }
1064
+ catch {
1065
+ continue;
1066
+ }
1067
+ subagents.push({ name, path: filePath, files: [file], frontmatter });
1068
+ }
1069
+ }
889
1070
  return subagents;
890
1071
  }
891
1072
  /**
@@ -984,6 +1165,42 @@ export function diffVersionSubagents(agent, version) {
984
1165
  }
985
1166
  }
986
1167
  }
1168
+ else if (agent === 'cursor') {
1169
+ const agentsDir = path.join(versionHome, '.cursor', 'agents');
1170
+ if (fs.existsSync(agentsDir)) {
1171
+ for (const file of fs.readdirSync(agentsDir)) {
1172
+ if (!file.endsWith('.md'))
1173
+ continue;
1174
+ const name = path.basename(file, '.md');
1175
+ if (!discovered.has(name))
1176
+ orphans.push(name);
1177
+ }
1178
+ }
1179
+ }
1180
+ else if (agent === 'forge') {
1181
+ const agentsDir = path.join(versionHome, '.forge', 'agents');
1182
+ if (fs.existsSync(agentsDir)) {
1183
+ for (const file of fs.readdirSync(agentsDir)) {
1184
+ if (!file.endsWith('.md'))
1185
+ continue;
1186
+ const name = path.basename(file, '.md');
1187
+ if (!discovered.has(name))
1188
+ orphans.push(name);
1189
+ }
1190
+ }
1191
+ }
1192
+ else if (agent === 'goose') {
1193
+ const agentsDir = path.join(versionHome, '.config', 'goose', 'agents');
1194
+ if (fs.existsSync(agentsDir)) {
1195
+ for (const file of fs.readdirSync(agentsDir)) {
1196
+ if (!file.endsWith('.yaml'))
1197
+ continue;
1198
+ const name = path.basename(file, '.yaml');
1199
+ if (!discovered.has(name))
1200
+ orphans.push(name);
1201
+ }
1202
+ }
1203
+ }
987
1204
  return { agent, version, orphans: orphans.sort() };
988
1205
  }
989
1206
  /**
@@ -1071,6 +1288,27 @@ export function removeSubagentFromVersion(agent, version, subagentName) {
1071
1288
  fs.renameSync(targetPath, path.join(trashDir, `${subagentName}.json.${stamp}`));
1072
1289
  }
1073
1290
  }
1291
+ else if (agent === 'cursor') {
1292
+ const targetPath = path.join(versionHome, '.cursor', 'agents', `${subagentName}.md`);
1293
+ if (fs.existsSync(targetPath)) {
1294
+ fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
1295
+ fs.renameSync(targetPath, path.join(trashDir, `${subagentName}.md.${stamp}`));
1296
+ }
1297
+ }
1298
+ else if (agent === 'forge') {
1299
+ const targetPath = path.join(versionHome, '.forge', 'agents', `${subagentName}.md`);
1300
+ if (fs.existsSync(targetPath)) {
1301
+ fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
1302
+ fs.renameSync(targetPath, path.join(trashDir, `${subagentName}.md.${stamp}`));
1303
+ }
1304
+ }
1305
+ else if (agent === 'goose') {
1306
+ const targetPath = path.join(versionHome, '.config', 'goose', 'agents', `${subagentName}.yaml`);
1307
+ if (fs.existsSync(targetPath)) {
1308
+ fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
1309
+ fs.renameSync(targetPath, path.join(trashDir, `${subagentName}.yaml.${stamp}`));
1310
+ }
1311
+ }
1074
1312
  return { success: true };
1075
1313
  }
1076
1314
  catch (err) {
@@ -146,19 +146,25 @@ export declare function setSessionHook(name: string, hook: string, command: stri
146
146
  * in the tmux server instead of launching a second tmux client against
147
147
  * the same socket from inside the hook. That self-client could race the
148
148
  * server under load and leave the dead split behind.
149
+ * v5 — `run-shell -b -C "kill-pane -t #{hook_pane}"` runs the targeted kill-pane
150
+ * in the background inside the server command queue. The synchronous
151
+ * variant could stall the hook on a loaded CI runner, letting the dead
152
+ * split survive until the test's wait timeout expired (flake in CI shard
153
+ * 3: pane-guarded pane-died hook / user split exit).
149
154
  */
150
- export declare const AGENT_HOOK_SCHEMA = 4;
155
+ export declare const AGENT_HOOK_SCHEMA = 5;
151
156
  /**
152
157
  * The guarded `pane-died` hook. Detach the client ONLY when the agent pane dies
153
158
  * (so the blocking attach in runInTmux returns and the exit status can be read);
154
159
  * a user split's death runs the else-branch, closing just that split. The
155
- * else-branch goes through `run-shell -C` with an explicit `-t #{hook_pane}`
160
+ * else-branch goes through `run-shell -b -C` with an explicit `-t #{hook_pane}`
156
161
  * target: tmux format-expands the command at fire time and executes it inside
157
- * the server command queue, so the event pane is always the one killed without
158
- * launching a second tmux client against the same socket. A bare `kill-pane`
159
- * relied on the hook context supplying a "current pane", while an external
160
- * self-client could race the server under load. Single source of truth: both
161
- * the spawn-wrap (exec.ts) and the daemon reconcile build the hook here, so the
162
+ * the server command queue in the background, so the event pane is always the
163
+ * one killed without launching a second tmux client against the same socket and
164
+ * without stalling the hook on a loaded runner. A bare `kill-pane` relied on
165
+ * the hook context supplying a "current pane", while an external self-client
166
+ * could race the server under load. Single source of truth: both the
167
+ * spawn-wrap (exec.ts) and the daemon reconcile build the hook here, so the
162
168
  * two can never drift.
163
169
  */
164
170
  export declare function agentPaneDiedHook(sessionName: string, agentPane: string): string;
@@ -93,6 +93,15 @@ export async function createSession(opts) {
93
93
  const res = await runTmux({ socket, args, env: opts.env });
94
94
  // Only the new-session command in the `;`-chained invocation emits output.
95
95
  const pane = /^%\d+$/.test(res.stdout.trim()) ? res.stdout.trim() : undefined;
96
+ // Keep the agent pane around after its process exits (so runInTmux can read
97
+ // the exit status and capture the final error), but do NOT keep that behavior
98
+ // for user-created splits. Apply remain-on-exit to the agent pane only, then
99
+ // revert the global default so future splits close automatically when their
100
+ // command finishes. The global-on above protects a fast-exiting agent during
101
+ // the brief window before the pane option is stamped.
102
+ if (pane) {
103
+ await runTmux({ socket, args: ['set-option', '-pt', pane, 'remain-on-exit', 'on', ';', 'set-option', '-g', 'remain-on-exit', 'off'], throwOnError: false }).catch(() => { });
104
+ }
96
105
  const meta = {
97
106
  name: opts.name,
98
107
  socket,
@@ -294,25 +303,31 @@ export async function setSessionHook(name, hook, command, socket) {
294
303
  * in the tmux server instead of launching a second tmux client against
295
304
  * the same socket from inside the hook. That self-client could race the
296
305
  * server under load and leave the dead split behind.
306
+ * v5 — `run-shell -b -C "kill-pane -t #{hook_pane}"` runs the targeted kill-pane
307
+ * in the background inside the server command queue. The synchronous
308
+ * variant could stall the hook on a loaded CI runner, letting the dead
309
+ * split survive until the test's wait timeout expired (flake in CI shard
310
+ * 3: pane-guarded pane-died hook / user split exit).
297
311
  */
298
- export const AGENT_HOOK_SCHEMA = 4;
312
+ export const AGENT_HOOK_SCHEMA = 5;
299
313
  /** Per-session tmux user-option that records which AGENT_HOOK_SCHEMA a session's hook is at. */
300
314
  const HOOK_SCHEMA_OPTION = '@ag_hook_schema';
301
315
  /**
302
316
  * The guarded `pane-died` hook. Detach the client ONLY when the agent pane dies
303
317
  * (so the blocking attach in runInTmux returns and the exit status can be read);
304
318
  * a user split's death runs the else-branch, closing just that split. The
305
- * else-branch goes through `run-shell -C` with an explicit `-t #{hook_pane}`
319
+ * else-branch goes through `run-shell -b -C` with an explicit `-t #{hook_pane}`
306
320
  * target: tmux format-expands the command at fire time and executes it inside
307
- * the server command queue, so the event pane is always the one killed without
308
- * launching a second tmux client against the same socket. A bare `kill-pane`
309
- * relied on the hook context supplying a "current pane", while an external
310
- * self-client could race the server under load. Single source of truth: both
311
- * the spawn-wrap (exec.ts) and the daemon reconcile build the hook here, so the
321
+ * the server command queue in the background, so the event pane is always the
322
+ * one killed without launching a second tmux client against the same socket and
323
+ * without stalling the hook on a loaded runner. A bare `kill-pane` relied on
324
+ * the hook context supplying a "current pane", while an external self-client
325
+ * could race the server under load. Single source of truth: both the
326
+ * spawn-wrap (exec.ts) and the daemon reconcile build the hook here, so the
312
327
  * two can never drift.
313
328
  */
314
329
  export function agentPaneDiedHook(sessionName, agentPane) {
315
- return `if -F '#{==:#{hook_pane},${agentPane}}' 'detach-client -s =${sessionName}' 'run-shell -C "kill-pane -t #{hook_pane}"'`;
330
+ return `if -F '#{==:#{hook_pane},${agentPane}}' 'detach-client -s =${sessionName}' 'run-shell -b -C "kill-pane -t #{hook_pane}"'`;
316
331
  }
317
332
  /** Stamp a session's hook-schema marker to the current version. */
318
333
  export async function markSessionHookSchema(name, socket) {
@@ -1148,6 +1148,72 @@ export function setGlobalDefault(agent, version) {
1148
1148
  }
1149
1149
  writeMeta(meta);
1150
1150
  }
1151
+ /**
1152
+ * Grok's official installer writes into ~/.grok/downloads, which (because we
1153
+ * symlink ~/.grok to the active version home) resolves to the PREVIOUS default
1154
+ * home during `agents add grok@<new>`. Move the freshly-downloaded binary and
1155
+ * its generic platform copy into the target version's isolated home so
1156
+ * `listInstalledVersions` and the shim resolve the right binary.
1157
+ */
1158
+ function relocateGrokBinaryToVersionHome(installedVersion) {
1159
+ const hostGrokLink = path.join(getHomeDir(), agentConfigDirName('grok'));
1160
+ let sourceDownloads;
1161
+ try {
1162
+ sourceDownloads = path.join(fs.readlinkSync(hostGrokLink), 'downloads');
1163
+ }
1164
+ catch {
1165
+ sourceDownloads = path.join(hostGrokLink, 'downloads');
1166
+ }
1167
+ const targetDownloads = path.join(getVersionHomePath('grok', installedVersion), agentConfigDirName('grok'), 'downloads');
1168
+ if (!fs.existsSync(sourceDownloads))
1169
+ return;
1170
+ if (path.resolve(sourceDownloads) === path.resolve(targetDownloads))
1171
+ return;
1172
+ const entries = fs.readdirSync(sourceDownloads).filter((e) => e.startsWith('grok-'));
1173
+ if (entries.length === 0)
1174
+ return;
1175
+ fs.mkdirSync(targetDownloads, { recursive: true });
1176
+ const escapedVersion = installedVersion.replace(/\./g, '\\.');
1177
+ const versionedPattern = new RegExp(`^grok-${escapedVersion}-`);
1178
+ const movedPaths = [];
1179
+ // Move the versioned binary first.
1180
+ for (const entry of entries) {
1181
+ if (!versionedPattern.test(entry))
1182
+ continue;
1183
+ const src = path.join(sourceDownloads, entry);
1184
+ const dst = path.join(targetDownloads, entry);
1185
+ if (fs.existsSync(dst))
1186
+ continue;
1187
+ try {
1188
+ fs.renameSync(src, dst);
1189
+ movedPaths.push(dst);
1190
+ }
1191
+ catch {
1192
+ /* ignore per-file failures */
1193
+ }
1194
+ }
1195
+ if (movedPaths.length === 0)
1196
+ return;
1197
+ // The installer also creates a generic platform binary (e.g. grok-macos-aarch64)
1198
+ // that is a copy of the versioned binary. Move it too if its size matches.
1199
+ const movedSize = fs.statSync(movedPaths[0]).size;
1200
+ for (const entry of entries) {
1201
+ if (versionedPattern.test(entry))
1202
+ continue; // already handled
1203
+ const src = path.join(sourceDownloads, entry);
1204
+ const dst = path.join(targetDownloads, entry);
1205
+ if (fs.existsSync(dst))
1206
+ continue;
1207
+ try {
1208
+ if (fs.statSync(src).size === movedSize) {
1209
+ fs.renameSync(src, dst);
1210
+ }
1211
+ }
1212
+ catch {
1213
+ /* ignore per-file failures */
1214
+ }
1215
+ }
1216
+ }
1151
1217
  /**
1152
1218
  * Install a specific version of an agent.
1153
1219
  */
@@ -1204,6 +1270,12 @@ export async function installVersion(agent, version, onProgress, opts) {
1204
1270
  const versionDir = getVersionDir(agent, installedVersion);
1205
1271
  fs.mkdirSync(versionDir, { recursive: true });
1206
1272
  fs.mkdirSync(path.join(versionDir, 'home'), { recursive: true });
1273
+ // Grok's installer drops the binary into ~/.grok/downloads, which currently
1274
+ // resolves to the PREVIOUS default home. Move it into the target version home
1275
+ // so version isolation is correct.
1276
+ if (agent === 'grok') {
1277
+ relocateGrokBinaryToVersionHome(installedVersion);
1278
+ }
1207
1279
  // Symlink the installed binary into the version's node_modules/.bin so
1208
1280
  // listInstalledVersions (which checks getBinaryPath) sees this version as
1209
1281
  // installed. Without this, `agents add antigravity@latest` succeeds
@@ -265,6 +265,15 @@ export declare function pruneStaleWorkflowSubagents(sharedAgentsDir: string, wor
265
265
  export declare function countWorkflowSubagents(workflowDir: string): number;
266
266
  /** Convert a canonical agents-cli workflow bundle into a Kimi flow skill. */
267
267
  export declare function transformWorkflowForKimi(workflowPath: string, name: string): string;
268
+ /**
269
+ * Convert a canonical agents-cli workflow bundle into an Antigravity workflow
270
+ * markdown file. Antigravity discovers workflows as flat `<name>.md` files under
271
+ * `~/.gemini/config/global_workflows/` (scanned by `agy` at startup) and exposes
272
+ * each as a `/<name>` slash command. Frontmatter carries the required `description`
273
+ * plus the shared `agents_workflow` ownership marker so agents-cli never clobbers a
274
+ * user-authored workflow of the same name.
275
+ */
276
+ export declare function transformWorkflowForAntigravity(workflowPath: string, name: string): string;
268
277
  /**
269
278
  * Resolve an `agents run <workflow>` reference.
270
279
  *
@@ -6,6 +6,7 @@
6
6
  * are composed at runtime by `agents run <workflow>`.
7
7
  */
8
8
  import * as fs from 'fs';
9
+ import * as os from 'os';
9
10
  import * as path from 'path';
10
11
  import * as yaml from 'yaml';
11
12
  import { capableAgents } from './capabilities.js';
@@ -369,6 +370,26 @@ export function transformWorkflowForKimi(workflowPath, name) {
369
370
  const instructions = (body || fm.description).trim();
370
371
  return `---\n${frontmatter}\n---\n\n\`\`\`d2\nBEGIN -> step -> END\nstep: |md\n${indentD2BlockString(instructions)}\n|\n\`\`\`\n`;
371
372
  }
373
+ /**
374
+ * Convert a canonical agents-cli workflow bundle into an Antigravity workflow
375
+ * markdown file. Antigravity discovers workflows as flat `<name>.md` files under
376
+ * `~/.gemini/config/global_workflows/` (scanned by `agy` at startup) and exposes
377
+ * each as a `/<name>` slash command. Frontmatter carries the required `description`
378
+ * plus the shared `agents_workflow` ownership marker so agents-cli never clobbers a
379
+ * user-authored workflow of the same name.
380
+ */
381
+ export function transformWorkflowForAntigravity(workflowPath, name) {
382
+ const fm = parseWorkflowFrontmatter(workflowPath);
383
+ if (!fm)
384
+ throw new Error(`Invalid WORKFLOW.md in ${workflowPath}`);
385
+ const body = getWorkflowBody(workflowPath) || fm.description;
386
+ const frontmatter = yaml.stringify({
387
+ description: fm.description,
388
+ name: fm.name || name,
389
+ [KIMI_WORKFLOW_MARKER]: name,
390
+ }).trim();
391
+ return `---\n${frontmatter}\n---\n\n${body.trim()}\n`;
392
+ }
372
393
  function expandWorkflowPath(ref) {
373
394
  if (ref === '~')
374
395
  return process.env.HOME ?? ref;
@@ -527,9 +548,24 @@ export function removeWorkflow(name) {
527
548
  return { success: false, error: err.message };
528
549
  }
529
550
  }
551
+ /**
552
+ * Antigravity user workflows are NOT version-isolated. `agy` scans a single,
553
+ * shared, HOME-global directory at startup — `~/.gemini/config/global_workflows/`
554
+ * — and that dir is a real directory in the user's home, never symlinked into a
555
+ * per-version home (only `~/.gemini/antigravity-cli` is version-scoped). Writing
556
+ * into a version home therefore lands somewhere agy never reads. So every
557
+ * antigravity version resolves to the same real shared dir; `versionHome` is
558
+ * intentionally ignored. (Verified via strace of `agy`: it opens
559
+ * `$HOME/.gemini/config/global_workflows/<name>.md` and never the version home.)
560
+ */
561
+ function antigravityWorkflowsDir() {
562
+ return path.join(process.env.HOME ?? os.homedir(), '.gemini', 'config', 'global_workflows');
563
+ }
530
564
  function workflowTargetRoot(agent, versionHome) {
531
565
  if (agent === 'kimi')
532
566
  return path.join(versionHome, '.kimi-code', 'skills');
567
+ if (agent === 'antigravity')
568
+ return antigravityWorkflowsDir();
533
569
  return path.join(versionHome, 'workflows');
534
570
  }
535
571
  /** List workflow names synced into a specific agent version home. */
@@ -543,6 +579,20 @@ export function listWorkflowsForAgent(agent, versionHome) {
543
579
  .filter(d => kimiWorkflowMarker(path.join(skillsDir, d.name, 'SKILL.md')) === d.name)
544
580
  .map(d => d.name);
545
581
  }
582
+ if (agent === 'antigravity') {
583
+ const dir = workflowTargetRoot(agent, versionHome);
584
+ if (!fs.existsSync(dir))
585
+ return [];
586
+ try {
587
+ return fs.readdirSync(dir, { withFileTypes: true })
588
+ .filter(d => d.isFile() && d.name.endsWith('.md') && !d.name.startsWith('.'))
589
+ .map(d => d.name.slice(0, -'.md'.length))
590
+ .filter(base => antigravityWorkflowMarker(path.join(dir, `${base}.md`)) === base);
591
+ }
592
+ catch {
593
+ return [];
594
+ }
595
+ }
546
596
  if (agent === 'goose') {
547
597
  const recipesDir = path.join(versionHome, '.config', 'goose', 'recipes');
548
598
  if (!fs.existsSync(recipesDir))
@@ -678,7 +728,17 @@ export function syncWorkflowToVersion(workflowPath, name, agent, versionHome) {
678
728
  return { success: true };
679
729
  }
680
730
  if (agent === 'antigravity') {
681
- return { success: false, error: 'Antigravity workflow sync is not supported for version homes' };
731
+ const targetDir = workflowTargetRoot(agent, versionHome);
732
+ const targetFile = path.join(targetDir, `${name}.md`);
733
+ if (fs.existsSync(targetFile)) {
734
+ const marker = antigravityWorkflowMarker(targetFile);
735
+ if (marker !== name) {
736
+ return { success: false, error: `Antigravity workflow '${name}' already exists and is not managed by agents-cli` };
737
+ }
738
+ }
739
+ fs.mkdirSync(targetDir, { recursive: true });
740
+ fs.writeFileSync(targetFile, transformWorkflowForAntigravity(workflowPath, name), 'utf-8');
741
+ return { success: true };
682
742
  }
683
743
  const targetDir = path.join(workflowTargetRoot(agent, versionHome), name);
684
744
  fs.mkdirSync(workflowTargetRoot(agent, versionHome), { recursive: true });
@@ -696,7 +756,20 @@ export function syncWorkflowToVersion(workflowPath, name, agent, versionHome) {
696
756
  export function removeWorkflowFromVersion(agent, version, name) {
697
757
  const versionHome = getVersionHomePath(agent, version);
698
758
  if (agent === 'antigravity') {
699
- return { success: false, error: 'Antigravity workflow sync is not supported for version homes' };
759
+ const targetFile = path.join(workflowTargetRoot(agent, versionHome), `${name}.md`);
760
+ if (!fs.existsSync(targetFile)) {
761
+ return { success: false, error: `Workflow '${name}' not synced to ${agent}@${version}` };
762
+ }
763
+ if (antigravityWorkflowMarker(targetFile) !== name) {
764
+ return { success: false, error: `Antigravity workflow '${name}' is not managed by agents-cli` };
765
+ }
766
+ try {
767
+ fs.rmSync(targetFile, { force: true });
768
+ return { success: true };
769
+ }
770
+ catch (err) {
771
+ return { success: false, error: err.message };
772
+ }
700
773
  }
701
774
  if (agent === 'goose') {
702
775
  const recipePath = path.join(versionHome, '.config', 'goose', 'recipes', `${name}.yaml`);
@@ -754,6 +827,15 @@ function kimiWorkflowMarker(filePath) {
754
827
  return null;
755
828
  }
756
829
  }
830
+ function antigravityWorkflowMarker(filePath) {
831
+ try {
832
+ const fm = parseSkillFrontmatter(filePath);
833
+ return typeof fm?.agents_workflow === 'string' ? fm.agents_workflow : null;
834
+ }
835
+ catch {
836
+ return null;
837
+ }
838
+ }
757
839
  /** Iterate all installed (agent, version) pairs that support workflows. */
758
840
  export function iterWorkflowsCapableVersions(filter) {
759
841
  const result = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.60",
3
+ "version": "1.20.62",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",