@phnx-labs/agents-cli 1.18.1 → 1.18.3

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 (56) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/commands/doctor.js +19 -5
  3. package/dist/commands/exec.js +9 -4
  4. package/dist/commands/plugins.js +58 -14
  5. package/dist/commands/view.js +16 -7
  6. package/dist/index.js +30 -0
  7. package/dist/lib/hooks.js +21 -3
  8. package/dist/lib/migrate.js +35 -12
  9. package/dist/lib/plugin-marketplace.d.ts +93 -0
  10. package/dist/lib/plugin-marketplace.js +239 -0
  11. package/dist/lib/plugins.d.ts +25 -13
  12. package/dist/lib/plugins.js +350 -566
  13. package/dist/lib/shims.d.ts +3 -1
  14. package/dist/lib/shims.js +81 -7
  15. package/dist/lib/staleness/checkers/commands.d.ts +7 -0
  16. package/dist/lib/staleness/checkers/commands.js +27 -0
  17. package/dist/lib/staleness/checkers/hooks.d.ts +13 -0
  18. package/dist/lib/staleness/checkers/hooks.js +63 -0
  19. package/dist/lib/staleness/checkers/mcp.d.ts +12 -0
  20. package/dist/lib/staleness/checkers/mcp.js +38 -0
  21. package/dist/lib/staleness/checkers/permissions.d.ts +17 -0
  22. package/dist/lib/staleness/checkers/permissions.js +73 -0
  23. package/dist/lib/staleness/checkers/plugins.d.ts +11 -0
  24. package/dist/lib/staleness/checkers/plugins.js +39 -0
  25. package/dist/lib/staleness/checkers/rules.d.ts +19 -0
  26. package/dist/lib/staleness/checkers/rules.js +86 -0
  27. package/dist/lib/staleness/checkers/skills.d.ts +7 -0
  28. package/dist/lib/staleness/checkers/skills.js +34 -0
  29. package/dist/lib/staleness/checkers/subagents.d.ts +12 -0
  30. package/dist/lib/staleness/checkers/subagents.js +39 -0
  31. package/dist/lib/staleness/checkers/types.d.ts +44 -0
  32. package/dist/lib/staleness/checkers/types.js +20 -0
  33. package/dist/lib/staleness/checkers/workflows.d.ts +10 -0
  34. package/dist/lib/staleness/checkers/workflows.js +37 -0
  35. package/dist/lib/staleness/fingerprint.d.ts +38 -0
  36. package/dist/lib/staleness/fingerprint.js +154 -0
  37. package/dist/lib/staleness/index.d.ts +26 -0
  38. package/dist/lib/staleness/index.js +122 -0
  39. package/dist/lib/staleness/layers.d.ts +37 -0
  40. package/dist/lib/staleness/layers.js +100 -0
  41. package/dist/lib/staleness/types.d.ts +56 -0
  42. package/dist/lib/staleness/types.js +6 -0
  43. package/dist/lib/state.d.ts +2 -0
  44. package/dist/lib/state.js +2 -0
  45. package/dist/lib/teams/agents.d.ts +11 -20
  46. package/dist/lib/teams/agents.js +55 -202
  47. package/dist/lib/teams/index.d.ts +3 -2
  48. package/dist/lib/teams/index.js +2 -2
  49. package/dist/lib/teams/persistence.d.ts +0 -38
  50. package/dist/lib/teams/persistence.js +7 -329
  51. package/dist/lib/teams/registry.js +7 -5
  52. package/dist/lib/types.d.ts +6 -0
  53. package/dist/lib/versions.js +34 -12
  54. package/package.json +1 -1
  55. package/dist/lib/sync-manifest.d.ts +0 -81
  56. package/dist/lib/sync-manifest.js +0 -450
@@ -14,6 +14,7 @@ import { execSync } from 'child_process';
14
14
  import { getPluginsDir, getTrashPluginsDir } from './state.js';
15
15
  import { listInstalledVersions, getVersionHomePath } from './versions.js';
16
16
  import { AGENTS, PLUGINS_CAPABLE_AGENTS } from './agents.js';
17
+ import { copyPluginToMarketplace, syncMarketplaceManifest, registerMarketplace, unregisterMarketplace, enablePluginInSettings, disablePluginInSettings, removePluginFromMarketplace, marketplaceIsEmpty, removeEmptyMarketplaceDir, isInstalledInMarketplace, marketplaceRoot, } from './plugin-marketplace.js';
17
18
  const PLUGIN_MANIFEST_DIR = '.claude-plugin';
18
19
  const PLUGIN_MANIFEST_FILE = 'plugin.json';
19
20
  const USER_CONFIG_FILE = '.user-config.json';
@@ -51,6 +52,9 @@ export function buildDiscoveredPlugin(pluginRoot, manifest) {
51
52
  commands: discoverPluginCommands(pluginRoot),
52
53
  agentDefs: discoverPluginAgentDefs(pluginRoot),
53
54
  bin: discoverPluginBin(pluginRoot),
55
+ mcpServers: discoverPluginMcpServers(pluginRoot),
56
+ lspServers: discoverPluginLspServers(pluginRoot),
57
+ monitors: discoverPluginMonitors(pluginRoot),
54
58
  hasMcp: fs.existsSync(path.join(pluginRoot, '.mcp.json')),
55
59
  hasSettings: pluginHasNonPermissionSettings(pluginRoot),
56
60
  };
@@ -149,6 +153,47 @@ export function discoverPluginBin(pluginRoot) {
149
153
  return [];
150
154
  return fs.readdirSync(binDir).filter(f => !f.startsWith('.'));
151
155
  }
156
+ /** Discover MCP server names from .mcp.json at the plugin root. */
157
+ export function discoverPluginMcpServers(pluginRoot) {
158
+ const mcpFile = path.join(pluginRoot, '.mcp.json');
159
+ if (!fs.existsSync(mcpFile))
160
+ return [];
161
+ try {
162
+ const parsed = JSON.parse(fs.readFileSync(mcpFile, 'utf-8'));
163
+ return parsed.mcpServers ? Object.keys(parsed.mcpServers) : [];
164
+ }
165
+ catch {
166
+ return [];
167
+ }
168
+ }
169
+ /** Discover LSP server keys from .lsp.json at the plugin root. */
170
+ export function discoverPluginLspServers(pluginRoot) {
171
+ const lspFile = path.join(pluginRoot, '.lsp.json');
172
+ if (!fs.existsSync(lspFile))
173
+ return [];
174
+ try {
175
+ const parsed = JSON.parse(fs.readFileSync(lspFile, 'utf-8'));
176
+ return Object.keys(parsed);
177
+ }
178
+ catch {
179
+ return [];
180
+ }
181
+ }
182
+ /** Discover monitor names from monitors/monitors.json. */
183
+ export function discoverPluginMonitors(pluginRoot) {
184
+ const monitorsFile = path.join(pluginRoot, 'monitors', 'monitors.json');
185
+ if (!fs.existsSync(monitorsFile))
186
+ return [];
187
+ try {
188
+ const parsed = JSON.parse(fs.readFileSync(monitorsFile, 'utf-8'));
189
+ if (!Array.isArray(parsed))
190
+ return [];
191
+ return parsed.map(m => m.name).filter((n) => typeof n === 'string');
192
+ }
193
+ catch {
194
+ return [];
195
+ }
196
+ }
152
197
  /** Return true if settings.json contains non-permission keys worth merging. */
153
198
  function pluginHasNonPermissionSettings(pluginRoot) {
154
199
  const settingsPath = path.join(pluginRoot, 'settings.json');
@@ -221,15 +266,18 @@ export function checkPluginDependencies(manifest) {
221
266
  /**
222
267
  * Sync a plugin to a specific agent version's home directory.
223
268
  *
224
- * For Claude:
225
- * 1. Copy plugin skills into version's skills dir (prefixed: pluginName--skillName)
226
- * 2. Copy plugin commands into version's commands dir (prefixed: pluginName--cmdName.md)
227
- * 3. Copy plugin agent defs into version's agents dir (prefixed: pluginName--agentName.md)
228
- * 4. Copy plugin bin/ into version home plugin-bin/<pluginName>/, note path in settings
229
- * 5. Read hooks/hooks.json, expand vars, merge into settings.json hooks
230
- * 6. Read .mcp.json, expand vars, merge mcpServers into settings.json
231
- * 7. Read settings.json, merge non-permission keys non-destructively into settings.json
232
- * 8. Read settings.json permissions, expand vars, merge into settings.json
269
+ * For plugins-capable agents (claude, openclaw):
270
+ * 1. Copy plugin source into <versionHome>/.<agent>/plugins/marketplaces/agents-cli/plugins/<name>/
271
+ * 2. Pre-expand ${user_config.*} variables in copied text files (Claude doesn't know this var).
272
+ * 3. (Re-)synthesize the marketplace.json catalog from the installed plugins.
273
+ * 4. Register the synthetic marketplace in known_marketplaces.json.
274
+ * 5. Mark <plugin>@agents-cli enabled in settings.json#enabledPlugins.
275
+ * 6. Migrate (remove) legacy dual-dash skills/commands/agents/bin/hooks/mcp entries.
276
+ *
277
+ * Claude/OpenClaw natively handle the plugin's skills, commands, agents, hooks,
278
+ * MCP servers, bin/, settings.json, and permissions once the plugin lives at the
279
+ * native install path and is marked enabled — see
280
+ * https://code.claude.com/docs/en/plugins.
233
281
  */
234
282
  export function syncPluginToVersion(plugin, agent, versionHome) {
235
283
  const result = {
@@ -247,522 +295,212 @@ export function syncPluginToVersion(plugin, agent, versionHome) {
247
295
  return result;
248
296
  }
249
297
  const userConfig = loadUserConfig(plugin.name);
250
- // 1. Sync skills
251
- result.skills = syncPluginSkills(plugin, agent, versionHome, userConfig);
252
- // 2. Sync commands (Claude + compatible agents only)
253
- if (agent === 'claude' || agent === 'openclaw') {
254
- result.commands = syncPluginCommands(plugin, agent, versionHome, userConfig);
255
- }
256
- // 3. Sync agent defs (Claude only)
257
- if (agent === 'claude') {
258
- result.agentDefs = syncPluginAgentDefs(plugin, agent, versionHome, userConfig);
259
- }
260
- // 4. Sync bin executables (Claude only for now)
261
- if (agent === 'claude') {
262
- result.bin = syncPluginBin(plugin, agent, versionHome);
263
- }
264
- // 5. Sync hooks (Claude only - uses settings.json hook registration)
265
- if (agent === 'claude') {
266
- result.hooks = syncPluginHooks(plugin, agent, versionHome, userConfig);
267
- }
268
- // 6. Sync MCP servers (Claude only)
269
- if (agent === 'claude') {
270
- result.mcp = syncPluginMcp(plugin, agent, versionHome, userConfig);
271
- }
272
- // 7. Merge non-permission settings keys non-destructively (Claude only)
273
- if (agent === 'claude') {
274
- result.settings = syncPluginSettings(plugin, agent, versionHome);
275
- }
276
- // 8. Sync permissions (Claude only - uses settings.json permissions)
277
- if (agent === 'claude') {
278
- result.permissions = syncPluginPermissions(plugin, agent, versionHome, userConfig);
279
- }
280
- result.success =
281
- result.skills.length > 0 ||
282
- result.commands.length > 0 ||
283
- result.agentDefs.length > 0 ||
284
- result.bin.length > 0 ||
285
- result.hooks.length > 0 ||
286
- result.mcp ||
287
- result.settings ||
288
- result.permissions;
298
+ // 1. Copy plugin to native marketplace install dir.
299
+ const installDir = copyPluginToMarketplace(plugin, agent, versionHome);
300
+ // 2. Pre-expand ${user_config.*} in the copy. Leave ${CLAUDE_PLUGIN_ROOT} /
301
+ // ${CLAUDE_PLUGIN_DATA} alone Claude expands those natively at runtime.
302
+ if (Object.keys(userConfig).length > 0) {
303
+ expandUserConfigInDir(installDir, userConfig);
304
+ }
305
+ // 3-5. Synthesize manifest, register marketplace, enable plugin.
306
+ syncMarketplaceManifest(agent, versionHome);
307
+ registerMarketplace(agent, versionHome);
308
+ enablePluginInSettings(plugin.name, agent, versionHome);
309
+ // 6. Migrate legacy dual-dash flat layout from previous versions of agents-cli.
310
+ migrateLegacyFlatLayout(plugin, agent, versionHome);
311
+ // Populate the result shape for backward-compatible callers/reporting.
312
+ result.skills = plugin.skills.map(s => `${plugin.name}:${s}`);
313
+ result.commands = plugin.commands.map(c => `${plugin.name}:${c}`);
314
+ result.agentDefs = plugin.agentDefs.map(a => `${plugin.name}:${a}`);
315
+ result.bin = plugin.bin;
316
+ result.hooks = plugin.hooks;
317
+ result.mcp = plugin.hasMcp;
318
+ result.settings = plugin.hasSettings;
319
+ result.permissions = pluginHasPermissions(plugin);
320
+ result.success = true;
289
321
  return result;
290
322
  }
291
- // ─── Individual sync functions ────────────────────────────────────────────────
292
- /**
293
- * Copy plugin skills into the version's skills directory.
294
- * Skills are prefixed with the plugin name: pluginName--skillName
295
- */
296
- function syncPluginSkills(plugin, agent, versionHome, userConfig) {
297
- const synced = [];
298
- const pluginSkillsDir = path.join(plugin.root, 'skills');
299
- if (!fs.existsSync(pluginSkillsDir))
300
- return synced;
301
- const targetSkillsDir = path.join(versionHome, `.${agent}`, 'skills');
302
- fs.mkdirSync(targetSkillsDir, { recursive: true });
303
- for (const skillName of plugin.skills) {
304
- const srcDir = path.join(pluginSkillsDir, skillName);
305
- const fsSafeName = `${plugin.name}--${skillName}`;
306
- const destDir = path.join(targetSkillsDir, fsSafeName);
307
- try {
308
- if (fs.existsSync(destDir)) {
309
- fs.rmSync(destDir, { recursive: true, force: true });
310
- }
311
- copyDirWithVarExpansion(srcDir, destDir, plugin.root, plugin.name, agent, versionHome, userConfig);
312
- synced.push(`${plugin.name}:${skillName}`);
313
- }
314
- catch {
315
- // Skip on error
316
- }
323
+ function pluginHasPermissions(plugin) {
324
+ const settingsPath = path.join(plugin.root, 'settings.json');
325
+ if (!fs.existsSync(settingsPath))
326
+ return false;
327
+ try {
328
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
329
+ return !!(parsed.permissions?.allow?.length || parsed.permissions?.deny?.length);
317
330
  }
318
- return synced;
319
- }
320
- /**
321
- * Copy plugin commands into the version's commands directory.
322
- * Commands are namespaced: pluginName--commandName.md
323
- */
324
- function syncPluginCommands(plugin, agent, versionHome, userConfig) {
325
- const synced = [];
326
- const pluginCommandsDir = path.join(plugin.root, 'commands');
327
- if (!fs.existsSync(pluginCommandsDir) || plugin.commands.length === 0)
328
- return synced;
329
- const agentConfig = AGENTS[agent];
330
- const commandsTarget = path.join(versionHome, `.${agent}`, agentConfig.commandsSubdir);
331
- fs.mkdirSync(commandsTarget, { recursive: true });
332
- for (const cmdName of plugin.commands) {
333
- const srcFile = path.join(pluginCommandsDir, `${cmdName}.md`);
334
- if (!fs.existsSync(srcFile))
335
- continue;
336
- const destName = `${plugin.name}--${cmdName}.md`;
337
- const destFile = path.join(commandsTarget, destName);
338
- try {
339
- let content = fs.readFileSync(srcFile, 'utf-8');
340
- content = expandPluginVars(content, plugin.root, plugin.name, agent, versionHome, userConfig);
341
- fs.writeFileSync(destFile, content, 'utf-8');
342
- synced.push(`${plugin.name}:${cmdName}`);
343
- }
344
- catch {
345
- // Skip on error
346
- }
331
+ catch {
332
+ return false;
347
333
  }
348
- return synced;
349
334
  }
350
335
  /**
351
- * Copy plugin agent definitions into the version's agents directory.
352
- * Agent defs are namespaced: pluginName--agentName.md
336
+ * Walk a directory and replace ${user_config.*} placeholders in text files.
337
+ * Leaves all other variables (${CLAUDE_PLUGIN_ROOT}, ${CLAUDE_PLUGIN_DATA}) alone.
353
338
  */
354
- function syncPluginAgentDefs(plugin, agent, versionHome, userConfig) {
355
- const synced = [];
356
- const pluginAgentsDir = path.join(plugin.root, 'agents');
357
- if (!fs.existsSync(pluginAgentsDir) || plugin.agentDefs.length === 0)
358
- return synced;
359
- const agentsTarget = path.join(versionHome, `.${agent}`, 'agents');
360
- fs.mkdirSync(agentsTarget, { recursive: true });
361
- for (const agentDefName of plugin.agentDefs) {
362
- const srcFile = path.join(pluginAgentsDir, `${agentDefName}.md`);
363
- if (!fs.existsSync(srcFile))
339
+ function expandUserConfigInDir(dir, userConfig) {
340
+ const textExtensions = new Set(['.md', '.json', '.sh', '.py', '.js', '.ts', '.yaml', '.yml', '.toml', '.txt']);
341
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
342
+ const full = path.join(dir, entry.name);
343
+ if (entry.isDirectory()) {
344
+ expandUserConfigInDir(full, userConfig);
364
345
  continue;
365
- const destName = `${plugin.name}--${agentDefName}.md`;
366
- const destFile = path.join(agentsTarget, destName);
367
- try {
368
- let content = fs.readFileSync(srcFile, 'utf-8');
369
- content = expandPluginVars(content, plugin.root, plugin.name, agent, versionHome, userConfig);
370
- fs.writeFileSync(destFile, content, 'utf-8');
371
- synced.push(`${plugin.name}:${agentDefName}`);
372
- }
373
- catch {
374
- // Skip on error
375
346
  }
376
- }
377
- return synced;
378
- }
379
- /**
380
- * Copy plugin bin executables into the version home plugin-bin/<pluginName>/ directory.
381
- * Records the bin path in settings.json under pluginBinPaths so callers can add it to PATH.
382
- */
383
- function syncPluginBin(plugin, agent, versionHome) {
384
- const synced = [];
385
- const pluginBinDir = path.join(plugin.root, 'bin');
386
- if (!fs.existsSync(pluginBinDir) || plugin.bin.length === 0)
387
- return synced;
388
- const targetBinDir = path.join(versionHome, `.${agent}`, 'plugin-bin', plugin.name);
389
- fs.mkdirSync(targetBinDir, { recursive: true });
390
- for (const binFile of plugin.bin) {
391
- const srcFile = path.join(pluginBinDir, binFile);
392
- if (!fs.existsSync(srcFile))
347
+ if (!textExtensions.has(path.extname(entry.name).toLowerCase()))
393
348
  continue;
394
- const destFile = path.join(targetBinDir, binFile);
395
349
  try {
396
- fs.copyFileSync(srcFile, destFile);
397
- const stat = fs.statSync(srcFile);
398
- fs.chmodSync(destFile, stat.mode | 0o111);
399
- synced.push(binFile);
400
- }
401
- catch {
402
- // Skip on error
403
- }
404
- }
405
- if (synced.length === 0)
406
- return synced;
407
- // Note the bin path in settings.json so the calling shim can add it to PATH.
408
- const configDir = path.join(versionHome, `.${agent}`);
409
- const settingsPath = path.join(configDir, 'settings.json');
410
- let settings = {};
411
- if (fs.existsSync(settingsPath)) {
412
- try {
413
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
350
+ const content = fs.readFileSync(full, 'utf-8');
351
+ if (!content.includes('${user_config.'))
352
+ continue;
353
+ const expanded = content.replace(/\$\{user_config\.([^}]+)\}/g, (_, key) => userConfig[key] ?? '');
354
+ if (expanded !== content) {
355
+ fs.writeFileSync(full, expanded, 'utf-8');
356
+ }
414
357
  }
415
- catch { /* start fresh */ }
416
- }
417
- if (!Array.isArray(settings.pluginBinPaths)) {
418
- settings.pluginBinPaths = [];
358
+ catch { /* skip unreadable */ }
419
359
  }
420
- const binPaths = settings.pluginBinPaths;
421
- if (!binPaths.includes(targetBinDir)) {
422
- binPaths.push(targetBinDir);
423
- }
424
- try {
425
- fs.mkdirSync(configDir, { recursive: true });
426
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
427
- }
428
- catch { /* ignore write errors */ }
429
- return synced;
430
360
  }
431
361
  /**
432
- * Merge plugin hooks into Claude's settings.json.
433
- * Reads the plugin's hooks/hooks.json and merges each event's hooks
434
- * into the version's settings.json, expanding variables.
362
+ * Remove legacy <plugin>--* entries from a version home, left by the previous
363
+ * flatten-based sync. Safe to call repeatedly only deletes paths matching the
364
+ * plugin's prefix.
435
365
  */
436
- function syncPluginHooks(plugin, agent, versionHome, userConfig) {
437
- const synced = [];
438
- const hooksFile = path.join(plugin.root, 'hooks', 'hooks.json');
439
- if (!fs.existsSync(hooksFile))
440
- return synced;
441
- let pluginHooks;
442
- try {
443
- pluginHooks = JSON.parse(fs.readFileSync(hooksFile, 'utf-8'));
444
- }
445
- catch {
446
- return synced;
447
- }
448
- const configDir = path.join(versionHome, `.${agent}`);
449
- const settingsPath = path.join(configDir, 'settings.json');
450
- let settings = {};
451
- if (fs.existsSync(settingsPath)) {
452
- try {
453
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
366
+ function migrateLegacyFlatLayout(plugin, agent, versionHome) {
367
+ const prefix = `${plugin.name}--`;
368
+ const agentRoot = path.join(versionHome, `.${agent}`);
369
+ // 1. skills
370
+ const skillsDir = path.join(agentRoot, 'skills');
371
+ if (fs.existsSync(skillsDir)) {
372
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
373
+ if (entry.isDirectory() && entry.name.startsWith(prefix)) {
374
+ try {
375
+ fs.rmSync(path.join(skillsDir, entry.name), { recursive: true, force: true });
376
+ }
377
+ catch { /* skip */ }
378
+ }
454
379
  }
455
- catch { /* start fresh */ }
456
380
  }
457
- if (!settings.hooks || typeof settings.hooks !== 'object') {
458
- settings.hooks = {};
459
- }
460
- const hooksConfig = settings.hooks;
461
- for (const [event, matcherGroups] of Object.entries(pluginHooks)) {
462
- if (!hooksConfig[event]) {
463
- hooksConfig[event] = [];
464
- }
465
- const eventEntries = hooksConfig[event];
466
- for (const group of matcherGroups) {
467
- const matcher = group.matcher || '';
468
- const expandedHooks = (group.hooks || []).map(h => ({
469
- ...h,
470
- command: expandPluginVars(h.command, plugin.root, plugin.name, agent, versionHome, userConfig),
471
- }));
472
- let matcherGroup = eventEntries.find(e => (e.matcher || '') === matcher);
473
- if (!matcherGroup) {
474
- matcherGroup = { matcher, hooks: [] };
475
- eventEntries.push(matcherGroup);
476
- }
477
- if (!matcherGroup.hooks) {
478
- matcherGroup.hooks = [];
479
- }
480
- for (const hook of expandedHooks) {
481
- const exists = matcherGroup.hooks.some(h => h.command === hook.command);
482
- if (!exists) {
483
- matcherGroup.hooks.push(hook);
381
+ // 2. commands
382
+ if (agent === 'claude' || agent === 'openclaw') {
383
+ const cmdsDir = path.join(agentRoot, AGENTS[agent]?.commandsSubdir ?? 'commands');
384
+ if (fs.existsSync(cmdsDir)) {
385
+ for (const entry of fs.readdirSync(cmdsDir, { withFileTypes: true })) {
386
+ if (entry.isFile() && entry.name.startsWith(prefix) && entry.name.endsWith('.md')) {
387
+ try {
388
+ fs.unlinkSync(path.join(cmdsDir, entry.name));
389
+ }
390
+ catch { /* skip */ }
484
391
  }
485
392
  }
486
393
  }
487
- synced.push(event);
488
- }
489
- try {
490
- fs.mkdirSync(configDir, { recursive: true });
491
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
492
- }
493
- catch { /* ignore write errors */ }
494
- return synced;
495
- }
496
- /**
497
- * Merge plugin .mcp.json MCP server definitions into Claude's settings.json.
498
- * Server names are namespaced as pluginName--serverName to avoid collisions.
499
- * Expands ${CLAUDE_PLUGIN_ROOT}, ${CLAUDE_PLUGIN_DATA}, ${user_config.*} in args and env.
500
- */
501
- function syncPluginMcp(plugin, agent, versionHome, userConfig) {
502
- const mcpFile = path.join(plugin.root, '.mcp.json');
503
- if (!fs.existsSync(mcpFile))
504
- return false;
505
- let pluginMcp;
506
- try {
507
- pluginMcp = JSON.parse(fs.readFileSync(mcpFile, 'utf-8'));
508
394
  }
509
- catch {
510
- return false;
511
- }
512
- const servers = pluginMcp.mcpServers;
513
- if (!servers || Object.keys(servers).length === 0)
514
- return false;
515
- const configDir = path.join(versionHome, `.${agent}`);
516
- const settingsPath = path.join(configDir, 'settings.json');
517
- let settings = {};
518
- if (fs.existsSync(settingsPath)) {
519
- try {
520
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
395
+ // 3. agent definitions
396
+ const agentsDir = path.join(agentRoot, 'agents');
397
+ if (fs.existsSync(agentsDir)) {
398
+ for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
399
+ if (entry.isFile() && entry.name.startsWith(prefix) && entry.name.endsWith('.md')) {
400
+ try {
401
+ fs.unlinkSync(path.join(agentsDir, entry.name));
402
+ }
403
+ catch { /* skip */ }
404
+ }
521
405
  }
522
- catch { /* start fresh */ }
523
- }
524
- if (!settings.mcpServers || typeof settings.mcpServers !== 'object') {
525
- settings.mcpServers = {};
526
406
  }
527
- const existing = settings.mcpServers;
528
- let merged = false;
529
- for (const [serverName, serverConfig] of Object.entries(servers)) {
530
- const namespacedName = `${plugin.name}--${serverName}`;
531
- // Expand variables inside the server config
532
- const configStr = expandPluginVars(JSON.stringify(serverConfig), plugin.root, plugin.name, agent, versionHome, userConfig);
533
- existing[namespacedName] = JSON.parse(configStr);
534
- merged = true;
535
- }
536
- if (merged) {
407
+ // 4. plugin-bin
408
+ const binDir = path.join(agentRoot, 'plugin-bin', plugin.name);
409
+ if (fs.existsSync(binDir)) {
537
410
  try {
538
- fs.mkdirSync(configDir, { recursive: true });
539
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
540
- }
541
- catch {
542
- return false;
411
+ fs.rmSync(binDir, { recursive: true, force: true });
543
412
  }
413
+ catch { /* skip */ }
544
414
  }
545
- return merged;
546
- }
547
- /**
548
- * Merge non-permission keys from plugin's settings.json non-destructively into agent settings.
549
- * Only adds keys that don't already exist — never overwrites user config.
550
- * Permission keys are handled separately by syncPluginPermissions.
551
- */
552
- function syncPluginSettings(plugin, agent, versionHome) {
553
- const pluginSettingsPath = path.join(plugin.root, 'settings.json');
554
- if (!fs.existsSync(pluginSettingsPath))
555
- return false;
556
- let pluginSettings;
415
+ // 5. settings.json — strip namespaced mcpServers, hooks referencing plugin
416
+ // root, permissions referencing plugin root, and pluginBinPaths entries.
417
+ const settingsPath = path.join(agentRoot, 'settings.json');
418
+ if (!fs.existsSync(settingsPath))
419
+ return;
420
+ let settings;
557
421
  try {
558
- pluginSettings = JSON.parse(fs.readFileSync(pluginSettingsPath, 'utf-8'));
422
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
559
423
  }
560
424
  catch {
561
- return false;
562
- }
563
- // Exclude permissions — those are handled by syncPluginPermissions
564
- const keysToMerge = Object.entries(pluginSettings).filter(([k]) => k !== 'permissions');
565
- if (keysToMerge.length === 0)
566
- return false;
567
- const configDir = path.join(versionHome, `.${agent}`);
568
- const settingsPath = path.join(configDir, 'settings.json');
569
- let settings = {};
570
- if (fs.existsSync(settingsPath)) {
571
- try {
572
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
573
- }
574
- catch { /* start fresh */ }
425
+ return;
575
426
  }
576
427
  let changed = false;
577
- for (const [key, value] of keysToMerge) {
578
- if (!(key in settings)) {
579
- settings[key] = value;
580
- changed = true;
581
- }
582
- }
583
- if (changed) {
584
- try {
585
- fs.mkdirSync(configDir, { recursive: true });
586
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
587
- }
588
- catch {
589
- return false;
590
- }
591
- }
592
- return changed;
593
- }
594
- /**
595
- * Merge plugin permissions into Claude's settings.json.
596
- * Reads the plugin's settings.json and merges permissions.allow / deny entries.
597
- */
598
- function syncPluginPermissions(plugin, agent, versionHome, userConfig) {
599
- const pluginSettingsPath = path.join(plugin.root, 'settings.json');
600
- if (!fs.existsSync(pluginSettingsPath))
601
- return false;
602
- let pluginSettings;
603
- try {
604
- pluginSettings = JSON.parse(fs.readFileSync(pluginSettingsPath, 'utf-8'));
605
- }
606
- catch {
607
- return false;
608
- }
609
- const pluginAllow = pluginSettings.permissions?.allow || [];
610
- const pluginDeny = pluginSettings.permissions?.deny || [];
611
- if (pluginAllow.length === 0 && pluginDeny.length === 0)
612
- return false;
613
- const expandedAllow = pluginAllow.map(rule => expandPluginVars(rule, plugin.root, plugin.name, agent, versionHome, userConfig));
614
- const expandedDeny = pluginDeny.map(rule => expandPluginVars(rule, plugin.root, plugin.name, agent, versionHome, userConfig));
615
- const configDir = path.join(versionHome, `.${agent}`);
616
- const settingsPath = path.join(configDir, 'settings.json');
617
- let settings = {};
618
- if (fs.existsSync(settingsPath)) {
619
- try {
620
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
621
- }
622
- catch { /* start fresh */ }
623
- }
624
- if (!settings.permissions || typeof settings.permissions !== 'object') {
625
- settings.permissions = { allow: [], deny: [] };
626
- }
627
- const perms = settings.permissions;
628
- if (!perms.allow)
629
- perms.allow = [];
630
- if (!perms.deny)
631
- perms.deny = [];
632
- for (const rule of expandedAllow) {
633
- if (!perms.allow.includes(rule)) {
634
- perms.allow.push(rule);
635
- }
636
- }
637
- for (const rule of expandedDeny) {
638
- if (!perms.deny.includes(rule)) {
639
- perms.deny.push(rule);
640
- }
641
- }
642
- try {
643
- fs.mkdirSync(configDir, { recursive: true });
644
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
645
- return true;
646
- }
647
- catch {
648
- return false;
649
- }
650
- }
651
- // ─── Utility ──────────────────────────────────────────────────────────────────
652
- /**
653
- * Copy a directory recursively, expanding plugin variables in text file contents.
654
- * Only expands variables in text files (.md, .json, .sh, .py, .js, .ts, .yaml, .yml, .toml).
655
- */
656
- function copyDirWithVarExpansion(src, dest, pluginRoot, pluginName, agent, versionHome, userConfig) {
657
- fs.mkdirSync(dest, { recursive: true });
658
- const entries = fs.readdirSync(src, { withFileTypes: true });
659
- const textExtensions = new Set(['.md', '.json', '.sh', '.py', '.js', '.ts', '.yaml', '.yml', '.toml', '.txt']);
660
- for (const entry of entries) {
661
- const srcPath = path.join(src, entry.name);
662
- const destPath = path.join(dest, entry.name);
663
- if (entry.isDirectory()) {
664
- copyDirWithVarExpansion(srcPath, destPath, pluginRoot, pluginName, agent, versionHome, userConfig);
665
- }
666
- else {
667
- const ext = path.extname(entry.name).toLowerCase();
668
- if (textExtensions.has(ext)) {
669
- let content = fs.readFileSync(srcPath, 'utf-8');
670
- content = expandPluginVars(content, pluginRoot, pluginName, agent, versionHome, userConfig);
671
- fs.writeFileSync(destPath, content, 'utf-8');
672
- }
673
- else {
674
- fs.copyFileSync(srcPath, destPath);
428
+ const pluginRoot = plugin.root;
429
+ const hooksCfg = settings.hooks;
430
+ if (hooksCfg && typeof hooksCfg === 'object') {
431
+ for (const [event, entries] of Object.entries(hooksCfg)) {
432
+ if (!Array.isArray(entries))
433
+ continue;
434
+ const groups = entries;
435
+ for (const group of groups) {
436
+ if (!Array.isArray(group.hooks))
437
+ continue;
438
+ const orig = group.hooks.length;
439
+ group.hooks = group.hooks.filter(h => !(typeof h.command === 'string' && h.command.includes(pluginRoot)));
440
+ if (group.hooks.length !== orig)
441
+ changed = true;
675
442
  }
676
- const stat = fs.statSync(srcPath);
677
- if (stat.mode & 0o111) {
678
- fs.chmodSync(destPath, stat.mode);
443
+ const kept = groups.filter(g => Array.isArray(g.hooks) && g.hooks.length > 0);
444
+ if (kept.length !== groups.length) {
445
+ hooksCfg[event] = kept;
446
+ changed = true;
679
447
  }
680
- }
681
- }
682
- }
683
- // ─── Sync status ──────────────────────────────────────────────────────────────
684
- /**
685
- * Check if a plugin is synced to a version by inspecting the version home.
686
- * Checks skills, commands, agent defs, bin, hook commands, and permissions.
687
- */
688
- export function isPluginSynced(plugin, agent, versionHome) {
689
- const prefix = `${plugin.name}--`;
690
- // Check 1: plugin skill directories
691
- if (plugin.skills.length > 0) {
692
- const skillsDir = path.join(versionHome, `.${agent}`, 'skills');
693
- if (fs.existsSync(skillsDir)) {
694
- for (const skillName of plugin.skills) {
695
- if (fs.existsSync(path.join(skillsDir, `${prefix}${skillName}`))) {
696
- return true;
697
- }
448
+ if (Array.isArray(hooksCfg[event]) && hooksCfg[event].length === 0) {
449
+ delete hooksCfg[event];
450
+ changed = true;
698
451
  }
699
452
  }
700
453
  }
701
- // Check 2: plugin command files
702
- if (plugin.commands.length > 0 && (agent === 'claude' || agent === 'openclaw')) {
703
- const agentConfig = AGENTS[agent];
704
- const commandsDir = path.join(versionHome, `.${agent}`, agentConfig.commandsSubdir);
705
- if (fs.existsSync(commandsDir)) {
706
- for (const cmdName of plugin.commands) {
707
- if (fs.existsSync(path.join(commandsDir, `${prefix}${cmdName}.md`))) {
708
- return true;
709
- }
454
+ const perms = settings.permissions;
455
+ if (perms && typeof perms === 'object') {
456
+ for (const key of ['allow', 'deny']) {
457
+ const list = perms[key];
458
+ if (!Array.isArray(list))
459
+ continue;
460
+ const kept = list.filter(r => !(typeof r === 'string' && r.includes(pluginRoot)));
461
+ if (kept.length !== list.length) {
462
+ perms[key] = kept;
463
+ changed = true;
710
464
  }
711
465
  }
712
466
  }
713
- // Check 3: plugin agent definition files
714
- if (plugin.agentDefs.length > 0 && agent === 'claude') {
715
- const agentsDir = path.join(versionHome, `.${agent}`, 'agents');
716
- if (fs.existsSync(agentsDir)) {
717
- for (const agentDefName of plugin.agentDefs) {
718
- if (fs.existsSync(path.join(agentsDir, `${prefix}${agentDefName}.md`))) {
719
- return true;
720
- }
467
+ const mcp = settings.mcpServers;
468
+ if (mcp && typeof mcp === 'object') {
469
+ for (const key of Object.keys(mcp)) {
470
+ if (key.startsWith(prefix)) {
471
+ delete mcp[key];
472
+ changed = true;
721
473
  }
722
474
  }
723
475
  }
724
- // Check 4: plugin bin directory
725
- if (plugin.bin.length > 0 && agent === 'claude') {
726
- const binDir = path.join(versionHome, `.${agent}`, 'plugin-bin', plugin.name);
727
- if (fs.existsSync(binDir)) {
728
- return true;
729
- }
730
- }
731
- // Check 5: plugin hooks registered in settings.json (commands referencing plugin root)
732
- if (plugin.hooks.length > 0 && agent === 'claude') {
733
- const settingsPath = path.join(versionHome, `.${agent}`, 'settings.json');
734
- if (fs.existsSync(settingsPath)) {
735
- try {
736
- const content = fs.readFileSync(settingsPath, 'utf-8');
737
- if (content.includes(plugin.root)) {
738
- return true;
739
- }
740
- }
741
- catch { /* ignore */ }
476
+ if (Array.isArray(settings.pluginBinPaths)) {
477
+ const targetBinDir = path.join(agentRoot, 'plugin-bin', plugin.name);
478
+ const before = settings.pluginBinPaths.length;
479
+ settings.pluginBinPaths = settings.pluginBinPaths.filter(p => p !== targetBinDir);
480
+ if (settings.pluginBinPaths.length !== before)
481
+ changed = true;
482
+ if (settings.pluginBinPaths.length === 0) {
483
+ delete settings.pluginBinPaths;
484
+ changed = true;
742
485
  }
743
486
  }
744
- // Check 6: plugin permissions in settings.json
745
- if (agent === 'claude') {
746
- const settingsPath = path.join(versionHome, `.${agent}`, 'settings.json');
747
- if (fs.existsSync(settingsPath)) {
748
- try {
749
- const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
750
- const allow = settings.permissions?.allow || [];
751
- if (allow.some((rule) => rule.includes(plugin.root))) {
752
- return true;
753
- }
754
- // Check MCP servers
755
- const mcpServers = settings.mcpServers;
756
- if (mcpServers) {
757
- const hasNamespacedServer = Object.keys(mcpServers).some(k => k.startsWith(prefix));
758
- if (hasNamespacedServer)
759
- return true;
760
- }
761
- }
762
- catch { /* ignore */ }
487
+ if (changed) {
488
+ try {
489
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
763
490
  }
491
+ catch { /* ignore */ }
764
492
  }
765
- return false;
493
+ }
494
+ // ─── Sync status ──────────────────────────────────────────────────────────────
495
+ /**
496
+ * Check if a plugin is synced to a version. True when the plugin lives at the
497
+ * native marketplace install path. Legacy dual-dash entries are not counted —
498
+ * they're treated as stale and migrated away on the next sync.
499
+ */
500
+ export function isPluginSynced(plugin, agent, versionHome) {
501
+ if (!PLUGINS_CAPABLE_AGENTS.includes(agent))
502
+ return false;
503
+ return isInstalledInMarketplace(plugin.name, agent, versionHome);
766
504
  }
767
505
  // ─── Removal ─────────────────────────────────────────────────────────────────
768
506
  /**
@@ -781,9 +519,32 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
781
519
  permissions: 0,
782
520
  mcp: 0,
783
521
  };
522
+ // 1. Remove the plugin from the marketplace install dir + disable it.
523
+ const removed = removePluginFromMarketplace(pluginName, agent, versionHome);
524
+ if (removed) {
525
+ result.skills.push(pluginName);
526
+ }
527
+ disablePluginInSettings(pluginName, agent, versionHome);
528
+ // 2. Refresh marketplace.json so it reflects what's left under plugins/.
529
+ syncMarketplaceManifest(agent, versionHome);
530
+ // 3. If we just removed the last plugin, drop the marketplace dir and the
531
+ // known_marketplaces.json entry too.
532
+ if (marketplaceIsEmpty(agent, versionHome)) {
533
+ removeEmptyMarketplaceDir(agent, versionHome);
534
+ unregisterMarketplace(agent, versionHome);
535
+ }
536
+ // 4. Strip any legacy dual-dash entries from prior agents-cli versions.
537
+ cleanLegacyFlatLayout(pluginName, pluginRoot, agent, versionHome, result);
538
+ return result;
539
+ }
540
+ /**
541
+ * Strip dual-dash flat-layout entries left behind by older agents-cli sync runs.
542
+ * Mutates `result` to record what was removed.
543
+ */
544
+ function cleanLegacyFlatLayout(pluginName, pluginRoot, agent, versionHome, result) {
784
545
  const prefix = `${pluginName}--`;
785
- // 1. Remove synced skill dirs
786
- const skillsDir = path.join(versionHome, `.${agent}`, 'skills');
546
+ const agentRoot = path.join(versionHome, `.${agent}`);
547
+ const skillsDir = path.join(agentRoot, 'skills');
787
548
  if (fs.existsSync(skillsDir)) {
788
549
  for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
789
550
  if (!entry.isDirectory() || !entry.name.startsWith(prefix))
@@ -792,92 +553,75 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
792
553
  fs.rmSync(path.join(skillsDir, entry.name), { recursive: true, force: true });
793
554
  result.skills.push(entry.name);
794
555
  }
795
- catch { /* skip on error */ }
556
+ catch { /* skip */ }
796
557
  }
797
558
  }
798
- // 2. Remove synced command files
799
559
  if (agent === 'claude' || agent === 'openclaw') {
800
- const agentConfig = AGENTS[agent];
801
- const commandsDir = path.join(versionHome, `.${agent}`, agentConfig.commandsSubdir);
560
+ const commandsDir = path.join(agentRoot, AGENTS[agent]?.commandsSubdir ?? 'commands');
802
561
  if (fs.existsSync(commandsDir)) {
803
562
  for (const entry of fs.readdirSync(commandsDir, { withFileTypes: true })) {
804
- if (!entry.isFile())
805
- continue;
806
- if (!entry.name.startsWith(prefix) || !entry.name.endsWith('.md'))
563
+ if (!entry.isFile() || !entry.name.startsWith(prefix) || !entry.name.endsWith('.md'))
807
564
  continue;
808
565
  try {
809
566
  fs.unlinkSync(path.join(commandsDir, entry.name));
810
567
  result.commands.push(entry.name);
811
568
  }
812
- catch { /* skip on error */ }
813
- }
814
- }
815
- }
816
- // 3. Remove synced agent def files
817
- if (agent === 'claude') {
818
- const agentsDir = path.join(versionHome, `.${agent}`, 'agents');
819
- if (fs.existsSync(agentsDir)) {
820
- for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
821
- if (!entry.isFile())
822
- continue;
823
- if (!entry.name.startsWith(prefix) || !entry.name.endsWith('.md'))
824
- continue;
825
- try {
826
- fs.unlinkSync(path.join(agentsDir, entry.name));
827
- result.agentDefs.push(entry.name);
828
- }
829
- catch { /* skip on error */ }
569
+ catch { /* skip */ }
830
570
  }
831
571
  }
832
572
  }
833
- // 4. Remove plugin-bin directory
834
- if (agent === 'claude') {
835
- const binDir = path.join(versionHome, `.${agent}`, 'plugin-bin', pluginName);
836
- if (fs.existsSync(binDir)) {
573
+ const agentsDir = path.join(agentRoot, 'agents');
574
+ if (fs.existsSync(agentsDir)) {
575
+ for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
576
+ if (!entry.isFile() || !entry.name.startsWith(prefix) || !entry.name.endsWith('.md'))
577
+ continue;
837
578
  try {
838
- fs.rmSync(binDir, { recursive: true, force: true });
839
- result.bin.push(binDir);
579
+ fs.unlinkSync(path.join(agentsDir, entry.name));
580
+ result.agentDefs.push(entry.name);
840
581
  }
841
- catch { /* skip on error */ }
582
+ catch { /* skip */ }
842
583
  }
843
584
  }
844
- if (agent !== 'claude') {
845
- return result;
585
+ const binDir = path.join(agentRoot, 'plugin-bin', pluginName);
586
+ if (fs.existsSync(binDir)) {
587
+ try {
588
+ fs.rmSync(binDir, { recursive: true, force: true });
589
+ result.bin.push(binDir);
590
+ }
591
+ catch { /* skip */ }
846
592
  }
847
- // 5 + 6 + 7: edit settings.json — strip hooks, permissions, mcpServers matching plugin
848
- const settingsPath = path.join(versionHome, `.${agent}`, 'settings.json');
593
+ const settingsPath = path.join(agentRoot, 'settings.json');
849
594
  if (!fs.existsSync(settingsPath))
850
- return result;
595
+ return;
851
596
  let settings;
852
597
  try {
853
598
  settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
854
599
  }
855
600
  catch {
856
- return result;
601
+ return;
857
602
  }
858
603
  let changed = false;
859
- // Strip hooks referencing plugin root
860
604
  const hooksConfig = settings.hooks;
861
605
  if (hooksConfig && typeof hooksConfig === 'object') {
862
606
  for (const [event, entries] of Object.entries(hooksConfig)) {
863
607
  if (!Array.isArray(entries))
864
608
  continue;
865
- const eventEntries = entries;
866
- for (const group of eventEntries) {
609
+ const groups = entries;
610
+ for (const group of groups) {
867
611
  if (!Array.isArray(group.hooks))
868
612
  continue;
869
- const originalLen = group.hooks.length;
613
+ const orig = group.hooks.length;
870
614
  group.hooks = group.hooks.filter(h => {
871
615
  const matches = typeof h.command === 'string' && h.command.includes(pluginRoot);
872
616
  if (matches)
873
617
  result.hooks.push(`${event}: ${h.command}`);
874
618
  return !matches;
875
619
  });
876
- if (group.hooks.length !== originalLen)
620
+ if (group.hooks.length !== orig)
877
621
  changed = true;
878
622
  }
879
- const kept = eventEntries.filter(g => Array.isArray(g.hooks) && g.hooks.length > 0);
880
- if (kept.length !== eventEntries.length) {
623
+ const kept = groups.filter(g => Array.isArray(g.hooks) && g.hooks.length > 0);
624
+ if (kept.length !== groups.length) {
881
625
  hooksConfig[event] = kept;
882
626
  changed = true;
883
627
  }
@@ -887,15 +631,14 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
887
631
  }
888
632
  }
889
633
  }
890
- // Strip permissions referencing plugin root
891
634
  const perms = settings.permissions;
892
635
  if (perms && typeof perms === 'object') {
893
636
  for (const key of ['allow', 'deny']) {
894
637
  const list = perms[key];
895
638
  if (!Array.isArray(list))
896
639
  continue;
897
- const kept = list.filter(rule => {
898
- const matches = typeof rule === 'string' && rule.includes(pluginRoot);
640
+ const kept = list.filter(r => {
641
+ const matches = typeof r === 'string' && r.includes(pluginRoot);
899
642
  if (matches)
900
643
  result.permissions += 1;
901
644
  return !matches;
@@ -906,52 +649,83 @@ export function removePluginFromVersion(pluginName, pluginRoot, agent, versionHo
906
649
  }
907
650
  }
908
651
  }
909
- // Strip namespaced MCP servers added by this plugin
910
- const mcpServers = settings.mcpServers;
911
- if (mcpServers && typeof mcpServers === 'object') {
912
- for (const serverName of Object.keys(mcpServers)) {
913
- if (serverName.startsWith(prefix)) {
914
- delete mcpServers[serverName];
652
+ const mcp = settings.mcpServers;
653
+ if (mcp && typeof mcp === 'object') {
654
+ for (const key of Object.keys(mcp)) {
655
+ if (key.startsWith(prefix)) {
656
+ delete mcp[key];
915
657
  result.mcp += 1;
916
658
  changed = true;
917
659
  }
918
660
  }
919
661
  }
920
- // Strip bin path from pluginBinPaths
921
662
  if (Array.isArray(settings.pluginBinPaths)) {
922
- const binDir = path.join(versionHome, `.${agent}`, 'plugin-bin', pluginName);
663
+ const targetBin = path.join(agentRoot, 'plugin-bin', pluginName);
923
664
  const before = settings.pluginBinPaths.length;
924
- settings.pluginBinPaths = settings.pluginBinPaths.filter(p => p !== binDir);
665
+ settings.pluginBinPaths = settings.pluginBinPaths.filter(p => p !== targetBin);
925
666
  if (settings.pluginBinPaths.length !== before)
926
667
  changed = true;
668
+ if (settings.pluginBinPaths.length === 0) {
669
+ delete settings.pluginBinPaths;
670
+ changed = true;
671
+ }
927
672
  }
928
673
  if (changed) {
929
674
  try {
930
675
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
931
676
  }
932
- catch { /* ignore write errors */ }
677
+ catch { /* ignore */ }
933
678
  }
934
- return result;
935
679
  }
936
680
  // ─── Orphan cleanup ───────────────────────────────────────────────────────────
937
681
  /**
938
- * Remove orphaned plugin skill directories from a version home.
939
- * Soft-deletes to ~/.agents/.trash/plugins/.
682
+ * Remove orphaned plugin entries from a version home. An entry is "orphan" if
683
+ * its plugin name is not in the active plugin set. Soft-deletes the affected
684
+ * marketplace plugin dir to ~/.agents/.trash/plugins/. Also cleans up any
685
+ * legacy dual-dash skills/ directories from older agents-cli versions.
940
686
  */
941
687
  export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames, version) {
942
688
  const removed = [];
689
+ // 1. Walk the native marketplace install dir and trash entries no longer active.
690
+ const mktPluginsDir = path.join(marketplaceRoot(agent, versionHome), 'plugins');
691
+ if (fs.existsSync(mktPluginsDir)) {
692
+ for (const entry of fs.readdirSync(mktPluginsDir, { withFileTypes: true })) {
693
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
694
+ continue;
695
+ if (activePluginNames.has(entry.name))
696
+ continue;
697
+ try {
698
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
699
+ const trashDir = path.join(getTrashPluginsDir(), agent, version || 'unknown', entry.name);
700
+ const trashDest = path.join(trashDir, stamp);
701
+ fs.mkdirSync(trashDir, { recursive: true, mode: 0o700 });
702
+ fs.renameSync(path.join(mktPluginsDir, entry.name), trashDest);
703
+ disablePluginInSettings(entry.name, agent, versionHome);
704
+ removed.push(entry.name);
705
+ }
706
+ catch { /* skip on error */ }
707
+ }
708
+ // Keep manifest in sync with on-disk state and drop the marketplace if empty.
709
+ if (removed.length > 0) {
710
+ syncMarketplaceManifest(agent, versionHome);
711
+ if (marketplaceIsEmpty(agent, versionHome)) {
712
+ removeEmptyMarketplaceDir(agent, versionHome);
713
+ unregisterMarketplace(agent, versionHome);
714
+ }
715
+ }
716
+ }
717
+ // 2. Sweep legacy dual-dash skills directories from older agents-cli versions.
943
718
  const skillsDir = path.join(versionHome, `.${agent}`, 'skills');
944
- if (!fs.existsSync(skillsDir))
945
- return removed;
946
- const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
947
- for (const entry of entries) {
948
- if (!entry.isDirectory())
949
- continue;
950
- const dashIdx = entry.name.indexOf('--');
951
- if (dashIdx === -1)
952
- continue;
953
- const pluginName = entry.name.slice(0, dashIdx);
954
- if (!activePluginNames.has(pluginName)) {
719
+ if (fs.existsSync(skillsDir)) {
720
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
721
+ if (!entry.isDirectory())
722
+ continue;
723
+ const dashIdx = entry.name.indexOf('--');
724
+ if (dashIdx === -1)
725
+ continue;
726
+ const pluginName = entry.name.slice(0, dashIdx);
727
+ if (activePluginNames.has(pluginName))
728
+ continue;
955
729
  try {
956
730
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
957
731
  const trashDir = path.join(getTrashPluginsDir(), agent, version || 'unknown', entry.name);
@@ -967,24 +741,34 @@ export function cleanOrphanedPluginSkills(agent, versionHome, activePluginNames,
967
741
  }
968
742
  export function diffVersionPlugins(agent, version) {
969
743
  const versionHome = getVersionHomePath(agent, version);
970
- const skillsDir = path.join(versionHome, `.${agent}`, 'skills');
744
+ const activePlugins = new Set(discoverPlugins().map(p => p.name));
971
745
  const orphans = [];
972
- if (!fs.existsSync(skillsDir)) {
973
- return { agent, version, orphans };
746
+ const mktPluginsDir = path.join(marketplaceRoot(agent, versionHome), 'plugins');
747
+ if (fs.existsSync(mktPluginsDir)) {
748
+ for (const entry of fs.readdirSync(mktPluginsDir, { withFileTypes: true })) {
749
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
750
+ continue;
751
+ if (!activePlugins.has(entry.name)) {
752
+ orphans.push(entry.name);
753
+ }
754
+ }
974
755
  }
975
- const activePlugins = new Set(discoverPlugins().map(p => p.name));
976
- for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
977
- if (!entry.isDirectory())
978
- continue;
979
- const dashIdx = entry.name.indexOf('--');
980
- if (dashIdx === -1)
981
- continue;
982
- const pluginName = entry.name.slice(0, dashIdx);
983
- if (!activePlugins.has(pluginName)) {
984
- orphans.push(entry.name);
756
+ // Also surface legacy dual-dash skill dirs as orphans during migration period.
757
+ const skillsDir = path.join(versionHome, `.${agent}`, 'skills');
758
+ if (fs.existsSync(skillsDir)) {
759
+ for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
760
+ if (!entry.isDirectory())
761
+ continue;
762
+ const dashIdx = entry.name.indexOf('--');
763
+ if (dashIdx === -1)
764
+ continue;
765
+ const pluginName = entry.name.slice(0, dashIdx);
766
+ if (!activePlugins.has(pluginName)) {
767
+ orphans.push(entry.name);
768
+ }
985
769
  }
986
770
  }
987
- return { agent, version, orphans: orphans.sort() };
771
+ return { agent, version, orphans: Array.from(new Set(orphans)).sort() };
988
772
  }
989
773
  export function iterPluginsCapableVersions(filter) {
990
774
  const pairs = [];