@phnx-labs/agents-cli 1.16.0 → 1.17.1

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 (64) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/commands/browser.js +248 -9
  3. package/dist/commands/cloud.js +8 -0
  4. package/dist/commands/exec.js +70 -1
  5. package/dist/commands/import.d.ts +24 -0
  6. package/dist/commands/import.js +203 -0
  7. package/dist/commands/plugins.js +179 -5
  8. package/dist/commands/prune.js +6 -0
  9. package/dist/commands/secrets.js +117 -19
  10. package/dist/commands/view.js +21 -8
  11. package/dist/commands/workflows.d.ts +10 -0
  12. package/dist/commands/workflows.js +457 -0
  13. package/dist/index.js +34 -16
  14. package/dist/lib/browser/cdp.js +7 -4
  15. package/dist/lib/browser/chrome.d.ts +10 -0
  16. package/dist/lib/browser/chrome.js +37 -2
  17. package/dist/lib/browser/drivers/local.js +13 -2
  18. package/dist/lib/browser/input.d.ts +1 -0
  19. package/dist/lib/browser/input.js +3 -0
  20. package/dist/lib/browser/ipc.js +14 -0
  21. package/dist/lib/browser/profiles.d.ts +5 -0
  22. package/dist/lib/browser/profiles.js +45 -0
  23. package/dist/lib/browser/service.d.ts +10 -0
  24. package/dist/lib/browser/service.js +29 -1
  25. package/dist/lib/browser/types.d.ts +11 -1
  26. package/dist/lib/cloud/rush.d.ts +28 -1
  27. package/dist/lib/cloud/rush.js +68 -13
  28. package/dist/lib/commands.d.ts +0 -15
  29. package/dist/lib/commands.js +5 -5
  30. package/dist/lib/hooks.js +24 -11
  31. package/dist/lib/import.d.ts +91 -0
  32. package/dist/lib/import.js +179 -0
  33. package/dist/lib/migrate.js +59 -1
  34. package/dist/lib/permissions.d.ts +0 -58
  35. package/dist/lib/permissions.js +10 -10
  36. package/dist/lib/plugins.d.ts +75 -34
  37. package/dist/lib/plugins.js +640 -133
  38. package/dist/lib/resource-patterns.d.ts +41 -0
  39. package/dist/lib/resource-patterns.js +82 -0
  40. package/dist/lib/resources/index.d.ts +17 -0
  41. package/dist/lib/resources/index.js +7 -0
  42. package/dist/lib/resources/types.d.ts +1 -1
  43. package/dist/lib/resources/workflows.d.ts +24 -0
  44. package/dist/lib/resources/workflows.js +110 -0
  45. package/dist/lib/resources.d.ts +6 -1
  46. package/dist/lib/resources.js +12 -2
  47. package/dist/lib/session/db.d.ts +18 -0
  48. package/dist/lib/session/db.js +106 -7
  49. package/dist/lib/session/discover.d.ts +6 -0
  50. package/dist/lib/session/discover.js +28 -17
  51. package/dist/lib/shims.d.ts +3 -51
  52. package/dist/lib/shims.js +18 -10
  53. package/dist/lib/sqlite.js +10 -4
  54. package/dist/lib/state.d.ts +15 -2
  55. package/dist/lib/state.js +29 -8
  56. package/dist/lib/types.d.ts +43 -14
  57. package/dist/lib/versions.d.ts +3 -0
  58. package/dist/lib/versions.js +139 -27
  59. package/dist/lib/workflows.d.ts +79 -0
  60. package/dist/lib/workflows.js +233 -0
  61. package/package.json +1 -5
  62. package/scripts/postinstall.js +59 -58
  63. package/dist/commands/fork.d.ts +0 -10
  64. package/dist/commands/fork.js +0 -146
@@ -1,19 +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';
11
+ import { execSync } from 'child_process';
10
12
  import { getPluginsDir, getTrashPluginsDir } from './state.js';
11
13
  import { listInstalledVersions, getVersionHomePath } from './versions.js';
12
14
  import { AGENTS, PLUGINS_CAPABLE_AGENTS } from './agents.js';
13
15
  const PLUGIN_MANIFEST_DIR = '.claude-plugin';
14
16
  const PLUGIN_MANIFEST_FILE = 'plugin.json';
17
+ const USER_CONFIG_FILE = '.user-config.json';
18
+ const SOURCE_FILE = '.source';
15
19
  /**
16
- * Discover all plugins in ~/.agents/plugins/.
20
+ * Discover all plugins in ~/.agents/.cache/plugins/.
17
21
  * A valid plugin has a .claude-plugin/plugin.json manifest.
18
22
  */
19
23
  export function discoverPlugins() {
@@ -30,17 +34,25 @@ export function discoverPlugins() {
30
34
  const manifest = loadPluginManifest(pluginRoot);
31
35
  if (!manifest)
32
36
  continue;
33
- plugins.push({
34
- name: manifest.name,
35
- root: pluginRoot,
36
- manifest,
37
- skills: discoverPluginSkills(pluginRoot),
38
- hooks: discoverPluginHooks(pluginRoot),
39
- scripts: discoverPluginScripts(pluginRoot),
40
- });
37
+ plugins.push(buildDiscoveredPlugin(pluginRoot, manifest));
41
38
  }
42
39
  return plugins;
43
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
+ }
44
56
  /**
45
57
  * Load a plugin manifest from a plugin directory.
46
58
  */
@@ -83,9 +95,7 @@ export function pluginSupportsAgent(plugin, agent) {
83
95
  }
84
96
  return true;
85
97
  }
86
- /**
87
- * Discover skill directories inside a plugin.
88
- */
98
+ // ─── Discovery helpers ────────────────────────────────────────────────────────
89
99
  function discoverPluginSkills(pluginRoot) {
90
100
  const skillsDir = path.join(pluginRoot, 'skills');
91
101
  if (!fs.existsSync(skillsDir))
@@ -94,9 +104,6 @@ function discoverPluginSkills(pluginRoot) {
94
104
  .filter(d => d.isDirectory() && !d.name.startsWith('.'))
95
105
  .map(d => d.name);
96
106
  }
97
- /**
98
- * Discover hook definitions inside a plugin.
99
- */
100
107
  function discoverPluginHooks(pluginRoot) {
101
108
  const hooksFile = path.join(pluginRoot, 'hooks', 'hooks.json');
102
109
  if (!fs.existsSync(hooksFile))
@@ -109,85 +116,228 @@ function discoverPluginHooks(pluginRoot) {
109
116
  return [];
110
117
  }
111
118
  }
112
- /**
113
- * Discover scripts inside a plugin.
114
- */
115
119
  function discoverPluginScripts(pluginRoot) {
116
120
  const scriptsDir = path.join(pluginRoot, 'scripts');
117
121
  if (!fs.existsSync(scriptsDir))
118
122
  return [];
119
123
  return fs.readdirSync(scriptsDir).filter(f => !f.startsWith('.'));
120
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 ───────────────────────────────────────────────────────
121
164
  /**
122
165
  * Expand plugin variables in a string.
123
166
  *
124
167
  * Variables:
125
- * ${CLAUDE_PLUGIN_ROOT} -> absolute path to plugin directory
126
- * ${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
127
171
  */
128
- export function expandPluginVars(str, pluginRoot, pluginName, agentId, versionHome) {
172
+ export function expandPluginVars(str, pluginRoot, pluginName, agentId, versionHome, userConfig) {
129
173
  const dataDir = path.join(versionHome, `.${agentId}`, 'plugin-data', pluginName);
130
- return str
174
+ let result = str
131
175
  .replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pluginRoot)
132
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;
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
+ }
133
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 ────────────────────────────────────────────────────
134
219
  /**
135
220
  * Sync a plugin to a specific agent version's home directory.
136
221
  *
137
222
  * For Claude:
138
- * 1. Copy plugin skills into version's skills dir (prefixed: pluginName:skillName)
139
- * 2. Read hooks/hooks.json, expand vars, merge into settings.json hooks
140
- * 3. Read settings.json, expand vars, merge permissions into settings.json
141
- *
142
- * For OpenClaw:
143
- * 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
144
231
  */
145
232
  export function syncPluginToVersion(plugin, agent, versionHome) {
146
- 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
+ };
147
244
  if (!pluginSupportsAgent(plugin, agent)) {
148
245
  return result;
149
246
  }
247
+ const userConfig = loadUserConfig(plugin.name);
150
248
  // 1. Sync skills
151
- const skillsResult = syncPluginSkills(plugin, agent, versionHome);
152
- result.skills = skillsResult;
153
- // 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)
259
+ if (agent === 'claude') {
260
+ result.bin = syncPluginBin(plugin, agent, versionHome);
261
+ }
262
+ // 5. Sync hooks (Claude only - uses settings.json hook registration)
263
+ if (agent === 'claude') {
264
+ result.hooks = syncPluginHooks(plugin, agent, versionHome, userConfig);
265
+ }
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)
154
271
  if (agent === 'claude') {
155
- const hooksResult = syncPluginHooks(plugin, agent, versionHome);
156
- result.hooks = hooksResult;
272
+ result.settings = syncPluginSettings(plugin, agent, versionHome);
157
273
  }
158
- // 3. Sync permissions (Claude only - uses settings.json permissions)
274
+ // 8. Sync permissions (Claude only - uses settings.json permissions)
159
275
  if (agent === 'claude') {
160
- result.permissions = syncPluginPermissions(plugin, agent, versionHome);
276
+ result.permissions = syncPluginPermissions(plugin, agent, versionHome, userConfig);
161
277
  }
162
- result.success = result.skills.length > 0 || result.hooks.length > 0 || result.permissions;
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;
163
287
  return result;
164
288
  }
289
+ // ─── Individual sync functions ────────────────────────────────────────────────
165
290
  /**
166
291
  * Copy plugin skills into the version's skills directory.
167
- * Skills are prefixed with the plugin name: pluginName:skillName
292
+ * Skills are prefixed with the plugin name: pluginName--skillName
168
293
  */
169
- function syncPluginSkills(plugin, agent, versionHome) {
294
+ function syncPluginSkills(plugin, agent, versionHome, userConfig) {
170
295
  const synced = [];
171
296
  const pluginSkillsDir = path.join(plugin.root, 'skills');
172
297
  if (!fs.existsSync(pluginSkillsDir))
173
298
  return synced;
174
- const agentConfig = AGENTS[agent];
175
299
  const targetSkillsDir = path.join(versionHome, `.${agent}`, 'skills');
176
300
  fs.mkdirSync(targetSkillsDir, { recursive: true });
177
301
  for (const skillName of plugin.skills) {
178
302
  const srcDir = path.join(pluginSkillsDir, skillName);
179
- // Prefix with plugin name for namespacing
180
- const prefixedName = `${plugin.name}:${skillName}`;
181
- // Use colon-to-dash for filesystem (colons not allowed on some systems)
182
- const fsSafeName = prefixedName.replace(/:/g, '--');
303
+ const fsSafeName = `${plugin.name}--${skillName}`;
183
304
  const destDir = path.join(targetSkillsDir, fsSafeName);
184
305
  try {
185
- // Remove existing and copy fresh
186
306
  if (fs.existsSync(destDir)) {
187
307
  fs.rmSync(destDir, { recursive: true, force: true });
188
308
  }
189
- copyDirWithVarExpansion(srcDir, destDir, plugin.root, plugin.name, agent, versionHome);
190
- synced.push(prefixedName);
309
+ copyDirWithVarExpansion(srcDir, destDir, plugin.root, plugin.name, agent, versionHome, userConfig);
310
+ synced.push(`${plugin.name}:${skillName}`);
311
+ }
312
+ catch {
313
+ // Skip on error
314
+ }
315
+ }
316
+ return synced;
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}`);
191
341
  }
192
342
  catch {
193
343
  // Skip on error
@@ -195,12 +345,93 @@ function syncPluginSkills(plugin, agent, versionHome) {
195
345
  }
196
346
  return synced;
197
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
+ }
198
429
  /**
199
430
  * Merge plugin hooks into Claude's settings.json.
200
431
  * Reads the plugin's hooks/hooks.json and merges each event's hooks
201
432
  * into the version's settings.json, expanding variables.
202
433
  */
203
- function syncPluginHooks(plugin, agent, versionHome) {
434
+ function syncPluginHooks(plugin, agent, versionHome, userConfig) {
204
435
  const synced = [];
205
436
  const hooksFile = path.join(plugin.root, 'hooks', 'hooks.json');
206
437
  if (!fs.existsSync(hooksFile))
@@ -212,7 +443,6 @@ function syncPluginHooks(plugin, agent, versionHome) {
212
443
  catch {
213
444
  return synced;
214
445
  }
215
- // Read existing settings.json
216
446
  const configDir = path.join(versionHome, `.${agent}`);
217
447
  const settingsPath = path.join(configDir, 'settings.json');
218
448
  let settings = {};
@@ -220,15 +450,12 @@ function syncPluginHooks(plugin, agent, versionHome) {
220
450
  try {
221
451
  settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
222
452
  }
223
- catch {
224
- // Start fresh if parse fails
225
- }
453
+ catch { /* start fresh */ }
226
454
  }
227
455
  if (!settings.hooks || typeof settings.hooks !== 'object') {
228
456
  settings.hooks = {};
229
457
  }
230
458
  const hooksConfig = settings.hooks;
231
- // Merge each event from the plugin
232
459
  for (const [event, matcherGroups] of Object.entries(pluginHooks)) {
233
460
  if (!hooksConfig[event]) {
234
461
  hooksConfig[event] = [];
@@ -238,9 +465,8 @@ function syncPluginHooks(plugin, agent, versionHome) {
238
465
  const matcher = group.matcher || '';
239
466
  const expandedHooks = (group.hooks || []).map(h => ({
240
467
  ...h,
241
- command: expandPluginVars(h.command, plugin.root, plugin.name, agent, versionHome),
468
+ command: expandPluginVars(h.command, plugin.root, plugin.name, agent, versionHome, userConfig),
242
469
  }));
243
- // Find or create matcher group
244
470
  let matcherGroup = eventEntries.find(e => (e.matcher || '') === matcher);
245
471
  if (!matcherGroup) {
246
472
  matcherGroup = { matcher, hooks: [] };
@@ -249,7 +475,6 @@ function syncPluginHooks(plugin, agent, versionHome) {
249
475
  if (!matcherGroup.hooks) {
250
476
  matcherGroup.hooks = [];
251
477
  }
252
- // Add hooks that aren't already registered (by command path)
253
478
  for (const hook of expandedHooks) {
254
479
  const exists = matcherGroup.hooks.some(h => h.command === hook.command);
255
480
  if (!exists) {
@@ -259,21 +484,116 @@ function syncPluginHooks(plugin, agent, versionHome) {
259
484
  }
260
485
  synced.push(event);
261
486
  }
262
- // Write back
263
487
  try {
264
488
  fs.mkdirSync(configDir, { recursive: true });
265
489
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
266
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
+ }
267
507
  catch {
268
- // Ignore write errors
508
+ return false;
269
509
  }
270
- 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;
271
591
  }
272
592
  /**
273
593
  * Merge plugin permissions into Claude's settings.json.
274
- * Reads the plugin's settings.json and merges permissions.allow entries.
594
+ * Reads the plugin's settings.json and merges permissions.allow / deny entries.
275
595
  */
276
- function syncPluginPermissions(plugin, agent, versionHome) {
596
+ function syncPluginPermissions(plugin, agent, versionHome, userConfig) {
277
597
  const pluginSettingsPath = path.join(plugin.root, 'settings.json');
278
598
  if (!fs.existsSync(pluginSettingsPath))
279
599
  return false;
@@ -288,10 +608,8 @@ function syncPluginPermissions(plugin, agent, versionHome) {
288
608
  const pluginDeny = pluginSettings.permissions?.deny || [];
289
609
  if (pluginAllow.length === 0 && pluginDeny.length === 0)
290
610
  return false;
291
- // Expand variables in permission rules
292
- const expandedAllow = pluginAllow.map(rule => expandPluginVars(rule, plugin.root, plugin.name, agent, versionHome));
293
- const expandedDeny = pluginDeny.map(rule => expandPluginVars(rule, plugin.root, plugin.name, agent, versionHome));
294
- // 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));
295
613
  const configDir = path.join(versionHome, `.${agent}`);
296
614
  const settingsPath = path.join(configDir, 'settings.json');
297
615
  let settings = {};
@@ -299,9 +617,7 @@ function syncPluginPermissions(plugin, agent, versionHome) {
299
617
  try {
300
618
  settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
301
619
  }
302
- catch {
303
- // Start fresh
304
- }
620
+ catch { /* start fresh */ }
305
621
  }
306
622
  if (!settings.permissions || typeof settings.permissions !== 'object') {
307
623
  settings.permissions = { allow: [], deny: [] };
@@ -311,19 +627,16 @@ function syncPluginPermissions(plugin, agent, versionHome) {
311
627
  perms.allow = [];
312
628
  if (!perms.deny)
313
629
  perms.deny = [];
314
- // Merge allow rules (deduplicate)
315
630
  for (const rule of expandedAllow) {
316
631
  if (!perms.allow.includes(rule)) {
317
632
  perms.allow.push(rule);
318
633
  }
319
634
  }
320
- // Merge deny rules (deduplicate)
321
635
  for (const rule of expandedDeny) {
322
636
  if (!perms.deny.includes(rule)) {
323
637
  perms.deny.push(rule);
324
638
  }
325
639
  }
326
- // Write back
327
640
  try {
328
641
  fs.mkdirSync(configDir, { recursive: true });
329
642
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
@@ -333,11 +646,12 @@ function syncPluginPermissions(plugin, agent, versionHome) {
333
646
  return false;
334
647
  }
335
648
  }
649
+ // ─── Utility ──────────────────────────────────────────────────────────────────
336
650
  /**
337
- * Copy a directory recursively, expanding plugin variables in file contents.
651
+ * Copy a directory recursively, expanding plugin variables in text file contents.
338
652
  * Only expands variables in text files (.md, .json, .sh, .py, .js, .ts, .yaml, .yml, .toml).
339
653
  */
340
- function copyDirWithVarExpansion(src, dest, pluginRoot, pluginName, agent, versionHome) {
654
+ function copyDirWithVarExpansion(src, dest, pluginRoot, pluginName, agent, versionHome, userConfig) {
341
655
  fs.mkdirSync(dest, { recursive: true });
342
656
  const entries = fs.readdirSync(src, { withFileTypes: true });
343
657
  const textExtensions = new Set(['.md', '.json', '.sh', '.py', '.js', '.ts', '.yaml', '.yml', '.toml', '.txt']);
@@ -345,21 +659,18 @@ function copyDirWithVarExpansion(src, dest, pluginRoot, pluginName, agent, versi
345
659
  const srcPath = path.join(src, entry.name);
346
660
  const destPath = path.join(dest, entry.name);
347
661
  if (entry.isDirectory()) {
348
- copyDirWithVarExpansion(srcPath, destPath, pluginRoot, pluginName, agent, versionHome);
662
+ copyDirWithVarExpansion(srcPath, destPath, pluginRoot, pluginName, agent, versionHome, userConfig);
349
663
  }
350
664
  else {
351
665
  const ext = path.extname(entry.name).toLowerCase();
352
666
  if (textExtensions.has(ext)) {
353
- // Expand variables in text files
354
667
  let content = fs.readFileSync(srcPath, 'utf-8');
355
- content = expandPluginVars(content, pluginRoot, pluginName, agent, versionHome);
668
+ content = expandPluginVars(content, pluginRoot, pluginName, agent, versionHome, userConfig);
356
669
  fs.writeFileSync(destPath, content, 'utf-8');
357
670
  }
358
671
  else {
359
- // Binary copy
360
672
  fs.copyFileSync(srcPath, destPath);
361
673
  }
362
- // Preserve executable permission
363
674
  const stat = fs.statSync(srcPath);
364
675
  if (stat.mode & 0o111) {
365
676
  fs.chmodSync(destPath, stat.mode);
@@ -367,41 +678,68 @@ function copyDirWithVarExpansion(src, dest, pluginRoot, pluginName, agent, versi
367
678
  }
368
679
  }
369
680
  }
681
+ // ─── Sync status ──────────────────────────────────────────────────────────────
370
682
  /**
371
683
  * Check if a plugin is synced to a version by inspecting the version home.
372
- * Checks multiple signals: skills directories, hook commands in settings.json,
373
- * and plugin permissions in settings.json.
684
+ * Checks skills, commands, agent defs, bin, hook commands, and permissions.
374
685
  */
375
686
  export function isPluginSynced(plugin, agent, versionHome) {
376
- // Check 1: plugin skill directories exist
687
+ const prefix = `${plugin.name}--`;
688
+ // Check 1: plugin skill directories
377
689
  if (plugin.skills.length > 0) {
378
690
  const skillsDir = path.join(versionHome, `.${agent}`, 'skills');
379
691
  if (fs.existsSync(skillsDir)) {
380
692
  for (const skillName of plugin.skills) {
381
- const fsSafeName = `${plugin.name}--${skillName}`;
382
- if (fs.existsSync(path.join(skillsDir, fsSafeName))) {
693
+ if (fs.existsSync(path.join(skillsDir, `${prefix}${skillName}`))) {
694
+ return true;
695
+ }
696
+ }
697
+ }
698
+ }
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`))) {
383
717
  return true;
384
718
  }
385
719
  }
386
720
  }
387
721
  }
388
- // Check 2: plugin hooks registered in settings.json (commands referencing plugin root)
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)
389
730
  if (plugin.hooks.length > 0 && agent === 'claude') {
390
731
  const settingsPath = path.join(versionHome, `.${agent}`, 'settings.json');
391
732
  if (fs.existsSync(settingsPath)) {
392
733
  try {
393
734
  const content = fs.readFileSync(settingsPath, 'utf-8');
394
- // Check if any hook command references this plugin's root path
395
735
  if (content.includes(plugin.root)) {
396
736
  return true;
397
737
  }
398
738
  }
399
- catch {
400
- // Ignore read errors
401
- }
739
+ catch { /* ignore */ }
402
740
  }
403
741
  }
404
- // Check 3: plugin permissions in settings.json (rules referencing plugin root)
742
+ // Check 6: plugin permissions in settings.json
405
743
  if (agent === 'claude') {
406
744
  const settingsPath = path.join(versionHome, `.${agent}`, 'settings.json');
407
745
  if (fs.existsSync(settingsPath)) {
@@ -411,48 +749,100 @@ export function isPluginSynced(plugin, agent, versionHome) {
411
749
  if (allow.some((rule) => rule.includes(plugin.root))) {
412
750
  return true;
413
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
+ }
414
759
  }
415
- catch {
416
- // Ignore parse errors
417
- }
760
+ catch { /* ignore */ }
418
761
  }
419
762
  }
420
763
  return false;
421
764
  }
765
+ // ─── Removal ─────────────────────────────────────────────────────────────────
422
766
  /**
423
767
  * Remove a plugin from a specific agent version's home directory.
424
- * Inverse of syncPluginToVersion:
425
- * 1. Delete synced skill directories (pluginName--*)
426
- * 2. Strip hook entries whose commands reference the plugin root
427
- * 3. Strip permission rules that reference the plugin root
768
+ * Inverse of syncPluginToVersion.
428
769
  *
429
- * Works whether or not the plugin source still exists on disk, because it
430
- * 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.
431
771
  */
432
772
  export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHome) {
433
- 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}--`;
434
783
  // 1. Remove synced skill dirs
435
784
  const skillsDir = path.join(versionHome, `.${agent}`, 'skills');
436
785
  if (fs.existsSync(skillsDir)) {
437
- const prefix = `${pluginName}--`;
438
786
  for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
439
- if (!entry.isDirectory())
440
- continue;
441
- if (!entry.name.startsWith(prefix))
787
+ if (!entry.isDirectory() || !entry.name.startsWith(prefix))
442
788
  continue;
443
789
  try {
444
790
  fs.rmSync(path.join(skillsDir, entry.name), { recursive: true, force: true });
445
791
  result.skills.push(entry.name);
446
792
  }
447
- catch {
448
- // 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 */ }
449
811
  }
450
812
  }
451
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);
838
+ }
839
+ catch { /* skip on error */ }
840
+ }
841
+ }
452
842
  if (agent !== 'claude') {
453
843
  return result;
454
844
  }
455
- // 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
456
846
  const settingsPath = path.join(versionHome, `.${agent}`, 'settings.json');
457
847
  if (!fs.existsSync(settingsPath))
458
848
  return result;
@@ -484,13 +874,11 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
484
874
  if (group.hooks.length !== originalLen)
485
875
  changed = true;
486
876
  }
487
- // Drop matcher groups whose hooks array is now empty
488
877
  const kept = eventEntries.filter(g => Array.isArray(g.hooks) && g.hooks.length > 0);
489
878
  if (kept.length !== eventEntries.length) {
490
879
  hooksConfig[event] = kept;
491
880
  changed = true;
492
881
  }
493
- // Drop the event key entirely if it has no matcher groups left
494
882
  if (Array.isArray(hooksConfig[event]) && hooksConfig[event].length === 0) {
495
883
  delete hooksConfig[event];
496
884
  changed = true;
@@ -516,21 +904,37 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
516
904
  }
517
905
  }
518
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
+ }
519
926
  if (changed) {
520
927
  try {
521
928
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
522
929
  }
523
- catch {
524
- // Ignore write errors
525
- }
930
+ catch { /* ignore write errors */ }
526
931
  }
527
932
  return result;
528
933
  }
934
+ // ─── Orphan cleanup ───────────────────────────────────────────────────────────
529
935
  /**
530
936
  * Remove orphaned plugin skill directories from a version home.
531
937
  * Soft-deletes to ~/.agents/.trash/plugins/.
532
- * An orphan is a skill dir with the plugin prefix pattern (name--skill)
533
- * where the plugin no longer exists in ~/.agents/plugins/.
534
938
  */
535
939
  export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames, version) {
536
940
  const removed = [];
@@ -541,7 +945,6 @@ export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames,
541
945
  for (const entry of entries) {
542
946
  if (!entry.isDirectory())
543
947
  continue;
544
- // Plugin skill dirs use the pattern: pluginName--skillName
545
948
  const dashIdx = entry.name.indexOf('--');
546
949
  if (dashIdx === -1)
547
950
  continue;
@@ -555,17 +958,11 @@ export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames,
555
958
  fs.renameSync(path.join(skillsDir, entry.name), trashDest);
556
959
  removed.push(entry.name);
557
960
  }
558
- catch {
559
- // Skip on error
560
- }
961
+ catch { /* skip on error */ }
561
962
  }
562
963
  }
563
964
  return removed;
564
965
  }
565
- /**
566
- * Compare a version home's plugin skills against discovered plugins.
567
- * Returns orphan plugin skill names (pattern: pluginName--skillName).
568
- */
569
966
  export function diffVersionPlugins(agent, version) {
570
967
  const versionHome = getVersionHomePath(agent, version);
571
968
  const skillsDir = path.join(versionHome, `.${agent}`, 'skills');
@@ -574,8 +971,7 @@ export function diffVersionPlugins(agent, version) {
574
971
  return { agent, version, orphans };
575
972
  }
576
973
  const activePlugins = new Set(discoverPlugins().map(p => p.name));
577
- const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
578
- for (const entry of entries) {
974
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
579
975
  if (!entry.isDirectory())
580
976
  continue;
581
977
  const dashIdx = entry.name.indexOf('--');
@@ -588,9 +984,6 @@ export function diffVersionPlugins(agent, version) {
588
984
  }
589
985
  return { agent, version, orphans: orphans.sort() };
590
986
  }
591
- /**
592
- * Iterate all (agent, version) pairs that support plugins and are installed.
593
- */
594
987
  export function iterPluginsCapableVersions(filter) {
595
988
  const pairs = [];
596
989
  const agents = filter?.agent ? [filter.agent] : PLUGINS_CAPABLE_AGENTS;
@@ -606,10 +999,6 @@ export function iterPluginsCapableVersions(filter) {
606
999
  }
607
1000
  return pairs;
608
1001
  }
609
- /**
610
- * Remove a single orphan plugin skill from a version home.
611
- * Soft-deletes to ~/.agents/.trash/plugins/.
612
- */
613
1002
  export function removePluginSkillFromVersion(agent, version, skillName) {
614
1003
  const versionHome = getVersionHomePath(agent, version);
615
1004
  const skillPath = path.join(versionHome, `.${agent}`, 'skills', skillName);
@@ -628,3 +1017,121 @@ export function removePluginSkillFromVersion(agent, version, skillName) {
628
1017
  }
629
1018
  return { success: true };
630
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
+ }