@phnx-labs/agents-cli 1.15.0 → 1.17.0

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 (111) hide show
  1. package/CHANGELOG.md +143 -39
  2. package/README.md +6 -6
  3. package/dist/commands/alias.js +2 -2
  4. package/dist/commands/browser-picker.d.ts +21 -0
  5. package/dist/commands/browser-picker.js +114 -0
  6. package/dist/commands/browser.js +793 -83
  7. package/dist/commands/cloud.js +8 -0
  8. package/dist/commands/commands.js +72 -22
  9. package/dist/commands/daemon.js +2 -2
  10. package/dist/commands/exec.js +70 -1
  11. package/dist/commands/hooks.js +71 -26
  12. package/dist/commands/mcp.js +81 -39
  13. package/dist/commands/plugins.js +224 -17
  14. package/dist/commands/prune.js +29 -1
  15. package/dist/commands/pull.js +3 -3
  16. package/dist/commands/repo.js +1 -1
  17. package/dist/commands/routines.js +2 -2
  18. package/dist/commands/secrets.js +154 -20
  19. package/dist/commands/sessions.js +62 -19
  20. package/dist/commands/{init.d.ts → setup.d.ts} +7 -6
  21. package/dist/commands/{init.js → setup.js} +22 -21
  22. package/dist/commands/skills.js +60 -19
  23. package/dist/commands/subagents.js +41 -13
  24. package/dist/commands/utils.d.ts +16 -0
  25. package/dist/commands/utils.js +32 -0
  26. package/dist/commands/view.js +78 -20
  27. package/dist/commands/workflows.d.ts +10 -0
  28. package/dist/commands/workflows.js +457 -0
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.js +48 -36
  31. package/dist/lib/agents.js +2 -2
  32. package/dist/lib/auto-pull-worker.js +2 -3
  33. package/dist/lib/auto-pull.js +2 -2
  34. package/dist/lib/browser/cdp.d.ts +7 -1
  35. package/dist/lib/browser/cdp.js +32 -1
  36. package/dist/lib/browser/chrome.d.ts +10 -0
  37. package/dist/lib/browser/chrome.js +41 -3
  38. package/dist/lib/browser/devices.d.ts +4 -0
  39. package/dist/lib/browser/devices.js +27 -0
  40. package/dist/lib/browser/drivers/local.js +22 -6
  41. package/dist/lib/browser/drivers/ssh.js +9 -2
  42. package/dist/lib/browser/input.d.ts +1 -0
  43. package/dist/lib/browser/input.js +3 -0
  44. package/dist/lib/browser/ipc.js +158 -23
  45. package/dist/lib/browser/profiles.d.ts +10 -2
  46. package/dist/lib/browser/profiles.js +122 -37
  47. package/dist/lib/browser/service.d.ts +91 -13
  48. package/dist/lib/browser/service.js +767 -132
  49. package/dist/lib/browser/types.d.ts +91 -3
  50. package/dist/lib/browser/types.js +16 -0
  51. package/dist/lib/cloud/rush.d.ts +28 -1
  52. package/dist/lib/cloud/rush.js +69 -14
  53. package/dist/lib/cloud/store.js +2 -2
  54. package/dist/lib/commands.d.ts +1 -15
  55. package/dist/lib/commands.js +11 -7
  56. package/dist/lib/daemon.js +2 -3
  57. package/dist/lib/doctor-diff.js +4 -4
  58. package/dist/lib/events.js +2 -2
  59. package/dist/lib/hooks.d.ts +11 -7
  60. package/dist/lib/hooks.js +138 -49
  61. package/dist/lib/migrate.d.ts +1 -1
  62. package/dist/lib/migrate.js +1237 -22
  63. package/dist/lib/models.js +2 -2
  64. package/dist/lib/permissions.d.ts +8 -66
  65. package/dist/lib/permissions.js +18 -18
  66. package/dist/lib/plugins.d.ts +94 -24
  67. package/dist/lib/plugins.js +702 -123
  68. package/dist/lib/pty-server.js +9 -10
  69. package/dist/lib/resource-patterns.d.ts +41 -0
  70. package/dist/lib/resource-patterns.js +82 -0
  71. package/dist/lib/resources/hooks.d.ts +5 -1
  72. package/dist/lib/resources/hooks.js +21 -4
  73. package/dist/lib/resources/index.d.ts +17 -0
  74. package/dist/lib/resources/index.js +7 -0
  75. package/dist/lib/resources/types.d.ts +1 -1
  76. package/dist/lib/resources/workflows.d.ts +24 -0
  77. package/dist/lib/resources/workflows.js +110 -0
  78. package/dist/lib/resources.d.ts +6 -1
  79. package/dist/lib/resources.js +12 -2
  80. package/dist/lib/rotate.js +3 -4
  81. package/dist/lib/session/active.d.ts +3 -0
  82. package/dist/lib/session/active.js +92 -6
  83. package/dist/lib/session/cloud.js +2 -2
  84. package/dist/lib/session/db.d.ts +18 -0
  85. package/dist/lib/session/db.js +109 -5
  86. package/dist/lib/session/discover.d.ts +6 -0
  87. package/dist/lib/session/discover.js +55 -29
  88. package/dist/lib/session/team-filter.js +2 -2
  89. package/dist/lib/shims.d.ts +4 -52
  90. package/dist/lib/shims.js +23 -15
  91. package/dist/lib/skills.js +6 -2
  92. package/dist/lib/sqlite.js +10 -4
  93. package/dist/lib/state.d.ts +101 -16
  94. package/dist/lib/state.js +179 -31
  95. package/dist/lib/subagents.d.ts +28 -0
  96. package/dist/lib/subagents.js +98 -1
  97. package/dist/lib/sync-manifest.d.ts +1 -1
  98. package/dist/lib/sync-manifest.js +3 -3
  99. package/dist/lib/teams/persistence.js +15 -5
  100. package/dist/lib/teams/registry.js +2 -2
  101. package/dist/lib/types.d.ts +75 -17
  102. package/dist/lib/types.js +3 -3
  103. package/dist/lib/usage.js +2 -2
  104. package/dist/lib/versions.d.ts +3 -0
  105. package/dist/lib/versions.js +158 -47
  106. package/dist/lib/workflows.d.ts +79 -0
  107. package/dist/lib/workflows.js +233 -0
  108. package/package.json +1 -5
  109. package/scripts/postinstall.js +60 -59
  110. package/dist/commands/fork.d.ts +0 -10
  111. package/dist/commands/fork.js +0 -146
@@ -1,18 +1,23 @@
1
1
  /**
2
2
  * Plugin discovery, validation, and syncing.
3
3
  *
4
- * Plugins are bundles in ~/.agents/plugins/ that package skills, hooks, and
5
- * scripts under a single manifest (plugin.yaml). This module discovers plugins,
6
- * validates their manifests, and syncs their contents into agent version homes.
4
+ * Plugins are bundles in ~/.agents/.cache/plugins/ that package skills, hooks,
5
+ * commands, agents, bin scripts, MCP servers, and settings under a single
6
+ * manifest (plugin.json). This module discovers plugins, validates their
7
+ * manifests, and syncs their contents into agent version homes.
7
8
  */
8
9
  import * as fs from 'fs';
9
10
  import * as path from 'path';
10
- import { getPluginsDir } from './state.js';
11
+ import { execSync } from 'child_process';
12
+ import { getPluginsDir, getTrashPluginsDir } from './state.js';
13
+ import { listInstalledVersions, getVersionHomePath } from './versions.js';
11
14
  import { AGENTS, PLUGINS_CAPABLE_AGENTS } from './agents.js';
12
15
  const PLUGIN_MANIFEST_DIR = '.claude-plugin';
13
16
  const PLUGIN_MANIFEST_FILE = 'plugin.json';
17
+ const USER_CONFIG_FILE = '.user-config.json';
18
+ const SOURCE_FILE = '.source';
14
19
  /**
15
- * Discover all plugins in ~/.agents/plugins/.
20
+ * Discover all plugins in ~/.agents/.cache/plugins/.
16
21
  * A valid plugin has a .claude-plugin/plugin.json manifest.
17
22
  */
18
23
  export function discoverPlugins() {
@@ -29,17 +34,25 @@ export function discoverPlugins() {
29
34
  const manifest = loadPluginManifest(pluginRoot);
30
35
  if (!manifest)
31
36
  continue;
32
- plugins.push({
33
- name: manifest.name,
34
- root: pluginRoot,
35
- manifest,
36
- skills: discoverPluginSkills(pluginRoot),
37
- hooks: discoverPluginHooks(pluginRoot),
38
- scripts: discoverPluginScripts(pluginRoot),
39
- });
37
+ plugins.push(buildDiscoveredPlugin(pluginRoot, manifest));
40
38
  }
41
39
  return plugins;
42
40
  }
41
+ export function buildDiscoveredPlugin(pluginRoot, manifest) {
42
+ return {
43
+ name: manifest.name,
44
+ root: pluginRoot,
45
+ manifest,
46
+ skills: discoverPluginSkills(pluginRoot),
47
+ hooks: discoverPluginHooks(pluginRoot),
48
+ scripts: discoverPluginScripts(pluginRoot),
49
+ commands: discoverPluginCommands(pluginRoot),
50
+ agentDefs: discoverPluginAgentDefs(pluginRoot),
51
+ bin: discoverPluginBin(pluginRoot),
52
+ hasMcp: fs.existsSync(path.join(pluginRoot, '.mcp.json')),
53
+ hasSettings: pluginHasNonPermissionSettings(pluginRoot),
54
+ };
55
+ }
43
56
  /**
44
57
  * Load a plugin manifest from a plugin directory.
45
58
  */
@@ -82,9 +95,7 @@ export function pluginSupportsAgent(plugin, agent) {
82
95
  }
83
96
  return true;
84
97
  }
85
- /**
86
- * Discover skill directories inside a plugin.
87
- */
98
+ // ─── Discovery helpers ────────────────────────────────────────────────────────
88
99
  function discoverPluginSkills(pluginRoot) {
89
100
  const skillsDir = path.join(pluginRoot, 'skills');
90
101
  if (!fs.existsSync(skillsDir))
@@ -93,9 +104,6 @@ function discoverPluginSkills(pluginRoot) {
93
104
  .filter(d => d.isDirectory() && !d.name.startsWith('.'))
94
105
  .map(d => d.name);
95
106
  }
96
- /**
97
- * Discover hook definitions inside a plugin.
98
- */
99
107
  function discoverPluginHooks(pluginRoot) {
100
108
  const hooksFile = path.join(pluginRoot, 'hooks', 'hooks.json');
101
109
  if (!fs.existsSync(hooksFile))
@@ -108,85 +116,198 @@ function discoverPluginHooks(pluginRoot) {
108
116
  return [];
109
117
  }
110
118
  }
111
- /**
112
- * Discover scripts inside a plugin.
113
- */
114
119
  function discoverPluginScripts(pluginRoot) {
115
120
  const scriptsDir = path.join(pluginRoot, 'scripts');
116
121
  if (!fs.existsSync(scriptsDir))
117
122
  return [];
118
123
  return fs.readdirSync(scriptsDir).filter(f => !f.startsWith('.'));
119
124
  }
125
+ /** Discover command .md files inside a plugin's commands/ directory. */
126
+ export function discoverPluginCommands(pluginRoot) {
127
+ const commandsDir = path.join(pluginRoot, 'commands');
128
+ if (!fs.existsSync(commandsDir))
129
+ return [];
130
+ return fs.readdirSync(commandsDir)
131
+ .filter(f => f.endsWith('.md') && !f.startsWith('.'))
132
+ .map(f => f.slice(0, -3));
133
+ }
134
+ /** Discover agent definition .md files inside a plugin's agents/ directory. */
135
+ export function discoverPluginAgentDefs(pluginRoot) {
136
+ const agentsDir = path.join(pluginRoot, 'agents');
137
+ if (!fs.existsSync(agentsDir))
138
+ return [];
139
+ return fs.readdirSync(agentsDir)
140
+ .filter(f => f.endsWith('.md') && !f.startsWith('.'))
141
+ .map(f => f.slice(0, -3));
142
+ }
143
+ /** Discover executable files in a plugin's bin/ directory. */
144
+ export function discoverPluginBin(pluginRoot) {
145
+ const binDir = path.join(pluginRoot, 'bin');
146
+ if (!fs.existsSync(binDir))
147
+ return [];
148
+ return fs.readdirSync(binDir).filter(f => !f.startsWith('.'));
149
+ }
150
+ /** Return true if settings.json contains non-permission keys worth merging. */
151
+ function pluginHasNonPermissionSettings(pluginRoot) {
152
+ const settingsPath = path.join(pluginRoot, 'settings.json');
153
+ if (!fs.existsSync(settingsPath))
154
+ return false;
155
+ try {
156
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
157
+ return Object.keys(parsed).some(k => k !== 'permissions');
158
+ }
159
+ catch {
160
+ return false;
161
+ }
162
+ }
163
+ // ─── Variable expansion ───────────────────────────────────────────────────────
120
164
  /**
121
165
  * Expand plugin variables in a string.
122
166
  *
123
167
  * Variables:
124
- * ${CLAUDE_PLUGIN_ROOT} -> absolute path to plugin directory
125
- * ${CLAUDE_PLUGIN_DATA} -> per-version data directory for this plugin
168
+ * ${CLAUDE_PLUGIN_ROOT} -> absolute path to plugin directory
169
+ * ${CLAUDE_PLUGIN_DATA} -> per-version data directory for this plugin
170
+ * ${user_config.<key>} -> value from plugin's .user-config.json
126
171
  */
127
- export function expandPluginVars(str, pluginRoot, pluginName, agentId, versionHome) {
172
+ export function expandPluginVars(str, pluginRoot, pluginName, agentId, versionHome, userConfig) {
128
173
  const dataDir = path.join(versionHome, `.${agentId}`, 'plugin-data', pluginName);
129
- return str
174
+ let result = str
130
175
  .replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pluginRoot)
131
176
  .replace(/\$\{CLAUDE_PLUGIN_DATA\}/g, dataDir);
177
+ if (userConfig && Object.keys(userConfig).length > 0) {
178
+ result = result.replace(/\$\{user_config\.([^}]+)\}/g, (_, key) => {
179
+ return userConfig[key] ?? '';
180
+ });
181
+ }
182
+ return result;
132
183
  }
184
+ // ─── userConfig storage ───────────────────────────────────────────────────────
185
+ /**
186
+ * Load persisted user config for a plugin from .user-config.json.
187
+ */
188
+ export function loadUserConfig(pluginName) {
189
+ const configPath = path.join(getPluginsDir(), pluginName, USER_CONFIG_FILE);
190
+ if (!fs.existsSync(configPath))
191
+ return {};
192
+ try {
193
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
194
+ }
195
+ catch {
196
+ return {};
197
+ }
198
+ }
199
+ /**
200
+ * Persist user config for a plugin to .user-config.json.
201
+ */
202
+ export function saveUserConfig(pluginName, config) {
203
+ const configPath = path.join(getPluginsDir(), pluginName, USER_CONFIG_FILE);
204
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
205
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
206
+ }
207
+ // ─── Dependency checking ──────────────────────────────────────────────────────
208
+ /**
209
+ * Check plugin dependencies against installed plugins.
210
+ * Returns names of missing dependencies (warning only — not a hard error).
211
+ */
212
+ export function checkPluginDependencies(manifest) {
213
+ if (!manifest.dependencies || manifest.dependencies.length === 0)
214
+ return [];
215
+ const installed = new Set(discoverPlugins().map(p => p.name));
216
+ return manifest.dependencies.filter(dep => !installed.has(dep));
217
+ }
218
+ // ─── Main sync entry point ────────────────────────────────────────────────────
133
219
  /**
134
220
  * Sync a plugin to a specific agent version's home directory.
135
221
  *
136
222
  * For Claude:
137
- * 1. Copy plugin skills into version's skills dir (prefixed: pluginName:skillName)
138
- * 2. Read hooks/hooks.json, expand vars, merge into settings.json hooks
139
- * 3. Read settings.json, expand vars, merge permissions into settings.json
140
- *
141
- * For OpenClaw:
142
- * 1. Copy plugin skills into version's skills dir
223
+ * 1. Copy plugin skills into version's skills dir (prefixed: pluginName--skillName)
224
+ * 2. Copy plugin commands into version's commands dir (prefixed: pluginName--cmdName.md)
225
+ * 3. Copy plugin agent defs into version's agents dir (prefixed: pluginName--agentName.md)
226
+ * 4. Copy plugin bin/ into version home plugin-bin/<pluginName>/, note path in settings
227
+ * 5. Read hooks/hooks.json, expand vars, merge into settings.json hooks
228
+ * 6. Read .mcp.json, expand vars, merge mcpServers into settings.json
229
+ * 7. Read settings.json, merge non-permission keys non-destructively into settings.json
230
+ * 8. Read settings.json permissions, expand vars, merge into settings.json
143
231
  */
144
232
  export function syncPluginToVersion(plugin, agent, versionHome) {
145
- const result = { success: false, skills: [], hooks: [], permissions: false };
233
+ const result = {
234
+ success: false,
235
+ skills: [],
236
+ commands: [],
237
+ agentDefs: [],
238
+ bin: [],
239
+ hooks: [],
240
+ permissions: false,
241
+ mcp: false,
242
+ settings: false,
243
+ };
146
244
  if (!pluginSupportsAgent(plugin, agent)) {
147
245
  return result;
148
246
  }
247
+ const userConfig = loadUserConfig(plugin.name);
149
248
  // 1. Sync skills
150
- const skillsResult = syncPluginSkills(plugin, agent, versionHome);
151
- result.skills = skillsResult;
152
- // 2. Sync hooks (Claude only - uses settings.json hook registration)
249
+ result.skills = syncPluginSkills(plugin, agent, versionHome, userConfig);
250
+ // 2. Sync commands (Claude + compatible agents only)
251
+ if (agent === 'claude' || agent === 'openclaw') {
252
+ result.commands = syncPluginCommands(plugin, agent, versionHome, userConfig);
253
+ }
254
+ // 3. Sync agent defs (Claude only)
255
+ if (agent === 'claude') {
256
+ result.agentDefs = syncPluginAgentDefs(plugin, agent, versionHome, userConfig);
257
+ }
258
+ // 4. Sync bin executables (Claude only for now)
153
259
  if (agent === 'claude') {
154
- const hooksResult = syncPluginHooks(plugin, agent, versionHome);
155
- result.hooks = hooksResult;
260
+ result.bin = syncPluginBin(plugin, agent, versionHome);
156
261
  }
157
- // 3. Sync permissions (Claude only - uses settings.json permissions)
262
+ // 5. Sync hooks (Claude only - uses settings.json hook registration)
158
263
  if (agent === 'claude') {
159
- result.permissions = syncPluginPermissions(plugin, agent, versionHome);
264
+ result.hooks = syncPluginHooks(plugin, agent, versionHome, userConfig);
160
265
  }
161
- result.success = result.skills.length > 0 || result.hooks.length > 0 || result.permissions;
266
+ // 6. Sync MCP servers (Claude only)
267
+ if (agent === 'claude') {
268
+ result.mcp = syncPluginMcp(plugin, agent, versionHome, userConfig);
269
+ }
270
+ // 7. Merge non-permission settings keys non-destructively (Claude only)
271
+ if (agent === 'claude') {
272
+ result.settings = syncPluginSettings(plugin, agent, versionHome);
273
+ }
274
+ // 8. Sync permissions (Claude only - uses settings.json permissions)
275
+ if (agent === 'claude') {
276
+ result.permissions = syncPluginPermissions(plugin, agent, versionHome, userConfig);
277
+ }
278
+ result.success =
279
+ result.skills.length > 0 ||
280
+ result.commands.length > 0 ||
281
+ result.agentDefs.length > 0 ||
282
+ result.bin.length > 0 ||
283
+ result.hooks.length > 0 ||
284
+ result.mcp ||
285
+ result.settings ||
286
+ result.permissions;
162
287
  return result;
163
288
  }
289
+ // ─── Individual sync functions ────────────────────────────────────────────────
164
290
  /**
165
291
  * Copy plugin skills into the version's skills directory.
166
- * Skills are prefixed with the plugin name: pluginName:skillName
292
+ * Skills are prefixed with the plugin name: pluginName--skillName
167
293
  */
168
- function syncPluginSkills(plugin, agent, versionHome) {
294
+ function syncPluginSkills(plugin, agent, versionHome, userConfig) {
169
295
  const synced = [];
170
296
  const pluginSkillsDir = path.join(plugin.root, 'skills');
171
297
  if (!fs.existsSync(pluginSkillsDir))
172
298
  return synced;
173
- const agentConfig = AGENTS[agent];
174
299
  const targetSkillsDir = path.join(versionHome, `.${agent}`, 'skills');
175
300
  fs.mkdirSync(targetSkillsDir, { recursive: true });
176
301
  for (const skillName of plugin.skills) {
177
302
  const srcDir = path.join(pluginSkillsDir, skillName);
178
- // Prefix with plugin name for namespacing
179
- const prefixedName = `${plugin.name}:${skillName}`;
180
- // Use colon-to-dash for filesystem (colons not allowed on some systems)
181
- const fsSafeName = prefixedName.replace(/:/g, '--');
303
+ const fsSafeName = `${plugin.name}--${skillName}`;
182
304
  const destDir = path.join(targetSkillsDir, fsSafeName);
183
305
  try {
184
- // Remove existing and copy fresh
185
306
  if (fs.existsSync(destDir)) {
186
307
  fs.rmSync(destDir, { recursive: true, force: true });
187
308
  }
188
- copyDirWithVarExpansion(srcDir, destDir, plugin.root, plugin.name, agent, versionHome);
189
- synced.push(prefixedName);
309
+ copyDirWithVarExpansion(srcDir, destDir, plugin.root, plugin.name, agent, versionHome, userConfig);
310
+ synced.push(`${plugin.name}:${skillName}`);
190
311
  }
191
312
  catch {
192
313
  // Skip on error
@@ -194,12 +315,123 @@ function syncPluginSkills(plugin, agent, versionHome) {
194
315
  }
195
316
  return synced;
196
317
  }
318
+ /**
319
+ * Copy plugin commands into the version's commands directory.
320
+ * Commands are namespaced: pluginName--commandName.md
321
+ */
322
+ function syncPluginCommands(plugin, agent, versionHome, userConfig) {
323
+ const synced = [];
324
+ const pluginCommandsDir = path.join(plugin.root, 'commands');
325
+ if (!fs.existsSync(pluginCommandsDir) || plugin.commands.length === 0)
326
+ return synced;
327
+ const agentConfig = AGENTS[agent];
328
+ const commandsTarget = path.join(versionHome, `.${agent}`, agentConfig.commandsSubdir);
329
+ fs.mkdirSync(commandsTarget, { recursive: true });
330
+ for (const cmdName of plugin.commands) {
331
+ const srcFile = path.join(pluginCommandsDir, `${cmdName}.md`);
332
+ if (!fs.existsSync(srcFile))
333
+ continue;
334
+ const destName = `${plugin.name}--${cmdName}.md`;
335
+ const destFile = path.join(commandsTarget, destName);
336
+ try {
337
+ let content = fs.readFileSync(srcFile, 'utf-8');
338
+ content = expandPluginVars(content, plugin.root, plugin.name, agent, versionHome, userConfig);
339
+ fs.writeFileSync(destFile, content, 'utf-8');
340
+ synced.push(`${plugin.name}:${cmdName}`);
341
+ }
342
+ catch {
343
+ // Skip on error
344
+ }
345
+ }
346
+ return synced;
347
+ }
348
+ /**
349
+ * Copy plugin agent definitions into the version's agents directory.
350
+ * Agent defs are namespaced: pluginName--agentName.md
351
+ */
352
+ function syncPluginAgentDefs(plugin, agent, versionHome, userConfig) {
353
+ const synced = [];
354
+ const pluginAgentsDir = path.join(plugin.root, 'agents');
355
+ if (!fs.existsSync(pluginAgentsDir) || plugin.agentDefs.length === 0)
356
+ return synced;
357
+ const agentsTarget = path.join(versionHome, `.${agent}`, 'agents');
358
+ fs.mkdirSync(agentsTarget, { recursive: true });
359
+ for (const agentDefName of plugin.agentDefs) {
360
+ const srcFile = path.join(pluginAgentsDir, `${agentDefName}.md`);
361
+ if (!fs.existsSync(srcFile))
362
+ continue;
363
+ const destName = `${plugin.name}--${agentDefName}.md`;
364
+ const destFile = path.join(agentsTarget, destName);
365
+ try {
366
+ let content = fs.readFileSync(srcFile, 'utf-8');
367
+ content = expandPluginVars(content, plugin.root, plugin.name, agent, versionHome, userConfig);
368
+ fs.writeFileSync(destFile, content, 'utf-8');
369
+ synced.push(`${plugin.name}:${agentDefName}`);
370
+ }
371
+ catch {
372
+ // Skip on error
373
+ }
374
+ }
375
+ return synced;
376
+ }
377
+ /**
378
+ * Copy plugin bin executables into the version home plugin-bin/<pluginName>/ directory.
379
+ * Records the bin path in settings.json under pluginBinPaths so callers can add it to PATH.
380
+ */
381
+ function syncPluginBin(plugin, agent, versionHome) {
382
+ const synced = [];
383
+ const pluginBinDir = path.join(plugin.root, 'bin');
384
+ if (!fs.existsSync(pluginBinDir) || plugin.bin.length === 0)
385
+ return synced;
386
+ const targetBinDir = path.join(versionHome, `.${agent}`, 'plugin-bin', plugin.name);
387
+ fs.mkdirSync(targetBinDir, { recursive: true });
388
+ for (const binFile of plugin.bin) {
389
+ const srcFile = path.join(pluginBinDir, binFile);
390
+ if (!fs.existsSync(srcFile))
391
+ continue;
392
+ const destFile = path.join(targetBinDir, binFile);
393
+ try {
394
+ fs.copyFileSync(srcFile, destFile);
395
+ const stat = fs.statSync(srcFile);
396
+ fs.chmodSync(destFile, stat.mode | 0o111);
397
+ synced.push(binFile);
398
+ }
399
+ catch {
400
+ // Skip on error
401
+ }
402
+ }
403
+ if (synced.length === 0)
404
+ return synced;
405
+ // Note the bin path in settings.json so the calling shim can add it to PATH.
406
+ const configDir = path.join(versionHome, `.${agent}`);
407
+ const settingsPath = path.join(configDir, 'settings.json');
408
+ let settings = {};
409
+ if (fs.existsSync(settingsPath)) {
410
+ try {
411
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
412
+ }
413
+ catch { /* start fresh */ }
414
+ }
415
+ if (!Array.isArray(settings.pluginBinPaths)) {
416
+ settings.pluginBinPaths = [];
417
+ }
418
+ const binPaths = settings.pluginBinPaths;
419
+ if (!binPaths.includes(targetBinDir)) {
420
+ binPaths.push(targetBinDir);
421
+ }
422
+ try {
423
+ fs.mkdirSync(configDir, { recursive: true });
424
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
425
+ }
426
+ catch { /* ignore write errors */ }
427
+ return synced;
428
+ }
197
429
  /**
198
430
  * Merge plugin hooks into Claude's settings.json.
199
431
  * Reads the plugin's hooks/hooks.json and merges each event's hooks
200
432
  * into the version's settings.json, expanding variables.
201
433
  */
202
- function syncPluginHooks(plugin, agent, versionHome) {
434
+ function syncPluginHooks(plugin, agent, versionHome, userConfig) {
203
435
  const synced = [];
204
436
  const hooksFile = path.join(plugin.root, 'hooks', 'hooks.json');
205
437
  if (!fs.existsSync(hooksFile))
@@ -211,7 +443,6 @@ function syncPluginHooks(plugin, agent, versionHome) {
211
443
  catch {
212
444
  return synced;
213
445
  }
214
- // Read existing settings.json
215
446
  const configDir = path.join(versionHome, `.${agent}`);
216
447
  const settingsPath = path.join(configDir, 'settings.json');
217
448
  let settings = {};
@@ -219,15 +450,12 @@ function syncPluginHooks(plugin, agent, versionHome) {
219
450
  try {
220
451
  settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
221
452
  }
222
- catch {
223
- // Start fresh if parse fails
224
- }
453
+ catch { /* start fresh */ }
225
454
  }
226
455
  if (!settings.hooks || typeof settings.hooks !== 'object') {
227
456
  settings.hooks = {};
228
457
  }
229
458
  const hooksConfig = settings.hooks;
230
- // Merge each event from the plugin
231
459
  for (const [event, matcherGroups] of Object.entries(pluginHooks)) {
232
460
  if (!hooksConfig[event]) {
233
461
  hooksConfig[event] = [];
@@ -237,9 +465,8 @@ function syncPluginHooks(plugin, agent, versionHome) {
237
465
  const matcher = group.matcher || '';
238
466
  const expandedHooks = (group.hooks || []).map(h => ({
239
467
  ...h,
240
- command: expandPluginVars(h.command, plugin.root, plugin.name, agent, versionHome),
468
+ command: expandPluginVars(h.command, plugin.root, plugin.name, agent, versionHome, userConfig),
241
469
  }));
242
- // Find or create matcher group
243
470
  let matcherGroup = eventEntries.find(e => (e.matcher || '') === matcher);
244
471
  if (!matcherGroup) {
245
472
  matcherGroup = { matcher, hooks: [] };
@@ -248,7 +475,6 @@ function syncPluginHooks(plugin, agent, versionHome) {
248
475
  if (!matcherGroup.hooks) {
249
476
  matcherGroup.hooks = [];
250
477
  }
251
- // Add hooks that aren't already registered (by command path)
252
478
  for (const hook of expandedHooks) {
253
479
  const exists = matcherGroup.hooks.some(h => h.command === hook.command);
254
480
  if (!exists) {
@@ -258,21 +484,116 @@ function syncPluginHooks(plugin, agent, versionHome) {
258
484
  }
259
485
  synced.push(event);
260
486
  }
261
- // Write back
262
487
  try {
263
488
  fs.mkdirSync(configDir, { recursive: true });
264
489
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
265
490
  }
491
+ catch { /* ignore write errors */ }
492
+ return synced;
493
+ }
494
+ /**
495
+ * Merge plugin .mcp.json MCP server definitions into Claude's settings.json.
496
+ * Server names are namespaced as pluginName--serverName to avoid collisions.
497
+ * Expands ${CLAUDE_PLUGIN_ROOT}, ${CLAUDE_PLUGIN_DATA}, ${user_config.*} in args and env.
498
+ */
499
+ function syncPluginMcp(plugin, agent, versionHome, userConfig) {
500
+ const mcpFile = path.join(plugin.root, '.mcp.json');
501
+ if (!fs.existsSync(mcpFile))
502
+ return false;
503
+ let pluginMcp;
504
+ try {
505
+ pluginMcp = JSON.parse(fs.readFileSync(mcpFile, 'utf-8'));
506
+ }
266
507
  catch {
267
- // Ignore write errors
508
+ return false;
268
509
  }
269
- return synced;
510
+ const servers = pluginMcp.mcpServers;
511
+ if (!servers || Object.keys(servers).length === 0)
512
+ return false;
513
+ const configDir = path.join(versionHome, `.${agent}`);
514
+ const settingsPath = path.join(configDir, 'settings.json');
515
+ let settings = {};
516
+ if (fs.existsSync(settingsPath)) {
517
+ try {
518
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
519
+ }
520
+ catch { /* start fresh */ }
521
+ }
522
+ if (!settings.mcpServers || typeof settings.mcpServers !== 'object') {
523
+ settings.mcpServers = {};
524
+ }
525
+ const existing = settings.mcpServers;
526
+ let merged = false;
527
+ for (const [serverName, serverConfig] of Object.entries(servers)) {
528
+ const namespacedName = `${plugin.name}--${serverName}`;
529
+ // Expand variables inside the server config
530
+ const configStr = expandPluginVars(JSON.stringify(serverConfig), plugin.root, plugin.name, agent, versionHome, userConfig);
531
+ existing[namespacedName] = JSON.parse(configStr);
532
+ merged = true;
533
+ }
534
+ if (merged) {
535
+ try {
536
+ fs.mkdirSync(configDir, { recursive: true });
537
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
538
+ }
539
+ catch {
540
+ return false;
541
+ }
542
+ }
543
+ return merged;
544
+ }
545
+ /**
546
+ * Merge non-permission keys from plugin's settings.json non-destructively into agent settings.
547
+ * Only adds keys that don't already exist — never overwrites user config.
548
+ * Permission keys are handled separately by syncPluginPermissions.
549
+ */
550
+ function syncPluginSettings(plugin, agent, versionHome) {
551
+ const pluginSettingsPath = path.join(plugin.root, 'settings.json');
552
+ if (!fs.existsSync(pluginSettingsPath))
553
+ return false;
554
+ let pluginSettings;
555
+ try {
556
+ pluginSettings = JSON.parse(fs.readFileSync(pluginSettingsPath, 'utf-8'));
557
+ }
558
+ catch {
559
+ return false;
560
+ }
561
+ // Exclude permissions — those are handled by syncPluginPermissions
562
+ const keysToMerge = Object.entries(pluginSettings).filter(([k]) => k !== 'permissions');
563
+ if (keysToMerge.length === 0)
564
+ return false;
565
+ const configDir = path.join(versionHome, `.${agent}`);
566
+ const settingsPath = path.join(configDir, 'settings.json');
567
+ let settings = {};
568
+ if (fs.existsSync(settingsPath)) {
569
+ try {
570
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
571
+ }
572
+ catch { /* start fresh */ }
573
+ }
574
+ let changed = false;
575
+ for (const [key, value] of keysToMerge) {
576
+ if (!(key in settings)) {
577
+ settings[key] = value;
578
+ changed = true;
579
+ }
580
+ }
581
+ if (changed) {
582
+ try {
583
+ fs.mkdirSync(configDir, { recursive: true });
584
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
585
+ }
586
+ catch {
587
+ return false;
588
+ }
589
+ }
590
+ return changed;
270
591
  }
271
592
  /**
272
593
  * Merge plugin permissions into Claude's settings.json.
273
- * Reads the plugin's settings.json and merges permissions.allow entries.
594
+ * Reads the plugin's settings.json and merges permissions.allow / deny entries.
274
595
  */
275
- function syncPluginPermissions(plugin, agent, versionHome) {
596
+ function syncPluginPermissions(plugin, agent, versionHome, userConfig) {
276
597
  const pluginSettingsPath = path.join(plugin.root, 'settings.json');
277
598
  if (!fs.existsSync(pluginSettingsPath))
278
599
  return false;
@@ -287,10 +608,8 @@ function syncPluginPermissions(plugin, agent, versionHome) {
287
608
  const pluginDeny = pluginSettings.permissions?.deny || [];
288
609
  if (pluginAllow.length === 0 && pluginDeny.length === 0)
289
610
  return false;
290
- // Expand variables in permission rules
291
- const expandedAllow = pluginAllow.map(rule => expandPluginVars(rule, plugin.root, plugin.name, agent, versionHome));
292
- const expandedDeny = pluginDeny.map(rule => expandPluginVars(rule, plugin.root, plugin.name, agent, versionHome));
293
- // Read existing settings.json
611
+ const expandedAllow = pluginAllow.map(rule => expandPluginVars(rule, plugin.root, plugin.name, agent, versionHome, userConfig));
612
+ const expandedDeny = pluginDeny.map(rule => expandPluginVars(rule, plugin.root, plugin.name, agent, versionHome, userConfig));
294
613
  const configDir = path.join(versionHome, `.${agent}`);
295
614
  const settingsPath = path.join(configDir, 'settings.json');
296
615
  let settings = {};
@@ -298,9 +617,7 @@ function syncPluginPermissions(plugin, agent, versionHome) {
298
617
  try {
299
618
  settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
300
619
  }
301
- catch {
302
- // Start fresh
303
- }
620
+ catch { /* start fresh */ }
304
621
  }
305
622
  if (!settings.permissions || typeof settings.permissions !== 'object') {
306
623
  settings.permissions = { allow: [], deny: [] };
@@ -310,19 +627,16 @@ function syncPluginPermissions(plugin, agent, versionHome) {
310
627
  perms.allow = [];
311
628
  if (!perms.deny)
312
629
  perms.deny = [];
313
- // Merge allow rules (deduplicate)
314
630
  for (const rule of expandedAllow) {
315
631
  if (!perms.allow.includes(rule)) {
316
632
  perms.allow.push(rule);
317
633
  }
318
634
  }
319
- // Merge deny rules (deduplicate)
320
635
  for (const rule of expandedDeny) {
321
636
  if (!perms.deny.includes(rule)) {
322
637
  perms.deny.push(rule);
323
638
  }
324
639
  }
325
- // Write back
326
640
  try {
327
641
  fs.mkdirSync(configDir, { recursive: true });
328
642
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
@@ -332,11 +646,12 @@ function syncPluginPermissions(plugin, agent, versionHome) {
332
646
  return false;
333
647
  }
334
648
  }
649
+ // ─── Utility ──────────────────────────────────────────────────────────────────
335
650
  /**
336
- * Copy a directory recursively, expanding plugin variables in file contents.
651
+ * Copy a directory recursively, expanding plugin variables in text file contents.
337
652
  * Only expands variables in text files (.md, .json, .sh, .py, .js, .ts, .yaml, .yml, .toml).
338
653
  */
339
- function copyDirWithVarExpansion(src, dest, pluginRoot, pluginName, agent, versionHome) {
654
+ function copyDirWithVarExpansion(src, dest, pluginRoot, pluginName, agent, versionHome, userConfig) {
340
655
  fs.mkdirSync(dest, { recursive: true });
341
656
  const entries = fs.readdirSync(src, { withFileTypes: true });
342
657
  const textExtensions = new Set(['.md', '.json', '.sh', '.py', '.js', '.ts', '.yaml', '.yml', '.toml', '.txt']);
@@ -344,21 +659,18 @@ function copyDirWithVarExpansion(src, dest, pluginRoot, pluginName, agent, versi
344
659
  const srcPath = path.join(src, entry.name);
345
660
  const destPath = path.join(dest, entry.name);
346
661
  if (entry.isDirectory()) {
347
- copyDirWithVarExpansion(srcPath, destPath, pluginRoot, pluginName, agent, versionHome);
662
+ copyDirWithVarExpansion(srcPath, destPath, pluginRoot, pluginName, agent, versionHome, userConfig);
348
663
  }
349
664
  else {
350
665
  const ext = path.extname(entry.name).toLowerCase();
351
666
  if (textExtensions.has(ext)) {
352
- // Expand variables in text files
353
667
  let content = fs.readFileSync(srcPath, 'utf-8');
354
- content = expandPluginVars(content, pluginRoot, pluginName, agent, versionHome);
668
+ content = expandPluginVars(content, pluginRoot, pluginName, agent, versionHome, userConfig);
355
669
  fs.writeFileSync(destPath, content, 'utf-8');
356
670
  }
357
671
  else {
358
- // Binary copy
359
672
  fs.copyFileSync(srcPath, destPath);
360
673
  }
361
- // Preserve executable permission
362
674
  const stat = fs.statSync(srcPath);
363
675
  if (stat.mode & 0o111) {
364
676
  fs.chmodSync(destPath, stat.mode);
@@ -366,41 +678,68 @@ function copyDirWithVarExpansion(src, dest, pluginRoot, pluginName, agent, versi
366
678
  }
367
679
  }
368
680
  }
681
+ // ─── Sync status ──────────────────────────────────────────────────────────────
369
682
  /**
370
683
  * Check if a plugin is synced to a version by inspecting the version home.
371
- * Checks multiple signals: skills directories, hook commands in settings.json,
372
- * and plugin permissions in settings.json.
684
+ * Checks skills, commands, agent defs, bin, hook commands, and permissions.
373
685
  */
374
686
  export function isPluginSynced(plugin, agent, versionHome) {
375
- // Check 1: plugin skill directories exist
687
+ const prefix = `${plugin.name}--`;
688
+ // Check 1: plugin skill directories
376
689
  if (plugin.skills.length > 0) {
377
690
  const skillsDir = path.join(versionHome, `.${agent}`, 'skills');
378
691
  if (fs.existsSync(skillsDir)) {
379
692
  for (const skillName of plugin.skills) {
380
- const fsSafeName = `${plugin.name}--${skillName}`;
381
- if (fs.existsSync(path.join(skillsDir, fsSafeName))) {
693
+ if (fs.existsSync(path.join(skillsDir, `${prefix}${skillName}`))) {
382
694
  return true;
383
695
  }
384
696
  }
385
697
  }
386
698
  }
387
- // Check 2: plugin hooks registered in settings.json (commands referencing plugin root)
699
+ // Check 2: plugin command files
700
+ if (plugin.commands.length > 0 && (agent === 'claude' || agent === 'openclaw')) {
701
+ const agentConfig = AGENTS[agent];
702
+ const commandsDir = path.join(versionHome, `.${agent}`, agentConfig.commandsSubdir);
703
+ if (fs.existsSync(commandsDir)) {
704
+ for (const cmdName of plugin.commands) {
705
+ if (fs.existsSync(path.join(commandsDir, `${prefix}${cmdName}.md`))) {
706
+ return true;
707
+ }
708
+ }
709
+ }
710
+ }
711
+ // Check 3: plugin agent definition files
712
+ if (plugin.agentDefs.length > 0 && agent === 'claude') {
713
+ const agentsDir = path.join(versionHome, `.${agent}`, 'agents');
714
+ if (fs.existsSync(agentsDir)) {
715
+ for (const agentDefName of plugin.agentDefs) {
716
+ if (fs.existsSync(path.join(agentsDir, `${prefix}${agentDefName}.md`))) {
717
+ return true;
718
+ }
719
+ }
720
+ }
721
+ }
722
+ // Check 4: plugin bin directory
723
+ if (plugin.bin.length > 0 && agent === 'claude') {
724
+ const binDir = path.join(versionHome, `.${agent}`, 'plugin-bin', plugin.name);
725
+ if (fs.existsSync(binDir)) {
726
+ return true;
727
+ }
728
+ }
729
+ // Check 5: plugin hooks registered in settings.json (commands referencing plugin root)
388
730
  if (plugin.hooks.length > 0 && agent === 'claude') {
389
731
  const settingsPath = path.join(versionHome, `.${agent}`, 'settings.json');
390
732
  if (fs.existsSync(settingsPath)) {
391
733
  try {
392
734
  const content = fs.readFileSync(settingsPath, 'utf-8');
393
- // Check if any hook command references this plugin's root path
394
735
  if (content.includes(plugin.root)) {
395
736
  return true;
396
737
  }
397
738
  }
398
- catch {
399
- // Ignore read errors
400
- }
739
+ catch { /* ignore */ }
401
740
  }
402
741
  }
403
- // Check 3: plugin permissions in settings.json (rules referencing plugin root)
742
+ // Check 6: plugin permissions in settings.json
404
743
  if (agent === 'claude') {
405
744
  const settingsPath = path.join(versionHome, `.${agent}`, 'settings.json');
406
745
  if (fs.existsSync(settingsPath)) {
@@ -410,48 +749,100 @@ export function isPluginSynced(plugin, agent, versionHome) {
410
749
  if (allow.some((rule) => rule.includes(plugin.root))) {
411
750
  return true;
412
751
  }
752
+ // Check MCP servers
753
+ const mcpServers = settings.mcpServers;
754
+ if (mcpServers) {
755
+ const hasNamespacedServer = Object.keys(mcpServers).some(k => k.startsWith(prefix));
756
+ if (hasNamespacedServer)
757
+ return true;
758
+ }
413
759
  }
414
- catch {
415
- // Ignore parse errors
416
- }
760
+ catch { /* ignore */ }
417
761
  }
418
762
  }
419
763
  return false;
420
764
  }
765
+ // ─── Removal ─────────────────────────────────────────────────────────────────
421
766
  /**
422
767
  * Remove a plugin from a specific agent version's home directory.
423
- * Inverse of syncPluginToVersion:
424
- * 1. Delete synced skill directories (pluginName--*)
425
- * 2. Strip hook entries whose commands reference the plugin root
426
- * 3. Strip permission rules that reference the plugin root
768
+ * Inverse of syncPluginToVersion.
427
769
  *
428
- * Works whether or not the plugin source still exists on disk, because it
429
- * matches by the plugin's name and root path rather than re-reading manifests.
770
+ * Works whether or not the plugin source still exists on disk.
430
771
  */
431
772
  export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHome) {
432
- const result = { skills: [], hooks: [], permissions: 0 };
773
+ const result = {
774
+ skills: [],
775
+ commands: [],
776
+ agentDefs: [],
777
+ bin: [],
778
+ hooks: [],
779
+ permissions: 0,
780
+ mcp: 0,
781
+ };
782
+ const prefix = `${pluginName}--`;
433
783
  // 1. Remove synced skill dirs
434
784
  const skillsDir = path.join(versionHome, `.${agent}`, 'skills');
435
785
  if (fs.existsSync(skillsDir)) {
436
- const prefix = `${pluginName}--`;
437
786
  for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
438
- if (!entry.isDirectory())
439
- continue;
440
- if (!entry.name.startsWith(prefix))
787
+ if (!entry.isDirectory() || !entry.name.startsWith(prefix))
441
788
  continue;
442
789
  try {
443
790
  fs.rmSync(path.join(skillsDir, entry.name), { recursive: true, force: true });
444
791
  result.skills.push(entry.name);
445
792
  }
446
- catch {
447
- // Skip on error
793
+ catch { /* skip on error */ }
794
+ }
795
+ }
796
+ // 2. Remove synced command files
797
+ if (agent === 'claude' || agent === 'openclaw') {
798
+ const agentConfig = AGENTS[agent];
799
+ const commandsDir = path.join(versionHome, `.${agent}`, agentConfig.commandsSubdir);
800
+ if (fs.existsSync(commandsDir)) {
801
+ for (const entry of fs.readdirSync(commandsDir, { withFileTypes: true })) {
802
+ if (!entry.isFile())
803
+ continue;
804
+ if (!entry.name.startsWith(prefix) || !entry.name.endsWith('.md'))
805
+ continue;
806
+ try {
807
+ fs.unlinkSync(path.join(commandsDir, entry.name));
808
+ result.commands.push(entry.name);
809
+ }
810
+ catch { /* skip on error */ }
811
+ }
812
+ }
813
+ }
814
+ // 3. Remove synced agent def files
815
+ if (agent === 'claude') {
816
+ const agentsDir = path.join(versionHome, `.${agent}`, 'agents');
817
+ if (fs.existsSync(agentsDir)) {
818
+ for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
819
+ if (!entry.isFile())
820
+ continue;
821
+ if (!entry.name.startsWith(prefix) || !entry.name.endsWith('.md'))
822
+ continue;
823
+ try {
824
+ fs.unlinkSync(path.join(agentsDir, entry.name));
825
+ result.agentDefs.push(entry.name);
826
+ }
827
+ catch { /* skip on error */ }
828
+ }
829
+ }
830
+ }
831
+ // 4. Remove plugin-bin directory
832
+ if (agent === 'claude') {
833
+ const binDir = path.join(versionHome, `.${agent}`, 'plugin-bin', pluginName);
834
+ if (fs.existsSync(binDir)) {
835
+ try {
836
+ fs.rmSync(binDir, { recursive: true, force: true });
837
+ result.bin.push(binDir);
448
838
  }
839
+ catch { /* skip on error */ }
449
840
  }
450
841
  }
451
842
  if (agent !== 'claude') {
452
843
  return result;
453
844
  }
454
- // 2 + 3: edit settings.json — strip hooks and permissions matching plugin root
845
+ // 5 + 6 + 7: edit settings.json — strip hooks, permissions, mcpServers matching plugin
455
846
  const settingsPath = path.join(versionHome, `.${agent}`, 'settings.json');
456
847
  if (!fs.existsSync(settingsPath))
457
848
  return result;
@@ -483,13 +874,11 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
483
874
  if (group.hooks.length !== originalLen)
484
875
  changed = true;
485
876
  }
486
- // Drop matcher groups whose hooks array is now empty
487
877
  const kept = eventEntries.filter(g => Array.isArray(g.hooks) && g.hooks.length > 0);
488
878
  if (kept.length !== eventEntries.length) {
489
879
  hooksConfig[event] = kept;
490
880
  changed = true;
491
881
  }
492
- // Drop the event key entirely if it has no matcher groups left
493
882
  if (Array.isArray(hooksConfig[event]) && hooksConfig[event].length === 0) {
494
883
  delete hooksConfig[event];
495
884
  changed = true;
@@ -515,22 +904,39 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
515
904
  }
516
905
  }
517
906
  }
907
+ // Strip namespaced MCP servers added by this plugin
908
+ const mcpServers = settings.mcpServers;
909
+ if (mcpServers && typeof mcpServers === 'object') {
910
+ for (const serverName of Object.keys(mcpServers)) {
911
+ if (serverName.startsWith(prefix)) {
912
+ delete mcpServers[serverName];
913
+ result.mcp += 1;
914
+ changed = true;
915
+ }
916
+ }
917
+ }
918
+ // Strip bin path from pluginBinPaths
919
+ if (Array.isArray(settings.pluginBinPaths)) {
920
+ const binDir = path.join(versionHome, `.${agent}`, 'plugin-bin', pluginName);
921
+ const before = settings.pluginBinPaths.length;
922
+ settings.pluginBinPaths = settings.pluginBinPaths.filter(p => p !== binDir);
923
+ if (settings.pluginBinPaths.length !== before)
924
+ changed = true;
925
+ }
518
926
  if (changed) {
519
927
  try {
520
928
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
521
929
  }
522
- catch {
523
- // Ignore write errors
524
- }
930
+ catch { /* ignore write errors */ }
525
931
  }
526
932
  return result;
527
933
  }
934
+ // ─── Orphan cleanup ───────────────────────────────────────────────────────────
528
935
  /**
529
936
  * Remove orphaned plugin skill directories from a version home.
530
- * An orphan is a skill dir with the plugin prefix pattern (name--skill)
531
- * where the plugin no longer exists in ~/.agents/plugins/.
937
+ * Soft-deletes to ~/.agents/.trash/plugins/.
532
938
  */
533
- export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames) {
939
+ export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames, version) {
534
940
  const removed = [];
535
941
  const skillsDir = path.join(versionHome, `.${agent}`, 'skills');
536
942
  if (!fs.existsSync(skillsDir))
@@ -539,20 +945,193 @@ export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames)
539
945
  for (const entry of entries) {
540
946
  if (!entry.isDirectory())
541
947
  continue;
542
- // Plugin skill dirs use the pattern: pluginName--skillName
543
948
  const dashIdx = entry.name.indexOf('--');
544
949
  if (dashIdx === -1)
545
950
  continue;
546
951
  const pluginName = entry.name.slice(0, dashIdx);
547
952
  if (!activePluginNames.has(pluginName)) {
548
953
  try {
549
- fs.rmSync(path.join(skillsDir, entry.name), { recursive: true, force: true });
954
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
955
+ const trashDir = path.join(getTrashPluginsDir(), agent, version || 'unknown', entry.name);
956
+ const trashDest = path.join(trashDir, stamp);
957
+ fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
958
+ fs.renameSync(path.join(skillsDir, entry.name), trashDest);
550
959
  removed.push(entry.name);
551
960
  }
552
- catch {
553
- // Skip on error
554
- }
961
+ catch { /* skip on error */ }
555
962
  }
556
963
  }
557
964
  return removed;
558
965
  }
966
+ export function diffVersionPlugins(agent, version) {
967
+ const versionHome = getVersionHomePath(agent, version);
968
+ const skillsDir = path.join(versionHome, `.${agent}`, 'skills');
969
+ const orphans = [];
970
+ if (!fs.existsSync(skillsDir)) {
971
+ return { agent, version, orphans };
972
+ }
973
+ const activePlugins = new Set(discoverPlugins().map(p => p.name));
974
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
975
+ if (!entry.isDirectory())
976
+ continue;
977
+ const dashIdx = entry.name.indexOf('--');
978
+ if (dashIdx === -1)
979
+ continue;
980
+ const pluginName = entry.name.slice(0, dashIdx);
981
+ if (!activePlugins.has(pluginName)) {
982
+ orphans.push(entry.name);
983
+ }
984
+ }
985
+ return { agent, version, orphans: orphans.sort() };
986
+ }
987
+ export function iterPluginsCapableVersions(filter) {
988
+ const pairs = [];
989
+ const agents = filter?.agent ? [filter.agent] : PLUGINS_CAPABLE_AGENTS;
990
+ for (const agent of agents) {
991
+ if (!PLUGINS_CAPABLE_AGENTS.includes(agent))
992
+ continue;
993
+ const versions = listInstalledVersions(agent);
994
+ for (const version of versions) {
995
+ if (filter?.version && filter.version !== version)
996
+ continue;
997
+ pairs.push({ agent, version });
998
+ }
999
+ }
1000
+ return pairs;
1001
+ }
1002
+ export function removePluginSkillFromVersion(agent, version, skillName) {
1003
+ const versionHome = getVersionHomePath(agent, version);
1004
+ const skillPath = path.join(versionHome, `.${agent}`, 'skills', skillName);
1005
+ if (!fs.existsSync(skillPath)) {
1006
+ return { success: true };
1007
+ }
1008
+ try {
1009
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1010
+ const trashDir = path.join(getTrashPluginsDir(), agent, version, skillName);
1011
+ const trashDest = path.join(trashDir, stamp);
1012
+ fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
1013
+ fs.renameSync(skillPath, trashDest);
1014
+ }
1015
+ catch (err) {
1016
+ return { success: false, error: err.message };
1017
+ }
1018
+ return { success: true };
1019
+ }
1020
+ // ─── Install / Update ─────────────────────────────────────────────────────────
1021
+ /**
1022
+ * Parse an install spec of the form `name@source` or just `source`.
1023
+ * Source can be a git URL or an absolute/relative local path.
1024
+ */
1025
+ export function parseInstallSpec(spec) {
1026
+ // Check for name@source form
1027
+ const atIdx = spec.indexOf('@');
1028
+ if (atIdx > 0) {
1029
+ const name = spec.slice(0, atIdx);
1030
+ const source = spec.slice(atIdx + 1);
1031
+ return { name, source };
1032
+ }
1033
+ return { name: null, source: spec };
1034
+ }
1035
+ /**
1036
+ * Install a plugin from a git URL or local path.
1037
+ * Clones/copies to ~/.agents/.cache/plugins/<name>/.
1038
+ * Returns the installed plugin name and root path.
1039
+ */
1040
+ export async function installPlugin(spec) {
1041
+ const { name: specName, source } = parseInstallSpec(spec);
1042
+ // Resolve local path (handle ~)
1043
+ const isLocalPath = source.startsWith('/') || source.startsWith('./') || source.startsWith('../') || source.startsWith('~');
1044
+ const resolvedSource = isLocalPath
1045
+ ? source.replace(/^~/, process.env.HOME || '~')
1046
+ : source;
1047
+ const pluginsDir = getPluginsDir();
1048
+ fs.mkdirSync(pluginsDir, { recursive: true });
1049
+ // If local path, load manifest to get the name
1050
+ let targetName = specName;
1051
+ if (!targetName) {
1052
+ if (isLocalPath) {
1053
+ const manifest = loadPluginManifest(resolvedSource);
1054
+ if (!manifest)
1055
+ throw new Error(`No valid plugin.json found at ${resolvedSource}`);
1056
+ targetName = manifest.name;
1057
+ }
1058
+ else {
1059
+ // Derive from git URL: last path segment without .git
1060
+ targetName = path.basename(resolvedSource).replace(/\.git$/, '');
1061
+ }
1062
+ }
1063
+ const targetRoot = path.join(pluginsDir, targetName);
1064
+ const isNew = !fs.existsSync(targetRoot);
1065
+ if (isLocalPath) {
1066
+ // Copy local directory
1067
+ if (fs.existsSync(targetRoot)) {
1068
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1069
+ }
1070
+ fs.cpSync(resolvedSource, targetRoot, { recursive: true });
1071
+ }
1072
+ else {
1073
+ // Git clone
1074
+ if (fs.existsSync(targetRoot)) {
1075
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1076
+ }
1077
+ execSync(`git clone --depth 1 ${JSON.stringify(resolvedSource)} ${JSON.stringify(targetRoot)}`, {
1078
+ stdio: 'pipe',
1079
+ });
1080
+ }
1081
+ // Validate manifest
1082
+ const manifest = loadPluginManifest(targetRoot);
1083
+ if (!manifest) {
1084
+ fs.rmSync(targetRoot, { recursive: true, force: true });
1085
+ throw new Error(`Installed source has no valid .claude-plugin/plugin.json`);
1086
+ }
1087
+ // Persist source for future updates
1088
+ fs.writeFileSync(path.join(targetRoot, SOURCE_FILE), JSON.stringify({ source, isGit: !isLocalPath }), 'utf-8');
1089
+ return { name: manifest.name, root: targetRoot, isNew };
1090
+ }
1091
+ /**
1092
+ * Update an installed plugin by re-pulling from its original source.
1093
+ * Returns true if the update succeeded.
1094
+ */
1095
+ export async function updatePlugin(name) {
1096
+ const plugin = getPlugin(name);
1097
+ if (!plugin) {
1098
+ return { success: false, error: `Plugin '${name}' not found` };
1099
+ }
1100
+ const sourceFile = path.join(plugin.root, SOURCE_FILE);
1101
+ if (!fs.existsSync(sourceFile)) {
1102
+ return { success: false, error: `No source recorded for '${name}' — was it installed with 'agents plugins install'?` };
1103
+ }
1104
+ let sourceInfo;
1105
+ try {
1106
+ sourceInfo = JSON.parse(fs.readFileSync(sourceFile, 'utf-8'));
1107
+ }
1108
+ catch {
1109
+ return { success: false, error: `Could not read source info for '${name}'` };
1110
+ }
1111
+ try {
1112
+ if (sourceInfo.isGit) {
1113
+ execSync(`git -C ${JSON.stringify(plugin.root)} pull --ff-only`, { stdio: 'pipe' });
1114
+ }
1115
+ else {
1116
+ const resolvedSource = sourceInfo.source.replace(/^~/, process.env.HOME || '~');
1117
+ if (!fs.existsSync(resolvedSource)) {
1118
+ return { success: false, error: `Source path no longer exists: ${resolvedSource}` };
1119
+ }
1120
+ // Preserve .user-config.json and .source during re-copy
1121
+ const userConfigPath = path.join(plugin.root, USER_CONFIG_FILE);
1122
+ const userConfigBackup = fs.existsSync(userConfigPath)
1123
+ ? fs.readFileSync(userConfigPath, 'utf-8')
1124
+ : null;
1125
+ fs.rmSync(plugin.root, { recursive: true, force: true });
1126
+ fs.cpSync(resolvedSource, plugin.root, { recursive: true });
1127
+ fs.writeFileSync(path.join(plugin.root, SOURCE_FILE), JSON.stringify(sourceInfo), 'utf-8');
1128
+ if (userConfigBackup !== null) {
1129
+ fs.writeFileSync(userConfigPath, userConfigBackup, 'utf-8');
1130
+ }
1131
+ }
1132
+ }
1133
+ catch (err) {
1134
+ return { success: false, error: err.message };
1135
+ }
1136
+ return { success: true };
1137
+ }