@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.
- package/CHANGELOG.md +26 -0
- package/dist/commands/doctor.d.ts +5 -2
- package/dist/commands/doctor.js +126 -27
- 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/heal.d.ts +107 -0
- package/dist/lib/heal.js +279 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- 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/session/remote.d.ts +33 -0
- package/dist/lib/session/remote.js +114 -0
- 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 +7 -0
- package/dist/lib/versions.d.ts +3 -1
- package/dist/lib/versions.js +35 -3
- package/package.json +3 -3
package/dist/lib/doctor-diff.js
CHANGED
|
@@ -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
|
|
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:
|
|
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>;
|
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
|
+
}
|
|
Binary file
|
|
@@ -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
|
package/dist/lib/plugins.d.ts
CHANGED
|
@@ -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.
|