@phnx-labs/agents-cli 1.20.3 → 1.20.5

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 (193) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +48 -17
  3. package/dist/commands/cli.js +1 -1
  4. package/dist/commands/cloud.js +1 -1
  5. package/dist/commands/commands.js +2 -0
  6. package/dist/commands/doctor.js +1 -1
  7. package/dist/commands/exec.js +52 -16
  8. package/dist/commands/hooks.js +6 -6
  9. package/dist/commands/import.js +90 -37
  10. package/dist/commands/inspect.d.ts +26 -0
  11. package/dist/commands/inspect.js +590 -0
  12. package/dist/commands/mcp.js +17 -16
  13. package/dist/commands/models.js +1 -1
  14. package/dist/commands/packages.js +6 -4
  15. package/dist/commands/permissions.js +13 -12
  16. package/dist/commands/plugins.d.ts +13 -0
  17. package/dist/commands/plugins.js +100 -11
  18. package/dist/commands/prune.js +3 -2
  19. package/dist/commands/pull.d.ts +12 -5
  20. package/dist/commands/pull.js +26 -422
  21. package/dist/commands/push.d.ts +14 -0
  22. package/dist/commands/push.js +30 -0
  23. package/dist/commands/repo.d.ts +1 -1
  24. package/dist/commands/repo.js +155 -112
  25. package/dist/commands/resource-view.d.ts +2 -0
  26. package/dist/commands/resource-view.js +12 -3
  27. package/dist/commands/routines.js +32 -7
  28. package/dist/commands/rules.js +1 -1
  29. package/dist/commands/sessions.js +1 -0
  30. package/dist/commands/setup.d.ts +3 -3
  31. package/dist/commands/setup.js +15 -15
  32. package/dist/commands/skills.js +6 -5
  33. package/dist/commands/subagents.js +5 -4
  34. package/dist/commands/sync.d.ts +18 -5
  35. package/dist/commands/sync.js +251 -65
  36. package/dist/commands/teams.js +1 -0
  37. package/dist/commands/tmux.d.ts +25 -0
  38. package/dist/commands/tmux.js +415 -0
  39. package/dist/commands/trash.d.ts +2 -2
  40. package/dist/commands/trash.js +1 -1
  41. package/dist/commands/versions.js +2 -2
  42. package/dist/commands/view.js +14 -4
  43. package/dist/commands/workflows.js +4 -3
  44. package/dist/commands/worktree.d.ts +4 -5
  45. package/dist/commands/worktree.js +4 -4
  46. package/dist/index.js +68 -20
  47. package/dist/lib/agents.d.ts +19 -10
  48. package/dist/lib/agents.js +102 -28
  49. package/dist/lib/auto-pull-worker.d.ts +1 -1
  50. package/dist/lib/auto-pull-worker.js +2 -2
  51. package/dist/lib/auto-pull.d.ts +1 -1
  52. package/dist/lib/auto-pull.js +1 -1
  53. package/dist/lib/beta.d.ts +1 -1
  54. package/dist/lib/beta.js +1 -1
  55. package/dist/lib/capabilities.js +2 -0
  56. package/dist/lib/commands.d.ts +28 -1
  57. package/dist/lib/commands.js +125 -20
  58. package/dist/lib/doctor-diff.js +2 -2
  59. package/dist/lib/exec.d.ts +14 -0
  60. package/dist/lib/exec.js +39 -5
  61. package/dist/lib/fuzzy.d.ts +12 -2
  62. package/dist/lib/fuzzy.js +29 -4
  63. package/dist/lib/git.js +8 -1
  64. package/dist/lib/hooks.d.ts +2 -2
  65. package/dist/lib/hooks.js +97 -10
  66. package/dist/lib/import.d.ts +21 -0
  67. package/dist/lib/import.js +55 -2
  68. package/dist/lib/mcp.js +32 -2
  69. package/dist/lib/migrate.d.ts +51 -0
  70. package/dist/lib/migrate.js +227 -1
  71. package/dist/lib/models.js +62 -15
  72. package/dist/lib/permissions.d.ts +36 -2
  73. package/dist/lib/permissions.js +217 -7
  74. package/dist/lib/plugin-marketplace.d.ts +108 -40
  75. package/dist/lib/plugin-marketplace.js +243 -94
  76. package/dist/lib/plugins.d.ts +21 -4
  77. package/dist/lib/plugins.js +130 -49
  78. package/dist/lib/profiles-presets.js +12 -12
  79. package/dist/lib/project-launch.d.ts +65 -0
  80. package/dist/lib/project-launch.js +367 -0
  81. package/dist/lib/pty-client.js +1 -1
  82. package/dist/lib/pty-server.d.ts +1 -1
  83. package/dist/lib/pty-server.js +28 -4
  84. package/dist/lib/refresh.d.ts +26 -0
  85. package/dist/lib/refresh.js +315 -0
  86. package/dist/lib/resource-patterns.d.ts +1 -1
  87. package/dist/lib/resource-patterns.js +1 -1
  88. package/dist/lib/resources/commands.js +2 -2
  89. package/dist/lib/resources/hooks.d.ts +1 -1
  90. package/dist/lib/resources/hooks.js +1 -1
  91. package/dist/lib/resources/mcp.d.ts +1 -1
  92. package/dist/lib/resources/mcp.js +5 -6
  93. package/dist/lib/resources/permissions.js +5 -2
  94. package/dist/lib/resources/rules.js +3 -2
  95. package/dist/lib/resources/skills.js +3 -2
  96. package/dist/lib/resources/types.d.ts +1 -1
  97. package/dist/lib/resources.js +2 -2
  98. package/dist/lib/rotate.d.ts +1 -1
  99. package/dist/lib/rotate.js +1 -1
  100. package/dist/lib/routines.d.ts +16 -4
  101. package/dist/lib/routines.js +67 -17
  102. package/dist/lib/rules/compile.js +22 -10
  103. package/dist/lib/rules/rules.js +3 -3
  104. package/dist/lib/runner.js +16 -3
  105. package/dist/lib/scheduler.js +15 -1
  106. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  107. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  108. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +9 -1
  109. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
  110. package/dist/lib/secrets/linux.d.ts +44 -9
  111. package/dist/lib/secrets/linux.js +302 -48
  112. package/dist/lib/session/db.js +15 -2
  113. package/dist/lib/session/discover.js +118 -3
  114. package/dist/lib/session/parse.js +3 -0
  115. package/dist/lib/session/types.d.ts +1 -1
  116. package/dist/lib/session/types.js +1 -1
  117. package/dist/lib/shims.d.ts +10 -9
  118. package/dist/lib/shims.js +101 -50
  119. package/dist/lib/skills.d.ts +1 -1
  120. package/dist/lib/skills.js +10 -9
  121. package/dist/lib/staleness/detectors/commands.d.ts +3 -0
  122. package/dist/lib/staleness/detectors/commands.js +46 -0
  123. package/dist/lib/staleness/detectors/hooks.d.ts +3 -0
  124. package/dist/lib/staleness/detectors/hooks.js +44 -0
  125. package/dist/lib/staleness/detectors/mcp.d.ts +3 -0
  126. package/dist/lib/staleness/detectors/mcp.js +31 -0
  127. package/dist/lib/staleness/detectors/permissions.d.ts +3 -0
  128. package/dist/lib/staleness/detectors/permissions.js +201 -0
  129. package/dist/lib/staleness/detectors/plugins.d.ts +8 -0
  130. package/dist/lib/staleness/detectors/plugins.js +23 -0
  131. package/dist/lib/staleness/detectors/rules.d.ts +3 -0
  132. package/dist/lib/staleness/detectors/rules.js +34 -0
  133. package/dist/lib/staleness/detectors/skills.d.ts +3 -0
  134. package/dist/lib/staleness/detectors/skills.js +71 -0
  135. package/dist/lib/staleness/detectors/subagents.d.ts +3 -0
  136. package/dist/lib/staleness/detectors/subagents.js +50 -0
  137. package/dist/lib/staleness/detectors/types.d.ts +22 -0
  138. package/dist/lib/staleness/detectors/types.js +1 -0
  139. package/dist/lib/staleness/detectors/workflows.d.ts +3 -0
  140. package/dist/lib/staleness/detectors/workflows.js +28 -0
  141. package/dist/lib/staleness/registry.d.ts +26 -0
  142. package/dist/lib/staleness/registry.js +123 -0
  143. package/dist/lib/staleness/writers/commands.d.ts +3 -0
  144. package/dist/lib/staleness/writers/commands.js +111 -0
  145. package/dist/lib/staleness/writers/hooks.d.ts +3 -0
  146. package/dist/lib/staleness/writers/hooks.js +47 -0
  147. package/dist/lib/staleness/writers/kinds.d.ts +10 -0
  148. package/dist/lib/staleness/writers/kinds.js +15 -0
  149. package/dist/lib/staleness/writers/lazy-map.d.ts +13 -0
  150. package/dist/lib/staleness/writers/lazy-map.js +19 -0
  151. package/dist/lib/staleness/writers/mcp.d.ts +10 -0
  152. package/dist/lib/staleness/writers/mcp.js +19 -0
  153. package/dist/lib/staleness/writers/permissions.d.ts +13 -0
  154. package/dist/lib/staleness/writers/permissions.js +26 -0
  155. package/dist/lib/staleness/writers/plugins.d.ts +7 -0
  156. package/dist/lib/staleness/writers/plugins.js +31 -0
  157. package/dist/lib/staleness/writers/rules.d.ts +7 -0
  158. package/dist/lib/staleness/writers/rules.js +55 -0
  159. package/dist/lib/staleness/writers/skills.d.ts +3 -0
  160. package/dist/lib/staleness/writers/skills.js +81 -0
  161. package/dist/lib/staleness/writers/sources.d.ts +16 -0
  162. package/dist/lib/staleness/writers/sources.js +72 -0
  163. package/dist/lib/staleness/writers/subagents.d.ts +3 -0
  164. package/dist/lib/staleness/writers/subagents.js +53 -0
  165. package/dist/lib/staleness/writers/types.d.ts +36 -0
  166. package/dist/lib/staleness/writers/types.js +1 -0
  167. package/dist/lib/staleness/writers/workflows.d.ts +7 -0
  168. package/dist/lib/staleness/writers/workflows.js +31 -0
  169. package/dist/lib/state.d.ts +34 -11
  170. package/dist/lib/state.js +58 -13
  171. package/dist/lib/subagents.d.ts +0 -2
  172. package/dist/lib/subagents.js +6 -6
  173. package/dist/lib/teams/agents.js +1 -1
  174. package/dist/lib/teams/parsers.d.ts +1 -1
  175. package/dist/lib/tmux/binary.d.ts +67 -0
  176. package/dist/lib/tmux/binary.js +141 -0
  177. package/dist/lib/tmux/index.d.ts +8 -0
  178. package/dist/lib/tmux/index.js +8 -0
  179. package/dist/lib/tmux/paths.d.ts +17 -0
  180. package/dist/lib/tmux/paths.js +30 -0
  181. package/dist/lib/tmux/session.d.ts +122 -0
  182. package/dist/lib/tmux/session.js +305 -0
  183. package/dist/lib/types.d.ts +58 -7
  184. package/dist/lib/types.js +1 -1
  185. package/dist/lib/usage.js +1 -1
  186. package/dist/lib/versions.d.ts +4 -4
  187. package/dist/lib/versions.js +154 -491
  188. package/dist/lib/workflows.d.ts +2 -4
  189. package/dist/lib/workflows.js +3 -4
  190. package/package.json +7 -7
  191. package/scripts/postinstall.js +16 -63
  192. package/dist/commands/status.d.ts +0 -9
  193. package/dist/commands/status.js +0 -25
@@ -2,7 +2,7 @@
2
2
  * Version management module for agents-cli.
3
3
  *
4
4
  * Handles installing, removing, listing, and switching between agent CLI versions.
5
- * Each version is installed into an isolated directory under ~/.agents-system/versions/{agent}/{version}/
5
+ * Each version is installed into an isolated directory under ~/.agents/.system/versions/{agent}/{version}/
6
6
  * with its own HOME directory for config isolation. Resources (commands, skills, hooks, memory,
7
7
  * MCP servers, permissions, subagents, plugins) from ~/.agents/ are synced into version homes
8
8
  * via copies or conversions (not symlinks).
@@ -21,27 +21,22 @@ import * as yaml from 'yaml';
21
21
  import { exec, execFile } from 'child_process';
22
22
  import { promisify } from 'util';
23
23
  import chalk from 'chalk';
24
- import * as TOML from 'smol-toml';
25
24
  import { checkbox, select } from '@inquirer/prompts';
26
25
  import { getVersionsDir, ensureAgentsDir, readMeta, writeMeta, getCommandsDir, getSkillsDir, getHooksDir, getResolvedRulesDir, getUserRulesDir, getVersionResources, ensureVersionResourcePatterns, getProjectAgentsDir, getPromptcutsPath, getUserPromptcutsPath, getEnabledExtraRepos, getAgentsDir, getUserAgentsDir, getTrashVersionsDir, getActiveRulesPreset } from './state.js';
27
26
  import { defaultPatterns, expandPatterns } from './resource-patterns.js';
28
27
  import { listResources } from './resources.js';
29
- import { AGENTS, getAccountEmail, MCP_CAPABLE_AGENTS, COMMANDS_CAPABLE_AGENTS, getMcpConfigPathForHome, parseMcpConfig, resolveAgentName, formatAgentError } from './agents.js';
30
- import { applyPermissionsToVersion as applyPermsToVersion, PERMISSIONS_CAPABLE_AGENTS, discoverPermissionGroups, buildPermissionsFromGroups, CODEX_RULES_FILENAME, getActivePermissionPresetName, readPermissionPresetRecipe, PERMISSION_PRESET_ENV_VAR } from './permissions.js';
31
- import { installMcpServers, parseMcpServerConfig } from './mcp.js';
32
- import { markdownToToml } from './convert.js';
28
+ import { AGENTS, getAccountEmail, resolveAgentName, formatAgentError } from './agents.js';
29
+ import { discoverPermissionGroups, getActivePermissionPresetName, readPermissionPresetRecipe, PERMISSION_PRESET_ENV_VAR } from './permissions.js';
30
+ import { parseMcpServerConfig } from './mcp.js';
33
31
  import { createVersionedAlias, removeVersionedAlias, getConfigSymlinkVersion, ensureClaudeInsideSymlink } from './shims.js';
34
- import { listInstalledSubagents, transformSubagentForClaude, syncSubagentToOpenclaw, SUBAGENT_CAPABLE_AGENTS } from './subagents.js';
35
- import { WORKFLOW_CAPABLE_AGENTS, listInstalledWorkflows, syncWorkflowToVersion } from './workflows.js';
36
- import { registerHooksToSettings } from './hooks.js';
32
+ import { importInstallScriptBinary } from './import.js';
37
33
  import { supports, explainSkip } from './capabilities.js';
38
- import { discoverPlugins, syncPluginToVersion, isPluginSynced, pluginSupportsAgent, cleanOrphanedPluginSkills } from './plugins.js';
39
- import { composeRulesFromState } from './rules/compose.js';
34
+ import { discoverPlugins } from './plugins.js';
40
35
  import { loadManifest, saveManifest, buildManifest as buildSyncManifest, isStale } from './staleness/index.js';
41
- import { PLUGINS_CAPABLE_AGENTS } from './agents.js';
42
36
  import { emit } from './events.js';
43
37
  import { safeJoin } from './paths.js';
44
- import { installCommandSkillToVersion, listCommandSkillsInVersion, shouldInstallCommandAsSkill } from './command-skills.js';
38
+ import { listCommandSkillsInVersion, shouldInstallCommandAsSkill } from './command-skills.js';
39
+ import { getWriter, getDetector } from './staleness/registry.js';
45
40
  /** Promisified exec for running shell commands. */
46
41
  const execAsync = promisify(exec);
47
42
  const execFileAsync = promisify(execFile);
@@ -280,10 +275,8 @@ function skillDirsMatch(src, dest) {
280
275
  * This is the source of truth - not the tracking in agents.yaml.
281
276
  */
282
277
  export function getActuallySyncedResources(agent, version, options = {}) {
283
- const agentConfig = AGENTS[agent];
284
278
  const versionHome = path.join(getVersionsDir(), agent, version, 'home');
285
- const configDir = path.join(versionHome, `.${agent}`);
286
- const projectAgentsDir = getProjectAgentsDir(options.cwd || process.cwd());
279
+ const cwd = options.cwd || process.cwd();
287
280
  const result = {
288
281
  commands: [],
289
282
  skills: [],
@@ -296,219 +289,20 @@ export function getActuallySyncedResources(agent, version, options = {}) {
296
289
  workflows: [],
297
290
  promptcuts: false,
298
291
  };
299
- // Commands - check what files exist in version home.
300
- // For agent/version pairs that store commands as converted skills (e.g. Codex >= 0.117.0),
301
- // detect them via the agents_command marker in skills/<name>/SKILL.md — otherwise the
302
- // diff falsely reports every command as "new" every run and re-prompts on `agents view`.
303
- if (shouldInstallCommandAsSkill(agent, version)) {
304
- result.commands = listCommandSkillsInVersion(path.join(configDir));
305
- }
306
- else {
307
- const commandsDir = path.join(configDir, agentConfig.commandsSubdir);
308
- if (fs.existsSync(commandsDir)) {
309
- const ext = agentConfig.format === 'toml' ? '.toml' : '.md';
310
- result.commands = fs.readdirSync(commandsDir)
311
- .filter(f => f.endsWith(ext))
312
- .map(f => f.replace(new RegExp(`\\${ext}$`), ''));
313
- }
314
- }
315
- // Skills - check what directories exist AND content matches central source
316
- const skillsDir = path.join(configDir, 'skills');
317
- const centralSkillsDir = getSkillsDir();
318
- const projectSkillsDir = projectAgentsDir ? path.join(projectAgentsDir, 'skills') : null;
319
- const userAgentsDir = getUserAgentsDir();
320
- const extraRepos = getEnabledExtraRepos();
321
- if (fs.existsSync(skillsDir)) {
322
- const installedSkills = fs.readdirSync(skillsDir, { withFileTypes: true })
323
- .filter(d => d.isDirectory() && !d.name.startsWith('.'))
324
- .map(d => d.name);
325
- for (const skill of installedSkills) {
326
- const versionSkillDir = path.join(skillsDir, skill);
327
- const sourceCandidates = [
328
- projectSkillsDir ? path.join(projectSkillsDir, skill) : null,
329
- path.join(userAgentsDir, 'skills', skill),
330
- path.join(centralSkillsDir, skill),
331
- ...extraRepos.map((e) => path.join(e.dir, 'skills', skill)),
332
- ];
333
- const sourceDir = sourceCandidates.find((p) => p && fs.existsSync(p)) || null;
334
- if (!sourceDir) {
335
- // True orphan — no source in project, primary, or any extra. Still
336
- // count as synced so version-home cleanup knows it's accounted for.
337
- result.skills.push(skill);
338
- continue;
339
- }
340
- const allMatch = skillDirsMatch(sourceDir, versionSkillDir);
341
- if (allMatch) {
342
- result.skills.push(skill);
343
- }
344
- }
345
- }
346
- // Hooks - check what files exist AND content matches central source
347
- const hooksDir = path.join(configDir, 'hooks');
348
- const centralHooksDir = getHooksDir();
349
- const projectHooksDir = projectAgentsDir ? path.join(projectAgentsDir, 'hooks') : null;
350
- const userHooksDir = path.join(userAgentsDir, 'hooks');
351
- if (fs.existsSync(hooksDir)) {
352
- const installedHooks = fs.readdirSync(hooksDir).filter(f => !f.startsWith('.'));
353
- for (const hook of installedHooks) {
354
- const projectFile = projectHooksDir ? path.join(projectHooksDir, hook) : null;
355
- const centralFile = path.join(centralHooksDir, hook);
356
- const userFile = path.join(userHooksDir, hook);
357
- const versionFile = path.join(hooksDir, hook);
358
- const hasProject = projectFile ? fs.existsSync(projectFile) : false;
359
- const hasUser = fs.existsSync(userFile);
360
- const hasCentral = fs.existsSync(centralFile);
361
- const sourceFile = hasProject ? projectFile : hasUser ? userFile : centralFile;
362
- if (!hasProject && !hasCentral && !hasUser) {
363
- result.hooks.push(hook);
364
- continue;
365
- }
366
- try {
367
- const centralContent = fs.readFileSync(sourceFile, 'utf-8');
368
- const versionContent = fs.readFileSync(versionFile, 'utf-8');
369
- if (centralContent === versionContent) {
370
- result.hooks.push(hook);
371
- }
372
- }
373
- catch {
374
- // If read fails, consider not synced
375
- }
376
- }
377
- }
378
- // Rules — single composed instruction file per agent. If the file exists in
379
- // the version home, we consider the active preset synced. Available presets
380
- // are surfaced from rules.yaml; this set is the subset that materialized.
381
- const instrFile = path.join(configDir, agentConfig.instructionsFile);
382
- if (fs.existsSync(instrFile)) {
383
- const activePreset = getActiveRulesPreset(agent, version);
384
- result.memory.push(activePreset);
385
- }
386
- // MCP - use canonical config path + parser per agent
387
- if (MCP_CAPABLE_AGENTS.includes(agent)) {
388
- const mcpConfigPath = getMcpConfigPathForHome(agent, versionHome);
389
- if (fs.existsSync(mcpConfigPath)) {
390
- try {
391
- const servers = parseMcpConfig(agent, mcpConfigPath);
392
- result.mcp = Object.keys(servers);
393
- }
394
- catch {
395
- // Ignore parse errors
396
- }
397
- }
398
- }
399
- // Permissions - check agent-specific config files
400
- const settingsPath = path.join(configDir, 'settings.json');
401
- if (PERMISSIONS_CAPABLE_AGENTS.includes(agent)) {
402
- if (agent === 'claude' && fs.existsSync(settingsPath)) {
403
- // Claude: check settings.json permissions.allow and deny
404
- try {
405
- const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
406
- const allowRules = settings.permissions?.allow || [];
407
- const denyRules = settings.permissions?.deny || [];
408
- if (allowRules.length > 0 || denyRules.length > 0) {
409
- const permGroups = discoverPermissionGroups();
410
- const appliedGroups = [];
411
- for (const group of permGroups) {
412
- const groupSet = buildPermissionsFromGroups([group.name]);
413
- // Empty groups (like header files) are considered synced if ANY permissions are applied
414
- if (groupSet.allow.length === 0 && (!groupSet.deny || groupSet.deny.length === 0)) {
415
- appliedGroups.push(group.name);
416
- continue;
417
- }
418
- const hasAllowRule = groupSet.allow.some(rule => allowRules.includes(rule));
419
- const hasDenyRule = groupSet.deny?.some(rule => denyRules.includes(rule)) || false;
420
- if (hasAllowRule || hasDenyRule) {
421
- appliedGroups.push(group.name);
422
- }
423
- }
424
- result.permissions = appliedGroups;
425
- }
426
- }
427
- catch {
428
- // Ignore parse errors
429
- }
430
- }
431
- else if (agent === 'codex') {
432
- // Codex: config.toml for approval_policy/sandbox_mode, .rules for deny
433
- const codexConfigPath = path.join(configDir, 'config.toml');
434
- const codexRulesPath = path.join(configDir, 'rules', CODEX_RULES_FILENAME);
435
- const hasConfig = fs.existsSync(codexConfigPath);
436
- const hasRules = fs.existsSync(codexRulesPath);
437
- if (hasConfig || hasRules) {
438
- try {
439
- // Codex format is lossy — all groups merge into a few keys.
440
- // If any permission artifacts exist, all groups were applied together.
441
- let hasPermKeys = false;
442
- if (hasConfig) {
443
- const content = fs.readFileSync(codexConfigPath, 'utf-8');
444
- const config = TOML.parse(content);
445
- hasPermKeys = !!(config.approval_policy || config.sandbox_mode || config.sandbox_workspace_write);
446
- }
447
- if (hasPermKeys || hasRules) {
448
- result.permissions = discoverPermissionGroups().map(g => g.name);
449
- }
450
- }
451
- catch {
452
- // Ignore parse errors
453
- }
454
- }
455
- }
456
- else if (agent === 'opencode') {
457
- // OpenCode: opencode.jsonc for permission.bash
458
- const opencodeConfigPath = path.join(configDir, 'opencode.jsonc');
459
- if (fs.existsSync(opencodeConfigPath)) {
460
- try {
461
- const content = fs.readFileSync(opencodeConfigPath, 'utf-8');
462
- const stripped = content.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
463
- const config = JSON.parse(stripped);
464
- if (config.permission && Object.keys(config.permission.bash || {}).length > 0) {
465
- result.permissions = discoverPermissionGroups().map(g => g.name);
466
- }
467
- }
468
- catch {
469
- // Ignore parse errors
470
- }
471
- }
472
- }
473
- }
474
- // Subagents - check agent-specific locations
475
- if (SUBAGENT_CAPABLE_AGENTS.includes(agent)) {
476
- if (agent === 'claude') {
477
- const agentsDir = path.join(configDir, 'agents');
478
- if (fs.existsSync(agentsDir)) {
479
- result.subagents = fs.readdirSync(agentsDir)
480
- .filter(f => f.endsWith('.md'))
481
- .map(f => f.replace('.md', ''));
482
- }
483
- }
484
- else if (agent === 'openclaw') {
485
- // OpenClaw: directories with AGENTS.md
486
- const openclawDir = path.join(versionHome, '.openclaw');
487
- if (fs.existsSync(openclawDir)) {
488
- result.subagents = fs.readdirSync(openclawDir, { withFileTypes: true })
489
- .filter(d => d.isDirectory() && fs.existsSync(path.join(openclawDir, d.name, 'AGENTS.md')))
490
- .map(d => d.name);
491
- }
492
- }
493
- }
494
- // Plugins - check which discovered plugins have their skills in the version
495
- if (PLUGINS_CAPABLE_AGENTS.includes(agent)) {
496
- const allPlugins = discoverPlugins();
497
- for (const plugin of allPlugins) {
498
- if (isPluginSynced(plugin, agent, versionHome)) {
499
- result.plugins.push(plugin.name);
500
- }
501
- }
502
- }
503
- // Workflows - check {versionHome}/workflows/ for synced workflow directories
504
- if (WORKFLOW_CAPABLE_AGENTS.includes(agent)) {
505
- const workflowsDir = path.join(versionHome, 'workflows');
506
- if (fs.existsSync(workflowsDir)) {
507
- result.workflows = fs.readdirSync(workflowsDir, { withFileTypes: true })
508
- .filter(d => d.isDirectory() && fs.existsSync(path.join(workflowsDir, d.name, 'WORKFLOW.md')))
509
- .map(d => d.name);
510
- }
511
- }
292
+ // Dispatch each kind through DETECTORS. The registry guarantees a detector
293
+ // exists for every supported (agent, kind) pair; unsupported pairs leave
294
+ // the field empty. The previous per-agent if-ladder silently dropped
295
+ // antigravity/gemini/grok detection see PR description for details.
296
+ const ctx = { version, versionHome, cwd };
297
+ result.commands = getDetector('commands', agent)?.list(ctx) ?? [];
298
+ result.skills = getDetector('skills', agent)?.list(ctx) ?? [];
299
+ result.hooks = getDetector('hooks', agent)?.list(ctx) ?? [];
300
+ result.memory = getDetector('rules', agent)?.list(ctx) ?? [];
301
+ result.mcp = getDetector('mcp', agent)?.list(ctx) ?? [];
302
+ result.permissions = getDetector('permissions', agent)?.list(ctx) ?? [];
303
+ result.subagents = getDetector('subagents', agent)?.list(ctx) ?? [];
304
+ result.plugins = getDetector('plugins', agent)?.list(ctx) ?? [];
305
+ result.workflows = getDetector('workflows', agent)?.list(ctx) ?? [];
512
306
  return result;
513
307
  }
514
308
  /**
@@ -659,9 +453,9 @@ export function hasNewResources(diff, agent, version) {
659
453
  const hooksApply = agent ? supports(agent, 'hooks', version).ok : true;
660
454
  const mcpApply = agent ? supports(agent, 'mcp', version).ok : true;
661
455
  const permsApply = agent ? supports(agent, 'allowlist', version).ok : true;
662
- const subagentsApply = agent ? SUBAGENT_CAPABLE_AGENTS.includes(agent) : true;
456
+ const subagentsApply = agent ? supports(agent, 'subagents', version).ok : true;
663
457
  const pluginsApply = agent ? supports(agent, 'plugins', version).ok : true;
664
- const workflowsApply = agent ? WORKFLOW_CAPABLE_AGENTS.includes(agent) : true;
458
+ const workflowsApply = agent ? supports(agent, 'workflows', version).ok : true;
665
459
  return ((diff.commands.length > 0 && commandsApply) ||
666
460
  diff.skills.length > 0 ||
667
461
  (diff.hooks.length > 0 && hooksApply) ||
@@ -682,8 +476,9 @@ function buildNewResourcesSummary(newResources, agent, version) {
682
476
  // Use version-aware gates so Codex >= 0.117.0 (which converts commands to skills) doesn't
683
477
  // double-count and so "16 commands" never appears in the summary when commands have
684
478
  // already been emitted as skills in the version home.
685
- const commandsApply = version ? supports(agent, 'commands', version).ok : COMMANDS_CAPABLE_AGENTS.includes(agent);
479
+ const commandsApply = supports(agent, 'commands', version).ok;
686
480
  const commandsAsSkills = version ? shouldInstallCommandAsSkill(agent, version) : false;
481
+ const rulesApply = supports(agent, 'rules', version).ok;
687
482
  if (newResources.commands.length > 0 && (commandsApply || commandsAsSkills)) {
688
483
  parts.push(`${newResources.commands.length} command${newResources.commands.length === 1 ? '' : 's'}`);
689
484
  }
@@ -693,22 +488,22 @@ function buildNewResourcesSummary(newResources, agent, version) {
693
488
  if (newResources.hooks.length > 0 && agentConfig.supportsHooks) {
694
489
  parts.push(`${newResources.hooks.length} hook${newResources.hooks.length === 1 ? '' : 's'}`);
695
490
  }
696
- if (newResources.memory.length > 0 && (commandsApply || commandsAsSkills)) {
491
+ if (newResources.memory.length > 0 && rulesApply) {
697
492
  parts.push(`${newResources.memory.length} rule file${newResources.memory.length === 1 ? '' : 's'}`);
698
493
  }
699
- if (newResources.mcp.length > 0 && MCP_CAPABLE_AGENTS.includes(agent)) {
494
+ if (newResources.mcp.length > 0 && supports(agent, 'mcp', version).ok) {
700
495
  parts.push(`${newResources.mcp.length} MCP${newResources.mcp.length === 1 ? '' : 's'}`);
701
496
  }
702
- if (newResources.permissions.length > 0 && PERMISSIONS_CAPABLE_AGENTS.includes(agent)) {
497
+ if (newResources.permissions.length > 0 && supports(agent, 'allowlist', version).ok) {
703
498
  parts.push(`${newResources.permissions.length} permission group${newResources.permissions.length === 1 ? '' : 's'}`);
704
499
  }
705
- if (newResources.subagents.length > 0 && SUBAGENT_CAPABLE_AGENTS.includes(agent)) {
500
+ if (newResources.subagents.length > 0 && supports(agent, 'subagents', version).ok) {
706
501
  parts.push(`${newResources.subagents.length} subagent${newResources.subagents.length === 1 ? '' : 's'}`);
707
502
  }
708
- if (newResources.plugins.length > 0 && PLUGINS_CAPABLE_AGENTS.includes(agent)) {
503
+ if (newResources.plugins.length > 0 && supports(agent, 'plugins', version).ok) {
709
504
  parts.push(`${newResources.plugins.length} plugin${newResources.plugins.length === 1 ? '' : 's'}`);
710
505
  }
711
- if (newResources.workflows.length > 0 && WORKFLOW_CAPABLE_AGENTS.includes(agent)) {
506
+ if (newResources.workflows.length > 0 && supports(agent, 'workflows', version).ok) {
712
507
  parts.push(`${newResources.workflows.length} workflow${newResources.workflows.length === 1 ? '' : 's'}`);
713
508
  }
714
509
  return parts.join(', ');
@@ -723,9 +518,10 @@ export async function promptNewResourceSelection(agent, newResources, version) {
723
518
  // Version-aware gates. When version is known, prefer per-version capability checks; the
724
519
  // commands branch is allowed when either native commands are supported OR when the
725
520
  // version emits commands as converted skills (Codex >= 0.117.0).
726
- const commandsApply = version ? supports(agent, 'commands', version).ok : COMMANDS_CAPABLE_AGENTS.includes(agent);
521
+ const commandsApply = supports(agent, 'commands', version).ok;
727
522
  const commandsAsSkills = version ? shouldInstallCommandAsSkill(agent, version) : false;
728
523
  const commandsBranch = commandsApply || commandsAsSkills;
524
+ const rulesBranch = supports(agent, 'rules', version).ok;
729
525
  // Get permission group info for display
730
526
  const permissionGroups = discoverPermissionGroups();
731
527
  const newPermissionGroups = permissionGroups.filter(g => newResources.permissions.includes(g.name));
@@ -755,17 +551,17 @@ export async function promptNewResourceSelection(agent, newResources, version) {
755
551
  selection.skills = newResources.skills;
756
552
  if (newResources.hooks.length > 0 && agentConfig.supportsHooks)
757
553
  selection.hooks = newResources.hooks;
758
- if (newResources.memory.length > 0 && commandsBranch)
554
+ if (newResources.memory.length > 0 && rulesBranch)
759
555
  selection.memory = newResources.memory;
760
- if (newResources.mcp.length > 0 && MCP_CAPABLE_AGENTS.includes(agent))
556
+ if (newResources.mcp.length > 0 && supports(agent, 'mcp', version).ok)
761
557
  selection.mcp = newResources.mcp;
762
- if (newResources.permissions.length > 0 && PERMISSIONS_CAPABLE_AGENTS.includes(agent))
558
+ if (newResources.permissions.length > 0 && supports(agent, 'allowlist', version).ok)
763
559
  selection.permissions = newResources.permissions;
764
- if (newResources.subagents.length > 0 && SUBAGENT_CAPABLE_AGENTS.includes(agent))
560
+ if (newResources.subagents.length > 0 && supports(agent, 'subagents', version).ok)
765
561
  selection.subagents = newResources.subagents;
766
- if (newResources.plugins.length > 0 && PLUGINS_CAPABLE_AGENTS.includes(agent))
562
+ if (newResources.plugins.length > 0 && supports(agent, 'plugins', version).ok)
767
563
  selection.plugins = newResources.plugins;
768
- if (newResources.workflows.length > 0 && WORKFLOW_CAPABLE_AGENTS.includes(agent))
564
+ if (newResources.workflows.length > 0 && supports(agent, 'workflows', version).ok)
769
565
  selection.workflows = newResources.workflows;
770
566
  return selection;
771
567
  }
@@ -794,7 +590,7 @@ export async function promptNewResourceSelection(agent, newResources, version) {
794
590
  if (selected.length > 0)
795
591
  selection.hooks = selected;
796
592
  }
797
- if (newResources.memory.length > 0 && commandsBranch) {
593
+ if (newResources.memory.length > 0 && rulesBranch) {
798
594
  const selected = await checkbox({
799
595
  message: 'Select new rule files to sync:',
800
596
  choices: newResources.memory.map(m => ({ name: m, value: m, checked: true })),
@@ -802,7 +598,7 @@ export async function promptNewResourceSelection(agent, newResources, version) {
802
598
  if (selected.length > 0)
803
599
  selection.memory = selected;
804
600
  }
805
- if (newResources.mcp.length > 0 && MCP_CAPABLE_AGENTS.includes(agent)) {
601
+ if (newResources.mcp.length > 0 && supports(agent, 'mcp', version).ok) {
806
602
  const selected = await checkbox({
807
603
  message: 'Select new MCPs to sync:',
808
604
  choices: newResources.mcp.map(m => ({ name: m, value: m, checked: true })),
@@ -810,7 +606,7 @@ export async function promptNewResourceSelection(agent, newResources, version) {
810
606
  if (selected.length > 0)
811
607
  selection.mcp = selected;
812
608
  }
813
- if (newResources.permissions.length > 0 && PERMISSIONS_CAPABLE_AGENTS.includes(agent)) {
609
+ if (newResources.permissions.length > 0 && supports(agent, 'allowlist', version).ok) {
814
610
  const selected = await checkbox({
815
611
  message: 'Select new permission groups to sync:',
816
612
  choices: newPermissionGroups.map(g => ({
@@ -822,7 +618,7 @@ export async function promptNewResourceSelection(agent, newResources, version) {
822
618
  if (selected.length > 0)
823
619
  selection.permissions = selected;
824
620
  }
825
- if (newResources.subagents.length > 0 && SUBAGENT_CAPABLE_AGENTS.includes(agent)) {
621
+ if (newResources.subagents.length > 0 && supports(agent, 'subagents', version).ok) {
826
622
  const selected = await checkbox({
827
623
  message: 'Select new subagents to sync:',
828
624
  choices: newResources.subagents.map(s => ({ name: s, value: s, checked: true })),
@@ -830,7 +626,7 @@ export async function promptNewResourceSelection(agent, newResources, version) {
830
626
  if (selected.length > 0)
831
627
  selection.subagents = selected;
832
628
  }
833
- if (newResources.plugins.length > 0 && PLUGINS_CAPABLE_AGENTS.includes(agent)) {
629
+ if (newResources.plugins.length > 0 && supports(agent, 'plugins', version).ok) {
834
630
  const allPlugins = discoverPlugins();
835
631
  const pluginMap = new Map(allPlugins.map(p => [p.name, p]));
836
632
  const selected = await checkbox({
@@ -844,7 +640,7 @@ export async function promptNewResourceSelection(agent, newResources, version) {
844
640
  if (selected.length > 0)
845
641
  selection.plugins = selected;
846
642
  }
847
- if (newResources.workflows.length > 0 && WORKFLOW_CAPABLE_AGENTS.includes(agent)) {
643
+ if (newResources.workflows.length > 0 && supports(agent, 'workflows', version).ok) {
848
644
  const selected = await checkbox({
849
645
  message: 'Select new workflows to sync:',
850
646
  choices: newResources.workflows.map(w => ({ name: w, value: w, checked: true })),
@@ -866,14 +662,14 @@ export async function promptResourceSelection(agent) {
866
662
  const permissionGroups = discoverPermissionGroups();
867
663
  const totalPermissionRules = permissionGroups.reduce((sum, g) => sum + g.ruleCount, 0);
868
664
  const categories = [
869
- { key: 'commands', label: 'Commands', available: COMMANDS_CAPABLE_AGENTS.includes(agent) && available.commands.length > 0, displayCount: `${available.commands.length} available` },
665
+ { key: 'commands', label: 'Commands', available: supports(agent, 'commands').ok && available.commands.length > 0, displayCount: `${available.commands.length} available` },
870
666
  { key: 'skills', label: 'Skills', available: available.skills.length > 0, displayCount: `${available.skills.length} available` },
871
667
  { key: 'hooks', label: 'Hooks', available: agentConfig.supportsHooks && available.hooks.length > 0, displayCount: `${available.hooks.length} available` },
872
- { key: 'memory', label: 'Rules', available: COMMANDS_CAPABLE_AGENTS.includes(agent) && available.memory.length > 0, displayCount: `${available.memory.length} available` },
873
- { key: 'mcp', label: 'MCPs', available: MCP_CAPABLE_AGENTS.includes(agent) && available.mcp.length > 0, displayCount: `${available.mcp.length} available` },
874
- { key: 'permissions', label: 'Permissions', available: PERMISSIONS_CAPABLE_AGENTS.includes(agent) && permissionGroups.length > 0, displayCount: `${permissionGroups.length} groups, ${totalPermissionRules} rules` },
875
- { key: 'subagents', label: 'Subagents', available: SUBAGENT_CAPABLE_AGENTS.includes(agent) && available.subagents.length > 0, displayCount: `${available.subagents.length} available` },
876
- { key: 'plugins', label: 'Plugins', available: PLUGINS_CAPABLE_AGENTS.includes(agent) && available.plugins.length > 0, displayCount: `${available.plugins.length} available` },
668
+ { key: 'memory', label: 'Rules', available: supports(agent, 'rules').ok && available.memory.length > 0, displayCount: `${available.memory.length} available` },
669
+ { key: 'mcp', label: 'MCPs', available: supports(agent, 'mcp').ok && available.mcp.length > 0, displayCount: `${available.mcp.length} available` },
670
+ { key: 'permissions', label: 'Permissions', available: supports(agent, 'allowlist').ok && permissionGroups.length > 0, displayCount: `${permissionGroups.length} groups, ${totalPermissionRules} rules` },
671
+ { key: 'subagents', label: 'Subagents', available: supports(agent, 'subagents').ok && available.subagents.length > 0, displayCount: `${available.subagents.length} available` },
672
+ { key: 'plugins', label: 'Plugins', available: supports(agent, 'plugins').ok && available.plugins.length > 0, displayCount: `${available.plugins.length} available` },
877
673
  ];
878
674
  const availableCategories = categories.filter(c => c.available);
879
675
  if (availableCategories.length === 0) {
@@ -1050,7 +846,7 @@ export async function getLatestNpmVersion(agent) {
1050
846
  if (!agentConfig.npmPackage)
1051
847
  return null;
1052
848
  try {
1053
- const { stdout } = await execFileAsync('npm', ['view', agentConfig.npmPackage, 'version']);
849
+ const { stdout } = await execFileAsync('npm', ['view', agentConfig.npmPackage, 'version'], { shell: process.platform === 'win32' });
1054
850
  return stdout.trim();
1055
851
  }
1056
852
  catch {
@@ -1176,6 +972,26 @@ export async function installVersion(agent, version, onProgress) {
1176
972
  const versionDir = getVersionDir(agent, installedVersion);
1177
973
  fs.mkdirSync(versionDir, { recursive: true });
1178
974
  fs.mkdirSync(path.join(versionDir, 'home'), { recursive: true });
975
+ // Symlink the installed binary into the version's node_modules/.bin so
976
+ // listInstalledVersions (which checks getBinaryPath) sees this version as
977
+ // installed. Without this, `agents add antigravity@latest` succeeds
978
+ // but `agents view` shows the agent under "Not Managed" because
979
+ // listInstalledVersions returns [] — the installer drops the binary in
980
+ // ~/.local/bin (or similar) rather than the version's node_modules/.bin.
981
+ // Grok is special-cased in getBinaryPath itself (binary lives in
982
+ // ~/.grok/downloads), so we skip the symlink there.
983
+ if (agent !== 'grok') {
984
+ try {
985
+ const { stdout: whichOut } = await execFileAsync(process.platform === 'win32' ? 'where' : 'which', [agentConfig.cliCommand]);
986
+ const installedBinary = whichOut.trim().split('\n')[0];
987
+ if (installedBinary && fs.existsSync(installedBinary)) {
988
+ importInstallScriptBinary({ agentId: agent, npmPackage: agentConfig.npmPackage, cliCommand: agentConfig.cliCommand }, installedVersion, installedBinary, versionDir);
989
+ }
990
+ }
991
+ catch {
992
+ /* binary missing from PATH — install script failed silently; surface via the existing version.install error path below isn't possible here since the script returned 0. Leave the version dir empty so getBinaryPath check correctly reports it uninstalled. */
993
+ }
994
+ }
1179
995
  createVersionedAlias(agent, installedVersion);
1180
996
  emit('version.install', { agent, version: installedVersion });
1181
997
  return { success: true, installedVersion };
@@ -1198,8 +1014,9 @@ export async function installVersion(agent, version, onProgress) {
1198
1014
  : `${agentConfig.npmPackage}@${version}`;
1199
1015
  try {
1200
1016
  // Check npm is available
1017
+ const winShell = process.platform === 'win32';
1201
1018
  try {
1202
- await execFileAsync('which', ['npm']);
1019
+ await execFileAsync('npm', ['--version'], { shell: winShell });
1203
1020
  }
1204
1021
  catch {
1205
1022
  return {
@@ -1209,7 +1026,7 @@ export async function installVersion(agent, version, onProgress) {
1209
1026
  };
1210
1027
  }
1211
1028
  onProgress?.(`Installing ${packageSpec}...`);
1212
- const { stdout } = await execFileAsync('npm', ['install', packageSpec], { cwd: versionDir });
1029
+ const { stdout } = await execFileAsync('npm', ['install', packageSpec], { cwd: versionDir, shell: winShell });
1213
1030
  // Determine the actual installed version
1214
1031
  let installedVersion = version;
1215
1032
  if (version === 'latest') {
@@ -1282,10 +1099,10 @@ function removeInstallArtifacts(versionDir) {
1282
1099
  }
1283
1100
  }
1284
1101
  /**
1285
- * Soft-delete a version directory by moving it to ~/.agents-system/trash/versions/.
1102
+ * Soft-delete a version directory by moving it to ~/.agents/.system/trash/versions/.
1286
1103
  * Returns the trash path on success or null on failure / no source.
1287
1104
  *
1288
- * Trash layout: ~/.agents-system/trash/versions/<agent>/<version>/<timestamp>/
1105
+ * Trash layout: ~/.agents/.system/trash/versions/<agent>/<version>/<timestamp>/
1289
1106
  * The timestamp suffix lets a user soft-delete the same version twice (after
1290
1107
  * re-install) without collision and gives a chronological audit trail.
1291
1108
  *
@@ -1314,7 +1131,7 @@ export function softDeleteVersionDir(agent, version) {
1314
1131
  * Remove a specific version of an agent.
1315
1132
  *
1316
1133
  * Soft-delete only: moves the entire version directory (including `home/`)
1317
- * to ~/.agents-system/trash/versions/. Recoverable via `agents trash restore`.
1134
+ * to ~/.agents/.system/trash/versions/. Recoverable via `agents trash restore`.
1318
1135
  * Nothing is hard-deleted.
1319
1136
  */
1320
1137
  export function removeVersion(agent, version) {
@@ -1453,7 +1270,7 @@ export function resolveVersionAliasLoose(agent, raw) {
1453
1270
  return raw;
1454
1271
  }
1455
1272
  /**
1456
- * Get version specified in a project-root agents.yaml (not the user ~/.agents-system/agents.yaml).
1273
+ * Get version specified in a project-root agents.yaml (not the user ~/.agents/.system/agents.yaml).
1457
1274
  */
1458
1275
  export function getProjectVersion(agent, startPath) {
1459
1276
  const userAgentsYaml = path.join(getUserAgentsDir(), 'agents.yaml');
@@ -1519,8 +1336,7 @@ export async function getInstalledVersion(agent, version) {
1519
1336
  async function getCliVersionFromPath(agent) {
1520
1337
  const agentConfig = AGENTS[agent];
1521
1338
  try {
1522
- await execFileAsync('which', [agentConfig.cliCommand]);
1523
- const { stdout } = await execFileAsync(agentConfig.cliCommand, ['--version'], { timeout: 3000 });
1339
+ const { stdout } = await execFileAsync(agentConfig.cliCommand, ['--version'], { timeout: 3000, shell: process.platform === 'win32' });
1524
1340
  const match = stdout.match(/(\d+\.\d+\.\d+)/);
1525
1341
  return match ? match[1] : null;
1526
1342
  }
@@ -1818,59 +1634,23 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
1818
1634
  }
1819
1635
  return [];
1820
1636
  };
1821
- // Sync commands
1637
+ // Sync commands — dispatch through WRITERS.commands. The writer dispatches
1638
+ // between native (file copy / TOML conversion) and commands-as-skills
1639
+ // (grok, Codex >= 0.117.0) based on `shouldInstallCommandAsSkill`. The
1640
+ // previous COMMANDS_CAPABLE_AGENTS gate excluded grok even though it
1641
+ // takes the commands-as-skills path — silently dropping every command.
1642
+ const commandsWriter = getWriter('commands', agent);
1822
1643
  const commandsToSync = selection
1823
1644
  ? resolveSelection(selection.commands, available.commands)
1824
1645
  : available.commands; // No selection = sync all
1825
- if (commandsToSync.length > 0 && COMMANDS_CAPABLE_AGENTS.includes(agent)) {
1646
+ if (commandsToSync.length > 0 && commandsWriter) {
1826
1647
  const commandsTarget = path.join(agentDir, agentConfig.commandsSubdir);
1827
1648
  const commandsAsSkills = shouldInstallCommandAsSkill(agent, version);
1828
1649
  if (commandsAsSkills) {
1829
1650
  removePath(commandsTarget);
1830
1651
  }
1831
- else {
1832
- fs.mkdirSync(commandsTarget, { recursive: true });
1833
- }
1834
- const syncedCommands = [];
1835
- for (const cmd of commandsToSync) {
1836
- // Commands are content that gets injected into the agent's prompt
1837
- // surface (slash commands, skill bodies). We intentionally do NOT pull
1838
- // from the project's own .agents/commands/ directory: a cloned public
1839
- // repo could ship a command whose body instructs the agent to do
1840
- // something harmful the next time the user invokes it. Commands must
1841
- // come from the user's central ~/.agents/commands/, the system layer,
1842
- // or an explicitly enabled extra repo. Same defense as hooks below.
1843
- const candidates = [
1844
- safeJoin(path.join(userAgentsDir, 'commands'), `${cmd}.md`),
1845
- safeJoin(getCommandsDir(), `${cmd}.md`),
1846
- ...extraRepos.map((e) => safeJoin(path.join(e.dir, 'commands'), `${cmd}.md`)),
1847
- ];
1848
- const srcFile = candidates.find((p) => p && fs.existsSync(p) && !fs.lstatSync(p).isSymbolicLink()) || null;
1849
- if (!srcFile)
1850
- continue;
1851
- if (commandsAsSkills) {
1852
- // Project skills dir is intentionally excluded for the same reason
1853
- // commands are: the body of a project skill becomes agent context.
1854
- const skillSourceDirs = [
1855
- path.join(userAgentsDir, 'skills'),
1856
- getSkillsDir(),
1857
- ...extraRepos.map((e) => path.join(e.dir, 'skills')),
1858
- ];
1859
- const installed = installCommandSkillToVersion(agentDir, cmd, srcFile, skillSourceDirs);
1860
- if (!installed.success)
1861
- continue;
1862
- }
1863
- else if (agentConfig.format === 'toml') {
1864
- const content = fs.readFileSync(srcFile, 'utf-8');
1865
- const tomlContent = markdownToToml(cmd, content);
1866
- fs.writeFileSync(safeJoin(commandsTarget, `${cmd}.toml`), tomlContent);
1867
- }
1868
- else {
1869
- fs.copyFileSync(srcFile, safeJoin(commandsTarget, `${cmd}.md`));
1870
- }
1871
- syncedCommands.push(cmd);
1872
- }
1873
- result.commands = syncedCommands.length > 0;
1652
+ const r = commandsWriter.write({ version, versionHome, selection: commandsToSync, cwd });
1653
+ result.commands = r.synced.length > 0;
1874
1654
  }
1875
1655
  // Orphan-sweep stale top-level command files from previous syncs under a
1876
1656
  // different cwd. Only runs in "full sync" mode — i.e. when the caller did
@@ -1879,7 +1659,7 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
1879
1659
  // alone), so the sweep would be a contract violation there. The
1880
1660
  // cross-project leak always comes from the no-selection shim auto-sync at
1881
1661
  // launch.
1882
- if (!userPassedSelection && COMMANDS_CAPABLE_AGENTS.includes(agent) && !shouldInstallCommandAsSkill(agent, version)) {
1662
+ if (!userPassedSelection && commandsWriter && !shouldInstallCommandAsSkill(agent, version)) {
1883
1663
  const commandsTargetSweep = path.join(agentDir, agentConfig.commandsSubdir);
1884
1664
  if (fs.existsSync(commandsTargetSweep)) {
1885
1665
  const ext = agentConfig.format === 'toml' ? '.toml' : '.md';
@@ -1896,51 +1676,20 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
1896
1676
  }
1897
1677
  }
1898
1678
  }
1899
- // Sync skills (skip if agent natively reads ~/.agents/skills/)
1679
+ // Sync skills dispatch through WRITERS.skills. Agents that natively read
1680
+ // ~/.agents/skills/ (Gemini) are not registered; we clear the version-home
1681
+ // skills dir for them so a stale per-version copy never shadows central.
1682
+ const skillsWriter = getWriter('skills', agent);
1683
+ const skillsToSync = selection
1684
+ ? resolveSelection(selection.skills, available.skills)
1685
+ : available.skills;
1900
1686
  if (agentConfig.nativeAgentsSkillsDir) {
1901
- // Clean up stale skills symlink/dir — agent reads from ~/.agents/skills/ directly
1902
- const skillsTarget = path.join(agentDir, 'skills');
1903
- removePath(skillsTarget);
1687
+ removePath(path.join(agentDir, 'skills'));
1904
1688
  }
1905
- else {
1906
- const skillsToSync = selection
1907
- ? resolveSelection(selection.skills, available.skills)
1908
- : available.skills;
1689
+ else if (skillsWriter) {
1909
1690
  if (skillsToSync.length > 0) {
1910
- const skillsTarget = path.join(agentDir, 'skills');
1911
- // Old version homes may have skills -> ~/.agents/skills. Replace the
1912
- // parent symlink before touching children so removePath(destDir) cannot
1913
- // delete the central source through it.
1914
- try {
1915
- if (fs.lstatSync(skillsTarget).isSymbolicLink()) {
1916
- removePath(skillsTarget);
1917
- }
1918
- }
1919
- catch { /* target does not exist yet */ }
1920
- fs.mkdirSync(skillsTarget, { recursive: true });
1921
- const syncedSkills = [];
1922
- for (const skill of skillsToSync) {
1923
- // Same defense as commands and hooks: don't pull skills from the
1924
- // project's .agents/skills/ directory. A skill's contents (SKILL.md
1925
- // and any auxiliary scripts) get loaded into the agent's tool/context
1926
- // surface, and a malicious public repo could ship a SKILL.md whose
1927
- // body coerces the agent. Trusted layers only.
1928
- const skillCandidates = [
1929
- safeJoin(path.join(userAgentsDir, 'skills'), skill),
1930
- safeJoin(getSkillsDir(), skill),
1931
- ...extraRepos.map((e) => safeJoin(path.join(e.dir, 'skills'), skill)),
1932
- ];
1933
- const srcDir = skillCandidates.find((p) => fs.existsSync(p) &&
1934
- !fs.lstatSync(p).isSymbolicLink() &&
1935
- fs.lstatSync(p).isDirectory()) || null;
1936
- if (!srcDir)
1937
- continue;
1938
- const destDir = safeJoin(skillsTarget, skill);
1939
- removePath(destDir);
1940
- copyDir(srcDir, destDir);
1941
- syncedSkills.push(skill);
1942
- }
1943
- result.skills = syncedSkills.length > 0;
1691
+ const r = skillsWriter.write({ version, versionHome, selection: skillsToSync, cwd });
1692
+ result.skills = r.synced.length > 0;
1944
1693
  }
1945
1694
  // Orphan-sweep stale skill directories from previous syncs under a
1946
1695
  // different cwd. Only runs in "full sync" mode (no explicit selection) —
@@ -1958,9 +1707,11 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
1958
1707
  }
1959
1708
  }
1960
1709
  }
1961
- // Sync hooks (if agent supports them at this version)
1710
+ // Sync hooks dispatch through WRITERS.hooks. supports() gate enforces
1711
+ // the version cutoff (codex >= 0.116.0, gemini >= 0.26.0).
1962
1712
  const hooksGate = supports(agent, 'hooks', version);
1963
- if (agentConfig.supportsHooks) {
1713
+ const hooksWriter = getWriter('hooks', agent);
1714
+ if (agentConfig.supportsHooks && hooksWriter) {
1964
1715
  if (!hooksGate.ok) {
1965
1716
  console.warn(explainSkip(agent, 'hooks', hooksGate, version) + ' -- skipped');
1966
1717
  }
@@ -1969,35 +1720,13 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
1969
1720
  ? resolveSelection(selection.hooks, available.hooks)
1970
1721
  : available.hooks;
1971
1722
  if (hooksToSync.length > 0) {
1972
- const centralHooks = getHooksDir();
1973
- const hooksTarget = path.join(agentDir, 'hooks');
1974
- fs.mkdirSync(hooksTarget, { recursive: true });
1975
- const syncedHooks = [];
1976
- for (const hook of hooksToSync) {
1977
- // Hooks are executable shell scripts that run on agent events. We
1978
- // intentionally do NOT pull from the project's own .agents/hooks/
1979
- // directory: that would let any cloned public repo plant an
1980
- // executable that fires the next time the user runs `agents use`
1981
- // inside that repo. Hooks must come from the user's central
1982
- // ~/.agents/hooks/ or an explicitly enabled extra repo.
1983
- const candidates = [
1984
- safeJoin(path.join(userAgentsDir, 'hooks'), hook),
1985
- safeJoin(centralHooks, hook),
1986
- ...extraRepos.map((e) => safeJoin(path.join(e.dir, 'hooks'), hook)),
1987
- ];
1988
- const srcFile = candidates.find((p) => p && fs.existsSync(p) && !fs.lstatSync(p).isSymbolicLink()) || null;
1989
- if (!srcFile)
1990
- continue;
1991
- const destFile = safeJoin(hooksTarget, hook);
1992
- fs.copyFileSync(srcFile, destFile);
1993
- fs.chmodSync(destFile, 0o755);
1994
- syncedHooks.push(hook);
1995
- }
1723
+ const r = hooksWriter.write({ version, versionHome, selection: hooksToSync, cwd });
1996
1724
  // Remove orphan files from version home. The trusted set is the
1997
1725
  // manifest-declared hook list (`available.hooks`) — auxiliary files
1998
1726
  // like README.md or promptcuts.yaml may exist alongside hooks at the
1999
1727
  // source but are not hooks and must not linger in version homes from
2000
1728
  // older syncs.
1729
+ const hooksTarget = path.join(agentDir, 'hooks');
2001
1730
  const trustedHookNames = new Set(available.hooks);
2002
1731
  if (fs.existsSync(hooksTarget)) {
2003
1732
  for (const file of fs.readdirSync(hooksTarget).filter(f => !f.startsWith('.'))) {
@@ -2006,23 +1735,19 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2006
1735
  }
2007
1736
  }
2008
1737
  }
2009
- result.hooks = syncedHooks.length > 0;
2010
- // Register hooks into agent-native settings.json/hooks.json. Gemini
2011
- // shipped hooks in 0.26.0; gate already passed above so this is safe.
2012
- // Grok auto-discovers from ~/.grok/hooks/ so the script copy above
2013
- // is sufficient — no settings.json registration needed.
2014
- if (agent === 'claude' || agent === 'codex' || agent === 'gemini' || agent === 'antigravity') {
2015
- registerHooksToSettings(agent, versionHome);
2016
- }
1738
+ result.hooks = r.synced.length > 0;
2017
1739
  }
2018
1740
  }
2019
1741
  }
2020
- // Sync rules — compose from layered subrules + active preset and write a
2021
- // single inlined instruction file. No @-import expansion; no per-fragment
2022
- // copies. Project rules are NOT synced into the version home they are
2023
- // composed into the workspace at agents-run time (see compileRulesForProject).
1742
+ // Sync rules — dispatch through WRITERS.rules. The registry routes to the
1743
+ // single-target writer for any agent that declares `rules: { file }` in
1744
+ // its capability matrix (grok included; the previous gate used the wrong
1745
+ // CAPABLE_AGENTS list and silently skipped it). Project rules are NOT
1746
+ // synced into the version home — they are composed into the workspace at
1747
+ // agents-run time (see compileRulesForProject).
2024
1748
  const skipMemory = selection && (selection.memory === undefined || (Array.isArray(selection.memory) && selection.memory.length === 0));
2025
- if (!skipMemory && COMMANDS_CAPABLE_AGENTS.includes(agent)) {
1749
+ const rulesWriter = getWriter('rules', agent);
1750
+ if (!skipMemory && rulesWriter) {
2026
1751
  try {
2027
1752
  // If selection.memory names a single preset, treat it as a one-shot
2028
1753
  // override; otherwise read the persisted active preset.
@@ -2030,13 +1755,8 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2030
1755
  ? selection.memory[0]
2031
1756
  : null;
2032
1757
  const preset = overridePreset || getActiveRulesPreset(agent, version);
2033
- const composed = composeRulesFromState({ preset });
2034
- const targetName = agentConfig.instructionsFile;
2035
- const destFile = safeJoin(agentDir, targetName);
2036
- fs.mkdirSync(path.dirname(destFile), { recursive: true });
2037
- removePath(destFile);
2038
- fs.writeFileSync(destFile, composed.content);
2039
- result.memory.push(targetName);
1758
+ const r = rulesWriter.write({ version, versionHome, selection: { preset }, cwd });
1759
+ result.memory.push(...r.synced);
2040
1760
  // rulesPreset is tracked separately via setActiveRulesPreset.
2041
1761
  }
2042
1762
  catch (err) {
@@ -2068,6 +1788,7 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2068
1788
  console.warn(`${PERMISSION_PRESET_ENV_VAR}=${activePresetName} but no recipe at ~/.agents/permissions/presets/${activePresetName}.yaml — falling back to all groups`);
2069
1789
  }
2070
1790
  }
1791
+ const permissionsWriter = getWriter('permissions', agent);
2071
1792
  let permsToSync;
2072
1793
  if (selection) {
2073
1794
  permsToSync = resolveSelection(selection.permissions, allGroupNames);
@@ -2082,18 +1803,12 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2082
1803
  }
2083
1804
  }
2084
1805
  else {
2085
- permsToSync = PERMISSIONS_CAPABLE_AGENTS.includes(agent)
2086
- ? (presetFilteredGroups ?? allGroupNames)
2087
- : [];
1806
+ permsToSync = permissionsWriter ? (presetFilteredGroups ?? allGroupNames) : [];
2088
1807
  }
2089
- if (permsToSync.length > 0 && PERMISSIONS_CAPABLE_AGENTS.includes(agent)) {
2090
- // Build permissions from selected groups
2091
- const builtPerms = buildPermissionsFromGroups(permsToSync);
2092
- if (builtPerms.allow.length > 0 || (builtPerms.deny && builtPerms.deny.length > 0)) {
2093
- const permResult = applyPermsToVersion(agent, builtPerms, versionHome, true);
2094
- result.permissions = permResult.success;
2095
- // permissions patterns already written via ensureVersionResourcePatterns above.
2096
- }
1808
+ if (permsToSync.length > 0 && permissionsWriter) {
1809
+ const r = permissionsWriter.write({ version, versionHome, selection: permsToSync, cwd });
1810
+ result.permissions = r.synced.length > 0;
1811
+ // permissions patterns already written via ensureVersionResourcePatterns above.
2097
1812
  }
2098
1813
  // Install MCP servers (if agent supports them)
2099
1814
  // For Claude/Codex: uses CLI commands (claude mcp add, codex mcp add)
@@ -2108,57 +1823,29 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2108
1823
  // user entry, so name-collision shadowing is not fully closed here —
2109
1824
  // tracked separately for a follow-up in lib/mcp.ts.)
2110
1825
  const projectScopedMcpNames = new Set(getScopedMcpResources(cwd).filter(r => r.scope === 'project').map(r => r.name));
1826
+ const mcpWriter = getWriter('mcp', agent);
2111
1827
  const mcpToSyncAll = selection
2112
1828
  ? resolveSelection(selection.mcp, available.mcp)
2113
- : (MCP_CAPABLE_AGENTS.includes(agent) ? available.mcp : []);
1829
+ : (mcpWriter ? available.mcp : []);
2114
1830
  const mcpToSync = mcpToSyncAll.filter(n => !projectScopedMcpNames.has(n));
2115
- if (mcpToSync.length > 0 && MCP_CAPABLE_AGENTS.includes(agent)) {
2116
- const mcpResult = installMcpServers(agent, version, versionHome, mcpToSync, { cwd });
2117
- result.mcp = mcpResult.applied;
1831
+ if (mcpToSync.length > 0 && mcpWriter) {
1832
+ const r = mcpWriter.write({ version, versionHome, selection: mcpToSync, cwd });
1833
+ result.mcp = r.synced;
2118
1834
  // mcp patterns already written via ensureVersionResourcePatterns above.
2119
1835
  }
2120
- // Sync subagents (claude and openclaw only).
2121
- // Note: listInstalledSubagents (used to populate the map below) reads only
2122
- // user + system layers — never project. Subagents bundle prompts that fire
2123
- // when the agent delegates work, so a cloned public repo must not be able
2124
- // to plant a subagent the user later invokes. Same defense as hooks.
1836
+ // Sync subagents dispatch through WRITERS.subagents. listInstalledSubagents
1837
+ // reads only user + system layers (project excluded for the same defense
1838
+ // as commands/skills/hooks).
1839
+ const subagentsWriter = getWriter('subagents', agent);
2125
1840
  const subagentsToSync = selection
2126
1841
  ? resolveSelection(selection.subagents, available.subagents)
2127
- : (SUBAGENT_CAPABLE_AGENTS.includes(agent) ? available.subagents : []);
2128
- if (subagentsToSync.length > 0 && SUBAGENT_CAPABLE_AGENTS.includes(agent)) {
2129
- const allSubagents = listInstalledSubagents();
2130
- const subagentsMap = new Map(allSubagents.map(s => [s.name, s]));
2131
- for (const name of subagentsToSync) {
2132
- const subagent = subagentsMap.get(name);
2133
- if (!subagent)
2134
- continue;
2135
- try {
2136
- if (agent === 'claude') {
2137
- // Claude: flatten to single .md file
2138
- const agentsDir = path.join(agentDir, 'agents');
2139
- fs.mkdirSync(agentsDir, { recursive: true });
2140
- const transformed = transformSubagentForClaude(subagent.path);
2141
- fs.writeFileSync(safeJoin(agentsDir, `${subagent.name}.md`), transformed);
2142
- result.subagents.push(subagent.name);
2143
- }
2144
- else if (agent === 'openclaw') {
2145
- // OpenClaw: copy full directory, rename AGENT.md -> AGENTS.md
2146
- const targetDir = safeJoin(path.join(versionHome, '.openclaw'), subagent.name);
2147
- const syncResult = syncSubagentToOpenclaw(subagent.path, targetDir);
2148
- if (syncResult.success) {
2149
- result.subagents.push(subagent.name);
2150
- }
2151
- }
2152
- }
2153
- catch { /* resource sync failed for this item */ }
2154
- }
2155
- // Orphan-sweep stale subagents. Same selection-mode guard as the
2156
- // commands/skills sweeps above. Claude stores them as flat .md files
2157
- // under `<agentDir>/agents/`; OpenClaw stores them as subdirs at the
2158
- // same level as commands/skills/hooks/plugins (no isolated parent dir),
2159
- // which means a directory-readdir sweep would unsafely hit unrelated
2160
- // resources. For OpenClaw we lean on the existing per-name copy path —
2161
- // if the user wants strict isolation on OpenClaw, track via manifest.
1842
+ : (subagentsWriter ? available.subagents : []);
1843
+ if (subagentsToSync.length > 0 && subagentsWriter) {
1844
+ const r = subagentsWriter.write({ version, versionHome, selection: subagentsToSync, cwd });
1845
+ result.subagents.push(...r.synced);
1846
+ // Orphan-sweep for Claude only see comment on commands/skills sweep
1847
+ // for the no-selection guard. OpenClaw stores subagents as siblings of
1848
+ // other resources so a readdir sweep would over-reach.
2162
1849
  if (!userPassedSelection && agent === 'claude') {
2163
1850
  const claudeAgentsDir = path.join(agentDir, 'agents');
2164
1851
  if (fs.existsSync(claudeAgentsDir)) {
@@ -2175,48 +1862,24 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2175
1862
  }
2176
1863
  }
2177
1864
  }
2178
- // subagent patterns already written via ensureVersionResourcePatterns above.
2179
1865
  }
2180
- // Sync plugins (claude and openclaw)
1866
+ // Sync plugins dispatch through WRITERS.plugins.
1867
+ const pluginsWriter = getWriter('plugins', agent);
2181
1868
  const pluginsToSync = selection
2182
1869
  ? resolveSelection(selection.plugins, available.plugins)
2183
- : (PLUGINS_CAPABLE_AGENTS.includes(agent) ? available.plugins : []);
2184
- if (pluginsToSync.length > 0 && PLUGINS_CAPABLE_AGENTS.includes(agent)) {
2185
- const allPlugins = discoverPlugins();
2186
- const pluginMap = new Map(allPlugins.map(p => [p.name, p]));
2187
- // Clean orphaned plugin skills from plugins that no longer exist
2188
- const activePluginNames = new Set(allPlugins.map(p => p.name));
2189
- cleanOrphanedPluginSkills(agent, versionHome, activePluginNames);
2190
- for (const name of pluginsToSync) {
2191
- const plugin = pluginMap.get(name);
2192
- if (!plugin || !pluginSupportsAgent(plugin, agent))
2193
- continue;
2194
- const pluginResult = syncPluginToVersion(plugin, agent, versionHome, { version });
2195
- if (pluginResult.success) {
2196
- result.plugins.push(name);
2197
- }
2198
- }
2199
- // plugin patterns already written via ensureVersionResourcePatterns above.
1870
+ : (pluginsWriter ? available.plugins : []);
1871
+ if (pluginsToSync.length > 0 && pluginsWriter) {
1872
+ const r = pluginsWriter.write({ version, versionHome, selection: pluginsToSync, cwd });
1873
+ result.plugins.push(...r.synced);
2200
1874
  }
2201
- // Sync workflows (claude only)
1875
+ // Sync workflows dispatch through WRITERS.workflows.
1876
+ const workflowsWriter = getWriter('workflows', agent);
2202
1877
  const workflowsToSync = selection
2203
1878
  ? resolveSelection(selection.workflows, available.workflows)
2204
- : (WORKFLOW_CAPABLE_AGENTS.includes(agent) ? available.workflows : []);
2205
- if (workflowsToSync.length > 0 && WORKFLOW_CAPABLE_AGENTS.includes(agent)) {
2206
- const allWorkflows = listInstalledWorkflows();
2207
- for (const name of workflowsToSync) {
2208
- const workflow = allWorkflows.get(name);
2209
- if (!workflow)
2210
- continue;
2211
- try {
2212
- const syncResult = syncWorkflowToVersion(workflow.path, name, agent, versionHome);
2213
- if (syncResult.success) {
2214
- result.workflows.push(name);
2215
- }
2216
- }
2217
- catch { /* resource sync failed for this item */ }
2218
- }
2219
- // workflow patterns already written via ensureVersionResourcePatterns above.
1879
+ : (workflowsWriter ? available.workflows : []);
1880
+ if (workflowsToSync.length > 0 && workflowsWriter) {
1881
+ const r = workflowsWriter.write({ version, versionHome, selection: workflowsToSync, cwd });
1882
+ result.workflows.push(...r.synced);
2220
1883
  }
2221
1884
  // Write manifest after a full sync (no user-passed selection) so the next
2222
1885
  // launch can skip the slow path. Pattern-derived selections still count as