@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.
Files changed (44) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/CHANGELOG.md +38 -0
  3. package/MIGRATION.md +18 -0
  4. package/README.md +86 -3
  5. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  6. package/dist/discovery/deprecation-report.md +1 -1
  7. package/dist/discovery/discovery-manifest.json +1 -1
  8. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  9. package/dist/discovery/discovery-manifest.summary.md +1 -1
  10. package/dist/discovery/orphan-report.md +1 -1
  11. package/dist/discovery/packs.json +1 -1
  12. package/dist/discovery/trust-report.md +1 -1
  13. package/dist/discovery/workspaces.json +1 -1
  14. package/dist/install/install.mjs +586 -386
  15. package/dist/mcp/registry-manifest.json +2 -2
  16. package/docs/contracts/CHANGELOG-conventions.md +8 -5
  17. package/docs/contracts/branch-protection-policy.md +14 -5
  18. package/docs/contracts/mcp-phase-1-scope.md +75 -0
  19. package/docs/contracts/mcp-tool-inventory.md +22 -15
  20. package/docs/contracts/release-pr-gating.md +22 -11
  21. package/docs/decisions/ADR-111-canonical-glama-listing.md +97 -0
  22. package/docs/decisions/ADR-112-stdio-lite-stays-read-only.md +92 -0
  23. package/docs/decisions/ADR-113-ci-native-release-label-trigger.md +201 -0
  24. package/docs/decisions/INDEX.md +3 -0
  25. package/docs/distribution/registry-submissions.md +1 -1
  26. package/docs/getting-started.md +8 -6
  27. package/docs/installation.md +13 -14
  28. package/docs/mcp-server.md +32 -20
  29. package/docs/setup/mcp-cloud-endpoints.md +9 -2
  30. package/package.json +2 -2
  31. package/src/scripts/_cli/cmd_doctor.ts +111 -11
  32. package/src/scripts/_cli/cmd_uninstall.ts +22 -0
  33. package/src/scripts/_cli/cmd_upgrade.ts +74 -13
  34. package/src/scripts/_lib/claude_settings_hooks.ts +251 -0
  35. package/src/scripts/condense.ts +7 -29
  36. package/src/scripts/install +23 -4
  37. package/src/scripts/install.ts +30 -0
  38. package/src/scripts/lint_glama_drift.ts +75 -0
  39. package/src/scripts/mcp_exec/safety_envelope.ts +105 -0
  40. package/src/scripts/mcp_registry_submit.sh +14 -3
  41. package/src/scripts/mcp_server/__main__.ts +6 -8
  42. package/src/scripts/mcp_server/consumer_tool_catalog.json +78 -3
  43. package/src/scripts/mcp_server/tools.ts +731 -0
  44. 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 steps: string[][] = [
508
- ['npm', 'install', '-g', target],
509
- // --no-ui: the setup wizard is an onboarding surface, not an upgrade
510
- // step — its foreground server used to block the upgrade until Ctrl-C.
511
- [_agent_config_bin(), 'global', '--no-ui'],
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
- for (const cmd of steps) {
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}): ` + `${cmd.join(' ')}`,
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 (no-op when the plugin is not installed — the file
546
- // projection is a full install on its own).
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. Run `agent-config doctor` to verify ' +
562
- 'PATH + plugin parity.',
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
+ }
@@ -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
- // Python: `manifest = yaml.safe_load(...) or {}`.
1967
- let manifest = _yamlParse(_readText(manifest_path)) as Record<string, unknown> | null;
1968
- if (manifest === null || manifest === undefined) {
1969
- manifest = {};
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 [ac_event, concerns] of Object.entries(claude_events)) {
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
 
@@ -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,58p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
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 minus the --no-ui flag,
286
- # which the bash orchestrator never receives (it errors on unknown args).
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
  }
@@ -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());