@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
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
*/
|
|
56
56
|
|
|
57
57
|
import * as fs from 'node:fs';
|
|
58
|
+
import * as os from 'node:os';
|
|
58
59
|
import * as path from 'node:path';
|
|
59
60
|
import process from 'node:process';
|
|
60
61
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
@@ -62,6 +63,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
62
63
|
import * as fs_atomic from '../_lib/fs_atomic.js';
|
|
63
64
|
import * as installed_lock from '../_lib/installed_lock.js';
|
|
64
65
|
import * as installed_tools from '../_lib/installed_tools.js';
|
|
66
|
+
import { remove_managed_hooks } from '../_lib/claude_settings_hooks.js';
|
|
65
67
|
import { resolve_project_root } from '../_lib/agent_settings.js';
|
|
66
68
|
import { subtract_pointers } from '../_lib/json_pointers.js';
|
|
67
69
|
import { PROJECT_BRIDGE_MARKERS, USER_SCOPE_PATHS } from '../install.js';
|
|
@@ -771,6 +773,26 @@ function _uninstall_global(opts: Options): number {
|
|
|
771
773
|
removed_names.push(tool);
|
|
772
774
|
}
|
|
773
775
|
}
|
|
776
|
+
// Managed Claude hooks live in ~/.claude/settings.json (single-surface
|
|
777
|
+
// model) — remove exactly our entries, never user-owned ones.
|
|
778
|
+
if (tools.includes('claude-code')) {
|
|
779
|
+
const settings_path = path.join(os.homedir(), '.claude', 'settings.json');
|
|
780
|
+
if (opts.dry_run) {
|
|
781
|
+
_print(_stdout, ` · claude-code: would remove managed hooks from ${settings_path}`);
|
|
782
|
+
} else {
|
|
783
|
+
try {
|
|
784
|
+
const res = remove_managed_hooks(settings_path);
|
|
785
|
+
if (res.changed) {
|
|
786
|
+
_print(
|
|
787
|
+
_stdout,
|
|
788
|
+
` · claude-code: removed managed hooks (${res.events.join(', ')})`,
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
} catch (e) {
|
|
792
|
+
_print(_stderr, `⚠️ claude-code: managed-hook removal skipped — ${String(e)}`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
774
796
|
if (lock !== null && !opts.dry_run) {
|
|
775
797
|
const remaining = (lock.tools ?? []).filter((t) => !tools.includes(t));
|
|
776
798
|
const version = String((lock as unknown as AnyDict)['agent_config_version'] ?? '');
|
|
@@ -90,7 +90,7 @@ import * as installed_lock from '../_lib/installed_lock.js';
|
|
|
90
90
|
import * as update_check from '../_lib/update_check.js';
|
|
91
91
|
import * as cli_wrapper from '../_lib/cli_wrapper.js';
|
|
92
92
|
import * as claude_plugin from '../_lib/claude_plugin.js';
|
|
93
|
-
import { event4u_root } from '../_lib/user_global_paths.js';
|
|
93
|
+
import { event4u_root, write_target } from '../_lib/user_global_paths.js';
|
|
94
94
|
import { project_settings_path } from '../_lib/agent_settings.js';
|
|
95
95
|
import { _is_source_repo } from './cmd_refresh.js';
|
|
96
96
|
|
|
@@ -407,10 +407,11 @@ function _refresh_claude_plugin(
|
|
|
407
407
|
interface Args {
|
|
408
408
|
check: boolean;
|
|
409
409
|
dry_run: boolean;
|
|
410
|
+
converge: boolean;
|
|
410
411
|
}
|
|
411
412
|
|
|
412
413
|
const PROG = 'agent-config upgrade';
|
|
413
|
-
const USAGE = `usage: ${PROG} [-h] [--check] [--dry-run]\n`;
|
|
414
|
+
const USAGE = `usage: ${PROG} [-h] [--check] [--dry-run] [--converge]\n`;
|
|
414
415
|
|
|
415
416
|
function _argError(msg: string): never {
|
|
416
417
|
_stderr.write(USAGE);
|
|
@@ -419,7 +420,7 @@ function _argError(msg: string): never {
|
|
|
419
420
|
}
|
|
420
421
|
|
|
421
422
|
function _parse(argv: string[]): Args {
|
|
422
|
-
const args: Args = { check: false, dry_run: false };
|
|
423
|
+
const args: Args = { check: false, dry_run: false, converge: false };
|
|
423
424
|
const positionals: string[] = [];
|
|
424
425
|
let i = 0;
|
|
425
426
|
while (i < argv.length) {
|
|
@@ -447,6 +448,14 @@ function _parse(argv: string[]): Args {
|
|
|
447
448
|
i += 1;
|
|
448
449
|
continue;
|
|
449
450
|
}
|
|
451
|
+
if (flag === '--converge') {
|
|
452
|
+
if (inlineVal !== null) {
|
|
453
|
+
_argError(`argument --converge: ignored explicit argument '${inlineVal}'`);
|
|
454
|
+
}
|
|
455
|
+
args.converge = true;
|
|
456
|
+
i += 1;
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
450
459
|
if (a.startsWith('-') && a !== '-') {
|
|
451
460
|
_argError(`unrecognized arguments: ${a}`);
|
|
452
461
|
}
|
|
@@ -504,12 +513,11 @@ export async function main(argv: string[] | null = null, options: MainOptions =
|
|
|
504
513
|
}
|
|
505
514
|
|
|
506
515
|
const target = `${PACKAGE_NAME}@latest`;
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
];
|
|
516
|
+
const npm_cmd: string[] = ['npm', 'install', '-g', target];
|
|
517
|
+
// --no-ui: the setup wizard is an onboarding surface, not an upgrade
|
|
518
|
+
// step — its foreground server used to block the upgrade until Ctrl-C.
|
|
519
|
+
const global_cmd: string[] = [_agent_config_bin(), 'global', '--no-ui'];
|
|
520
|
+
const steps: string[][] = [npm_cmd, global_cmd];
|
|
513
521
|
|
|
514
522
|
const project_root = options.project_root ?? process.cwd();
|
|
515
523
|
const sync_targets = _settings_sync_targets(project_root);
|
|
@@ -529,23 +537,73 @@ export async function main(argv: string[] | null = null, options: MainOptions =
|
|
|
529
537
|
return 0;
|
|
530
538
|
}
|
|
531
539
|
|
|
532
|
-
|
|
540
|
+
// Step 1 — the new package version. Nothing after makes sense without
|
|
541
|
+
// it, so this is the only hard-abort step.
|
|
542
|
+
_print(out, '→ ' + npm_cmd.join(' '));
|
|
543
|
+
const npm_rc = runner(npm_cmd);
|
|
544
|
+
if (npm_rc !== 0) {
|
|
545
|
+
_print(
|
|
546
|
+
err,
|
|
547
|
+
`❌ agent-config upgrade: step failed (exit ${npm_rc}): ` + `${npm_cmd.join(' ')}`,
|
|
548
|
+
);
|
|
549
|
+
return 1;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Steps 2..N — independent. A failing step no longer skips the rest:
|
|
553
|
+
// the 8.2.0 failure class (aborted `global` step silently skipping the
|
|
554
|
+
// plugin refresh + settings sync) is closed by running every remaining
|
|
555
|
+
// step and reporting per-step outcomes in the summary. A failed
|
|
556
|
+
// essential step (global deploy) still exits 1 — after the others ran.
|
|
557
|
+
const failed_essential: string[] = [];
|
|
558
|
+
for (const cmd of steps.slice(1)) {
|
|
533
559
|
_print(out, '→ ' + cmd.join(' '));
|
|
534
560
|
const rc = runner(cmd);
|
|
535
561
|
if (rc !== 0) {
|
|
562
|
+
failed_essential.push(cmd.join(' '));
|
|
536
563
|
_print(
|
|
537
564
|
err,
|
|
538
|
-
`❌ agent-config upgrade: step failed (exit ${rc}):
|
|
565
|
+
`❌ agent-config upgrade: step failed (exit ${rc}): ${cmd.join(' ')} — ` +
|
|
566
|
+
'continuing with the remaining steps.',
|
|
539
567
|
);
|
|
540
|
-
return 1;
|
|
541
568
|
}
|
|
542
569
|
}
|
|
543
570
|
|
|
544
571
|
// Refresh the OPTIONAL Claude Code plugin so its command surface tracks
|
|
545
|
-
// the upgrade
|
|
546
|
-
//
|
|
572
|
+
// the upgrade — gated on the plugin actually being installed (empty step
|
|
573
|
+
// list otherwise). Deprecation window of the single-surface model: once
|
|
574
|
+
// the plugin is retired this block disappears with it.
|
|
547
575
|
_refresh_claude_plugin(plugin_steps, runner, out, err);
|
|
548
576
|
|
|
577
|
+
// Duplicate-surface convergence (road-to-install-path-convergence
|
|
578
|
+
// Phase 3). Consent model (council Q2, 2026-07-07):
|
|
579
|
+
// --converge → explicit consent for this run; persists
|
|
580
|
+
// install.auto_converge: true as standing consent.
|
|
581
|
+
// standing key true → auto-converge (the user opted in earlier).
|
|
582
|
+
// no key + TTY → converge asks ONE y/N when duplicates exist.
|
|
583
|
+
// no key + non-TTY → today's print-only prompt; NEVER a silent
|
|
584
|
+
// mutation of a user-owned surface.
|
|
585
|
+
{
|
|
586
|
+
const converge_mod = await import('./cmd_converge.js');
|
|
587
|
+
const consent = converge_mod.read_consent(write_target('agent-settings.yml'));
|
|
588
|
+
const tty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
589
|
+
if (args.converge || consent || tty) {
|
|
590
|
+
const rc = converge_mod.main(args.converge ? ['--yes'] : [], { out, err });
|
|
591
|
+
if (rc !== 0 && (args.converge || consent)) {
|
|
592
|
+
failed_essential.push('agent-config converge');
|
|
593
|
+
}
|
|
594
|
+
} else if (plugin_steps.length > 0) {
|
|
595
|
+
_print(
|
|
596
|
+
out,
|
|
597
|
+
'ℹ️ Claude Code marketplace plugin detected — deprecated surface. Hooks now ' +
|
|
598
|
+
'live in ~/.claude/settings.json (wired by the global step above), so the ' +
|
|
599
|
+
'plugin only duplicates skills/commands. Remove it with:\n' +
|
|
600
|
+
' claude plugin uninstall ' +
|
|
601
|
+
`${claude_plugin.CLAUDE_PLUGIN_ID}@${claude_plugin.CLAUDE_MARKETPLACE_NAME}\n` +
|
|
602
|
+
' (or: agent-config converge)',
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
549
607
|
// Bring existing settings files up to the NEW template (additive; the
|
|
550
608
|
// subprocess resolves the freshly installed binary + template).
|
|
551
609
|
_sync_settings_files(sync_targets, runner, out, err);
|
|
@@ -556,10 +614,37 @@ export async function main(argv: string[] | null = null, options: MainOptions =
|
|
|
556
614
|
// (marker-guarded; foreign hooks are never touched).
|
|
557
615
|
_maybe_refresh_git_hook(project_root, runner, out, err);
|
|
558
616
|
|
|
617
|
+
// Doctor runs LAST, every time, non-fatal: its duplicate-surface and
|
|
618
|
+
// hook-wiring findings ARE the upgrade summary's health section — a
|
|
619
|
+
// mixed state (stale plugin, missing hooks) is named in the same run
|
|
620
|
+
// instead of waiting for the user to think of running doctor.
|
|
621
|
+
const doctor_cmd = [_agent_config_bin(), 'doctor'];
|
|
622
|
+
_print(out, '→ ' + doctor_cmd.join(' '));
|
|
623
|
+
const doctor_rc = runner(doctor_cmd);
|
|
624
|
+
if (doctor_rc !== 0) {
|
|
625
|
+
_print(
|
|
626
|
+
err,
|
|
627
|
+
`⚠️ agent-config doctor reported findings (exit ${doctor_rc}) — ` +
|
|
628
|
+
'see the check output above for per-item fixes.',
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (failed_essential.length > 0) {
|
|
633
|
+
_print(
|
|
634
|
+
err,
|
|
635
|
+
`❌ agent-config upgrade finished with ${failed_essential.length} failed ` +
|
|
636
|
+
`step(s):\n` +
|
|
637
|
+
failed_essential.map((c) => ` · ${c}`).join('\n') +
|
|
638
|
+
'\n Re-run `agent-config upgrade`, or run the failed step directly. ' +
|
|
639
|
+
'`agent-config doctor` names anything left in a mixed state.',
|
|
640
|
+
);
|
|
641
|
+
return 1;
|
|
642
|
+
}
|
|
643
|
+
|
|
559
644
|
_print(
|
|
560
645
|
out,
|
|
561
|
-
'✅ agent-config upgraded
|
|
562
|
-
'
|
|
646
|
+
'✅ agent-config upgraded' +
|
|
647
|
+
(doctor_rc === 0 ? ' — doctor green.' : ' — review the doctor findings above.'),
|
|
563
648
|
);
|
|
564
649
|
return 0;
|
|
565
650
|
}
|
|
@@ -105,6 +105,11 @@ Tier 1 — power-user (release shape, audit, migration):
|
|
|
105
105
|
Lists missing, modified, and foreign files.
|
|
106
106
|
Exits 1 on drift, 2 on missing lockfile.
|
|
107
107
|
Flags: --json | --project=<path>
|
|
108
|
+
converge Consented cleanup of duplicate install surfaces
|
|
109
|
+
(surface-matrix driven). Never touches
|
|
110
|
+
user-authored files; persists standing consent
|
|
111
|
+
(install.auto_converge) on first use.
|
|
112
|
+
Flags: --dry-run | --yes
|
|
108
113
|
explain Read-only decision-chain trace.
|
|
109
114
|
Usage: explain config | explain rule <name>
|
|
110
115
|
| explain route "<text>"
|
|
@@ -959,6 +964,13 @@ cmd_doctor() {
|
|
|
959
964
|
exec_ts "$PACKAGE_ROOT/src/scripts/_cli/cmd_doctor.ts" "$@"
|
|
960
965
|
}
|
|
961
966
|
|
|
967
|
+
# `agent-config converge` — consented duplicate-surface cleanup.
|
|
968
|
+
# Exit codes: 0 converged/nothing-to-do/dry-run · 1 refused/failed · 2 usage.
|
|
969
|
+
# See scripts/_cli/cmd_converge.ts.
|
|
970
|
+
cmd_converge() {
|
|
971
|
+
exec_ts "$PACKAGE_ROOT/src/scripts/_cli/cmd_converge.ts" "$@"
|
|
972
|
+
}
|
|
973
|
+
|
|
962
974
|
# `agent-config conformance` — consumer conformance contract
|
|
963
975
|
# (road-to-flow-learnings Phase 0). Doctor --ci semantics plus the five
|
|
964
976
|
# consumer checks (txlog tail, router pointers, dispatcher smoke,
|
|
@@ -1048,6 +1060,7 @@ main() {
|
|
|
1048
1060
|
uninstall) cmd_uninstall "$@" ;;
|
|
1049
1061
|
prune) cmd_prune "$@" ;;
|
|
1050
1062
|
doctor) cmd_doctor "$@" ;;
|
|
1063
|
+
converge) cmd_converge "$@" ;;
|
|
1051
1064
|
conformance) cmd_conformance "$@" ;;
|
|
1052
1065
|
versions) cmd_versions "$@" ;;
|
|
1053
1066
|
explain) cmd_explain "$@" ;;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Managed Claude Code hook registration in settings.json.
|
|
3
|
+
*
|
|
4
|
+
* road-to-claude-code-single-surface Phase 1: the marketplace plugin used to
|
|
5
|
+
* be the only carrier of the deterministic hook matrix (hooks/hooks.json).
|
|
6
|
+
* This module registers the same matrix directly in a Claude Code settings
|
|
7
|
+
* file (`~/.claude/settings.json` for the global install) so the plugin is
|
|
8
|
+
* no longer required for hooks.
|
|
9
|
+
*
|
|
10
|
+
* Contract (parity findings: agents/settings/contexts/claude-code-hook-parity.md):
|
|
11
|
+
*
|
|
12
|
+
* - The hook schema is identical between plugin hooks.json and settings.json;
|
|
13
|
+
* Claude Code fires all sources in parallel and dedupes identical command
|
|
14
|
+
* strings — so a still-installed plugin never double-fires during the
|
|
15
|
+
* migration window.
|
|
16
|
+
* - Managed entries are identified by the dispatch signature inside the
|
|
17
|
+
* command string (MANAGED_SIGNATURE). No extra marker field is written —
|
|
18
|
+
* content-derived identity keeps the entry byte-identical to the plugin's,
|
|
19
|
+
* which is what makes the dedup guarantee hold.
|
|
20
|
+
* - User-owned entries (anything without the signature) are never touched,
|
|
21
|
+
* reordered, or removed. Managed entries are replaced in place per event.
|
|
22
|
+
* - Writes are atomic (temp file + rename via atomicWriteFile) and guarded
|
|
23
|
+
* by a sidecar lockfile so two concurrent installers cannot tear the file.
|
|
24
|
+
* - A settings file that exists but does not parse as JSON is NEVER
|
|
25
|
+
* overwritten — the operation throws CorruptSettingsError and leaves the
|
|
26
|
+
* file byte-identical (the user's file outranks our convenience).
|
|
27
|
+
*
|
|
28
|
+
* The hook matrix itself derives from src/scripts/hook_manifest.yaml via
|
|
29
|
+
* build_claude_hook_matrix() — the same single source generate_plugin_hooks()
|
|
30
|
+
* (condense.ts) uses for the plugin's hooks/hooks.json.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import * as fs from 'node:fs';
|
|
34
|
+
import * as path from 'node:path';
|
|
35
|
+
|
|
36
|
+
import * as YAML from 'yaml';
|
|
37
|
+
|
|
38
|
+
import { atomicWriteFile } from '../../install/atomic.js';
|
|
39
|
+
|
|
40
|
+
// Bundle note: a static import of the CJS `yaml` package requires the
|
|
41
|
+
// createRequire banner in `build:install-bundle` (package.json) — without it
|
|
42
|
+
// the esbuild ESM bundle throws `Dynamic require of "process" is not
|
|
43
|
+
// supported` at module init. A bare lazy `require('yaml')` is NOT an
|
|
44
|
+
// alternative: it is a ReferenceError under plain tsx/node ESM (CI).
|
|
45
|
+
function _yaml_parse(text: string): unknown {
|
|
46
|
+
return YAML.parse(text);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Substring that identifies an entry as agent-config-managed. */
|
|
50
|
+
export const MANAGED_SIGNATURE = 'dispatch:hook --platform claude';
|
|
51
|
+
|
|
52
|
+
export class CorruptSettingsError extends Error {
|
|
53
|
+
constructor(file: string, cause: string) {
|
|
54
|
+
super(
|
|
55
|
+
`${file} exists but is not valid JSON (${cause}). ` +
|
|
56
|
+
'Refusing to overwrite a user-owned settings file — fix or remove it, then re-run.',
|
|
57
|
+
);
|
|
58
|
+
this.name = 'CorruptSettingsError';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class SettingsLockError extends Error {
|
|
63
|
+
constructor(lockPath: string) {
|
|
64
|
+
super(
|
|
65
|
+
`settings lock ${lockPath} is held by another process. ` +
|
|
66
|
+
'Re-run when the concurrent install finishes (stale lock: delete the file).',
|
|
67
|
+
);
|
|
68
|
+
this.name = 'SettingsLockError';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface HookCommand {
|
|
73
|
+
type: string;
|
|
74
|
+
command: string;
|
|
75
|
+
[k: string]: unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface HookGroup {
|
|
79
|
+
matcher?: string;
|
|
80
|
+
hooks: HookCommand[];
|
|
81
|
+
[k: string]: unknown;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** native event name → full shell command, e.g. { SessionStart: "BIN=..." }. */
|
|
85
|
+
export type ClaudeHookMatrix = Record<string, string>;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Derive the Claude hook matrix from hook_manifest.yaml — the SAME source
|
|
89
|
+
* generate_plugin_hooks() uses, so settings-registered hooks and the plugin's
|
|
90
|
+
* hooks.json can never drift from each other.
|
|
91
|
+
*/
|
|
92
|
+
export function build_claude_hook_matrix(manifest_path: string): ClaudeHookMatrix {
|
|
93
|
+
const raw = fs.readFileSync(manifest_path, 'utf8');
|
|
94
|
+
const manifest = (_yaml_parse(raw) ?? {}) as Record<string, unknown>;
|
|
95
|
+
const hook_spec = (manifest['schema_version'] as unknown) ?? 1;
|
|
96
|
+
const platforms = (manifest['platforms'] ?? {}) as Record<string, unknown>;
|
|
97
|
+
const claude_events = ((platforms['claude'] ?? {}) as Record<string, unknown>) || {};
|
|
98
|
+
const aliasesAll = (manifest['native_event_aliases'] ?? {}) as Record<string, unknown>;
|
|
99
|
+
const aliases = ((aliasesAll['claude'] ?? {}) as Record<string, unknown>) || {};
|
|
100
|
+
|
|
101
|
+
const ac_to_native: Record<string, string> = {};
|
|
102
|
+
for (const [native, ac] of Object.entries(aliases)) {
|
|
103
|
+
ac_to_native[String(ac)] = native;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const matrix: ClaudeHookMatrix = {};
|
|
107
|
+
for (const [ac_event, concerns] of Object.entries(claude_events)) {
|
|
108
|
+
if (!concerns || (Array.isArray(concerns) && concerns.length === 0)) continue;
|
|
109
|
+
const native = ac_to_native[ac_event];
|
|
110
|
+
if (native === undefined) continue;
|
|
111
|
+
matrix[native] =
|
|
112
|
+
'BIN="$CLAUDE_PROJECT_DIR/agent-config"; [ -x "$BIN" ] || BIN=agent-config; ' +
|
|
113
|
+
`"$BIN" dispatch:hook --platform claude --event ${ac_event} ` +
|
|
114
|
+
`--native-event ${native} --project-dir "$CLAUDE_PROJECT_DIR" ` +
|
|
115
|
+
`--min-version ${String(hook_spec)}`;
|
|
116
|
+
}
|
|
117
|
+
return matrix;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function _is_managed_group(group: unknown): boolean {
|
|
121
|
+
if (typeof group !== 'object' || group === null || Array.isArray(group)) return false;
|
|
122
|
+
const hooks = (group as HookGroup).hooks;
|
|
123
|
+
if (!Array.isArray(hooks)) return false;
|
|
124
|
+
return hooks.some(
|
|
125
|
+
(h) =>
|
|
126
|
+
typeof h === 'object' &&
|
|
127
|
+
h !== null &&
|
|
128
|
+
typeof (h as HookCommand).command === 'string' &&
|
|
129
|
+
(h as HookCommand).command.includes(MANAGED_SIGNATURE),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function _read_settings(settings_path: string): Record<string, unknown> {
|
|
134
|
+
if (!fs.existsSync(settings_path)) return {};
|
|
135
|
+
const raw = fs.readFileSync(settings_path, 'utf8');
|
|
136
|
+
if (raw.trim() === '') return {};
|
|
137
|
+
let parsed: unknown;
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(raw);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
throw new CorruptSettingsError(settings_path, (e as Error).message);
|
|
142
|
+
}
|
|
143
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
144
|
+
throw new CorruptSettingsError(settings_path, 'top level is not an object');
|
|
145
|
+
}
|
|
146
|
+
return parsed as Record<string, unknown>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function _with_lock<T>(settings_path: string, fn: () => T): T {
|
|
150
|
+
const lockPath = settings_path + '.agent-config.lock';
|
|
151
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
152
|
+
let fd: number;
|
|
153
|
+
try {
|
|
154
|
+
fd = fs.openSync(lockPath, 'wx');
|
|
155
|
+
} catch {
|
|
156
|
+
throw new SettingsLockError(lockPath);
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
return fn();
|
|
160
|
+
} finally {
|
|
161
|
+
fs.closeSync(fd);
|
|
162
|
+
fs.rmSync(lockPath, { force: true });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface EnsureResult {
|
|
167
|
+
/** true when the file content changed on disk. */
|
|
168
|
+
changed: boolean;
|
|
169
|
+
/** native events whose managed entry was added or replaced. */
|
|
170
|
+
events: string[];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Idempotently register the managed hook matrix in a Claude settings file.
|
|
175
|
+
* User entries are preserved untouched; managed entries are replaced in
|
|
176
|
+
* place; a second run with the same matrix produces zero diff.
|
|
177
|
+
*/
|
|
178
|
+
export function ensure_managed_hooks(
|
|
179
|
+
settings_path: string,
|
|
180
|
+
matrix: ClaudeHookMatrix,
|
|
181
|
+
): EnsureResult {
|
|
182
|
+
return _with_lock(settings_path, () => {
|
|
183
|
+
const settings = _read_settings(settings_path);
|
|
184
|
+
const before = JSON.stringify(settings);
|
|
185
|
+
|
|
186
|
+
const hooks = ((settings['hooks'] ?? {}) as Record<string, unknown>) || {};
|
|
187
|
+
const touched: string[] = [];
|
|
188
|
+
|
|
189
|
+
for (const [native, command] of Object.entries(matrix)) {
|
|
190
|
+
const existing = Array.isArray(hooks[native]) ? (hooks[native] as unknown[]) : [];
|
|
191
|
+
const user_groups = existing.filter((g) => !_is_managed_group(g));
|
|
192
|
+
const managed_group: HookGroup = {
|
|
193
|
+
hooks: [{ type: 'command', command }],
|
|
194
|
+
};
|
|
195
|
+
const next = [...user_groups, managed_group];
|
|
196
|
+
if (JSON.stringify(next) !== JSON.stringify(existing)) touched.push(native);
|
|
197
|
+
hooks[native] = next;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
settings['hooks'] = hooks;
|
|
201
|
+
const after = JSON.stringify(settings);
|
|
202
|
+
if (after === before) {
|
|
203
|
+
return { changed: false, events: [] };
|
|
204
|
+
}
|
|
205
|
+
atomicWriteFile(settings_path, JSON.stringify(settings, null, 2) + '\n');
|
|
206
|
+
return { changed: true, events: touched };
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface RemoveResult {
|
|
211
|
+
changed: boolean;
|
|
212
|
+
/** native events a managed entry was removed from. */
|
|
213
|
+
events: string[];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Remove exactly the managed entries (dispatch-signature match) from a
|
|
218
|
+
* Claude settings file. User entries, other settings keys, and files
|
|
219
|
+
* without managed entries are left byte-identical. Missing file → no-op.
|
|
220
|
+
*/
|
|
221
|
+
export function remove_managed_hooks(settings_path: string): RemoveResult {
|
|
222
|
+
if (!fs.existsSync(settings_path)) return { changed: false, events: [] };
|
|
223
|
+
return _with_lock(settings_path, () => {
|
|
224
|
+
const settings = _read_settings(settings_path);
|
|
225
|
+
const hooks = settings['hooks'];
|
|
226
|
+
if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks)) {
|
|
227
|
+
return { changed: false, events: [] };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const hooksRec = hooks as Record<string, unknown>;
|
|
231
|
+
const touched: string[] = [];
|
|
232
|
+
for (const [native, groups] of Object.entries(hooksRec)) {
|
|
233
|
+
if (!Array.isArray(groups)) continue;
|
|
234
|
+
const kept = groups.filter((g) => !_is_managed_group(g));
|
|
235
|
+
if (kept.length !== groups.length) {
|
|
236
|
+
touched.push(native);
|
|
237
|
+
if (kept.length === 0) {
|
|
238
|
+
delete hooksRec[native];
|
|
239
|
+
} else {
|
|
240
|
+
hooksRec[native] = kept;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (touched.length === 0) return { changed: false, events: [] };
|
|
245
|
+
if (Object.keys(hooksRec).length === 0) {
|
|
246
|
+
delete settings['hooks'];
|
|
247
|
+
}
|
|
248
|
+
atomicWriteFile(settings_path, JSON.stringify(settings, null, 2) + '\n');
|
|
249
|
+
return { changed: true, events: touched };
|
|
250
|
+
});
|
|
251
|
+
}
|