@event4u/agent-config 8.2.0 → 8.3.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 +3 -3
- package/CHANGELOG.md +38 -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/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/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/scripts/_cli/cmd_doctor.ts +111 -11
- package/src/scripts/_cli/cmd_uninstall.ts +22 -0
- package/src/scripts/_cli/cmd_upgrade.ts +74 -13
- package/src/scripts/_lib/claude_settings_hooks.ts +251 -0
- package/src/scripts/condense.ts +7 -29
- 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/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
|
@@ -504,12 +504,11 @@ export async function main(argv: string[] | null = null, options: MainOptions =
|
|
|
504
504
|
}
|
|
505
505
|
|
|
506
506
|
const target = `${PACKAGE_NAME}@latest`;
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
];
|
|
507
|
+
const npm_cmd: string[] = ['npm', 'install', '-g', target];
|
|
508
|
+
// --no-ui: the setup wizard is an onboarding surface, not an upgrade
|
|
509
|
+
// step — its foreground server used to block the upgrade until Ctrl-C.
|
|
510
|
+
const global_cmd: string[] = [_agent_config_bin(), 'global', '--no-ui'];
|
|
511
|
+
const steps: string[][] = [npm_cmd, global_cmd];
|
|
513
512
|
|
|
514
513
|
const project_root = options.project_root ?? process.cwd();
|
|
515
514
|
const sync_targets = _settings_sync_targets(project_root);
|
|
@@ -529,23 +528,58 @@ export async function main(argv: string[] | null = null, options: MainOptions =
|
|
|
529
528
|
return 0;
|
|
530
529
|
}
|
|
531
530
|
|
|
532
|
-
|
|
531
|
+
// Step 1 — the new package version. Nothing after makes sense without
|
|
532
|
+
// it, so this is the only hard-abort step.
|
|
533
|
+
_print(out, '→ ' + npm_cmd.join(' '));
|
|
534
|
+
const npm_rc = runner(npm_cmd);
|
|
535
|
+
if (npm_rc !== 0) {
|
|
536
|
+
_print(
|
|
537
|
+
err,
|
|
538
|
+
`❌ agent-config upgrade: step failed (exit ${npm_rc}): ` + `${npm_cmd.join(' ')}`,
|
|
539
|
+
);
|
|
540
|
+
return 1;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Steps 2..N — independent. A failing step no longer skips the rest:
|
|
544
|
+
// the 8.2.0 failure class (aborted `global` step silently skipping the
|
|
545
|
+
// plugin refresh + settings sync) is closed by running every remaining
|
|
546
|
+
// step and reporting per-step outcomes in the summary. A failed
|
|
547
|
+
// essential step (global deploy) still exits 1 — after the others ran.
|
|
548
|
+
const failed_essential: string[] = [];
|
|
549
|
+
for (const cmd of steps.slice(1)) {
|
|
533
550
|
_print(out, '→ ' + cmd.join(' '));
|
|
534
551
|
const rc = runner(cmd);
|
|
535
552
|
if (rc !== 0) {
|
|
553
|
+
failed_essential.push(cmd.join(' '));
|
|
536
554
|
_print(
|
|
537
555
|
err,
|
|
538
|
-
`❌ agent-config upgrade: step failed (exit ${rc}):
|
|
556
|
+
`❌ agent-config upgrade: step failed (exit ${rc}): ${cmd.join(' ')} — ` +
|
|
557
|
+
'continuing with the remaining steps.',
|
|
539
558
|
);
|
|
540
|
-
return 1;
|
|
541
559
|
}
|
|
542
560
|
}
|
|
543
561
|
|
|
544
562
|
// Refresh the OPTIONAL Claude Code plugin so its command surface tracks
|
|
545
|
-
// the upgrade
|
|
546
|
-
//
|
|
563
|
+
// the upgrade — gated on the plugin actually being installed (empty step
|
|
564
|
+
// list otherwise). Deprecation window of the single-surface model: once
|
|
565
|
+
// the plugin is retired this block disappears with it.
|
|
547
566
|
_refresh_claude_plugin(plugin_steps, runner, out, err);
|
|
548
567
|
|
|
568
|
+
// Migration prompt (single-surface model): hooks were wired into
|
|
569
|
+
// ~/.claude/settings.json by the `global` step above, so an installed
|
|
570
|
+
// plugin is now a duplicate surface. Surface the one-line removal —
|
|
571
|
+
// NEVER uninstall autonomously (the plugin is a user-owned surface).
|
|
572
|
+
if (plugin_steps.length > 0) {
|
|
573
|
+
_print(
|
|
574
|
+
out,
|
|
575
|
+
'ℹ️ Claude Code marketplace plugin detected — deprecated surface. Hooks now ' +
|
|
576
|
+
'live in ~/.claude/settings.json (wired by the global step above), so the ' +
|
|
577
|
+
'plugin only duplicates skills/commands. Remove it with:\n' +
|
|
578
|
+
' claude plugin uninstall ' +
|
|
579
|
+
`${claude_plugin.CLAUDE_PLUGIN_ID}@${claude_plugin.CLAUDE_MARKETPLACE_NAME}`,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
549
583
|
// Bring existing settings files up to the NEW template (additive; the
|
|
550
584
|
// subprocess resolves the freshly installed binary + template).
|
|
551
585
|
_sync_settings_files(sync_targets, runner, out, err);
|
|
@@ -556,10 +590,37 @@ export async function main(argv: string[] | null = null, options: MainOptions =
|
|
|
556
590
|
// (marker-guarded; foreign hooks are never touched).
|
|
557
591
|
_maybe_refresh_git_hook(project_root, runner, out, err);
|
|
558
592
|
|
|
593
|
+
// Doctor runs LAST, every time, non-fatal: its duplicate-surface and
|
|
594
|
+
// hook-wiring findings ARE the upgrade summary's health section — a
|
|
595
|
+
// mixed state (stale plugin, missing hooks) is named in the same run
|
|
596
|
+
// instead of waiting for the user to think of running doctor.
|
|
597
|
+
const doctor_cmd = [_agent_config_bin(), 'doctor'];
|
|
598
|
+
_print(out, '→ ' + doctor_cmd.join(' '));
|
|
599
|
+
const doctor_rc = runner(doctor_cmd);
|
|
600
|
+
if (doctor_rc !== 0) {
|
|
601
|
+
_print(
|
|
602
|
+
err,
|
|
603
|
+
`⚠️ agent-config doctor reported findings (exit ${doctor_rc}) — ` +
|
|
604
|
+
'see the check output above for per-item fixes.',
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (failed_essential.length > 0) {
|
|
609
|
+
_print(
|
|
610
|
+
err,
|
|
611
|
+
`❌ agent-config upgrade finished with ${failed_essential.length} failed ` +
|
|
612
|
+
`step(s):\n` +
|
|
613
|
+
failed_essential.map((c) => ` · ${c}`).join('\n') +
|
|
614
|
+
'\n Re-run `agent-config upgrade`, or run the failed step directly. ' +
|
|
615
|
+
'`agent-config doctor` names anything left in a mixed state.',
|
|
616
|
+
);
|
|
617
|
+
return 1;
|
|
618
|
+
}
|
|
619
|
+
|
|
559
620
|
_print(
|
|
560
621
|
out,
|
|
561
|
-
'✅ agent-config upgraded
|
|
562
|
-
'
|
|
622
|
+
'✅ agent-config upgraded' +
|
|
623
|
+
(doctor_rc === 0 ? ' — doctor green.' : ' — review the doctor findings above.'),
|
|
563
624
|
);
|
|
564
625
|
return 0;
|
|
565
626
|
}
|
|
@@ -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
|
+
}
|
package/src/scripts/condense.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
resolve_logical as _agent_src_resolve_logical,
|
|
40
40
|
strip_source_prefix,
|
|
41
41
|
} from './_lib/agent_src.js';
|
|
42
|
+
import { build_claude_hook_matrix } from './_lib/claude_settings_hooks.js';
|
|
42
43
|
import { project_settings_path, load_agent_settings } from './_lib/agent_settings.js';
|
|
43
44
|
import { info, success, flush_summary, resolve_level } from './_lib/script_output.js';
|
|
44
45
|
import {
|
|
@@ -1963,36 +1964,13 @@ export function generate_plugin_hooks(): number {
|
|
|
1963
1964
|
return 0;
|
|
1964
1965
|
}
|
|
1965
1966
|
|
|
1966
|
-
//
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
const hook_spec = (manifest['schema_version'] as unknown) ?? 1;
|
|
1972
|
-
const platforms = (manifest['platforms'] ?? {}) as Record<string, unknown>;
|
|
1973
|
-
const claude_events = ((platforms['claude'] ?? {}) as Record<string, unknown>) || {};
|
|
1974
|
-
const nativeAliasesAll = (manifest['native_event_aliases'] ?? {}) as Record<string, unknown>;
|
|
1975
|
-
const aliases = ((nativeAliasesAll['claude'] ?? {}) as Record<string, unknown>) || {};
|
|
1976
|
-
// Reverse the native→agent-config map.
|
|
1977
|
-
const ac_to_native: Record<string, string> = {};
|
|
1978
|
-
for (const [native, ac] of Object.entries(aliases)) {
|
|
1979
|
-
ac_to_native[String(ac)] = native;
|
|
1980
|
-
}
|
|
1981
|
-
|
|
1967
|
+
// Single source of truth: the SAME matrix derivation the installer uses
|
|
1968
|
+
// for the managed settings.json block (road-to-claude-code-single-surface
|
|
1969
|
+
// Phase 4) — plugin hooks.json and settings hooks cannot drift by
|
|
1970
|
+
// construction. Backstop: tests/install/claude_hook_matrix_parity.test.ts.
|
|
1971
|
+
const matrix = build_claude_hook_matrix(manifest_path);
|
|
1982
1972
|
const hooks: Record<string, unknown[]> = {};
|
|
1983
|
-
for (const [
|
|
1984
|
-
if (!concerns || (Array.isArray(concerns) && concerns.length === 0)) {
|
|
1985
|
-
continue;
|
|
1986
|
-
}
|
|
1987
|
-
const native = ac_to_native[ac_event];
|
|
1988
|
-
if (native === undefined) {
|
|
1989
|
-
continue;
|
|
1990
|
-
}
|
|
1991
|
-
const command =
|
|
1992
|
-
'BIN="$CLAUDE_PROJECT_DIR/agent-config"; [ -x "$BIN" ] || BIN=agent-config; ' +
|
|
1993
|
-
`"$BIN" dispatch:hook --platform claude --event ${ac_event} ` +
|
|
1994
|
-
`--native-event ${native} --project-dir "$CLAUDE_PROJECT_DIR" ` +
|
|
1995
|
-
`--min-version ${String(hook_spec)}`;
|
|
1973
|
+
for (const [native, command] of Object.entries(matrix)) {
|
|
1996
1974
|
hooks[native] = [{ hooks: [{ type: 'command', command }] }];
|
|
1997
1975
|
}
|
|
1998
1976
|
|
package/src/scripts/install
CHANGED
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
# the comma-separated values are unioned.
|
|
28
28
|
# --list-tools Print supported tool IDs with descriptions, then exit
|
|
29
29
|
# --yes, -y Non-interactive mode: do not prompt (default for non-TTY)
|
|
30
|
+
# --no-ui Unattended install: never launch the browser setup
|
|
31
|
+
# wizard and skip the terminal tool picker (tools default
|
|
32
|
+
# to all). Used by `agent-config upgrade`.
|
|
30
33
|
# --force Overwrite existing bridge files
|
|
31
34
|
# --dry-run Show what payload sync would do (does not run bridges)
|
|
32
35
|
# --verbose Detailed payload sync output
|
|
@@ -95,12 +98,13 @@ MINIMAL=false
|
|
|
95
98
|
VALIDATE_ONLY=false
|
|
96
99
|
FLEET_CONFIG=""
|
|
97
100
|
USER_TYPE=""
|
|
101
|
+
NO_UI=false
|
|
98
102
|
|
|
99
103
|
# Single source of truth for valid tool IDs (also referenced by install.sh / install.py).
|
|
100
104
|
VALID_TOOLS="claude-code claude-desktop cursor windsurf cline gemini-cli copilot augment aider codex roocode continue kilocode zed jetbrains kiro all"
|
|
101
105
|
|
|
102
106
|
show_help() {
|
|
103
|
-
sed -n '3,
|
|
107
|
+
sed -n '3,61p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
list_tools() {
|
|
@@ -167,6 +171,7 @@ while [[ $# -gt 0 ]]; do
|
|
|
167
171
|
--ai=*) TOOLS="${TOOLS:+$TOOLS,}${1#*=}"; TOOLS_EXPLICIT=true; shift ;;
|
|
168
172
|
--list-tools) LIST_TOOLS=true; shift ;;
|
|
169
173
|
--yes|-y) YES=true; shift ;;
|
|
174
|
+
--no-ui) NO_UI=true; shift ;;
|
|
170
175
|
--force) FORCE=true; shift ;;
|
|
171
176
|
--dry-run) DRY_RUN=true; shift ;;
|
|
172
177
|
--verbose) VERBOSE=true; shift ;;
|
|
@@ -194,6 +199,15 @@ if $LIST_TOOLS; then
|
|
|
194
199
|
exit 0
|
|
195
200
|
fi
|
|
196
201
|
|
|
202
|
+
# --no-ui = unattended install (the `agent-config upgrade` path). Export the
|
|
203
|
+
# suppression env so every downstream stage (install.ts honors both the flag
|
|
204
|
+
# and AGENT_CONFIG_NO_UI) skips the browser setup wizard — the wizard is an
|
|
205
|
+
# onboarding surface whose foreground server used to block `upgrade` until
|
|
206
|
+
# Ctrl-C.
|
|
207
|
+
if $NO_UI; then
|
|
208
|
+
export AGENT_CONFIG_NO_UI=1
|
|
209
|
+
fi
|
|
210
|
+
|
|
197
211
|
# road-to-global-only-install § Phase 3.3 — consumer-floor gate.
|
|
198
212
|
#
|
|
199
213
|
# Consumer installs are global-only (ADR-020). Explicit --scope=project
|
|
@@ -282,8 +296,8 @@ prompt_tools() {
|
|
|
282
296
|
# When an interactive global install will hand off to the browser wizard,
|
|
283
297
|
# the wizard is the single tool-selection surface — skip the terminal
|
|
284
298
|
# picker so it does not pre-empt (and, via TOOLS_EXPLICIT, suppress) the
|
|
285
|
-
# GUI. Mirrors install.py::_wizard_should_launch
|
|
286
|
-
#
|
|
299
|
+
# GUI. Mirrors install.py::_wizard_should_launch (the --no-ui flag is
|
|
300
|
+
# handled here too — it suppresses both the wizard and the picker).
|
|
287
301
|
# Headless paths (no TTY / CI / AGENT_CONFIG_NO_UI) still get the picker.
|
|
288
302
|
wizard_will_handle_tools=false
|
|
289
303
|
if $GLOBAL && [[ -t 0 && -t 1 && -z "${CI:-}" ]] \
|
|
@@ -291,8 +305,12 @@ if $GLOBAL && [[ -t 0 && -t 1 && -z "${CI:-}" ]] \
|
|
|
291
305
|
wizard_will_handle_tools=true
|
|
292
306
|
fi
|
|
293
307
|
|
|
308
|
+
# --no-ui also skips the terminal picker: the flag means "fully unattended"
|
|
309
|
+
# (its only caller today is `agent-config upgrade`, where a mid-upgrade
|
|
310
|
+
# prompt reads as a hang). Tools fall through to the "all" default below —
|
|
311
|
+
# the same effective selection the wizard-handoff path used.
|
|
294
312
|
if ! $MINIMAL && ! $TOOLS_EXPLICIT && ! $YES && ! $QUIET && ! $LIST_TOOLS \
|
|
295
|
-
&& [[ -t 0 && -t 1 ]] && ! $wizard_will_handle_tools; then
|
|
313
|
+
&& [[ -t 0 && -t 1 ]] && ! $wizard_will_handle_tools && ! $NO_UI; then
|
|
296
314
|
prompt_tools
|
|
297
315
|
TOOLS_EXPLICIT=true
|
|
298
316
|
fi
|
|
@@ -416,6 +434,7 @@ run_bridges() {
|
|
|
416
434
|
[[ -n "$CUSTOM_PATH" ]] && args+=(--custom-path="$CUSTOM_PATH")
|
|
417
435
|
$OFFLINE && args+=(--offline)
|
|
418
436
|
$MINIMAL && args+=(--minimal)
|
|
437
|
+
$NO_UI && args+=(--no-ui)
|
|
419
438
|
args+=(--tools="$TOOLS")
|
|
420
439
|
"${runner[@]}" "${args[@]}"
|
|
421
440
|
}
|
package/src/scripts/install.ts
CHANGED
|
@@ -78,6 +78,7 @@ import * as global_deploy_inventory from './_lib/global_deploy_inventory.js';
|
|
|
78
78
|
import * as installed_tools from './_lib/installed_tools.js';
|
|
79
79
|
import * as user_global_paths from './_lib/user_global_paths.js';
|
|
80
80
|
import * as claude_desktop_bundler from './_lib/claude_desktop_bundler.js';
|
|
81
|
+
import * as claude_settings_hooks from './_lib/claude_settings_hooks.js';
|
|
81
82
|
import { find_project_root_with_anchor, load_agent_settings } from './_lib/agent_settings.js';
|
|
82
83
|
import { detect_module_roots } from './_lib/module_detection.js';
|
|
83
84
|
import {
|
|
@@ -2914,6 +2915,35 @@ function _deploy_global_content(
|
|
|
2914
2915
|
}
|
|
2915
2916
|
_emit_progress({ type: 'reaped', tool: tool_id, count: reaped.length });
|
|
2916
2917
|
results[tool_id] = [written_total, skipped_total, 'deployed', written_paths];
|
|
2918
|
+
|
|
2919
|
+
// Single-surface model (road-to-claude-code-single-surface): the
|
|
2920
|
+
// deterministic hook matrix registers directly in the user-scope
|
|
2921
|
+
// settings file — the marketplace plugin is no longer required for
|
|
2922
|
+
// hooks. Failure here must not sink the content deploy (hooks can
|
|
2923
|
+
// be re-wired via `agent-config refresh --global` / doctor).
|
|
2924
|
+
if (tool_id === 'claude-code') {
|
|
2925
|
+
try {
|
|
2926
|
+
const manifest = path.join(package_root, 'src', 'scripts', 'hook_manifest.yaml');
|
|
2927
|
+
const matrix = claude_settings_hooks.build_claude_hook_matrix(manifest);
|
|
2928
|
+
const res = claude_settings_hooks.ensure_managed_hooks(
|
|
2929
|
+
path.join(anchor, 'settings.json'),
|
|
2930
|
+
matrix,
|
|
2931
|
+
);
|
|
2932
|
+
if (!state.QUIET) {
|
|
2933
|
+
if (res.changed) {
|
|
2934
|
+
success(
|
|
2935
|
+
`claude-code: managed hooks registered in ~/.claude/settings.json ` +
|
|
2936
|
+
`(${res.events.length} event(s))`,
|
|
2937
|
+
);
|
|
2938
|
+
} else {
|
|
2939
|
+
skip('claude-code: managed hooks already registered');
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
} catch (e) {
|
|
2943
|
+
warn(`claude-code: managed-hook registration failed — ${String(e)}`);
|
|
2944
|
+
_emit_progress({ type: 'hooks_failed', tool: tool_id, error: String(e) });
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2917
2947
|
}
|
|
2918
2948
|
return results;
|
|
2919
2949
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Guard against `internal/glama/README.md` drifting from the committed
|
|
4
|
+
* `internal/glama/build` / `internal/glama/run` scripts it documents.
|
|
5
|
+
*
|
|
6
|
+
* Extracts the single canonical command line each script actually runs
|
|
7
|
+
* (the last non-comment, non-`set -euo pipefail` line — `exec ` stripped)
|
|
8
|
+
* and asserts that literal string appears verbatim somewhere in the README.
|
|
9
|
+
* Catches the exact failure mode fixed in road-to-mcp-full-power.md Phase 1:
|
|
10
|
+
* the README described a Python/uv build long after the scripts moved to
|
|
11
|
+
* Node/tsx.
|
|
12
|
+
*
|
|
13
|
+
* Exit codes: 0 no drift, 1 one or more scripts' canonical command is not
|
|
14
|
+
* documented verbatim in the README.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
22
|
+
const REPO = path.resolve(path.dirname(_HERE), '..', '..');
|
|
23
|
+
const GLAMA_DIR = path.join(REPO, 'internal', 'glama');
|
|
24
|
+
|
|
25
|
+
interface ScriptSpec {
|
|
26
|
+
file: string;
|
|
27
|
+
/** Strip this exact prefix from the extracted command line before comparing. */
|
|
28
|
+
stripPrefix?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SCRIPTS: readonly ScriptSpec[] = [
|
|
32
|
+
{ file: 'build' },
|
|
33
|
+
{ file: 'run', stripPrefix: 'exec ' },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function _canonicalCommand(scriptPath: string, stripPrefix?: string): string {
|
|
37
|
+
const lines = fs.readFileSync(scriptPath, 'utf8').split('\n');
|
|
38
|
+
const candidates = lines
|
|
39
|
+
.map((l) => l.trim())
|
|
40
|
+
.filter((l) => l.length > 0 && !l.startsWith('#') && l !== 'set -euo pipefail');
|
|
41
|
+
const last = candidates[candidates.length - 1] ?? '';
|
|
42
|
+
return stripPrefix && last.startsWith(stripPrefix) ? last.slice(stripPrefix.length) : last;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function main(): number {
|
|
46
|
+
const readmePath = path.join(GLAMA_DIR, 'README.md');
|
|
47
|
+
const readme = fs.readFileSync(readmePath, 'utf8');
|
|
48
|
+
|
|
49
|
+
let failed = false;
|
|
50
|
+
for (const spec of SCRIPTS) {
|
|
51
|
+
const scriptPath = path.join(GLAMA_DIR, spec.file);
|
|
52
|
+
const cmd = _canonicalCommand(scriptPath, spec.stripPrefix);
|
|
53
|
+
if (!cmd) {
|
|
54
|
+
console.error(`❌ internal/glama/${spec.file}: could not extract a canonical command line`);
|
|
55
|
+
failed = true;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!readme.includes(cmd)) {
|
|
59
|
+
console.error(
|
|
60
|
+
`❌ drift: internal/glama/${spec.file} runs \`${cmd}\` — not found verbatim in internal/glama/README.md`,
|
|
61
|
+
);
|
|
62
|
+
failed = true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (failed) {
|
|
67
|
+
console.error('\nUpdate internal/glama/README.md to match the committed build/run scripts.');
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
process.stdout.write('✅ internal/glama/README.md matches build/run scripts — no drift.\n');
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
process.exit(main());
|