@phnx-labs/agents-cli 1.20.25 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/commands/doctor.d.ts +5 -2
  3. package/dist/commands/doctor.js +126 -27
  4. package/dist/commands/inspect.d.ts +2 -1
  5. package/dist/commands/inspect.js +1 -1
  6. package/dist/commands/menubar.js +6 -1
  7. package/dist/commands/repo.js +40 -0
  8. package/dist/commands/secrets.js +16 -12
  9. package/dist/commands/sessions.js +20 -1
  10. package/dist/index.js +2 -12
  11. package/dist/lib/agent-spec.d.ts +36 -0
  12. package/dist/lib/agent-spec.js +157 -0
  13. package/dist/lib/agents.js +1 -0
  14. package/dist/lib/daemon.js +32 -0
  15. package/dist/lib/doctor-diff.d.ts +7 -0
  16. package/dist/lib/doctor-diff.js +18 -13
  17. package/dist/lib/fs-atomic.d.ts +3 -2
  18. package/dist/lib/fs-atomic.js +22 -7
  19. package/dist/lib/heal.d.ts +107 -0
  20. package/dist/lib/heal.js +279 -0
  21. package/dist/lib/hooks.js +36 -1
  22. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  23. package/dist/lib/menubar/install-menubar.d.ts +27 -3
  24. package/dist/lib/menubar/install-menubar.js +74 -9
  25. package/dist/lib/plugin-marketplace.d.ts +18 -0
  26. package/dist/lib/plugin-marketplace.js +67 -1
  27. package/dist/lib/plugins.d.ts +23 -1
  28. package/dist/lib/plugins.js +55 -10
  29. package/dist/lib/resources/rules.d.ts +5 -2
  30. package/dist/lib/resources/rules.js +39 -10
  31. package/dist/lib/rules/compose.d.ts +22 -0
  32. package/dist/lib/rules/compose.js +114 -9
  33. package/dist/lib/secrets/agent.d.ts +4 -2
  34. package/dist/lib/secrets/agent.js +6 -4
  35. package/dist/lib/secrets/bundles.d.ts +20 -14
  36. package/dist/lib/secrets/bundles.js +31 -10
  37. package/dist/lib/session/remote.d.ts +33 -0
  38. package/dist/lib/session/remote.js +114 -0
  39. package/dist/lib/staleness/checkers/rules.js +13 -1
  40. package/dist/lib/staleness/detectors/commands.js +7 -6
  41. package/dist/lib/staleness/writers/commands.js +7 -12
  42. package/dist/lib/startup/dev-build.d.ts +22 -0
  43. package/dist/lib/startup/dev-build.js +41 -0
  44. package/dist/lib/types.d.ts +13 -2
  45. package/dist/lib/versions.d.ts +3 -1
  46. package/dist/lib/versions.js +35 -3
  47. package/package.json +3 -3
@@ -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
+ }
package/dist/lib/hooks.js CHANGED
@@ -15,7 +15,8 @@ import * as TOML from 'smol-toml';
15
15
  import { AGENTS, agentConfigDirName } from './agents.js';
16
16
  import { supports, explainSkip, capableAgents } from './capabilities.js';
17
17
  import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
18
- import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir, getEnabledExtraRepos } from './state.js';
18
+ import { getHooksDir as getSystemHooksDir, getUserHooksDir, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, getTrashHooksDir, getEnabledExtraRepos, getResolvedRulesDir, getUserRulesDir } from './state.js';
19
+ import { collectSubruleHooksFromState } from './rules/compose.js';
19
20
  function getCentralHooksDir() { return getUserHooksDir(); }
20
21
  /**
21
22
  * Resolve a hook script's absolute path. Checks user dir first, then enabled
@@ -52,6 +53,12 @@ function getManagedHookPrefixes() {
52
53
  path.join(getUserAgentsDir(), 'hooks') + path.sep,
53
54
  ...extraDirs.map(d => path.join(d, 'hooks') + path.sep),
54
55
  path.join(getSystemAgentsDir(), 'hooks') + path.sep,
56
+ // Subrule-dir hook scripts register by their absolute source path under a
57
+ // rules `subrules/` tree. Cover those trees so a removed subrule/hook's
58
+ // stale settings entry gets garbage-collected like any other managed hook.
59
+ path.join(getUserRulesDir(), 'subrules') + path.sep,
60
+ ...extraDirs.map(d => path.join(d, 'rules', 'subrules') + path.sep),
61
+ path.join(getResolvedRulesDir(), 'subrules') + path.sep,
55
62
  ];
56
63
  }
57
64
  /**
@@ -159,6 +166,19 @@ const SCRIPT_EXTENSIONS = new Set([
159
166
  function isExecutable(mode) {
160
167
  return (mode & 0o111) !== 0;
161
168
  }
169
+ /**
170
+ * Ensure a script file carries an exec bit. Subrule-dir hook scripts are
171
+ * registered by their source path (not copied), so they must be executable in
172
+ * place. Best-effort: a chmod failure (read-only fs, foreign owner) is ignored.
173
+ */
174
+ function ensureExecutable(scriptPath) {
175
+ try {
176
+ const mode = fs.statSync(scriptPath).mode;
177
+ if (!isExecutable(mode))
178
+ fs.chmodSync(scriptPath, mode | 0o755);
179
+ }
180
+ catch { /* best effort */ }
181
+ }
162
182
  function getHooksDir(agentId) {
163
183
  const agent = AGENTS[agentId];
164
184
  const home = getEffectiveHome(agentId);
@@ -666,6 +686,15 @@ export function parseHookManifest(opts = {}) {
666
686
  const warn = opts.warn !== false;
667
687
  const merged = {};
668
688
  const systemHooks = {};
689
+ // Lowest-precedence layer: hooks declared inside active subrule directories.
690
+ // Seeded first so any same-key entry from system/user agents.yaml wins.
691
+ // Gated so a malformed hooks.yaml never breaks rule sync.
692
+ try {
693
+ const subruleHooks = collectSubruleHooksFromState();
694
+ for (const [name, def] of Object.entries(subruleHooks))
695
+ merged[name] = def;
696
+ }
697
+ catch { /* subrule hook collection is best-effort */ }
669
698
  // System layer: hooks: section of agents.yaml (npm-shipped, separate repo).
670
699
  const systemPath = path.join(getSystemAgentsDir(), 'agents.yaml');
671
700
  if (fs.existsSync(systemPath)) {
@@ -780,6 +809,12 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
780
809
  ? path.join(versionHome, agentConfigDirName(agentId), AGENTS[agentId].hooksDir)
781
810
  : null;
782
811
  const resolveScript = (script) => {
812
+ // Subrule-dir hooks declare an already-absolute script path. Use it
813
+ // directly (made executable) — these are not copied into the version home.
814
+ if (path.isAbsolute(script) && fs.existsSync(script)) {
815
+ ensureExecutable(script);
816
+ return script;
817
+ }
783
818
  if (overrideRoots) {
784
819
  return resolveContainedHookPath(path.join(overrideRoots[0], 'hooks'), script);
785
820
  }
@@ -35,21 +35,45 @@ export declare function ensureMenubarAppInstalled(opts?: {
35
35
  export declare function enableMenubarService(opts?: {
36
36
  clearOptOut?: boolean;
37
37
  }): boolean;
38
+ /**
39
+ * Pure staleness decision (no I/O) so the truth table is unit-testable. The
40
+ * installed service is stale when the helper binary is gone, or when it was
41
+ * installed by a different CLI version than the one now running — a version
42
+ * change is the signal that the plist's baked interpreter/entry/bundle paths
43
+ * and the helper binary itself may have drifted. A null installedVersion
44
+ * (pre-stamp install) counts as stale so old installs get re-stamped once.
45
+ */
46
+ export declare function isMenubarStale(opts: {
47
+ installedVersion: string | null;
48
+ currentVersion: string;
49
+ execExists: boolean;
50
+ }): boolean;
38
51
  /**
39
52
  * Stop + remove the menu-bar service and write the sticky opt-out so the
40
53
  * upgrade migration won't re-enable it.
41
54
  */
42
55
  export declare function disableMenubarService(): void;
43
56
  /**
44
- * Upgrade-time auto-enable. Runs from runMigration() once per sentinel bump.
45
- * No-ops if: not darwin, the user opted out, no helper bundle ships, or the
46
- * service is already installed. Best-effort — never throws into migration.
57
+ * Startup self-heal, run on every darwin CLI invocation (see src/index.ts).
58
+ * No-ops cheaply (a couple of existsSync + a tiny file read) unless work is
59
+ * needed:
60
+ * - fresh install (no service yet) -> enable
61
+ * - upgrade (version stamp changed) or -> re-enable: recopy the new helper
62
+ * the App Support helper went missing binary + rewrite the plist + kick
63
+ *
64
+ * Without the staleness re-enable, `npm update` refreshed the CLI but left the
65
+ * menu bar running the previous release's helper binary on a possibly-stale
66
+ * plist. No-ops if: not darwin, the user opted out, or no helper bundle ships.
67
+ * Best-effort — never throws into startup.
47
68
  */
48
69
  export declare function installMenubarLaunchAgentOnUpgrade(): void;
49
70
  export interface MenubarStatus {
50
71
  platform: string;
51
72
  source: string | null;
52
73
  installedApp: string | null;
74
+ installedVersion: string | null;
75
+ currentVersion: string;
76
+ stale: boolean;
53
77
  serviceInstalled: boolean;
54
78
  running: boolean;
55
79
  disabledByUser: boolean;
@@ -20,15 +20,38 @@ import * as fs from 'fs';
20
20
  import * as os from 'os';
21
21
  import * as path from 'path';
22
22
  import { getRuntimeStateDir, getHelpersDir } from '../state.js';
23
+ import { getCliVersion } from '../version.js';
23
24
  const APP_BUNDLE_NAME = 'MenubarHelper.app';
24
25
  const INSTALL_DIR_NAME = 'agents-cli';
25
26
  const SERVICE_LABEL = 'com.phnx-labs.agents-menubar';
26
27
  function onDarwin() {
27
28
  return process.platform === 'darwin';
28
29
  }
30
+ /** ~/Library/Application Support/agents-cli */
31
+ function installDir() {
32
+ return path.join(os.homedir(), 'Library', 'Application Support', INSTALL_DIR_NAME);
33
+ }
29
34
  /** ~/Library/Application Support/agents-cli/MenubarHelper.app */
30
35
  function installedAppPath() {
31
- return path.join(os.homedir(), 'Library', 'Application Support', INSTALL_DIR_NAME, APP_BUNDLE_NAME);
36
+ return path.join(installDir(), APP_BUNDLE_NAME);
37
+ }
38
+ /**
39
+ * Version stamp written next to the installed bundle. The upgrade self-heal
40
+ * compares this against the running CLI's version to decide whether the App
41
+ * Support copy + plist need to be rebuilt — without it, a `npm update` refreshes
42
+ * dist/index.js but leaves the menu bar running the OLD helper binary and a
43
+ * plist whose baked paths may have drifted.
44
+ */
45
+ function installedVersionMarkerPath() {
46
+ return path.join(installDir(), '.menubar-version');
47
+ }
48
+ function readInstalledMenubarVersion() {
49
+ try {
50
+ return fs.readFileSync(installedVersionMarkerPath(), 'utf-8').trim() || null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
32
55
  }
33
56
  /** Executable inside the installed bundle. */
34
57
  function installedExecutablePath() {
@@ -222,8 +245,34 @@ export function enableMenubarService(opts = { clearOptOut: true }) {
222
245
  execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${SERVICE_LABEL}`], { stdio: ['ignore', 'ignore', 'ignore'] });
223
246
  }
224
247
  catch { /* best effort */ }
248
+ // Stamp the version we just installed so the upgrade self-heal can tell when
249
+ // a later release ships a newer helper that needs reinstalling.
250
+ try {
251
+ fs.writeFileSync(installedVersionMarkerPath(), getCliVersion());
252
+ }
253
+ catch { /* best effort */ }
225
254
  return true;
226
255
  }
256
+ /**
257
+ * Pure staleness decision (no I/O) so the truth table is unit-testable. The
258
+ * installed service is stale when the helper binary is gone, or when it was
259
+ * installed by a different CLI version than the one now running — a version
260
+ * change is the signal that the plist's baked interpreter/entry/bundle paths
261
+ * and the helper binary itself may have drifted. A null installedVersion
262
+ * (pre-stamp install) counts as stale so old installs get re-stamped once.
263
+ */
264
+ export function isMenubarStale(opts) {
265
+ if (!opts.execExists)
266
+ return true;
267
+ return opts.installedVersion !== opts.currentVersion;
268
+ }
269
+ function menubarSetupStale() {
270
+ return isMenubarStale({
271
+ installedVersion: readInstalledMenubarVersion(),
272
+ currentVersion: getCliVersion(),
273
+ execExists: fs.existsSync(installedExecutablePath()),
274
+ });
275
+ }
227
276
  /**
228
277
  * Stop + remove the menu-bar service and write the sticky opt-out so the
229
278
  * upgrade migration won't re-enable it.
@@ -253,9 +302,17 @@ export function disableMenubarService() {
253
302
  catch { /* best effort */ }
254
303
  }
255
304
  /**
256
- * Upgrade-time auto-enable. Runs from runMigration() once per sentinel bump.
257
- * No-ops if: not darwin, the user opted out, no helper bundle ships, or the
258
- * service is already installed. Best-effort — never throws into migration.
305
+ * Startup self-heal, run on every darwin CLI invocation (see src/index.ts).
306
+ * No-ops cheaply (a couple of existsSync + a tiny file read) unless work is
307
+ * needed:
308
+ * - fresh install (no service yet) -> enable
309
+ * - upgrade (version stamp changed) or -> re-enable: recopy the new helper
310
+ * the App Support helper went missing binary + rewrite the plist + kick
311
+ *
312
+ * Without the staleness re-enable, `npm update` refreshed the CLI but left the
313
+ * menu bar running the previous release's helper binary on a possibly-stale
314
+ * plist. No-ops if: not darwin, the user opted out, or no helper bundle ships.
315
+ * Best-effort — never throws into startup.
259
316
  */
260
317
  export function installMenubarLaunchAgentOnUpgrade() {
261
318
  try {
@@ -263,14 +320,18 @@ export function installMenubarLaunchAgentOnUpgrade() {
263
320
  return;
264
321
  if (menubarDisabledByUser())
265
322
  return;
266
- if (menubarServiceInstalled())
267
- return;
268
323
  if (!sourceAppPath())
269
324
  return;
270
- enableMenubarService({ clearOptOut: false });
325
+ if (!menubarServiceInstalled()) {
326
+ enableMenubarService({ clearOptOut: false });
327
+ return;
328
+ }
329
+ if (menubarSetupStale()) {
330
+ enableMenubarService({ clearOptOut: false });
331
+ }
271
332
  }
272
333
  catch {
273
- /* never block migration on the menu bar */
334
+ /* never block startup on the menu bar */
274
335
  }
275
336
  }
276
337
  export function getMenubarStatus() {
@@ -280,11 +341,15 @@ export function getMenubarStatus() {
280
341
  const r = spawnSync('pgrep', ['-f', 'MenubarHelper'], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
281
342
  running = r.status === 0 && (r.stdout || '').trim().length > 0;
282
343
  }
344
+ const serviceInstalled = menubarServiceInstalled();
283
345
  return {
284
346
  platform: process.platform,
285
347
  source: sourceAppPath(),
286
348
  installedApp: fs.existsSync(dest) ? dest : null,
287
- serviceInstalled: menubarServiceInstalled(),
349
+ installedVersion: readInstalledMenubarVersion(),
350
+ currentVersion: getCliVersion(),
351
+ stale: onDarwin() && serviceInstalled && menubarSetupStale(),
352
+ serviceInstalled,
288
353
  running,
289
354
  disabledByUser: menubarDisabledByUser(),
290
355
  };
@@ -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