@phnx-labs/agents-cli 1.20.4 → 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 (190) hide show
  1. package/CHANGELOG.md +19 -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/inspect.d.ts +26 -0
  10. package/dist/commands/inspect.js +590 -0
  11. package/dist/commands/mcp.js +17 -16
  12. package/dist/commands/models.js +1 -1
  13. package/dist/commands/packages.js +6 -4
  14. package/dist/commands/permissions.js +13 -12
  15. package/dist/commands/plugins.d.ts +13 -0
  16. package/dist/commands/plugins.js +100 -11
  17. package/dist/commands/prune.js +3 -2
  18. package/dist/commands/pull.d.ts +12 -5
  19. package/dist/commands/pull.js +26 -422
  20. package/dist/commands/push.d.ts +14 -0
  21. package/dist/commands/push.js +30 -0
  22. package/dist/commands/repo.d.ts +1 -1
  23. package/dist/commands/repo.js +155 -112
  24. package/dist/commands/resource-view.d.ts +2 -0
  25. package/dist/commands/resource-view.js +12 -3
  26. package/dist/commands/routines.js +32 -7
  27. package/dist/commands/rules.js +1 -1
  28. package/dist/commands/sessions.js +1 -0
  29. package/dist/commands/setup.d.ts +3 -3
  30. package/dist/commands/setup.js +15 -15
  31. package/dist/commands/skills.js +6 -5
  32. package/dist/commands/subagents.js +5 -4
  33. package/dist/commands/sync.d.ts +18 -5
  34. package/dist/commands/sync.js +251 -65
  35. package/dist/commands/teams.js +1 -0
  36. package/dist/commands/tmux.d.ts +25 -0
  37. package/dist/commands/tmux.js +415 -0
  38. package/dist/commands/trash.d.ts +2 -2
  39. package/dist/commands/trash.js +1 -1
  40. package/dist/commands/versions.js +2 -2
  41. package/dist/commands/view.js +9 -4
  42. package/dist/commands/workflows.js +4 -3
  43. package/dist/commands/worktree.d.ts +4 -5
  44. package/dist/commands/worktree.js +4 -4
  45. package/dist/index.js +68 -20
  46. package/dist/lib/agents.d.ts +19 -10
  47. package/dist/lib/agents.js +79 -25
  48. package/dist/lib/auto-pull-worker.d.ts +1 -1
  49. package/dist/lib/auto-pull-worker.js +2 -2
  50. package/dist/lib/auto-pull.d.ts +1 -1
  51. package/dist/lib/auto-pull.js +1 -1
  52. package/dist/lib/beta.d.ts +1 -1
  53. package/dist/lib/beta.js +1 -1
  54. package/dist/lib/capabilities.js +2 -0
  55. package/dist/lib/commands.d.ts +28 -1
  56. package/dist/lib/commands.js +125 -20
  57. package/dist/lib/doctor-diff.js +2 -2
  58. package/dist/lib/exec.d.ts +14 -0
  59. package/dist/lib/exec.js +39 -5
  60. package/dist/lib/fuzzy.d.ts +12 -2
  61. package/dist/lib/fuzzy.js +29 -4
  62. package/dist/lib/git.js +8 -1
  63. package/dist/lib/hooks.d.ts +2 -2
  64. package/dist/lib/hooks.js +97 -10
  65. package/dist/lib/mcp.js +32 -2
  66. package/dist/lib/migrate.d.ts +51 -0
  67. package/dist/lib/migrate.js +227 -1
  68. package/dist/lib/models.js +62 -15
  69. package/dist/lib/permissions.d.ts +36 -2
  70. package/dist/lib/permissions.js +217 -7
  71. package/dist/lib/plugin-marketplace.d.ts +98 -40
  72. package/dist/lib/plugin-marketplace.js +196 -93
  73. package/dist/lib/plugins.d.ts +21 -4
  74. package/dist/lib/plugins.js +130 -49
  75. package/dist/lib/profiles-presets.js +12 -12
  76. package/dist/lib/project-launch.d.ts +65 -0
  77. package/dist/lib/project-launch.js +367 -0
  78. package/dist/lib/pty-client.js +1 -1
  79. package/dist/lib/pty-server.d.ts +1 -1
  80. package/dist/lib/pty-server.js +1 -1
  81. package/dist/lib/refresh.d.ts +26 -0
  82. package/dist/lib/refresh.js +315 -0
  83. package/dist/lib/resource-patterns.d.ts +1 -1
  84. package/dist/lib/resource-patterns.js +1 -1
  85. package/dist/lib/resources/commands.js +2 -2
  86. package/dist/lib/resources/hooks.d.ts +1 -1
  87. package/dist/lib/resources/hooks.js +1 -1
  88. package/dist/lib/resources/mcp.d.ts +1 -1
  89. package/dist/lib/resources/mcp.js +5 -6
  90. package/dist/lib/resources/permissions.js +5 -2
  91. package/dist/lib/resources/rules.js +3 -2
  92. package/dist/lib/resources/skills.js +3 -2
  93. package/dist/lib/resources/types.d.ts +1 -1
  94. package/dist/lib/resources.js +2 -2
  95. package/dist/lib/rotate.d.ts +1 -1
  96. package/dist/lib/rotate.js +1 -1
  97. package/dist/lib/routines.d.ts +16 -4
  98. package/dist/lib/routines.js +67 -17
  99. package/dist/lib/rules/compile.js +22 -10
  100. package/dist/lib/rules/rules.js +3 -3
  101. package/dist/lib/runner.js +16 -3
  102. package/dist/lib/scheduler.js +15 -1
  103. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  104. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  105. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +9 -1
  106. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
  107. package/dist/lib/secrets/linux.d.ts +44 -9
  108. package/dist/lib/secrets/linux.js +302 -48
  109. package/dist/lib/session/db.js +15 -2
  110. package/dist/lib/session/discover.js +118 -3
  111. package/dist/lib/session/parse.js +3 -0
  112. package/dist/lib/session/types.d.ts +1 -1
  113. package/dist/lib/session/types.js +1 -1
  114. package/dist/lib/shims.d.ts +10 -9
  115. package/dist/lib/shims.js +101 -50
  116. package/dist/lib/skills.d.ts +1 -1
  117. package/dist/lib/skills.js +10 -9
  118. package/dist/lib/staleness/detectors/commands.d.ts +3 -0
  119. package/dist/lib/staleness/detectors/commands.js +46 -0
  120. package/dist/lib/staleness/detectors/hooks.d.ts +3 -0
  121. package/dist/lib/staleness/detectors/hooks.js +44 -0
  122. package/dist/lib/staleness/detectors/mcp.d.ts +3 -0
  123. package/dist/lib/staleness/detectors/mcp.js +31 -0
  124. package/dist/lib/staleness/detectors/permissions.d.ts +3 -0
  125. package/dist/lib/staleness/detectors/permissions.js +201 -0
  126. package/dist/lib/staleness/detectors/plugins.d.ts +8 -0
  127. package/dist/lib/staleness/detectors/plugins.js +23 -0
  128. package/dist/lib/staleness/detectors/rules.d.ts +3 -0
  129. package/dist/lib/staleness/detectors/rules.js +34 -0
  130. package/dist/lib/staleness/detectors/skills.d.ts +3 -0
  131. package/dist/lib/staleness/detectors/skills.js +71 -0
  132. package/dist/lib/staleness/detectors/subagents.d.ts +3 -0
  133. package/dist/lib/staleness/detectors/subagents.js +50 -0
  134. package/dist/lib/staleness/detectors/types.d.ts +22 -0
  135. package/dist/lib/staleness/detectors/types.js +1 -0
  136. package/dist/lib/staleness/detectors/workflows.d.ts +3 -0
  137. package/dist/lib/staleness/detectors/workflows.js +28 -0
  138. package/dist/lib/staleness/registry.d.ts +26 -0
  139. package/dist/lib/staleness/registry.js +123 -0
  140. package/dist/lib/staleness/writers/commands.d.ts +3 -0
  141. package/dist/lib/staleness/writers/commands.js +111 -0
  142. package/dist/lib/staleness/writers/hooks.d.ts +3 -0
  143. package/dist/lib/staleness/writers/hooks.js +47 -0
  144. package/dist/lib/staleness/writers/kinds.d.ts +10 -0
  145. package/dist/lib/staleness/writers/kinds.js +15 -0
  146. package/dist/lib/staleness/writers/lazy-map.d.ts +13 -0
  147. package/dist/lib/staleness/writers/lazy-map.js +19 -0
  148. package/dist/lib/staleness/writers/mcp.d.ts +10 -0
  149. package/dist/lib/staleness/writers/mcp.js +19 -0
  150. package/dist/lib/staleness/writers/permissions.d.ts +13 -0
  151. package/dist/lib/staleness/writers/permissions.js +26 -0
  152. package/dist/lib/staleness/writers/plugins.d.ts +7 -0
  153. package/dist/lib/staleness/writers/plugins.js +31 -0
  154. package/dist/lib/staleness/writers/rules.d.ts +7 -0
  155. package/dist/lib/staleness/writers/rules.js +55 -0
  156. package/dist/lib/staleness/writers/skills.d.ts +3 -0
  157. package/dist/lib/staleness/writers/skills.js +81 -0
  158. package/dist/lib/staleness/writers/sources.d.ts +16 -0
  159. package/dist/lib/staleness/writers/sources.js +72 -0
  160. package/dist/lib/staleness/writers/subagents.d.ts +3 -0
  161. package/dist/lib/staleness/writers/subagents.js +53 -0
  162. package/dist/lib/staleness/writers/types.d.ts +36 -0
  163. package/dist/lib/staleness/writers/types.js +1 -0
  164. package/dist/lib/staleness/writers/workflows.d.ts +7 -0
  165. package/dist/lib/staleness/writers/workflows.js +31 -0
  166. package/dist/lib/state.d.ts +34 -11
  167. package/dist/lib/state.js +58 -13
  168. package/dist/lib/subagents.d.ts +0 -2
  169. package/dist/lib/subagents.js +6 -6
  170. package/dist/lib/teams/agents.js +1 -1
  171. package/dist/lib/teams/parsers.d.ts +1 -1
  172. package/dist/lib/tmux/binary.d.ts +67 -0
  173. package/dist/lib/tmux/binary.js +141 -0
  174. package/dist/lib/tmux/index.d.ts +8 -0
  175. package/dist/lib/tmux/index.js +8 -0
  176. package/dist/lib/tmux/paths.d.ts +17 -0
  177. package/dist/lib/tmux/paths.js +30 -0
  178. package/dist/lib/tmux/session.d.ts +122 -0
  179. package/dist/lib/tmux/session.js +305 -0
  180. package/dist/lib/types.d.ts +58 -7
  181. package/dist/lib/types.js +1 -1
  182. package/dist/lib/usage.js +1 -1
  183. package/dist/lib/versions.d.ts +4 -4
  184. package/dist/lib/versions.js +135 -493
  185. package/dist/lib/workflows.d.ts +2 -4
  186. package/dist/lib/workflows.js +3 -4
  187. package/package.json +2 -2
  188. package/scripts/postinstall.js +16 -63
  189. package/dist/commands/status.d.ts +0 -9
  190. 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,28 +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
32
  import { importInstallScriptBinary } from './import.js';
35
- import { listInstalledSubagents, transformSubagentForClaude, syncSubagentToOpenclaw, SUBAGENT_CAPABLE_AGENTS } from './subagents.js';
36
- import { WORKFLOW_CAPABLE_AGENTS, listInstalledWorkflows, syncWorkflowToVersion } from './workflows.js';
37
- import { registerHooksToSettings } from './hooks.js';
38
33
  import { supports, explainSkip } from './capabilities.js';
39
- import { discoverPlugins, syncPluginToVersion, isPluginSynced, pluginSupportsAgent, cleanOrphanedPluginSkills } from './plugins.js';
40
- import { composeRulesFromState } from './rules/compose.js';
34
+ import { discoverPlugins } from './plugins.js';
41
35
  import { loadManifest, saveManifest, buildManifest as buildSyncManifest, isStale } from './staleness/index.js';
42
- import { PLUGINS_CAPABLE_AGENTS } from './agents.js';
43
36
  import { emit } from './events.js';
44
37
  import { safeJoin } from './paths.js';
45
- import { installCommandSkillToVersion, listCommandSkillsInVersion, shouldInstallCommandAsSkill } from './command-skills.js';
38
+ import { listCommandSkillsInVersion, shouldInstallCommandAsSkill } from './command-skills.js';
39
+ import { getWriter, getDetector } from './staleness/registry.js';
46
40
  /** Promisified exec for running shell commands. */
47
41
  const execAsync = promisify(exec);
48
42
  const execFileAsync = promisify(execFile);
@@ -281,10 +275,8 @@ function skillDirsMatch(src, dest) {
281
275
  * This is the source of truth - not the tracking in agents.yaml.
282
276
  */
283
277
  export function getActuallySyncedResources(agent, version, options = {}) {
284
- const agentConfig = AGENTS[agent];
285
278
  const versionHome = path.join(getVersionsDir(), agent, version, 'home');
286
- const configDir = path.join(versionHome, `.${agent}`);
287
- const projectAgentsDir = getProjectAgentsDir(options.cwd || process.cwd());
279
+ const cwd = options.cwd || process.cwd();
288
280
  const result = {
289
281
  commands: [],
290
282
  skills: [],
@@ -297,219 +289,20 @@ export function getActuallySyncedResources(agent, version, options = {}) {
297
289
  workflows: [],
298
290
  promptcuts: false,
299
291
  };
300
- // Commands - check what files exist in version home.
301
- // For agent/version pairs that store commands as converted skills (e.g. Codex >= 0.117.0),
302
- // detect them via the agents_command marker in skills/<name>/SKILL.md — otherwise the
303
- // diff falsely reports every command as "new" every run and re-prompts on `agents view`.
304
- if (shouldInstallCommandAsSkill(agent, version)) {
305
- result.commands = listCommandSkillsInVersion(path.join(configDir));
306
- }
307
- else {
308
- const commandsDir = path.join(configDir, agentConfig.commandsSubdir);
309
- if (fs.existsSync(commandsDir)) {
310
- const ext = agentConfig.format === 'toml' ? '.toml' : '.md';
311
- result.commands = fs.readdirSync(commandsDir)
312
- .filter(f => f.endsWith(ext))
313
- .map(f => f.replace(new RegExp(`\\${ext}$`), ''));
314
- }
315
- }
316
- // Skills - check what directories exist AND content matches central source
317
- const skillsDir = path.join(configDir, 'skills');
318
- const centralSkillsDir = getSkillsDir();
319
- const projectSkillsDir = projectAgentsDir ? path.join(projectAgentsDir, 'skills') : null;
320
- const userAgentsDir = getUserAgentsDir();
321
- const extraRepos = getEnabledExtraRepos();
322
- if (fs.existsSync(skillsDir)) {
323
- const installedSkills = fs.readdirSync(skillsDir, { withFileTypes: true })
324
- .filter(d => d.isDirectory() && !d.name.startsWith('.'))
325
- .map(d => d.name);
326
- for (const skill of installedSkills) {
327
- const versionSkillDir = path.join(skillsDir, skill);
328
- const sourceCandidates = [
329
- projectSkillsDir ? path.join(projectSkillsDir, skill) : null,
330
- path.join(userAgentsDir, 'skills', skill),
331
- path.join(centralSkillsDir, skill),
332
- ...extraRepos.map((e) => path.join(e.dir, 'skills', skill)),
333
- ];
334
- const sourceDir = sourceCandidates.find((p) => p && fs.existsSync(p)) || null;
335
- if (!sourceDir) {
336
- // True orphan — no source in project, primary, or any extra. Still
337
- // count as synced so version-home cleanup knows it's accounted for.
338
- result.skills.push(skill);
339
- continue;
340
- }
341
- const allMatch = skillDirsMatch(sourceDir, versionSkillDir);
342
- if (allMatch) {
343
- result.skills.push(skill);
344
- }
345
- }
346
- }
347
- // Hooks - check what files exist AND content matches central source
348
- const hooksDir = path.join(configDir, 'hooks');
349
- const centralHooksDir = getHooksDir();
350
- const projectHooksDir = projectAgentsDir ? path.join(projectAgentsDir, 'hooks') : null;
351
- const userHooksDir = path.join(userAgentsDir, 'hooks');
352
- if (fs.existsSync(hooksDir)) {
353
- const installedHooks = fs.readdirSync(hooksDir).filter(f => !f.startsWith('.'));
354
- for (const hook of installedHooks) {
355
- const projectFile = projectHooksDir ? path.join(projectHooksDir, hook) : null;
356
- const centralFile = path.join(centralHooksDir, hook);
357
- const userFile = path.join(userHooksDir, hook);
358
- const versionFile = path.join(hooksDir, hook);
359
- const hasProject = projectFile ? fs.existsSync(projectFile) : false;
360
- const hasUser = fs.existsSync(userFile);
361
- const hasCentral = fs.existsSync(centralFile);
362
- const sourceFile = hasProject ? projectFile : hasUser ? userFile : centralFile;
363
- if (!hasProject && !hasCentral && !hasUser) {
364
- result.hooks.push(hook);
365
- continue;
366
- }
367
- try {
368
- const centralContent = fs.readFileSync(sourceFile, 'utf-8');
369
- const versionContent = fs.readFileSync(versionFile, 'utf-8');
370
- if (centralContent === versionContent) {
371
- result.hooks.push(hook);
372
- }
373
- }
374
- catch {
375
- // If read fails, consider not synced
376
- }
377
- }
378
- }
379
- // Rules — single composed instruction file per agent. If the file exists in
380
- // the version home, we consider the active preset synced. Available presets
381
- // are surfaced from rules.yaml; this set is the subset that materialized.
382
- const instrFile = path.join(configDir, agentConfig.instructionsFile);
383
- if (fs.existsSync(instrFile)) {
384
- const activePreset = getActiveRulesPreset(agent, version);
385
- result.memory.push(activePreset);
386
- }
387
- // MCP - use canonical config path + parser per agent
388
- if (MCP_CAPABLE_AGENTS.includes(agent)) {
389
- const mcpConfigPath = getMcpConfigPathForHome(agent, versionHome);
390
- if (fs.existsSync(mcpConfigPath)) {
391
- try {
392
- const servers = parseMcpConfig(agent, mcpConfigPath);
393
- result.mcp = Object.keys(servers);
394
- }
395
- catch {
396
- // Ignore parse errors
397
- }
398
- }
399
- }
400
- // Permissions - check agent-specific config files
401
- const settingsPath = path.join(configDir, 'settings.json');
402
- if (PERMISSIONS_CAPABLE_AGENTS.includes(agent)) {
403
- if (agent === 'claude' && fs.existsSync(settingsPath)) {
404
- // Claude: check settings.json permissions.allow and deny
405
- try {
406
- const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
407
- const allowRules = settings.permissions?.allow || [];
408
- const denyRules = settings.permissions?.deny || [];
409
- if (allowRules.length > 0 || denyRules.length > 0) {
410
- const permGroups = discoverPermissionGroups();
411
- const appliedGroups = [];
412
- for (const group of permGroups) {
413
- const groupSet = buildPermissionsFromGroups([group.name]);
414
- // Empty groups (like header files) are considered synced if ANY permissions are applied
415
- if (groupSet.allow.length === 0 && (!groupSet.deny || groupSet.deny.length === 0)) {
416
- appliedGroups.push(group.name);
417
- continue;
418
- }
419
- const hasAllowRule = groupSet.allow.some(rule => allowRules.includes(rule));
420
- const hasDenyRule = groupSet.deny?.some(rule => denyRules.includes(rule)) || false;
421
- if (hasAllowRule || hasDenyRule) {
422
- appliedGroups.push(group.name);
423
- }
424
- }
425
- result.permissions = appliedGroups;
426
- }
427
- }
428
- catch {
429
- // Ignore parse errors
430
- }
431
- }
432
- else if (agent === 'codex') {
433
- // Codex: config.toml for approval_policy/sandbox_mode, .rules for deny
434
- const codexConfigPath = path.join(configDir, 'config.toml');
435
- const codexRulesPath = path.join(configDir, 'rules', CODEX_RULES_FILENAME);
436
- const hasConfig = fs.existsSync(codexConfigPath);
437
- const hasRules = fs.existsSync(codexRulesPath);
438
- if (hasConfig || hasRules) {
439
- try {
440
- // Codex format is lossy — all groups merge into a few keys.
441
- // If any permission artifacts exist, all groups were applied together.
442
- let hasPermKeys = false;
443
- if (hasConfig) {
444
- const content = fs.readFileSync(codexConfigPath, 'utf-8');
445
- const config = TOML.parse(content);
446
- hasPermKeys = !!(config.approval_policy || config.sandbox_mode || config.sandbox_workspace_write);
447
- }
448
- if (hasPermKeys || hasRules) {
449
- result.permissions = discoverPermissionGroups().map(g => g.name);
450
- }
451
- }
452
- catch {
453
- // Ignore parse errors
454
- }
455
- }
456
- }
457
- else if (agent === 'opencode') {
458
- // OpenCode: opencode.jsonc for permission.bash
459
- const opencodeConfigPath = path.join(configDir, 'opencode.jsonc');
460
- if (fs.existsSync(opencodeConfigPath)) {
461
- try {
462
- const content = fs.readFileSync(opencodeConfigPath, 'utf-8');
463
- const stripped = content.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
464
- const config = JSON.parse(stripped);
465
- if (config.permission && Object.keys(config.permission.bash || {}).length > 0) {
466
- result.permissions = discoverPermissionGroups().map(g => g.name);
467
- }
468
- }
469
- catch {
470
- // Ignore parse errors
471
- }
472
- }
473
- }
474
- }
475
- // Subagents - check agent-specific locations
476
- if (SUBAGENT_CAPABLE_AGENTS.includes(agent)) {
477
- if (agent === 'claude') {
478
- const agentsDir = path.join(configDir, 'agents');
479
- if (fs.existsSync(agentsDir)) {
480
- result.subagents = fs.readdirSync(agentsDir)
481
- .filter(f => f.endsWith('.md'))
482
- .map(f => f.replace('.md', ''));
483
- }
484
- }
485
- else if (agent === 'openclaw') {
486
- // OpenClaw: directories with AGENTS.md
487
- const openclawDir = path.join(versionHome, '.openclaw');
488
- if (fs.existsSync(openclawDir)) {
489
- result.subagents = fs.readdirSync(openclawDir, { withFileTypes: true })
490
- .filter(d => d.isDirectory() && fs.existsSync(path.join(openclawDir, d.name, 'AGENTS.md')))
491
- .map(d => d.name);
492
- }
493
- }
494
- }
495
- // Plugins - check which discovered plugins have their skills in the version
496
- if (PLUGINS_CAPABLE_AGENTS.includes(agent)) {
497
- const allPlugins = discoverPlugins();
498
- for (const plugin of allPlugins) {
499
- if (isPluginSynced(plugin, agent, versionHome)) {
500
- result.plugins.push(plugin.name);
501
- }
502
- }
503
- }
504
- // Workflows - check {versionHome}/workflows/ for synced workflow directories
505
- if (WORKFLOW_CAPABLE_AGENTS.includes(agent)) {
506
- const workflowsDir = path.join(versionHome, 'workflows');
507
- if (fs.existsSync(workflowsDir)) {
508
- result.workflows = fs.readdirSync(workflowsDir, { withFileTypes: true })
509
- .filter(d => d.isDirectory() && fs.existsSync(path.join(workflowsDir, d.name, 'WORKFLOW.md')))
510
- .map(d => d.name);
511
- }
512
- }
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) ?? [];
513
306
  return result;
514
307
  }
515
308
  /**
@@ -660,9 +453,9 @@ export function hasNewResources(diff, agent, version) {
660
453
  const hooksApply = agent ? supports(agent, 'hooks', version).ok : true;
661
454
  const mcpApply = agent ? supports(agent, 'mcp', version).ok : true;
662
455
  const permsApply = agent ? supports(agent, 'allowlist', version).ok : true;
663
- const subagentsApply = agent ? SUBAGENT_CAPABLE_AGENTS.includes(agent) : true;
456
+ const subagentsApply = agent ? supports(agent, 'subagents', version).ok : true;
664
457
  const pluginsApply = agent ? supports(agent, 'plugins', version).ok : true;
665
- const workflowsApply = agent ? WORKFLOW_CAPABLE_AGENTS.includes(agent) : true;
458
+ const workflowsApply = agent ? supports(agent, 'workflows', version).ok : true;
666
459
  return ((diff.commands.length > 0 && commandsApply) ||
667
460
  diff.skills.length > 0 ||
668
461
  (diff.hooks.length > 0 && hooksApply) ||
@@ -683,8 +476,9 @@ function buildNewResourcesSummary(newResources, agent, version) {
683
476
  // Use version-aware gates so Codex >= 0.117.0 (which converts commands to skills) doesn't
684
477
  // double-count and so "16 commands" never appears in the summary when commands have
685
478
  // already been emitted as skills in the version home.
686
- const commandsApply = version ? supports(agent, 'commands', version).ok : COMMANDS_CAPABLE_AGENTS.includes(agent);
479
+ const commandsApply = supports(agent, 'commands', version).ok;
687
480
  const commandsAsSkills = version ? shouldInstallCommandAsSkill(agent, version) : false;
481
+ const rulesApply = supports(agent, 'rules', version).ok;
688
482
  if (newResources.commands.length > 0 && (commandsApply || commandsAsSkills)) {
689
483
  parts.push(`${newResources.commands.length} command${newResources.commands.length === 1 ? '' : 's'}`);
690
484
  }
@@ -694,22 +488,22 @@ function buildNewResourcesSummary(newResources, agent, version) {
694
488
  if (newResources.hooks.length > 0 && agentConfig.supportsHooks) {
695
489
  parts.push(`${newResources.hooks.length} hook${newResources.hooks.length === 1 ? '' : 's'}`);
696
490
  }
697
- if (newResources.memory.length > 0 && (commandsApply || commandsAsSkills)) {
491
+ if (newResources.memory.length > 0 && rulesApply) {
698
492
  parts.push(`${newResources.memory.length} rule file${newResources.memory.length === 1 ? '' : 's'}`);
699
493
  }
700
- if (newResources.mcp.length > 0 && MCP_CAPABLE_AGENTS.includes(agent)) {
494
+ if (newResources.mcp.length > 0 && supports(agent, 'mcp', version).ok) {
701
495
  parts.push(`${newResources.mcp.length} MCP${newResources.mcp.length === 1 ? '' : 's'}`);
702
496
  }
703
- if (newResources.permissions.length > 0 && PERMISSIONS_CAPABLE_AGENTS.includes(agent)) {
497
+ if (newResources.permissions.length > 0 && supports(agent, 'allowlist', version).ok) {
704
498
  parts.push(`${newResources.permissions.length} permission group${newResources.permissions.length === 1 ? '' : 's'}`);
705
499
  }
706
- if (newResources.subagents.length > 0 && SUBAGENT_CAPABLE_AGENTS.includes(agent)) {
500
+ if (newResources.subagents.length > 0 && supports(agent, 'subagents', version).ok) {
707
501
  parts.push(`${newResources.subagents.length} subagent${newResources.subagents.length === 1 ? '' : 's'}`);
708
502
  }
709
- if (newResources.plugins.length > 0 && PLUGINS_CAPABLE_AGENTS.includes(agent)) {
503
+ if (newResources.plugins.length > 0 && supports(agent, 'plugins', version).ok) {
710
504
  parts.push(`${newResources.plugins.length} plugin${newResources.plugins.length === 1 ? '' : 's'}`);
711
505
  }
712
- if (newResources.workflows.length > 0 && WORKFLOW_CAPABLE_AGENTS.includes(agent)) {
506
+ if (newResources.workflows.length > 0 && supports(agent, 'workflows', version).ok) {
713
507
  parts.push(`${newResources.workflows.length} workflow${newResources.workflows.length === 1 ? '' : 's'}`);
714
508
  }
715
509
  return parts.join(', ');
@@ -724,9 +518,10 @@ export async function promptNewResourceSelection(agent, newResources, version) {
724
518
  // Version-aware gates. When version is known, prefer per-version capability checks; the
725
519
  // commands branch is allowed when either native commands are supported OR when the
726
520
  // version emits commands as converted skills (Codex >= 0.117.0).
727
- const commandsApply = version ? supports(agent, 'commands', version).ok : COMMANDS_CAPABLE_AGENTS.includes(agent);
521
+ const commandsApply = supports(agent, 'commands', version).ok;
728
522
  const commandsAsSkills = version ? shouldInstallCommandAsSkill(agent, version) : false;
729
523
  const commandsBranch = commandsApply || commandsAsSkills;
524
+ const rulesBranch = supports(agent, 'rules', version).ok;
730
525
  // Get permission group info for display
731
526
  const permissionGroups = discoverPermissionGroups();
732
527
  const newPermissionGroups = permissionGroups.filter(g => newResources.permissions.includes(g.name));
@@ -756,17 +551,17 @@ export async function promptNewResourceSelection(agent, newResources, version) {
756
551
  selection.skills = newResources.skills;
757
552
  if (newResources.hooks.length > 0 && agentConfig.supportsHooks)
758
553
  selection.hooks = newResources.hooks;
759
- if (newResources.memory.length > 0 && commandsBranch)
554
+ if (newResources.memory.length > 0 && rulesBranch)
760
555
  selection.memory = newResources.memory;
761
- if (newResources.mcp.length > 0 && MCP_CAPABLE_AGENTS.includes(agent))
556
+ if (newResources.mcp.length > 0 && supports(agent, 'mcp', version).ok)
762
557
  selection.mcp = newResources.mcp;
763
- if (newResources.permissions.length > 0 && PERMISSIONS_CAPABLE_AGENTS.includes(agent))
558
+ if (newResources.permissions.length > 0 && supports(agent, 'allowlist', version).ok)
764
559
  selection.permissions = newResources.permissions;
765
- if (newResources.subagents.length > 0 && SUBAGENT_CAPABLE_AGENTS.includes(agent))
560
+ if (newResources.subagents.length > 0 && supports(agent, 'subagents', version).ok)
766
561
  selection.subagents = newResources.subagents;
767
- if (newResources.plugins.length > 0 && PLUGINS_CAPABLE_AGENTS.includes(agent))
562
+ if (newResources.plugins.length > 0 && supports(agent, 'plugins', version).ok)
768
563
  selection.plugins = newResources.plugins;
769
- if (newResources.workflows.length > 0 && WORKFLOW_CAPABLE_AGENTS.includes(agent))
564
+ if (newResources.workflows.length > 0 && supports(agent, 'workflows', version).ok)
770
565
  selection.workflows = newResources.workflows;
771
566
  return selection;
772
567
  }
@@ -795,7 +590,7 @@ export async function promptNewResourceSelection(agent, newResources, version) {
795
590
  if (selected.length > 0)
796
591
  selection.hooks = selected;
797
592
  }
798
- if (newResources.memory.length > 0 && commandsBranch) {
593
+ if (newResources.memory.length > 0 && rulesBranch) {
799
594
  const selected = await checkbox({
800
595
  message: 'Select new rule files to sync:',
801
596
  choices: newResources.memory.map(m => ({ name: m, value: m, checked: true })),
@@ -803,7 +598,7 @@ export async function promptNewResourceSelection(agent, newResources, version) {
803
598
  if (selected.length > 0)
804
599
  selection.memory = selected;
805
600
  }
806
- if (newResources.mcp.length > 0 && MCP_CAPABLE_AGENTS.includes(agent)) {
601
+ if (newResources.mcp.length > 0 && supports(agent, 'mcp', version).ok) {
807
602
  const selected = await checkbox({
808
603
  message: 'Select new MCPs to sync:',
809
604
  choices: newResources.mcp.map(m => ({ name: m, value: m, checked: true })),
@@ -811,7 +606,7 @@ export async function promptNewResourceSelection(agent, newResources, version) {
811
606
  if (selected.length > 0)
812
607
  selection.mcp = selected;
813
608
  }
814
- if (newResources.permissions.length > 0 && PERMISSIONS_CAPABLE_AGENTS.includes(agent)) {
609
+ if (newResources.permissions.length > 0 && supports(agent, 'allowlist', version).ok) {
815
610
  const selected = await checkbox({
816
611
  message: 'Select new permission groups to sync:',
817
612
  choices: newPermissionGroups.map(g => ({
@@ -823,7 +618,7 @@ export async function promptNewResourceSelection(agent, newResources, version) {
823
618
  if (selected.length > 0)
824
619
  selection.permissions = selected;
825
620
  }
826
- if (newResources.subagents.length > 0 && SUBAGENT_CAPABLE_AGENTS.includes(agent)) {
621
+ if (newResources.subagents.length > 0 && supports(agent, 'subagents', version).ok) {
827
622
  const selected = await checkbox({
828
623
  message: 'Select new subagents to sync:',
829
624
  choices: newResources.subagents.map(s => ({ name: s, value: s, checked: true })),
@@ -831,7 +626,7 @@ export async function promptNewResourceSelection(agent, newResources, version) {
831
626
  if (selected.length > 0)
832
627
  selection.subagents = selected;
833
628
  }
834
- if (newResources.plugins.length > 0 && PLUGINS_CAPABLE_AGENTS.includes(agent)) {
629
+ if (newResources.plugins.length > 0 && supports(agent, 'plugins', version).ok) {
835
630
  const allPlugins = discoverPlugins();
836
631
  const pluginMap = new Map(allPlugins.map(p => [p.name, p]));
837
632
  const selected = await checkbox({
@@ -845,7 +640,7 @@ export async function promptNewResourceSelection(agent, newResources, version) {
845
640
  if (selected.length > 0)
846
641
  selection.plugins = selected;
847
642
  }
848
- if (newResources.workflows.length > 0 && WORKFLOW_CAPABLE_AGENTS.includes(agent)) {
643
+ if (newResources.workflows.length > 0 && supports(agent, 'workflows', version).ok) {
849
644
  const selected = await checkbox({
850
645
  message: 'Select new workflows to sync:',
851
646
  choices: newResources.workflows.map(w => ({ name: w, value: w, checked: true })),
@@ -867,14 +662,14 @@ export async function promptResourceSelection(agent) {
867
662
  const permissionGroups = discoverPermissionGroups();
868
663
  const totalPermissionRules = permissionGroups.reduce((sum, g) => sum + g.ruleCount, 0);
869
664
  const categories = [
870
- { 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` },
871
666
  { key: 'skills', label: 'Skills', available: available.skills.length > 0, displayCount: `${available.skills.length} available` },
872
667
  { key: 'hooks', label: 'Hooks', available: agentConfig.supportsHooks && available.hooks.length > 0, displayCount: `${available.hooks.length} available` },
873
- { key: 'memory', label: 'Rules', available: COMMANDS_CAPABLE_AGENTS.includes(agent) && available.memory.length > 0, displayCount: `${available.memory.length} available` },
874
- { key: 'mcp', label: 'MCPs', available: MCP_CAPABLE_AGENTS.includes(agent) && available.mcp.length > 0, displayCount: `${available.mcp.length} available` },
875
- { key: 'permissions', label: 'Permissions', available: PERMISSIONS_CAPABLE_AGENTS.includes(agent) && permissionGroups.length > 0, displayCount: `${permissionGroups.length} groups, ${totalPermissionRules} rules` },
876
- { key: 'subagents', label: 'Subagents', available: SUBAGENT_CAPABLE_AGENTS.includes(agent) && available.subagents.length > 0, displayCount: `${available.subagents.length} available` },
877
- { 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` },
878
673
  ];
879
674
  const availableCategories = categories.filter(c => c.available);
880
675
  if (availableCategories.length === 0) {
@@ -1051,7 +846,7 @@ export async function getLatestNpmVersion(agent) {
1051
846
  if (!agentConfig.npmPackage)
1052
847
  return null;
1053
848
  try {
1054
- const { stdout } = await execFileAsync('npm', ['view', agentConfig.npmPackage, 'version']);
849
+ const { stdout } = await execFileAsync('npm', ['view', agentConfig.npmPackage, 'version'], { shell: process.platform === 'win32' });
1055
850
  return stdout.trim();
1056
851
  }
1057
852
  catch {
@@ -1187,8 +982,8 @@ export async function installVersion(agent, version, onProgress) {
1187
982
  // ~/.grok/downloads), so we skip the symlink there.
1188
983
  if (agent !== 'grok') {
1189
984
  try {
1190
- const { stdout: whichOut } = await execFileAsync('which', [agentConfig.cliCommand]);
1191
- const installedBinary = whichOut.trim();
985
+ const { stdout: whichOut } = await execFileAsync(process.platform === 'win32' ? 'where' : 'which', [agentConfig.cliCommand]);
986
+ const installedBinary = whichOut.trim().split('\n')[0];
1192
987
  if (installedBinary && fs.existsSync(installedBinary)) {
1193
988
  importInstallScriptBinary({ agentId: agent, npmPackage: agentConfig.npmPackage, cliCommand: agentConfig.cliCommand }, installedVersion, installedBinary, versionDir);
1194
989
  }
@@ -1219,8 +1014,9 @@ export async function installVersion(agent, version, onProgress) {
1219
1014
  : `${agentConfig.npmPackage}@${version}`;
1220
1015
  try {
1221
1016
  // Check npm is available
1017
+ const winShell = process.platform === 'win32';
1222
1018
  try {
1223
- await execFileAsync('which', ['npm']);
1019
+ await execFileAsync('npm', ['--version'], { shell: winShell });
1224
1020
  }
1225
1021
  catch {
1226
1022
  return {
@@ -1230,7 +1026,7 @@ export async function installVersion(agent, version, onProgress) {
1230
1026
  };
1231
1027
  }
1232
1028
  onProgress?.(`Installing ${packageSpec}...`);
1233
- const { stdout } = await execFileAsync('npm', ['install', packageSpec], { cwd: versionDir });
1029
+ const { stdout } = await execFileAsync('npm', ['install', packageSpec], { cwd: versionDir, shell: winShell });
1234
1030
  // Determine the actual installed version
1235
1031
  let installedVersion = version;
1236
1032
  if (version === 'latest') {
@@ -1303,10 +1099,10 @@ function removeInstallArtifacts(versionDir) {
1303
1099
  }
1304
1100
  }
1305
1101
  /**
1306
- * 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/.
1307
1103
  * Returns the trash path on success or null on failure / no source.
1308
1104
  *
1309
- * Trash layout: ~/.agents-system/trash/versions/<agent>/<version>/<timestamp>/
1105
+ * Trash layout: ~/.agents/.system/trash/versions/<agent>/<version>/<timestamp>/
1310
1106
  * The timestamp suffix lets a user soft-delete the same version twice (after
1311
1107
  * re-install) without collision and gives a chronological audit trail.
1312
1108
  *
@@ -1335,7 +1131,7 @@ export function softDeleteVersionDir(agent, version) {
1335
1131
  * Remove a specific version of an agent.
1336
1132
  *
1337
1133
  * Soft-delete only: moves the entire version directory (including `home/`)
1338
- * to ~/.agents-system/trash/versions/. Recoverable via `agents trash restore`.
1134
+ * to ~/.agents/.system/trash/versions/. Recoverable via `agents trash restore`.
1339
1135
  * Nothing is hard-deleted.
1340
1136
  */
1341
1137
  export function removeVersion(agent, version) {
@@ -1474,7 +1270,7 @@ export function resolveVersionAliasLoose(agent, raw) {
1474
1270
  return raw;
1475
1271
  }
1476
1272
  /**
1477
- * 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).
1478
1274
  */
1479
1275
  export function getProjectVersion(agent, startPath) {
1480
1276
  const userAgentsYaml = path.join(getUserAgentsDir(), 'agents.yaml');
@@ -1540,8 +1336,7 @@ export async function getInstalledVersion(agent, version) {
1540
1336
  async function getCliVersionFromPath(agent) {
1541
1337
  const agentConfig = AGENTS[agent];
1542
1338
  try {
1543
- await execFileAsync('which', [agentConfig.cliCommand]);
1544
- const { stdout } = await execFileAsync(agentConfig.cliCommand, ['--version'], { timeout: 3000 });
1339
+ const { stdout } = await execFileAsync(agentConfig.cliCommand, ['--version'], { timeout: 3000, shell: process.platform === 'win32' });
1545
1340
  const match = stdout.match(/(\d+\.\d+\.\d+)/);
1546
1341
  return match ? match[1] : null;
1547
1342
  }
@@ -1839,59 +1634,23 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
1839
1634
  }
1840
1635
  return [];
1841
1636
  };
1842
- // 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);
1843
1643
  const commandsToSync = selection
1844
1644
  ? resolveSelection(selection.commands, available.commands)
1845
1645
  : available.commands; // No selection = sync all
1846
- if (commandsToSync.length > 0 && COMMANDS_CAPABLE_AGENTS.includes(agent)) {
1646
+ if (commandsToSync.length > 0 && commandsWriter) {
1847
1647
  const commandsTarget = path.join(agentDir, agentConfig.commandsSubdir);
1848
1648
  const commandsAsSkills = shouldInstallCommandAsSkill(agent, version);
1849
1649
  if (commandsAsSkills) {
1850
1650
  removePath(commandsTarget);
1851
1651
  }
1852
- else {
1853
- fs.mkdirSync(commandsTarget, { recursive: true });
1854
- }
1855
- const syncedCommands = [];
1856
- for (const cmd of commandsToSync) {
1857
- // Commands are content that gets injected into the agent's prompt
1858
- // surface (slash commands, skill bodies). We intentionally do NOT pull
1859
- // from the project's own .agents/commands/ directory: a cloned public
1860
- // repo could ship a command whose body instructs the agent to do
1861
- // something harmful the next time the user invokes it. Commands must
1862
- // come from the user's central ~/.agents/commands/, the system layer,
1863
- // or an explicitly enabled extra repo. Same defense as hooks below.
1864
- const candidates = [
1865
- safeJoin(path.join(userAgentsDir, 'commands'), `${cmd}.md`),
1866
- safeJoin(getCommandsDir(), `${cmd}.md`),
1867
- ...extraRepos.map((e) => safeJoin(path.join(e.dir, 'commands'), `${cmd}.md`)),
1868
- ];
1869
- const srcFile = candidates.find((p) => p && fs.existsSync(p) && !fs.lstatSync(p).isSymbolicLink()) || null;
1870
- if (!srcFile)
1871
- continue;
1872
- if (commandsAsSkills) {
1873
- // Project skills dir is intentionally excluded for the same reason
1874
- // commands are: the body of a project skill becomes agent context.
1875
- const skillSourceDirs = [
1876
- path.join(userAgentsDir, 'skills'),
1877
- getSkillsDir(),
1878
- ...extraRepos.map((e) => path.join(e.dir, 'skills')),
1879
- ];
1880
- const installed = installCommandSkillToVersion(agentDir, cmd, srcFile, skillSourceDirs);
1881
- if (!installed.success)
1882
- continue;
1883
- }
1884
- else if (agentConfig.format === 'toml') {
1885
- const content = fs.readFileSync(srcFile, 'utf-8');
1886
- const tomlContent = markdownToToml(cmd, content);
1887
- fs.writeFileSync(safeJoin(commandsTarget, `${cmd}.toml`), tomlContent);
1888
- }
1889
- else {
1890
- fs.copyFileSync(srcFile, safeJoin(commandsTarget, `${cmd}.md`));
1891
- }
1892
- syncedCommands.push(cmd);
1893
- }
1894
- result.commands = syncedCommands.length > 0;
1652
+ const r = commandsWriter.write({ version, versionHome, selection: commandsToSync, cwd });
1653
+ result.commands = r.synced.length > 0;
1895
1654
  }
1896
1655
  // Orphan-sweep stale top-level command files from previous syncs under a
1897
1656
  // different cwd. Only runs in "full sync" mode — i.e. when the caller did
@@ -1900,7 +1659,7 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
1900
1659
  // alone), so the sweep would be a contract violation there. The
1901
1660
  // cross-project leak always comes from the no-selection shim auto-sync at
1902
1661
  // launch.
1903
- if (!userPassedSelection && COMMANDS_CAPABLE_AGENTS.includes(agent) && !shouldInstallCommandAsSkill(agent, version)) {
1662
+ if (!userPassedSelection && commandsWriter && !shouldInstallCommandAsSkill(agent, version)) {
1904
1663
  const commandsTargetSweep = path.join(agentDir, agentConfig.commandsSubdir);
1905
1664
  if (fs.existsSync(commandsTargetSweep)) {
1906
1665
  const ext = agentConfig.format === 'toml' ? '.toml' : '.md';
@@ -1917,51 +1676,20 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
1917
1676
  }
1918
1677
  }
1919
1678
  }
1920
- // 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;
1921
1686
  if (agentConfig.nativeAgentsSkillsDir) {
1922
- // Clean up stale skills symlink/dir — agent reads from ~/.agents/skills/ directly
1923
- const skillsTarget = path.join(agentDir, 'skills');
1924
- removePath(skillsTarget);
1687
+ removePath(path.join(agentDir, 'skills'));
1925
1688
  }
1926
- else {
1927
- const skillsToSync = selection
1928
- ? resolveSelection(selection.skills, available.skills)
1929
- : available.skills;
1689
+ else if (skillsWriter) {
1930
1690
  if (skillsToSync.length > 0) {
1931
- const skillsTarget = path.join(agentDir, 'skills');
1932
- // Old version homes may have skills -> ~/.agents/skills. Replace the
1933
- // parent symlink before touching children so removePath(destDir) cannot
1934
- // delete the central source through it.
1935
- try {
1936
- if (fs.lstatSync(skillsTarget).isSymbolicLink()) {
1937
- removePath(skillsTarget);
1938
- }
1939
- }
1940
- catch { /* target does not exist yet */ }
1941
- fs.mkdirSync(skillsTarget, { recursive: true });
1942
- const syncedSkills = [];
1943
- for (const skill of skillsToSync) {
1944
- // Same defense as commands and hooks: don't pull skills from the
1945
- // project's .agents/skills/ directory. A skill's contents (SKILL.md
1946
- // and any auxiliary scripts) get loaded into the agent's tool/context
1947
- // surface, and a malicious public repo could ship a SKILL.md whose
1948
- // body coerces the agent. Trusted layers only.
1949
- const skillCandidates = [
1950
- safeJoin(path.join(userAgentsDir, 'skills'), skill),
1951
- safeJoin(getSkillsDir(), skill),
1952
- ...extraRepos.map((e) => safeJoin(path.join(e.dir, 'skills'), skill)),
1953
- ];
1954
- const srcDir = skillCandidates.find((p) => fs.existsSync(p) &&
1955
- !fs.lstatSync(p).isSymbolicLink() &&
1956
- fs.lstatSync(p).isDirectory()) || null;
1957
- if (!srcDir)
1958
- continue;
1959
- const destDir = safeJoin(skillsTarget, skill);
1960
- removePath(destDir);
1961
- copyDir(srcDir, destDir);
1962
- syncedSkills.push(skill);
1963
- }
1964
- result.skills = syncedSkills.length > 0;
1691
+ const r = skillsWriter.write({ version, versionHome, selection: skillsToSync, cwd });
1692
+ result.skills = r.synced.length > 0;
1965
1693
  }
1966
1694
  // Orphan-sweep stale skill directories from previous syncs under a
1967
1695
  // different cwd. Only runs in "full sync" mode (no explicit selection) —
@@ -1979,9 +1707,11 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
1979
1707
  }
1980
1708
  }
1981
1709
  }
1982
- // 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).
1983
1712
  const hooksGate = supports(agent, 'hooks', version);
1984
- if (agentConfig.supportsHooks) {
1713
+ const hooksWriter = getWriter('hooks', agent);
1714
+ if (agentConfig.supportsHooks && hooksWriter) {
1985
1715
  if (!hooksGate.ok) {
1986
1716
  console.warn(explainSkip(agent, 'hooks', hooksGate, version) + ' -- skipped');
1987
1717
  }
@@ -1990,35 +1720,13 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
1990
1720
  ? resolveSelection(selection.hooks, available.hooks)
1991
1721
  : available.hooks;
1992
1722
  if (hooksToSync.length > 0) {
1993
- const centralHooks = getHooksDir();
1994
- const hooksTarget = path.join(agentDir, 'hooks');
1995
- fs.mkdirSync(hooksTarget, { recursive: true });
1996
- const syncedHooks = [];
1997
- for (const hook of hooksToSync) {
1998
- // Hooks are executable shell scripts that run on agent events. We
1999
- // intentionally do NOT pull from the project's own .agents/hooks/
2000
- // directory: that would let any cloned public repo plant an
2001
- // executable that fires the next time the user runs `agents use`
2002
- // inside that repo. Hooks must come from the user's central
2003
- // ~/.agents/hooks/ or an explicitly enabled extra repo.
2004
- const candidates = [
2005
- safeJoin(path.join(userAgentsDir, 'hooks'), hook),
2006
- safeJoin(centralHooks, hook),
2007
- ...extraRepos.map((e) => safeJoin(path.join(e.dir, 'hooks'), hook)),
2008
- ];
2009
- const srcFile = candidates.find((p) => p && fs.existsSync(p) && !fs.lstatSync(p).isSymbolicLink()) || null;
2010
- if (!srcFile)
2011
- continue;
2012
- const destFile = safeJoin(hooksTarget, hook);
2013
- fs.copyFileSync(srcFile, destFile);
2014
- fs.chmodSync(destFile, 0o755);
2015
- syncedHooks.push(hook);
2016
- }
1723
+ const r = hooksWriter.write({ version, versionHome, selection: hooksToSync, cwd });
2017
1724
  // Remove orphan files from version home. The trusted set is the
2018
1725
  // manifest-declared hook list (`available.hooks`) — auxiliary files
2019
1726
  // like README.md or promptcuts.yaml may exist alongside hooks at the
2020
1727
  // source but are not hooks and must not linger in version homes from
2021
1728
  // older syncs.
1729
+ const hooksTarget = path.join(agentDir, 'hooks');
2022
1730
  const trustedHookNames = new Set(available.hooks);
2023
1731
  if (fs.existsSync(hooksTarget)) {
2024
1732
  for (const file of fs.readdirSync(hooksTarget).filter(f => !f.startsWith('.'))) {
@@ -2027,23 +1735,19 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2027
1735
  }
2028
1736
  }
2029
1737
  }
2030
- result.hooks = syncedHooks.length > 0;
2031
- // Register hooks into agent-native settings.json/hooks.json. Gemini
2032
- // shipped hooks in 0.26.0; gate already passed above so this is safe.
2033
- // Grok auto-discovers from ~/.grok/hooks/ so the script copy above
2034
- // is sufficient — no settings.json registration needed.
2035
- if (agent === 'claude' || agent === 'codex' || agent === 'gemini' || agent === 'antigravity') {
2036
- registerHooksToSettings(agent, versionHome);
2037
- }
1738
+ result.hooks = r.synced.length > 0;
2038
1739
  }
2039
1740
  }
2040
1741
  }
2041
- // Sync rules — compose from layered subrules + active preset and write a
2042
- // single inlined instruction file. No @-import expansion; no per-fragment
2043
- // copies. Project rules are NOT synced into the version home they are
2044
- // 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).
2045
1748
  const skipMemory = selection && (selection.memory === undefined || (Array.isArray(selection.memory) && selection.memory.length === 0));
2046
- if (!skipMemory && COMMANDS_CAPABLE_AGENTS.includes(agent)) {
1749
+ const rulesWriter = getWriter('rules', agent);
1750
+ if (!skipMemory && rulesWriter) {
2047
1751
  try {
2048
1752
  // If selection.memory names a single preset, treat it as a one-shot
2049
1753
  // override; otherwise read the persisted active preset.
@@ -2051,13 +1755,8 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2051
1755
  ? selection.memory[0]
2052
1756
  : null;
2053
1757
  const preset = overridePreset || getActiveRulesPreset(agent, version);
2054
- const composed = composeRulesFromState({ preset });
2055
- const targetName = agentConfig.instructionsFile;
2056
- const destFile = safeJoin(agentDir, targetName);
2057
- fs.mkdirSync(path.dirname(destFile), { recursive: true });
2058
- removePath(destFile);
2059
- fs.writeFileSync(destFile, composed.content);
2060
- result.memory.push(targetName);
1758
+ const r = rulesWriter.write({ version, versionHome, selection: { preset }, cwd });
1759
+ result.memory.push(...r.synced);
2061
1760
  // rulesPreset is tracked separately via setActiveRulesPreset.
2062
1761
  }
2063
1762
  catch (err) {
@@ -2089,6 +1788,7 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2089
1788
  console.warn(`${PERMISSION_PRESET_ENV_VAR}=${activePresetName} but no recipe at ~/.agents/permissions/presets/${activePresetName}.yaml — falling back to all groups`);
2090
1789
  }
2091
1790
  }
1791
+ const permissionsWriter = getWriter('permissions', agent);
2092
1792
  let permsToSync;
2093
1793
  if (selection) {
2094
1794
  permsToSync = resolveSelection(selection.permissions, allGroupNames);
@@ -2103,18 +1803,12 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2103
1803
  }
2104
1804
  }
2105
1805
  else {
2106
- permsToSync = PERMISSIONS_CAPABLE_AGENTS.includes(agent)
2107
- ? (presetFilteredGroups ?? allGroupNames)
2108
- : [];
1806
+ permsToSync = permissionsWriter ? (presetFilteredGroups ?? allGroupNames) : [];
2109
1807
  }
2110
- if (permsToSync.length > 0 && PERMISSIONS_CAPABLE_AGENTS.includes(agent)) {
2111
- // Build permissions from selected groups
2112
- const builtPerms = buildPermissionsFromGroups(permsToSync);
2113
- if (builtPerms.allow.length > 0 || (builtPerms.deny && builtPerms.deny.length > 0)) {
2114
- const permResult = applyPermsToVersion(agent, builtPerms, versionHome, true);
2115
- result.permissions = permResult.success;
2116
- // permissions patterns already written via ensureVersionResourcePatterns above.
2117
- }
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.
2118
1812
  }
2119
1813
  // Install MCP servers (if agent supports them)
2120
1814
  // For Claude/Codex: uses CLI commands (claude mcp add, codex mcp add)
@@ -2129,57 +1823,29 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2129
1823
  // user entry, so name-collision shadowing is not fully closed here —
2130
1824
  // tracked separately for a follow-up in lib/mcp.ts.)
2131
1825
  const projectScopedMcpNames = new Set(getScopedMcpResources(cwd).filter(r => r.scope === 'project').map(r => r.name));
1826
+ const mcpWriter = getWriter('mcp', agent);
2132
1827
  const mcpToSyncAll = selection
2133
1828
  ? resolveSelection(selection.mcp, available.mcp)
2134
- : (MCP_CAPABLE_AGENTS.includes(agent) ? available.mcp : []);
1829
+ : (mcpWriter ? available.mcp : []);
2135
1830
  const mcpToSync = mcpToSyncAll.filter(n => !projectScopedMcpNames.has(n));
2136
- if (mcpToSync.length > 0 && MCP_CAPABLE_AGENTS.includes(agent)) {
2137
- const mcpResult = installMcpServers(agent, version, versionHome, mcpToSync, { cwd });
2138
- 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;
2139
1834
  // mcp patterns already written via ensureVersionResourcePatterns above.
2140
1835
  }
2141
- // Sync subagents (claude and openclaw only).
2142
- // Note: listInstalledSubagents (used to populate the map below) reads only
2143
- // user + system layers — never project. Subagents bundle prompts that fire
2144
- // when the agent delegates work, so a cloned public repo must not be able
2145
- // 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);
2146
1840
  const subagentsToSync = selection
2147
1841
  ? resolveSelection(selection.subagents, available.subagents)
2148
- : (SUBAGENT_CAPABLE_AGENTS.includes(agent) ? available.subagents : []);
2149
- if (subagentsToSync.length > 0 && SUBAGENT_CAPABLE_AGENTS.includes(agent)) {
2150
- const allSubagents = listInstalledSubagents();
2151
- const subagentsMap = new Map(allSubagents.map(s => [s.name, s]));
2152
- for (const name of subagentsToSync) {
2153
- const subagent = subagentsMap.get(name);
2154
- if (!subagent)
2155
- continue;
2156
- try {
2157
- if (agent === 'claude') {
2158
- // Claude: flatten to single .md file
2159
- const agentsDir = path.join(agentDir, 'agents');
2160
- fs.mkdirSync(agentsDir, { recursive: true });
2161
- const transformed = transformSubagentForClaude(subagent.path);
2162
- fs.writeFileSync(safeJoin(agentsDir, `${subagent.name}.md`), transformed);
2163
- result.subagents.push(subagent.name);
2164
- }
2165
- else if (agent === 'openclaw') {
2166
- // OpenClaw: copy full directory, rename AGENT.md -> AGENTS.md
2167
- const targetDir = safeJoin(path.join(versionHome, '.openclaw'), subagent.name);
2168
- const syncResult = syncSubagentToOpenclaw(subagent.path, targetDir);
2169
- if (syncResult.success) {
2170
- result.subagents.push(subagent.name);
2171
- }
2172
- }
2173
- }
2174
- catch { /* resource sync failed for this item */ }
2175
- }
2176
- // Orphan-sweep stale subagents. Same selection-mode guard as the
2177
- // commands/skills sweeps above. Claude stores them as flat .md files
2178
- // under `<agentDir>/agents/`; OpenClaw stores them as subdirs at the
2179
- // same level as commands/skills/hooks/plugins (no isolated parent dir),
2180
- // which means a directory-readdir sweep would unsafely hit unrelated
2181
- // resources. For OpenClaw we lean on the existing per-name copy path —
2182
- // 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.
2183
1849
  if (!userPassedSelection && agent === 'claude') {
2184
1850
  const claudeAgentsDir = path.join(agentDir, 'agents');
2185
1851
  if (fs.existsSync(claudeAgentsDir)) {
@@ -2196,48 +1862,24 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2196
1862
  }
2197
1863
  }
2198
1864
  }
2199
- // subagent patterns already written via ensureVersionResourcePatterns above.
2200
1865
  }
2201
- // Sync plugins (claude and openclaw)
1866
+ // Sync plugins dispatch through WRITERS.plugins.
1867
+ const pluginsWriter = getWriter('plugins', agent);
2202
1868
  const pluginsToSync = selection
2203
1869
  ? resolveSelection(selection.plugins, available.plugins)
2204
- : (PLUGINS_CAPABLE_AGENTS.includes(agent) ? available.plugins : []);
2205
- if (pluginsToSync.length > 0 && PLUGINS_CAPABLE_AGENTS.includes(agent)) {
2206
- const allPlugins = discoverPlugins();
2207
- const pluginMap = new Map(allPlugins.map(p => [p.name, p]));
2208
- // Clean orphaned plugin skills from plugins that no longer exist
2209
- const activePluginNames = new Set(allPlugins.map(p => p.name));
2210
- cleanOrphanedPluginSkills(agent, versionHome, activePluginNames);
2211
- for (const name of pluginsToSync) {
2212
- const plugin = pluginMap.get(name);
2213
- if (!plugin || !pluginSupportsAgent(plugin, agent))
2214
- continue;
2215
- const pluginResult = syncPluginToVersion(plugin, agent, versionHome, { version });
2216
- if (pluginResult.success) {
2217
- result.plugins.push(name);
2218
- }
2219
- }
2220
- // 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);
2221
1874
  }
2222
- // Sync workflows (claude only)
1875
+ // Sync workflows dispatch through WRITERS.workflows.
1876
+ const workflowsWriter = getWriter('workflows', agent);
2223
1877
  const workflowsToSync = selection
2224
1878
  ? resolveSelection(selection.workflows, available.workflows)
2225
- : (WORKFLOW_CAPABLE_AGENTS.includes(agent) ? available.workflows : []);
2226
- if (workflowsToSync.length > 0 && WORKFLOW_CAPABLE_AGENTS.includes(agent)) {
2227
- const allWorkflows = listInstalledWorkflows();
2228
- for (const name of workflowsToSync) {
2229
- const workflow = allWorkflows.get(name);
2230
- if (!workflow)
2231
- continue;
2232
- try {
2233
- const syncResult = syncWorkflowToVersion(workflow.path, name, agent, versionHome);
2234
- if (syncResult.success) {
2235
- result.workflows.push(name);
2236
- }
2237
- }
2238
- catch { /* resource sync failed for this item */ }
2239
- }
2240
- // 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);
2241
1883
  }
2242
1884
  // Write manifest after a full sync (no user-passed selection) so the next
2243
1885
  // launch can skip the slow path. Pattern-derived selections still count as