@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.
- package/CHANGELOG.md +37 -0
- package/dist/commands/doctor.d.ts +5 -2
- package/dist/commands/doctor.js +126 -27
- package/dist/commands/inspect.d.ts +2 -1
- package/dist/commands/inspect.js +1 -1
- package/dist/commands/menubar.js +6 -1
- package/dist/commands/repo.js +40 -0
- package/dist/commands/secrets.js +16 -12
- package/dist/commands/sessions.js +20 -1
- package/dist/index.js +2 -12
- package/dist/lib/agent-spec.d.ts +36 -0
- package/dist/lib/agent-spec.js +157 -0
- package/dist/lib/agents.js +1 -0
- package/dist/lib/daemon.js +32 -0
- package/dist/lib/doctor-diff.d.ts +7 -0
- package/dist/lib/doctor-diff.js +18 -13
- package/dist/lib/fs-atomic.d.ts +3 -2
- package/dist/lib/fs-atomic.js +22 -7
- package/dist/lib/heal.d.ts +107 -0
- package/dist/lib/heal.js +279 -0
- package/dist/lib/hooks.js +36 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +27 -3
- package/dist/lib/menubar/install-menubar.js +74 -9
- package/dist/lib/plugin-marketplace.d.ts +18 -0
- package/dist/lib/plugin-marketplace.js +67 -1
- package/dist/lib/plugins.d.ts +23 -1
- package/dist/lib/plugins.js +55 -10
- package/dist/lib/resources/rules.d.ts +5 -2
- package/dist/lib/resources/rules.js +39 -10
- package/dist/lib/rules/compose.d.ts +22 -0
- package/dist/lib/rules/compose.js +114 -9
- package/dist/lib/secrets/agent.d.ts +4 -2
- package/dist/lib/secrets/agent.js +6 -4
- package/dist/lib/secrets/bundles.d.ts +20 -14
- package/dist/lib/secrets/bundles.js +31 -10
- package/dist/lib/session/remote.d.ts +33 -0
- package/dist/lib/session/remote.js +114 -0
- package/dist/lib/staleness/checkers/rules.js +13 -1
- package/dist/lib/staleness/detectors/commands.js +7 -6
- package/dist/lib/staleness/writers/commands.js +7 -12
- package/dist/lib/startup/dev-build.d.ts +22 -0
- package/dist/lib/startup/dev-build.js +41 -0
- package/dist/lib/types.d.ts +13 -2
- package/dist/lib/versions.d.ts +3 -1
- package/dist/lib/versions.js +35 -3
- package/package.json +3 -3
package/dist/lib/heal.js
ADDED
|
@@ -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
|
}
|
|
Binary file
|
|
@@ -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
|
-
*
|
|
45
|
-
* No-ops
|
|
46
|
-
*
|
|
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(
|
|
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
|
-
*
|
|
257
|
-
* No-ops
|
|
258
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|