@phnx-labs/agents-cli 1.20.26 → 1.20.27

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.
@@ -80,13 +80,13 @@ function buildLayerBases(cwd, kind, opts = {}) {
80
80
  return out;
81
81
  }
82
82
  // ─── commands ─────────────────────────────────────────────────────────────────
83
- function diffCommands(agent, version, cwd) {
83
+ function diffCommands(agent, version, cwd, excludeProject = false) {
84
84
  const agentConfig = AGENTS[agent];
85
85
  const isToml = agentConfig.format === 'toml';
86
86
  const ext = isToml ? '.toml' : '.md';
87
87
  const homeDir = getVersionCommandsDir(agent, version);
88
88
  const installed = new Set(listCommandsInVersionHome(agent, version));
89
- const layerBases = buildLayerBases(cwd, 'commands');
89
+ const layerBases = buildLayerBases(cwd, 'commands', { excludeProject });
90
90
  const sourceByName = new Map();
91
91
  for (const base of layerBases) {
92
92
  if (!fs.existsSync(base.path))
@@ -190,10 +190,10 @@ function dirsContentMatch(src, dst) {
190
190
  }
191
191
  return true;
192
192
  }
193
- function diffSkills(agent, version, cwd) {
193
+ function diffSkills(agent, version, cwd, excludeProject = false) {
194
194
  const homeDir = getVersionSkillsDir(agent, version);
195
195
  const installed = new Set(listSkillsInVersionHome(agent, version));
196
- const layerBases = buildLayerBases(cwd, 'skills');
196
+ const layerBases = buildLayerBases(cwd, 'skills', { excludeProject });
197
197
  const sourceByName = new Map();
198
198
  for (const base of layerBases) {
199
199
  if (!fs.existsSync(base.path))
@@ -298,8 +298,8 @@ function diffHooks(agent, version, cwd) {
298
298
  return rows.sort((a, b) => a.name.localeCompare(b.name));
299
299
  }
300
300
  // ─── rules / memory ───────────────────────────────────────────────────────────
301
- function listRulesNames(cwd) {
302
- const projectDir = getProjectAgentsDir(cwd);
301
+ function listRulesNames(cwd, excludeProject = false) {
302
+ const projectDir = excludeProject ? null : getProjectAgentsDir(cwd);
303
303
  const userRules = getUserRulesDir();
304
304
  const systemRules = getResolvedRulesDir();
305
305
  const extras = getEnabledExtraRepos();
@@ -349,11 +349,11 @@ function expectedRuleContent(agent, name, sourcePath) {
349
349
  }
350
350
  return readSafe(sourcePath);
351
351
  }
352
- function diffRules(agent, version, cwd) {
352
+ function diffRules(agent, version, cwd, excludeProject = false) {
353
353
  const agentConfig = AGENTS[agent];
354
354
  const versionHome = getVersionHomePath(agent, version);
355
355
  const configDir = path.join(versionHome, agentConfigDirName(agent));
356
- const sourcesByName = listRulesNames(cwd);
356
+ const sourcesByName = listRulesNames(cwd, excludeProject);
357
357
  // Files actually present in the version home.
358
358
  const homeFiles = new Set();
359
359
  if (fs.existsSync(configDir)) {
@@ -432,9 +432,14 @@ function diffPromptcuts() {
432
432
  return [{ kind: 'promptcuts', name: 'promptcuts.yaml', status: 'ok', sourcePath }];
433
433
  }
434
434
  export function diffVersionResources(agent, version, options = {}) {
435
- const cwd = options.cwd ?? process.cwd();
435
+ const rawCwd = options.cwd ?? process.cwd();
436
+ const excludeProject = options.excludeProject ?? false;
436
437
  const home = getVersionHomePath(agent, version);
437
438
  const requested = new Set(options.kinds ?? ALL_KINDS);
439
+ // When excluding the project layer, resolve every per-cwd lookup against a
440
+ // neutral cwd so no `<cwd>/.agents/` is ever discovered.
441
+ const cwd = rawCwd;
442
+ const projectDir = excludeProject ? null : getProjectAgentsDir(cwd);
438
443
  const available = getAvailableResources(cwd);
439
444
  const synced = getActuallySyncedResources(agent, version, { cwd });
440
445
  const empty = {
@@ -449,13 +454,13 @@ export function diffVersionResources(agent, version, options = {}) {
449
454
  promptcuts: [],
450
455
  };
451
456
  if (requested.has('commands'))
452
- empty.commands = diffCommands(agent, version, cwd);
457
+ empty.commands = diffCommands(agent, version, cwd, excludeProject);
453
458
  if (requested.has('skills'))
454
- empty.skills = diffSkills(agent, version, cwd);
459
+ empty.skills = diffSkills(agent, version, cwd, excludeProject);
455
460
  if (requested.has('hooks'))
456
461
  empty.hooks = diffHooks(agent, version, cwd);
457
462
  if (requested.has('rules'))
458
- empty.rules = diffRules(agent, version, cwd);
463
+ empty.rules = diffRules(agent, version, cwd, excludeProject);
459
464
  if (requested.has('mcp'))
460
465
  empty.mcp = diffPresenceOnly('mcp', available.mcp, synced.mcp);
461
466
  if (requested.has('permissions'))
@@ -485,7 +490,7 @@ export function diffVersionResources(agent, version, options = {}) {
485
490
  home,
486
491
  cwd,
487
492
  layers: {
488
- project: getProjectAgentsDir(cwd),
493
+ project: projectDir,
489
494
  user: getUserAgentsDir(),
490
495
  system: getSystemAgentsDir(),
491
496
  extras: getEnabledExtraRepos().map((e) => ({ alias: e.alias, dir: e.dir })),
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Resource heal engine — close the gap between what DotAgents repos DEFINE and
3
+ * what is actually present/valid in each installed agent home.
4
+ *
5
+ * Powers two callers:
6
+ * - `agents doctor --fix` — explicit, operator-driven. Mode 'full': fills
7
+ * missing, overwrites drifted content, and refreshes stale plugins even when
8
+ * the baseline is unknown (the operator asked for it).
9
+ * - the routines daemon's periodic safety check — Mode 'safe': fixes only the
10
+ * unambiguous gaps (missing resources, Claude-invalid plugin manifests, and
11
+ * provably-unmodified stale plugins). Drift and risky refreshes are reported,
12
+ * never clobbered.
13
+ *
14
+ * Built on the LIVE-home diff (`diffVersionResources`) — NOT the staleness
15
+ * manifest. `isStale()` only compares the last-synced manifest against the
16
+ * sources, so home-side rot (a deleted, corrupted, or Claude-rejected file in a
17
+ * version home whose source never changed) is invisible to it and to the sync
18
+ * fast-guard. The diff reads the actual home, so heal catches exactly that class
19
+ * of drift — the kind that silently broke the `code` plugin on a non-default
20
+ * Claude version.
21
+ *
22
+ * Heal FILLS and FIXES; it never deletes. Orphan/extra removal stays the job of
23
+ * `agents prune cleanup`, so a heal pass can never lose work.
24
+ */
25
+ import type { AgentId } from './types.js';
26
+ import { type DoctorKind, type DiffStatus } from './doctor-diff.js';
27
+ export interface HealedResource {
28
+ kind: DoctorKind;
29
+ name: string;
30
+ /** Why it was healed: 'missing' (filled) or 'diff' (overwritten / re-pushed). */
31
+ was: DiffStatus;
32
+ }
33
+ export interface SkippedResource {
34
+ kind: DoctorKind;
35
+ name: string;
36
+ /** 'drift': hand-edited content left untouched in 'safe' mode.
37
+ * 'unreconcilable': heal wrote it but the diff still flags it — a source/home
38
+ * asymmetry the writer can't satisfy (e.g. a hook sidecar the installer omits),
39
+ * surfaced honestly instead of "fixed" on every pass. */
40
+ reason: 'drift' | 'unreconcilable';
41
+ }
42
+ export interface VersionHealResult {
43
+ agent: AgentId;
44
+ version: string;
45
+ healed: HealedResource[];
46
+ skipped: SkippedResource[];
47
+ }
48
+ export interface ManifestRepairResult {
49
+ plugin: string;
50
+ /** Bare-name fields stripped from the source plugin.json (e.g. ["skills"]). */
51
+ droppedFields: string[];
52
+ }
53
+ export interface PluginRefreshResult {
54
+ plugin: string;
55
+ from: string;
56
+ to: string;
57
+ }
58
+ export interface PluginRefreshSkip {
59
+ plugin: string;
60
+ from: string;
61
+ upstream: string;
62
+ /** 'modified': central diverged from baseline. 'no-baseline': pre-tracking install. */
63
+ reason: 'modified' | 'no-baseline';
64
+ }
65
+ export interface HealResult {
66
+ versions: VersionHealResult[];
67
+ repairedManifests: ManifestRepairResult[];
68
+ refreshedPlugins: PluginRefreshResult[];
69
+ skippedPlugins: PluginRefreshSkip[];
70
+ }
71
+ export interface HealOptions {
72
+ /** 'full' (doctor --fix): fix drift + refresh unknown-baseline plugins.
73
+ * 'safe' (daemon): missing + invalid-manifest + unmodified refresh only. */
74
+ mode: 'full' | 'safe';
75
+ /** Resolution cwd. Defaults to the home dir so no project layer is ever
76
+ * resolved — heal targets the GLOBAL install, never a project. Tests override. */
77
+ cwd?: string;
78
+ /** Scope to one agent; omit to heal every installed agent. */
79
+ agent?: AgentId;
80
+ /** Scope to specific versions of `agent`; omit for all installed versions. */
81
+ versions?: string[];
82
+ /** Compute the plan without writing anything. */
83
+ dryRun?: boolean;
84
+ }
85
+ /** True when a heal pass made (or would make) any change at all. */
86
+ export declare function healChangedAnything(r: HealResult): boolean;
87
+ /** One-line summary of a heal pass for daemon logs. */
88
+ export declare function summarizeHeal(r: HealResult): string;
89
+ /**
90
+ * Fire a native desktop notification when a background heal did something
91
+ * noteworthy. Best-effort — missing `osascript`/`notify-send` or no display is
92
+ * swallowed. Silent when the pass auto-fixed everything and nothing needs the
93
+ * operator (no point pinging them for routine self-healing).
94
+ */
95
+ export declare function notifyHeal(r: HealResult): void;
96
+ /**
97
+ * Strip Claude-invalid bare-name `skills`/`commands` fields from every central
98
+ * plugin's SOURCE plugin.json. Unambiguously safe (Claude auto-discovers both
99
+ * from their directories) and the precondition for those plugins loading at all.
100
+ */
101
+ export declare function repairCentralPluginManifests(dryRun?: boolean): ManifestRepairResult[];
102
+ /**
103
+ * Run a heal pass. Repairs the central plugin layer once (manifest + stale
104
+ * refresh), then reconciles every targeted (agent, version) home against its
105
+ * live diff. Returns a full account of what changed (or would, under dryRun).
106
+ */
107
+ export declare function heal(opts: HealOptions): Promise<HealResult>;
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Resource heal engine — close the gap between what DotAgents repos DEFINE and
3
+ * what is actually present/valid in each installed agent home.
4
+ *
5
+ * Powers two callers:
6
+ * - `agents doctor --fix` — explicit, operator-driven. Mode 'full': fills
7
+ * missing, overwrites drifted content, and refreshes stale plugins even when
8
+ * the baseline is unknown (the operator asked for it).
9
+ * - the routines daemon's periodic safety check — Mode 'safe': fixes only the
10
+ * unambiguous gaps (missing resources, Claude-invalid plugin manifests, and
11
+ * provably-unmodified stale plugins). Drift and risky refreshes are reported,
12
+ * never clobbered.
13
+ *
14
+ * Built on the LIVE-home diff (`diffVersionResources`) — NOT the staleness
15
+ * manifest. `isStale()` only compares the last-synced manifest against the
16
+ * sources, so home-side rot (a deleted, corrupted, or Claude-rejected file in a
17
+ * version home whose source never changed) is invisible to it and to the sync
18
+ * fast-guard. The diff reads the actual home, so heal catches exactly that class
19
+ * of drift — the kind that silently broke the `code` plugin on a non-default
20
+ * Claude version.
21
+ *
22
+ * Heal FILLS and FIXES; it never deletes. Orphan/extra removal stays the job of
23
+ * `agents prune cleanup`, so a heal pass can never lose work.
24
+ */
25
+ import { ALL_AGENT_IDS } from './agents.js';
26
+ import { syncResourcesToVersion, listInstalledVersions, getVersionHomePath, getActuallySyncedResources, compareVersions, } from './versions.js';
27
+ import { diffVersionResources, } from './doctor-diff.js';
28
+ import { discoverPlugins, updatePlugin, readPluginSourceInfo, getUpstreamManifestVersion, } from './plugins.js';
29
+ import { repairPluginManifestFile } from './plugin-marketplace.js';
30
+ import * as fs from 'fs';
31
+ import * as path from 'path';
32
+ import * as os from 'os';
33
+ import { spawn } from 'child_process';
34
+ // ─── diff → selection mapping ────────────────────────────────────────────────
35
+ // Which ResourceSelection key each healable diff kind writes through. `rules`
36
+ // re-syncs via the whole-memory channel (not name-scoped); `promptcuts` is not
37
+ // version-synced at all, so it is never healed here.
38
+ const KIND_TO_SELECTION = {
39
+ commands: 'commands',
40
+ skills: 'skills',
41
+ hooks: 'hooks',
42
+ mcp: 'mcp',
43
+ permissions: 'permissions',
44
+ subagents: 'subagents',
45
+ plugins: 'plugins',
46
+ };
47
+ function totalHealed(r) {
48
+ return r.versions.reduce((n, v) => n + v.healed.length, 0);
49
+ }
50
+ /** True when a heal pass made (or would make) any change at all. */
51
+ export function healChangedAnything(r) {
52
+ return (totalHealed(r) > 0 ||
53
+ r.repairedManifests.length > 0 ||
54
+ r.refreshedPlugins.length > 0);
55
+ }
56
+ /** One-line summary of a heal pass for daemon logs. */
57
+ export function summarizeHeal(r) {
58
+ const parts = [];
59
+ const healed = totalHealed(r);
60
+ if (healed > 0)
61
+ parts.push(`${healed} resource(s) healed`);
62
+ if (r.repairedManifests.length > 0)
63
+ parts.push(`${r.repairedManifests.length} manifest(s) repaired`);
64
+ if (r.refreshedPlugins.length > 0)
65
+ parts.push(`${r.refreshedPlugins.length} plugin(s) refreshed`);
66
+ if (r.skippedPlugins.length > 0)
67
+ parts.push(`${r.skippedPlugins.length} plugin(s) need attention`);
68
+ return parts.length > 0 ? parts.join(', ') : 'nothing to heal';
69
+ }
70
+ /**
71
+ * Fire a native desktop notification when a background heal did something
72
+ * noteworthy. Best-effort — missing `osascript`/`notify-send` or no display is
73
+ * swallowed. Silent when the pass auto-fixed everything and nothing needs the
74
+ * operator (no point pinging them for routine self-healing).
75
+ */
76
+ export function notifyHeal(r) {
77
+ const needsAttention = r.skippedPlugins.length;
78
+ const healed = totalHealed(r) + r.repairedManifests.length + r.refreshedPlugins.length;
79
+ if (needsAttention === 0 && healed === 0)
80
+ return;
81
+ const title = needsAttention > 0
82
+ ? `agents: ${needsAttention} plugin${needsAttention === 1 ? '' : 's'} need attention`
83
+ : 'agents: auto-healed config gaps';
84
+ const body = needsAttention > 0
85
+ ? `${summarizeHeal(r)}. Run: agents doctor --fix`
86
+ : summarizeHeal(r);
87
+ const platform = os.platform();
88
+ try {
89
+ if (platform === 'darwin') {
90
+ const safeTitle = title.replace(/"/g, '\\"');
91
+ const safeBody = body.replace(/"/g, '\\"');
92
+ const child = spawn('osascript', ['-e', `display notification "${safeBody}" with title "${safeTitle}"`], { detached: true, stdio: 'ignore' });
93
+ child.unref();
94
+ }
95
+ else if (platform === 'linux') {
96
+ const child = spawn('notify-send', [title, body], { detached: true, stdio: 'ignore' });
97
+ child.unref();
98
+ }
99
+ }
100
+ catch {
101
+ // Notification is best-effort; nothing to do.
102
+ }
103
+ }
104
+ // ─── central plugin layer (version-independent, runs once per heal) ──────────
105
+ /**
106
+ * Strip Claude-invalid bare-name `skills`/`commands` fields from every central
107
+ * plugin's SOURCE plugin.json. Unambiguously safe (Claude auto-discovers both
108
+ * from their directories) and the precondition for those plugins loading at all.
109
+ */
110
+ export function repairCentralPluginManifests(dryRun = false) {
111
+ const out = [];
112
+ for (const p of discoverPlugins()) {
113
+ const manifestPath = path.join(p.root, '.claude-plugin', 'plugin.json');
114
+ const dropped = repairPluginManifestFile(manifestPath, { dryRun });
115
+ if (dropped.length > 0)
116
+ out.push({ plugin: p.name, droppedFields: dropped });
117
+ }
118
+ return out;
119
+ }
120
+ /**
121
+ * Fast-forward central plugins whose local `.source` upstream now ships a newer
122
+ * version. `allowModified` (full mode) re-pulls regardless of baseline; safe
123
+ * mode refreshes only when the central copy is provably an untouched mirror of
124
+ * its last pull (baseline version === current version) and reports the rest.
125
+ */
126
+ async function refreshStaleCentralPlugins(opts) {
127
+ const refreshed = [];
128
+ const skipped = [];
129
+ for (const p of discoverPlugins()) {
130
+ const info = readPluginSourceInfo(p.root);
131
+ if (!info)
132
+ continue;
133
+ const upstream = getUpstreamManifestVersion(info); // null for git sources
134
+ if (!upstream)
135
+ continue;
136
+ const central = p.manifest.version;
137
+ if (compareVersions(upstream, central) <= 0)
138
+ continue; // central already current
139
+ const baselineKnown = info.version !== undefined;
140
+ const modified = baselineKnown && info.version !== central;
141
+ if (!opts.allowModified) {
142
+ // Safe mode never overwrites a copy it can't prove is pristine.
143
+ if (modified) {
144
+ skipped.push({ plugin: p.name, from: central, upstream, reason: 'modified' });
145
+ continue;
146
+ }
147
+ if (!baselineKnown) {
148
+ skipped.push({ plugin: p.name, from: central, upstream, reason: 'no-baseline' });
149
+ continue;
150
+ }
151
+ }
152
+ if (opts.dryRun) {
153
+ refreshed.push({ plugin: p.name, from: central, to: upstream });
154
+ continue;
155
+ }
156
+ const r = await updatePlugin(p.name);
157
+ if (r.success)
158
+ refreshed.push({ plugin: p.name, from: central, to: upstream });
159
+ }
160
+ return { refreshed, skipped };
161
+ }
162
+ // ─── per-version heal ────────────────────────────────────────────────────────
163
+ function healVersion(agent, version, opts) {
164
+ const result = { agent, version, healed: [], skipped: [] };
165
+ const home = getVersionHomePath(agent, version);
166
+ if (!fs.existsSync(home))
167
+ return result;
168
+ // Always resolve against non-project layers: the global version home is never
169
+ // reconciled against per-cwd project resources (they layer in at launch).
170
+ const diffOpts = { cwd: opts.cwd, excludeProject: true };
171
+ const report = diffVersionResources(agent, version, diffOpts);
172
+ const selection = {};
173
+ // Resources we attempt to write, tracked so the post-write re-diff can tell
174
+ // "actually fixed" from "writer couldn't satisfy the diff" (no false claims).
175
+ const attempted = [];
176
+ for (const rows of Object.values(report.kinds)) {
177
+ for (const row of rows) {
178
+ const isMissing = row.status === 'missing';
179
+ const isDrift = row.status === 'diff';
180
+ if (!isMissing && !isDrift)
181
+ continue;
182
+ if (isDrift && !opts.includeDrift) {
183
+ // Ambiguous content drift — could be a deliberate hand-edit. Report it
184
+ // (the daemon notifies); never silently overwrite in safe mode.
185
+ result.skipped.push({ kind: row.kind, name: row.name, reason: 'drift' });
186
+ continue;
187
+ }
188
+ if (row.kind === 'promptcuts')
189
+ continue; // not version-synced
190
+ if (row.kind === 'rules') {
191
+ selection.memory = 'all';
192
+ attempted.push({ kind: row.kind, name: row.name, was: row.status });
193
+ continue;
194
+ }
195
+ const key = KIND_TO_SELECTION[row.kind];
196
+ if (!key)
197
+ continue;
198
+ (selection[key] ??= []).push(row.name);
199
+ attempted.push({ kind: row.kind, name: row.name, was: row.status });
200
+ }
201
+ }
202
+ // Plugins are presence-only in the diff, so a stale/invalid-but-present plugin
203
+ // mirror never shows as 'diff' — yet its central source just changed (repaired
204
+ // or refreshed). Re-push those into this version's marketplace mirror, but only
205
+ // where the plugin is already installed (don't force-install into a version
206
+ // that opted out). These are verified by the central change, not the re-diff.
207
+ const pluginHealed = [];
208
+ if (opts.changedPlugins.size > 0) {
209
+ const synced = new Set(getActuallySyncedResources(agent, version, diffOpts).plugins);
210
+ const already = new Set(selection.plugins ?? []);
211
+ for (const name of opts.changedPlugins) {
212
+ if (!synced.has(name) || already.has(name))
213
+ continue;
214
+ (selection.plugins ??= []).push(name);
215
+ pluginHealed.push({ kind: 'plugins', name, was: 'diff' });
216
+ }
217
+ }
218
+ const hasWork = Object.keys(selection).length > 0;
219
+ if (!hasWork)
220
+ return result;
221
+ if (opts.dryRun) {
222
+ // No write — report the intended fixes as-is.
223
+ result.healed.push(...attempted, ...pluginHealed);
224
+ return result;
225
+ }
226
+ // Explicit selection => bypasses the manifest fast-guard and writes exactly
227
+ // these names (additive; no orphan-sweep), so nothing outside the gap moves.
228
+ syncResourcesToVersion(agent, version, selection, { cwd: opts.cwd });
229
+ result.healed.push(...pluginHealed);
230
+ // Verify: re-diff and only claim resources that actually flipped to ok. Ones
231
+ // still flagged are reported as 'unreconcilable' so repeated runs converge in
232
+ // messaging instead of "fixing" the same item forever.
233
+ const post = diffVersionResources(agent, version, diffOpts);
234
+ const stillBad = new Set();
235
+ for (const rows of Object.values(post.kinds)) {
236
+ for (const row of rows) {
237
+ if (row.status === 'missing' || row.status === 'diff')
238
+ stillBad.add(`${row.kind}:${row.name}`);
239
+ }
240
+ }
241
+ for (const a of attempted) {
242
+ if (stillBad.has(`${a.kind}:${a.name}`)) {
243
+ result.skipped.push({ kind: a.kind, name: a.name, reason: 'unreconcilable' });
244
+ }
245
+ else {
246
+ result.healed.push(a);
247
+ }
248
+ }
249
+ return result;
250
+ }
251
+ // ─── public entrypoint ────────────────────────────────────────────────────────
252
+ /**
253
+ * Run a heal pass. Repairs the central plugin layer once (manifest + stale
254
+ * refresh), then reconciles every targeted (agent, version) home against its
255
+ * live diff. Returns a full account of what changed (or would, under dryRun).
256
+ */
257
+ export async function heal(opts) {
258
+ const cwd = opts.cwd ?? os.homedir();
259
+ const full = opts.mode === 'full';
260
+ const repairedManifests = repairCentralPluginManifests(opts.dryRun);
261
+ const { refreshed, skipped: skippedPlugins } = await refreshStaleCentralPlugins({
262
+ dryRun: opts.dryRun,
263
+ allowModified: full,
264
+ });
265
+ const changedPlugins = new Set([
266
+ ...repairedManifests.map((r) => r.plugin),
267
+ ...refreshed.map((r) => r.plugin),
268
+ ]);
269
+ const targets = opts.agent
270
+ ? [{ agent: opts.agent, versions: opts.versions ?? listInstalledVersions(opts.agent) }]
271
+ : ALL_AGENT_IDS.map((a) => ({ agent: a, versions: listInstalledVersions(a) }));
272
+ const versions = [];
273
+ for (const t of targets) {
274
+ for (const v of t.versions) {
275
+ versions.push(healVersion(t.agent, v, { cwd, includeDrift: full, changedPlugins, dryRun: opts.dryRun }));
276
+ }
277
+ }
278
+ return { versions, repairedManifests, refreshedPlugins: refreshed, skippedPlugins };
279
+ }
@@ -113,6 +113,24 @@ export declare function copyPluginToMarketplace(plugin: DiscoveredPlugin, spec:
113
113
  * check would false-positive.
114
114
  */
115
115
  export declare function validateClaudePluginManifest(manifest: unknown): string[];
116
+ /**
117
+ * The repairable fields present-and-invalid in a parsed manifest. Drives both
118
+ * the dry-run preview (heal/doctor --fix) and the actual write below.
119
+ */
120
+ export declare function repairableManifestFields(manifest: unknown): string[];
121
+ /**
122
+ * Auto-repair a plugin's SOURCE plugin.json in place: delete any `skills`/
123
+ * `commands` field that holds bare names (Claude Code silently rejects the
124
+ * ENTIRE plugin otherwise). Claude auto-discovers both from their directories,
125
+ * so deletion is the canonical, lossless fix — exactly what the validator's
126
+ * warning already recommends. Returns the fields it dropped (empty = no change).
127
+ *
128
+ * Writes to the source manifest (not the regenerated marketplace copy) so the
129
+ * fix survives the next sync. Pass `{ dryRun }` to preview without writing.
130
+ */
131
+ export declare function repairPluginManifestFile(manifestPath: string, opts?: {
132
+ dryRun?: boolean;
133
+ }): string[];
116
134
  /**
117
135
  * Re-synthesize <marketplace>/.claude-plugin/marketplace.json from the plugins
118
136
  * already installed under <marketplace>/plugins/. Always run after add or remove
@@ -28,7 +28,7 @@
28
28
  import * as fs from 'fs';
29
29
  import { agentConfigDirName } from './agents.js';
30
30
  import * as path from 'path';
31
- import { getPluginsDir, getEnabledExtraRepos, getProjectPluginsDir } from './state.js';
31
+ import { getPluginsDir, getEnabledExtraRepos, getProjectPluginsDir, getSystemPluginsDir } from './state.js';
32
32
  /**
33
33
  * Canonical name for the user-repo marketplace (~/.agents/plugins/). Kept as an
34
34
  * exported constant for callers that operate on the user repo directly and for
@@ -76,6 +76,19 @@ function descriptionFor(spec) {
76
76
  */
77
77
  export function discoverMarketplaces(opts = {}) {
78
78
  const out = [];
79
+ // System repo — npm-shipped defaults (~/.agents/.system/plugins/) → the
80
+ // "agents-system" marketplace. Listed FIRST so it has the lowest precedence:
81
+ // consumers that dedupe by plugin name keep the LAST occurrence, letting
82
+ // user / extra / project plugins of the same name override a system one (same
83
+ // direction collectPluginScopes() in project-launch.ts uses). Without this,
84
+ // `agents sync` never discovers system plugins, so cleanOrphanedPluginSkills
85
+ // trashes whatever a project launch installed under agents-system and the
86
+ // marketplace gets unregistered on the next sync.
87
+ const systemRoot = getSystemPluginsDir();
88
+ if (dirExists(systemRoot)) {
89
+ const spec = { kind: 'system', root: systemRoot };
90
+ out.push({ spec, name: marketplaceNameFor(spec), pluginsRoot: systemRoot, description: descriptionFor(spec) });
91
+ }
79
92
  // User repo — always the canonical "agents-cli" marketplace.
80
93
  const userRoot = getPluginsDir();
81
94
  if (dirExists(userRoot)) {
@@ -233,6 +246,59 @@ export function validateClaudePluginManifest(manifest) {
233
246
  }
234
247
  return warnings;
235
248
  }
249
+ /**
250
+ * Fields safe to auto-repair by deletion. Scoped to `skills`/`commands` only —
251
+ * NOT `agents`: agents-cli overloads `agents` in plugin.json as its own
252
+ * `AgentId[]` targeting list (bare names like "claude"), so stripping it would
253
+ * destroy real metadata. (`validateClaudePluginManifest` still WARNS on a bare
254
+ * `agents` field; repairing it is a separate, deliberate non-goal here.)
255
+ */
256
+ const REPAIRABLE_PATH_FIELDS = ['skills', 'commands'];
257
+ /** True when a manifest field holds bare-name entries Claude Code rejects. */
258
+ function fieldHasBareEntries(value) {
259
+ if (value === undefined || value === null)
260
+ return false;
261
+ const entries = Array.isArray(value) ? value : [value];
262
+ return entries.some((e) => typeof e !== 'string' || !e.startsWith('./'));
263
+ }
264
+ /**
265
+ * The repairable fields present-and-invalid in a parsed manifest. Drives both
266
+ * the dry-run preview (heal/doctor --fix) and the actual write below.
267
+ */
268
+ export function repairableManifestFields(manifest) {
269
+ if (!manifest || typeof manifest !== 'object')
270
+ return [];
271
+ const m = manifest;
272
+ return REPAIRABLE_PATH_FIELDS.filter((f) => fieldHasBareEntries(m[f]));
273
+ }
274
+ /**
275
+ * Auto-repair a plugin's SOURCE plugin.json in place: delete any `skills`/
276
+ * `commands` field that holds bare names (Claude Code silently rejects the
277
+ * ENTIRE plugin otherwise). Claude auto-discovers both from their directories,
278
+ * so deletion is the canonical, lossless fix — exactly what the validator's
279
+ * warning already recommends. Returns the fields it dropped (empty = no change).
280
+ *
281
+ * Writes to the source manifest (not the regenerated marketplace copy) so the
282
+ * fix survives the next sync. Pass `{ dryRun }` to preview without writing.
283
+ */
284
+ export function repairPluginManifestFile(manifestPath, opts = {}) {
285
+ if (!fs.existsSync(manifestPath))
286
+ return [];
287
+ let manifest;
288
+ try {
289
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
290
+ }
291
+ catch {
292
+ return []; // unparseable manifest is a different failure; don't touch it.
293
+ }
294
+ const dropped = repairableManifestFields(manifest);
295
+ if (dropped.length === 0 || opts.dryRun)
296
+ return dropped;
297
+ for (const f of dropped)
298
+ delete manifest[f];
299
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
300
+ return dropped;
301
+ }
236
302
  // ─── Catalog synthesis ──────────────────────────────────────────────────────
237
303
  /**
238
304
  * Re-synthesize <marketplace>/.claude-plugin/marketplace.json from the plugins
@@ -65,7 +65,12 @@ export declare function loadPluginManifest(pluginRoot: string): PluginManifest |
65
65
  export declare function validatePluginName(name: string): boolean;
66
66
  export declare function assertPluginTargetContained(targetRoot: string, pluginsDir: string): void;
67
67
  /**
68
- * Get a specific plugin by name.
68
+ * Get a specific plugin by name. On a cross-marketplace name collision the
69
+ * highest-precedence scope wins (project > extra > user > system) — the same
70
+ * resolution the sync writer's Map(last-wins) dedupe and collectPluginScopes()
71
+ * use. discoverPlugins() yields low→high precedence order, so the LAST match is
72
+ * the winner; returning the first match would resolve to the lowest scope (e.g.
73
+ * a system plugin over the user's same-named one), which is exactly backwards.
69
74
  */
70
75
  export declare function getPlugin(name: string): DiscoveredPlugin | null;
71
76
  /**
@@ -212,6 +217,23 @@ export declare function installPlugin(spec: string): Promise<{
212
217
  isNew: boolean;
213
218
  capabilities: PluginCapabilities;
214
219
  }>;
220
+ /** Parsed `.source` provenance written by install/update. `version` is the
221
+ * upstream manifest version captured at the last pull (absent on pre-existing
222
+ * installs from before baseline tracking). */
223
+ export interface PluginSourceInfo {
224
+ source: string;
225
+ isGit: boolean;
226
+ version?: string;
227
+ }
228
+ /** Read a plugin's `.source` provenance, or null when absent/unreadable. */
229
+ export declare function readPluginSourceInfo(root: string): PluginSourceInfo | null;
230
+ /**
231
+ * Resolve the CURRENT upstream manifest version for a local-sourced plugin
232
+ * (the `.system`/local-path case). Returns null for git sources — reading their
233
+ * upstream version would need a network fetch, so git plugins are refreshed only
234
+ * via the explicit `agents plugins update`.
235
+ */
236
+ export declare function getUpstreamManifestVersion(info: PluginSourceInfo): string | null;
215
237
  /**
216
238
  * Update an installed plugin by re-pulling from its original source.
217
239
  * Returns true if the update succeeded.