@event4u/agent-config 8.2.0 → 8.4.0
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/.claude-plugin/marketplace.json +4 -426
- package/.claude-plugin/skills/install-agent-config/SKILL.md +32 -0
- package/CHANGELOG.md +57 -0
- package/MIGRATION.md +18 -0
- package/README.md +86 -3
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +1 -1
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +1 -1
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +1 -1
- package/dist/install/install.mjs +586 -386
- package/dist/mcp/registry-manifest.json +2 -2
- package/docs/contracts/CHANGELOG-conventions.md +8 -5
- package/docs/contracts/branch-protection-policy.md +14 -5
- package/docs/contracts/mcp-phase-1-scope.md +75 -0
- package/docs/contracts/mcp-tool-inventory.md +22 -15
- package/docs/contracts/release-pr-gating.md +22 -11
- package/docs/contracts/surface-matrix.md +103 -0
- package/docs/decisions/ADR-111-canonical-glama-listing.md +97 -0
- package/docs/decisions/ADR-112-stdio-lite-stays-read-only.md +92 -0
- package/docs/decisions/ADR-113-ci-native-release-label-trigger.md +201 -0
- package/docs/decisions/INDEX.md +3 -0
- package/docs/distribution/registry-submissions.md +1 -1
- package/docs/getting-started.md +8 -6
- package/docs/installation.md +13 -14
- package/docs/mcp-server.md +32 -20
- package/docs/setup/mcp-cloud-endpoints.md +9 -2
- package/package.json +2 -2
- package/src/config/surface-matrix.yml +213 -0
- package/src/scripts/_cli/cmd_converge.ts +399 -0
- package/src/scripts/_cli/cmd_doctor.ts +203 -9
- package/src/scripts/_cli/cmd_uninstall.ts +22 -0
- package/src/scripts/_cli/cmd_upgrade.ts +101 -16
- package/src/scripts/_dispatch.bash +13 -0
- package/src/scripts/_lib/claude_settings_hooks.ts +251 -0
- package/src/scripts/condense.ts +74 -65
- package/src/scripts/hook_manifest.yaml +16 -7
- package/src/scripts/install +23 -4
- package/src/scripts/install.ts +30 -0
- package/src/scripts/lint_glama_drift.ts +75 -0
- package/src/scripts/lint_marketplace.ts +54 -56
- package/src/scripts/lint_surface_matrix.ts +200 -0
- package/src/scripts/mcp_exec/safety_envelope.ts +105 -0
- package/src/scripts/mcp_registry_submit.sh +14 -3
- package/src/scripts/mcp_server/__main__.ts +6 -8
- package/src/scripts/mcp_server/consumer_tool_catalog.json +78 -3
- package/src/scripts/mcp_server/tools.ts +731 -0
- package/src/scripts/release.ts +203 -39
- package/src/scripts/surface_probe_hook.ts +231 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* `agent-config converge` — consented cleanup of duplicate install surfaces
|
|
4
|
+
* (road-to-install-path-convergence Phase 3).
|
|
5
|
+
*
|
|
6
|
+
* Reads src/config/surface-matrix.yml, detects every tool whose declared
|
|
7
|
+
* duplicate-class paths are ALL present, and — with consent — performs the
|
|
8
|
+
* per-tool cleanup: the tool's own uninstall command plus tagged-orphan
|
|
9
|
+
* reaping of the matrix-declared cache locations. Emits a convergence report
|
|
10
|
+
* (what was removed, why, rollback hint).
|
|
11
|
+
*
|
|
12
|
+
* Consent model (council Q2, 2026-07-07 — no silent mutation of a
|
|
13
|
+
* user-owned surface):
|
|
14
|
+
*
|
|
15
|
+
* - `install.auto_converge: true` in the GLOBAL settings file
|
|
16
|
+
* (~/.event4u/agent-config/agent-settings.yml) is standing consent. The
|
|
17
|
+
* first successful interactive/explicit converge persists it.
|
|
18
|
+
* - Without the key, a TTY run asks ONE y/N question; a non-TTY run refuses
|
|
19
|
+
* with instructions (automation must opt in via the key or --yes).
|
|
20
|
+
* - `--dry-run` prints the exact actions and exits 0 without consent.
|
|
21
|
+
* - `--yes` grants consent for this run AND persists the key (explicit
|
|
22
|
+
* automation opt-in).
|
|
23
|
+
*
|
|
24
|
+
* Hard floor: converge removes ONLY matrix-declared, package-tagged paths
|
|
25
|
+
* (`converge.reaps`) and runs ONLY matrix-declared uninstall commands. It
|
|
26
|
+
* never deletes user-authored files; any path outside the matrix allowlist
|
|
27
|
+
* is refused.
|
|
28
|
+
*
|
|
29
|
+
* Exit codes: 0 = converged / nothing to do / dry-run · 1 = refused or a
|
|
30
|
+
* step failed · 2 = usage error.
|
|
31
|
+
*/
|
|
32
|
+
import * as fs from 'node:fs';
|
|
33
|
+
import * as os from 'node:os';
|
|
34
|
+
import * as path from 'node:path';
|
|
35
|
+
import process from 'node:process';
|
|
36
|
+
import { spawnSync } from 'node:child_process';
|
|
37
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
38
|
+
|
|
39
|
+
import * as YAML from 'yaml';
|
|
40
|
+
|
|
41
|
+
import * as user_global_paths from '../_lib/user_global_paths.js';
|
|
42
|
+
|
|
43
|
+
type Dict = Record<string, unknown>;
|
|
44
|
+
|
|
45
|
+
interface OutSink {
|
|
46
|
+
write: (t: string) => void;
|
|
47
|
+
}
|
|
48
|
+
const _stdout: OutSink = { write: (t) => process.stdout.write(t) };
|
|
49
|
+
const _stderr: OutSink = { write: (t) => process.stderr.write(t) };
|
|
50
|
+
|
|
51
|
+
function _print(out: OutSink, line = ''): void {
|
|
52
|
+
out.write(`${line}\n`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type Runner = (cmd: string[]) => number;
|
|
56
|
+
|
|
57
|
+
function _default_runner(cmd: string[]): number {
|
|
58
|
+
const r = spawnSync(cmd[0] as string, cmd.slice(1), { stdio: 'inherit' });
|
|
59
|
+
if (r.error) {
|
|
60
|
+
_stderr.write(`agent-config converge: cannot run ${cmd[0]}: ${String(r.error)}\n`);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
return r.status ?? 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
67
|
+
|
|
68
|
+
/** Resolve the @event4u/agent-config package root (this repo). */
|
|
69
|
+
function _package_root(): string {
|
|
70
|
+
return path.resolve(path.dirname(_HERE), '..', '..', '..');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const SETTINGS_FILENAME = 'agent-settings.yml';
|
|
74
|
+
const CONSENT_KEY_PATH = ['install', 'auto_converge'] as const;
|
|
75
|
+
|
|
76
|
+
class ArgparseExit extends Error {
|
|
77
|
+
code: number;
|
|
78
|
+
constructor(code: number) {
|
|
79
|
+
super(`exit ${code}`);
|
|
80
|
+
this.code = code;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface Args {
|
|
85
|
+
dry_run: boolean;
|
|
86
|
+
yes: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const PROG = 'agent-config converge';
|
|
90
|
+
const USAGE = `usage: ${PROG} [-h] [--dry-run] [--yes]\n`;
|
|
91
|
+
|
|
92
|
+
function _argError(msg: string): never {
|
|
93
|
+
_stderr.write(USAGE);
|
|
94
|
+
_stderr.write(`${PROG}: error: ${msg}\n`);
|
|
95
|
+
throw new ArgparseExit(2);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function _parse(argv: string[]): Args {
|
|
99
|
+
const args: Args = { dry_run: false, yes: false };
|
|
100
|
+
for (const a of argv) {
|
|
101
|
+
if (a === '-h' || a === '--help') {
|
|
102
|
+
_stdout.write(USAGE);
|
|
103
|
+
throw new ArgparseExit(0);
|
|
104
|
+
}
|
|
105
|
+
if (a === '--dry-run') {
|
|
106
|
+
args.dry_run = true;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (a === '--yes' || a === '-y') {
|
|
110
|
+
args.yes = true;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
_argError(`unrecognized arguments: ${a}`);
|
|
114
|
+
}
|
|
115
|
+
return args;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface DuplicateFinding {
|
|
119
|
+
tool: string;
|
|
120
|
+
description: string;
|
|
121
|
+
command: string;
|
|
122
|
+
reaps: string[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface MatrixTool {
|
|
126
|
+
duplicate?: { description?: string; detect?: { all_of?: unknown } };
|
|
127
|
+
converge?: { command?: string; reaps?: unknown };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function _load_matrix(matrix_path: string): Record<string, MatrixTool> {
|
|
131
|
+
const raw = YAML.parse(fs.readFileSync(matrix_path, 'utf-8'), { version: '1.1' }) as Dict;
|
|
132
|
+
const tools = raw['tools'];
|
|
133
|
+
if (!tools || typeof tools !== 'object' || Array.isArray(tools)) {
|
|
134
|
+
throw new Error('surface-matrix.yml: `tools` must be a mapping');
|
|
135
|
+
}
|
|
136
|
+
return tools as Record<string, MatrixTool>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function _expand(p: string, home: string, pkg_root: string): string {
|
|
140
|
+
return p.startsWith('~/') ? path.join(home, p.slice(2)) : path.join(pkg_root, p);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function _exists(p: string): boolean {
|
|
144
|
+
try {
|
|
145
|
+
fs.statSync(p);
|
|
146
|
+
return true;
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Detect all live duplicate classes per the matrix. */
|
|
153
|
+
export function detect_duplicates(
|
|
154
|
+
tools: Record<string, MatrixTool>,
|
|
155
|
+
home: string,
|
|
156
|
+
pkg_root: string,
|
|
157
|
+
): DuplicateFinding[] {
|
|
158
|
+
const findings: DuplicateFinding[] = [];
|
|
159
|
+
for (const [tool_id, entry] of Object.entries(tools)) {
|
|
160
|
+
const all_of = entry.duplicate?.detect?.all_of;
|
|
161
|
+
if (!Array.isArray(all_of) || all_of.length === 0) {
|
|
162
|
+
continue; // no defined class (pending_evidence is documented, never acted on)
|
|
163
|
+
}
|
|
164
|
+
const present = all_of.every(
|
|
165
|
+
(p) => typeof p === 'string' && _exists(_expand(p, home, pkg_root)),
|
|
166
|
+
);
|
|
167
|
+
if (!present) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const reaps_raw = entry.converge?.reaps;
|
|
171
|
+
const reaps = Array.isArray(reaps_raw)
|
|
172
|
+
? reaps_raw.filter((r): r is string => typeof r === 'string' && r.startsWith('~/'))
|
|
173
|
+
: [];
|
|
174
|
+
findings.push({
|
|
175
|
+
tool: tool_id,
|
|
176
|
+
description: entry.duplicate?.description ?? 'duplicate install surface',
|
|
177
|
+
command: entry.converge?.command ?? '',
|
|
178
|
+
reaps,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return findings;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Read install.auto_converge from the global settings file. */
|
|
185
|
+
export function read_consent(settings_path: string): boolean {
|
|
186
|
+
try {
|
|
187
|
+
const raw = YAML.parse(fs.readFileSync(settings_path, 'utf-8'), { version: '1.1' }) as Dict;
|
|
188
|
+
const install = raw?.[CONSENT_KEY_PATH[0]];
|
|
189
|
+
if (install && typeof install === 'object' && !Array.isArray(install)) {
|
|
190
|
+
return (install as Dict)[CONSENT_KEY_PATH[1]] === true;
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
/* missing / unreadable → no consent */
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Persist install.auto_converge: true into the global settings file. */
|
|
199
|
+
export function persist_consent(settings_path: string, out: OutSink): void {
|
|
200
|
+
let doc: Dict = {};
|
|
201
|
+
try {
|
|
202
|
+
const parsed = YAML.parse(fs.readFileSync(settings_path, 'utf-8'), { version: '1.1' });
|
|
203
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
204
|
+
doc = parsed as Dict;
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
/* absent → create */
|
|
208
|
+
}
|
|
209
|
+
const install =
|
|
210
|
+
doc['install'] && typeof doc['install'] === 'object' && !Array.isArray(doc['install'])
|
|
211
|
+
? (doc['install'] as Dict)
|
|
212
|
+
: {};
|
|
213
|
+
if (install[CONSENT_KEY_PATH[1]] === true) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
install[CONSENT_KEY_PATH[1]] = true;
|
|
217
|
+
doc['install'] = install;
|
|
218
|
+
fs.mkdirSync(path.dirname(settings_path), { recursive: true });
|
|
219
|
+
fs.writeFileSync(settings_path, YAML.stringify(doc), 'utf-8');
|
|
220
|
+
_print(out, `✅ standing consent persisted: install.auto_converge: true (${settings_path})`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** One-time interactive y/N on a TTY; false everywhere else. */
|
|
224
|
+
function _tty_confirm(question: string): boolean {
|
|
225
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
// Synchronous single-line read from the TTY.
|
|
229
|
+
process.stdout.write(`${question} [y/N] `);
|
|
230
|
+
const buf = Buffer.alloc(64);
|
|
231
|
+
let answer = '';
|
|
232
|
+
try {
|
|
233
|
+
const fd = fs.openSync('/dev/tty', 'rs');
|
|
234
|
+
const n = fs.readSync(fd, buf, 0, 64, null);
|
|
235
|
+
fs.closeSync(fd);
|
|
236
|
+
answer = buf.toString('utf-8', 0, n).trim().toLowerCase();
|
|
237
|
+
} catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
return answer === 'y' || answer === 'yes';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface MainOptions {
|
|
244
|
+
runner?: Runner;
|
|
245
|
+
home?: string;
|
|
246
|
+
package_root?: string;
|
|
247
|
+
settings_path?: string;
|
|
248
|
+
confirm?: (question: string) => boolean;
|
|
249
|
+
out?: OutSink;
|
|
250
|
+
err?: OutSink;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function main(argv: string[] | null = null, opts: MainOptions = {}): number {
|
|
254
|
+
let args: Args;
|
|
255
|
+
try {
|
|
256
|
+
args = _parse(argv ?? process.argv.slice(2));
|
|
257
|
+
} catch (e) {
|
|
258
|
+
if (e instanceof ArgparseExit) {
|
|
259
|
+
return e.code;
|
|
260
|
+
}
|
|
261
|
+
throw e;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const out = opts.out ?? _stdout;
|
|
265
|
+
const err = opts.err ?? _stderr;
|
|
266
|
+
const runner = opts.runner ?? _default_runner;
|
|
267
|
+
const home = opts.home ?? os.homedir();
|
|
268
|
+
const pkg_root = opts.package_root ?? _package_root();
|
|
269
|
+
const settings_path =
|
|
270
|
+
opts.settings_path ?? user_global_paths.write_target(SETTINGS_FILENAME);
|
|
271
|
+
const confirm = opts.confirm ?? _tty_confirm;
|
|
272
|
+
|
|
273
|
+
const matrix_path = path.join(pkg_root, 'src', 'config', 'surface-matrix.yml');
|
|
274
|
+
if (!fs.existsSync(matrix_path)) {
|
|
275
|
+
_print(err, `❌ ${matrix_path} not found — reinstall the package (agent-config upgrade)`);
|
|
276
|
+
return 1;
|
|
277
|
+
}
|
|
278
|
+
let tools: Record<string, MatrixTool>;
|
|
279
|
+
try {
|
|
280
|
+
tools = _load_matrix(matrix_path);
|
|
281
|
+
} catch (e) {
|
|
282
|
+
_print(err, `❌ surface-matrix.yml unreadable: ${e instanceof Error ? e.message : String(e)}`);
|
|
283
|
+
return 1;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const findings = detect_duplicates(tools, home, pkg_root);
|
|
287
|
+
if (findings.length === 0) {
|
|
288
|
+
_print(out, '✅ no duplicate install surfaces detected — nothing to converge.');
|
|
289
|
+
return 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
_print(out, `Duplicate install surface(s) detected on ${findings.length} tool(s):`);
|
|
293
|
+
for (const f of findings) {
|
|
294
|
+
_print(out, ` · ${f.tool}: ${f.description}`);
|
|
295
|
+
}
|
|
296
|
+
_print(out);
|
|
297
|
+
|
|
298
|
+
if (args.dry_run) {
|
|
299
|
+
_print(out, 'Dry run — the following actions WOULD run (nothing was touched):');
|
|
300
|
+
for (const f of findings) {
|
|
301
|
+
if (f.command) {
|
|
302
|
+
_print(out, ` → ${f.command}`);
|
|
303
|
+
}
|
|
304
|
+
for (const reap of f.reaps) {
|
|
305
|
+
_print(out, ` → rm -rf ${reap} (package-tagged orphan, matrix-declared)`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Consent gate — standing key, --yes, or one interactive y/N.
|
|
312
|
+
let consented = args.yes || read_consent(settings_path);
|
|
313
|
+
if (!consented) {
|
|
314
|
+
consented = confirm(
|
|
315
|
+
`Converge now? This runs the per-tool uninstall command(s) above and ` +
|
|
316
|
+
`reaps the matrix-declared caches. Persists install.auto_converge: true.`,
|
|
317
|
+
);
|
|
318
|
+
if (!consented) {
|
|
319
|
+
_print(
|
|
320
|
+
err,
|
|
321
|
+
'❌ converge refused — no consent. Re-run interactively, pass --yes, or set\n' +
|
|
322
|
+
` install.auto_converge: true in ${settings_path}`,
|
|
323
|
+
);
|
|
324
|
+
return 1;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
persist_consent(settings_path, out);
|
|
328
|
+
|
|
329
|
+
// Perform the cleanup — matrix-declared actions ONLY.
|
|
330
|
+
let failures = 0;
|
|
331
|
+
const report: string[] = [];
|
|
332
|
+
for (const f of findings) {
|
|
333
|
+
if (f.command) {
|
|
334
|
+
_print(out, `→ ${f.command}`);
|
|
335
|
+
const rc = runner(f.command.split(' '));
|
|
336
|
+
if (rc !== 0) {
|
|
337
|
+
failures += 1;
|
|
338
|
+
_print(err, `⚠️ ${f.tool}: uninstall command exited ${rc} — continuing with reaps.`);
|
|
339
|
+
} else {
|
|
340
|
+
report.push(`${f.tool}: ran \`${f.command}\``);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
for (const reap of f.reaps) {
|
|
344
|
+
// Hard floor: reap paths come from the matrix allowlist only and
|
|
345
|
+
// are always ~/-anchored (enforced at load; non-~/ entries were
|
|
346
|
+
// filtered in detect_duplicates).
|
|
347
|
+
const abs = _expand(reap, home, pkg_root);
|
|
348
|
+
if (!_exists(abs)) {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
fs.rmSync(abs, { recursive: true, force: true });
|
|
353
|
+
report.push(`${f.tool}: reaped package-tagged orphan \`${reap}\``);
|
|
354
|
+
_print(out, `→ reaped ${reap}`);
|
|
355
|
+
} catch (e) {
|
|
356
|
+
failures += 1;
|
|
357
|
+
_print(err, `⚠️ ${f.tool}: could not reap ${reap}: ${e instanceof Error ? e.message : String(e)}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
_print(out);
|
|
363
|
+
_print(out, 'Convergence report:');
|
|
364
|
+
for (const line of report) {
|
|
365
|
+
_print(out, ` ✅ ${line}`);
|
|
366
|
+
}
|
|
367
|
+
if (report.length === 0) {
|
|
368
|
+
_print(out, ' (no actions completed)');
|
|
369
|
+
}
|
|
370
|
+
_print(
|
|
371
|
+
out,
|
|
372
|
+
'Rollback hint: a removed Claude Code plugin can be reinstalled with\n' +
|
|
373
|
+
' claude plugin marketplace add event4u-app/agent-config && ' +
|
|
374
|
+
'claude plugin install agent-config@event4u-agent-config\n' +
|
|
375
|
+
'(the file projection is untouched — content and hooks keep working).',
|
|
376
|
+
);
|
|
377
|
+
return failures > 0 ? 1 : 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function _isCliEntry(): boolean {
|
|
381
|
+
if (process.argv[1] === undefined) {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
385
|
+
if (import.meta.url === argvUrl) {
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
390
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
391
|
+
return here === argv;
|
|
392
|
+
} catch {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (_isCliEntry()) {
|
|
398
|
+
process.exit(main());
|
|
399
|
+
}
|
|
@@ -81,6 +81,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
81
81
|
import * as YAML from 'yaml';
|
|
82
82
|
|
|
83
83
|
import * as claude_plugin from '../_lib/claude_plugin.js';
|
|
84
|
+
import * as claude_settings_hooks from '../_lib/claude_settings_hooks.js';
|
|
84
85
|
import { _is_source_repo } from './cmd_refresh.js';
|
|
85
86
|
import * as installed_lock from '../_lib/installed_lock.js';
|
|
86
87
|
import * as installed_tools from '../_lib/installed_tools.js';
|
|
@@ -730,6 +731,8 @@ const CHECK_IDS = [
|
|
|
730
731
|
'scope',
|
|
731
732
|
'global-binary',
|
|
732
733
|
'claude-plugin',
|
|
734
|
+
'surface-state',
|
|
735
|
+
'hook-wiring',
|
|
733
736
|
'stale-orphans',
|
|
734
737
|
'manifest-integrity',
|
|
735
738
|
'lockfile-freshness',
|
|
@@ -749,6 +752,8 @@ const GLOBAL_CHECK_IDS: ReadonlySet<string> = new Set([
|
|
|
749
752
|
'scope',
|
|
750
753
|
'global-binary',
|
|
751
754
|
'claude-plugin',
|
|
755
|
+
'surface-state',
|
|
756
|
+
'hook-wiring',
|
|
752
757
|
'stale-orphans',
|
|
753
758
|
'mcp-mode',
|
|
754
759
|
'mcp-beta-readiness',
|
|
@@ -1005,12 +1010,14 @@ function lstripV(s: string): string {
|
|
|
1005
1010
|
}
|
|
1006
1011
|
|
|
1007
1012
|
/**
|
|
1008
|
-
* Claude Code plugin state
|
|
1009
|
-
*
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
1012
|
-
*
|
|
1013
|
-
*
|
|
1013
|
+
* Claude Code plugin state under the single-surface model
|
|
1014
|
+
* (road-to-claude-code-single-surface): the `~/.claude/` file projection is
|
|
1015
|
+
* THE distribution surface for Claude Code — content, and hooks via the
|
|
1016
|
+
* managed settings.json block. An installed marketplace plugin next to the
|
|
1017
|
+
* projection is a **duplicate surface**: every skill/command lists twice
|
|
1018
|
+
* and the plugin's SHA snapshot rots silently. That is `fail`, with the
|
|
1019
|
+
* uninstall command as the fix (never uninstalled autonomously — the plugin
|
|
1020
|
+
* is a user-owned surface).
|
|
1014
1021
|
*/
|
|
1015
1022
|
function _check_claude_plugin(): Dict {
|
|
1016
1023
|
const claude = shutilWhich('claude');
|
|
@@ -1027,11 +1034,27 @@ function _check_claude_plugin(): Dict {
|
|
|
1027
1034
|
id: 'claude-plugin',
|
|
1028
1035
|
status: 'ok',
|
|
1029
1036
|
message:
|
|
1030
|
-
'plugin not installed (
|
|
1031
|
-
'
|
|
1037
|
+
'plugin not installed (single-surface model — the ~/.claude/ file ' +
|
|
1038
|
+
'projection carries content AND hooks)',
|
|
1032
1039
|
remedy: '',
|
|
1033
1040
|
};
|
|
1034
1041
|
}
|
|
1042
|
+
const projection = path.join(os.homedir(), '.claude', 'skills');
|
|
1043
|
+
const projection_present = isDir(projection);
|
|
1044
|
+
if (projection_present) {
|
|
1045
|
+
return {
|
|
1046
|
+
id: 'claude-plugin',
|
|
1047
|
+
status: 'fail',
|
|
1048
|
+
message:
|
|
1049
|
+
'duplicate surface: the marketplace plugin AND the ~/.claude/ file ' +
|
|
1050
|
+
'projection are both installed — every skill/command lists twice and ' +
|
|
1051
|
+
'the plugin snapshot rots silently',
|
|
1052
|
+
remedy:
|
|
1053
|
+
`claude plugin uninstall ${claude_plugin.CLAUDE_PLUGIN_ID}@${claude_plugin.CLAUDE_MARKETPLACE_NAME} ` +
|
|
1054
|
+
'(hooks stay — they are registered in ~/.claude/settings.json; ' +
|
|
1055
|
+
'verify with the hook-wiring check)',
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1035
1058
|
const snapshot_v = lstripV(claude_plugin.claude_plugin_snapshot_version() || '');
|
|
1036
1059
|
const binary_v = lstripV(_current_package_version() || '');
|
|
1037
1060
|
if (snapshot_v && binary_v && snapshot_v !== binary_v) {
|
|
@@ -1046,12 +1069,179 @@ function _check_claude_plugin(): Dict {
|
|
|
1046
1069
|
}
|
|
1047
1070
|
return {
|
|
1048
1071
|
id: 'claude-plugin',
|
|
1072
|
+
status: 'warn',
|
|
1073
|
+
message:
|
|
1074
|
+
`plugin installed${snapshot_v ? ` (snapshot ${snapshot_v})` : ''} without the ` +
|
|
1075
|
+
'file projection — deprecated surface; the projection is the supported path',
|
|
1076
|
+
remedy: 'agent-config global (then uninstall the plugin per the printed hint)',
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Matrix-driven duplicate-surface check (road-to-install-path-convergence
|
|
1082
|
+
* Phase 2): reads src/config/surface-matrix.yml and flags every tool whose
|
|
1083
|
+
* declared duplicate-class detect paths ALL exist on this machine.
|
|
1084
|
+
* Generalizes the `claude-plugin` check — which stays as the named first
|
|
1085
|
+
* consumer — to every tool with a defined duplicate class; tools marked
|
|
1086
|
+
* `pending_evidence` are documented but never acted on.
|
|
1087
|
+
*/
|
|
1088
|
+
function _check_surface_state(): Dict {
|
|
1089
|
+
const matrix_path = path.join(_package_root(), 'src', 'config', 'surface-matrix.yml');
|
|
1090
|
+
if (!isFile(matrix_path)) {
|
|
1091
|
+
return {
|
|
1092
|
+
id: 'surface-state',
|
|
1093
|
+
status: 'skipped',
|
|
1094
|
+
message: 'surface-matrix.yml not found in this package',
|
|
1095
|
+
remedy: '',
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
let tools: Dict = {};
|
|
1099
|
+
try {
|
|
1100
|
+
const raw = YAML.parse(readText(matrix_path), { version: '1.1' }) as Dict;
|
|
1101
|
+
const t = raw['tools'];
|
|
1102
|
+
if (t && typeof t === 'object' && !Array.isArray(t)) {
|
|
1103
|
+
tools = t as Dict;
|
|
1104
|
+
}
|
|
1105
|
+
} catch {
|
|
1106
|
+
return {
|
|
1107
|
+
id: 'surface-state',
|
|
1108
|
+
status: 'warn',
|
|
1109
|
+
message: 'surface-matrix.yml unreadable — duplicate-surface state unknown',
|
|
1110
|
+
remedy: 'agent-config upgrade (reinstalls the package data files)',
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const _expand = (p: string): string =>
|
|
1115
|
+
p.startsWith('~/') ? path.join(os.homedir(), p.slice(2)) : path.join(_package_root(), p);
|
|
1116
|
+
|
|
1117
|
+
const violations: string[] = [];
|
|
1118
|
+
const remedies: string[] = [];
|
|
1119
|
+
let checked = 0;
|
|
1120
|
+
for (const [tool_id, entry] of Object.entries(tools)) {
|
|
1121
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
const dup = (entry as Dict)['duplicate'] as Dict | undefined;
|
|
1125
|
+
const detect = dup ? (dup['detect'] as Dict | undefined) : undefined;
|
|
1126
|
+
const all_of = detect ? (detect['all_of'] as unknown) : undefined;
|
|
1127
|
+
if (!Array.isArray(all_of) || all_of.length === 0) {
|
|
1128
|
+
continue;
|
|
1129
|
+
}
|
|
1130
|
+
checked += 1;
|
|
1131
|
+
const present = all_of.every((p) => typeof p === 'string' && pathExistsAny(_expand(p)));
|
|
1132
|
+
if (present) {
|
|
1133
|
+
violations.push(tool_id);
|
|
1134
|
+
const conv = (entry as Dict)['converge'] as Dict | undefined;
|
|
1135
|
+
const cmd = conv ? String(conv['command'] ?? '') : '';
|
|
1136
|
+
if (cmd) {
|
|
1137
|
+
remedies.push(cmd);
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (violations.length > 0) {
|
|
1143
|
+
return {
|
|
1144
|
+
id: 'surface-state',
|
|
1145
|
+
status: 'fail',
|
|
1146
|
+
message:
|
|
1147
|
+
`duplicate surface on ${violations.length} tool(s): ${violations.join(', ')} — ` +
|
|
1148
|
+
'canonical + secondary install surfaces are both present',
|
|
1149
|
+
remedy: `${remedies.join(' · ')}${remedies.length ? ' — or: ' : ''}agent-config converge`,
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
return {
|
|
1153
|
+
id: 'surface-state',
|
|
1049
1154
|
status: 'ok',
|
|
1050
|
-
message:
|
|
1155
|
+
message: `${checked} declared duplicate class(es) checked, none present`,
|
|
1051
1156
|
remedy: '',
|
|
1052
1157
|
};
|
|
1053
1158
|
}
|
|
1054
1159
|
|
|
1160
|
+
/** exists() that is true for files AND directories (surface detect paths). */
|
|
1161
|
+
function pathExistsAny(p: string): boolean {
|
|
1162
|
+
try {
|
|
1163
|
+
fs.statSync(p);
|
|
1164
|
+
return true;
|
|
1165
|
+
} catch {
|
|
1166
|
+
return false;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Managed hook wiring (single-surface model): the deterministic hook matrix
|
|
1172
|
+
* must be registered in ~/.claude/settings.json AND the dispatch target
|
|
1173
|
+
* (`agent-config` binary) must resolve on PATH. Both halves are required —
|
|
1174
|
+
* hooks without a binary silently no-op every session.
|
|
1175
|
+
*/
|
|
1176
|
+
function _check_hook_wiring(): Dict {
|
|
1177
|
+
const claude = shutilWhich('claude');
|
|
1178
|
+
if (claude === null) {
|
|
1179
|
+
return {
|
|
1180
|
+
id: 'hook-wiring',
|
|
1181
|
+
status: 'skipped',
|
|
1182
|
+
message: 'Claude Code CLI not on PATH — hook-wiring check not applicable',
|
|
1183
|
+
remedy: '',
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
const settings_path = path.join(os.homedir(), '.claude', 'settings.json');
|
|
1187
|
+
let missing: string[];
|
|
1188
|
+
try {
|
|
1189
|
+
const matrix = claude_settings_hooks.build_claude_hook_matrix(
|
|
1190
|
+
path.join(_package_root(), 'src', 'scripts', 'hook_manifest.yaml'),
|
|
1191
|
+
);
|
|
1192
|
+
const settings = pathExists(settings_path)
|
|
1193
|
+
? (JSON.parse(fs.readFileSync(settings_path, 'utf8')) as Record<string, unknown>)
|
|
1194
|
+
: {};
|
|
1195
|
+
const hooks = ((settings['hooks'] ?? {}) as Record<string, unknown>) || {};
|
|
1196
|
+
missing = Object.keys(matrix).filter((ev) => {
|
|
1197
|
+
const groups = hooks[ev];
|
|
1198
|
+
if (!Array.isArray(groups)) return true;
|
|
1199
|
+
return !JSON.stringify(groups).includes(claude_settings_hooks.MANAGED_SIGNATURE);
|
|
1200
|
+
});
|
|
1201
|
+
} catch (e) {
|
|
1202
|
+
return {
|
|
1203
|
+
id: 'hook-wiring',
|
|
1204
|
+
status: 'fail',
|
|
1205
|
+
message: `cannot evaluate managed hooks: ${String(e)}`,
|
|
1206
|
+
remedy: 'fix ~/.claude/settings.json (invalid JSON?), then agent-config refresh --global',
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
const binary_ok = shutilWhich('agent-config') !== null;
|
|
1210
|
+
if (missing.length === 0 && binary_ok) {
|
|
1211
|
+
return {
|
|
1212
|
+
id: 'hook-wiring',
|
|
1213
|
+
status: 'ok',
|
|
1214
|
+
message: 'managed hooks registered in ~/.claude/settings.json; dispatch binary on PATH',
|
|
1215
|
+
remedy: '',
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
if (missing.length > 0 && claude_plugin.claude_plugin_installed()) {
|
|
1219
|
+
return {
|
|
1220
|
+
id: 'hook-wiring',
|
|
1221
|
+
status: 'warn',
|
|
1222
|
+
message:
|
|
1223
|
+
`managed hooks missing for: ${missing.join(', ')} — currently carried by the ` +
|
|
1224
|
+
'installed plugin (deprecated surface)',
|
|
1225
|
+
remedy: 'agent-config refresh --global (writes the managed settings.json block)',
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
const parts: string[] = [];
|
|
1229
|
+
if (missing.length > 0) parts.push(`managed hooks missing for: ${missing.join(', ')}`);
|
|
1230
|
+
if (!binary_ok) parts.push('`agent-config` not on PATH (hooks would silently no-op)');
|
|
1231
|
+
if (parts.length === 0) {
|
|
1232
|
+
// hooks complete, binary missing was the only issue → covered above.
|
|
1233
|
+
parts.push('`agent-config` not on PATH');
|
|
1234
|
+
}
|
|
1235
|
+
return {
|
|
1236
|
+
id: 'hook-wiring',
|
|
1237
|
+
status: 'fail',
|
|
1238
|
+
message: parts.join('; '),
|
|
1239
|
+
remedy: binary_ok
|
|
1240
|
+
? 'agent-config refresh --global'
|
|
1241
|
+
: 'npm install -g @event4u/agent-config, then agent-config refresh --global',
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1055
1245
|
function _check_bridge_drift(
|
|
1056
1246
|
missing: Dict[],
|
|
1057
1247
|
modified: Dict[],
|
|
@@ -1818,6 +2008,8 @@ function _run_checks(
|
|
|
1818
2008
|
scope: () => _check_scope(project_root),
|
|
1819
2009
|
'global-binary': () => _check_global_binary(project_root),
|
|
1820
2010
|
'claude-plugin': _check_claude_plugin,
|
|
2011
|
+
'surface-state': _check_surface_state,
|
|
2012
|
+
'hook-wiring': _check_hook_wiring,
|
|
1821
2013
|
'stale-orphans': _check_stale_orphans,
|
|
1822
2014
|
'manifest-integrity': () => _check_manifest_integrity(manifest),
|
|
1823
2015
|
'lockfile-freshness': () => _check_lockfile_freshness(manifest),
|
|
@@ -1890,6 +2082,8 @@ function _run_checks_no_manifest(
|
|
|
1890
2082
|
scope: () => _check_scope(project_root),
|
|
1891
2083
|
'global-binary': () => _check_global_binary(project_root),
|
|
1892
2084
|
'claude-plugin': _check_claude_plugin,
|
|
2085
|
+
'surface-state': _check_surface_state,
|
|
2086
|
+
'hook-wiring': _check_hook_wiring,
|
|
1893
2087
|
'stale-orphans': _check_stale_orphans,
|
|
1894
2088
|
'manifest-integrity': () => _skipped_manifest_check('manifest-integrity'),
|
|
1895
2089
|
'lockfile-freshness': () => _skipped_manifest_check('lockfile-freshness'),
|