magus-cli 7.3.1 → 7.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.
Files changed (123) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  3. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  4. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  5. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  6. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  7. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  8. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  9. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  10. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  11. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  12. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  13. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  14. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  15. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  16. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  17. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  18. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  19. package/src/__tests__/blind/child-run.ts +114 -0
  20. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  21. package/src/__tests__/component-badge.test.ts +20 -36
  22. package/src/__tests__/footer-hints.test.ts +1 -1
  23. package/src/__tests__/manifest.test.ts +25 -17
  24. package/src/__tests__/marketplace-badge.test.ts +4 -4
  25. package/src/__tests__/models-screen-state.test.ts +36 -13
  26. package/src/__tests__/scope-squares.test.tsx +28 -114
  27. package/src/__tests__/shutdown.test.ts +578 -0
  28. package/src/__tests__/styles-manager.test.ts +4 -4
  29. package/src/__tests__/styles-screen-state.test.ts +26 -12
  30. package/src/__tests__/tabbar-layout.test.ts +44 -32
  31. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  32. package/src/__tests__/tool-probe.test.ts +47 -22
  33. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  34. package/src/cli/ansi.ts +2 -2
  35. package/src/cli/bootstrap.ts +33 -2
  36. package/src/cli/doctor.ts +105 -14
  37. package/src/cli/install.ts +8 -2
  38. package/src/cli/profile.ts +18 -1
  39. package/src/cli/router.ts +31 -18
  40. package/src/cli/update-view.ts +1 -1
  41. package/src/data/cli-tools.ts +9 -1
  42. package/src/services/claude-settings.ts +151 -73
  43. package/src/services/cli-tool-commands.ts +15 -5
  44. package/src/services/cli-tool-version.ts +101 -0
  45. package/src/services/hook-registration.ts +23 -6
  46. package/src/services/manifest.ts +117 -52
  47. package/src/services/plugin-manager.ts +19 -5
  48. package/src/services/profile-adopt.ts +7 -2
  49. package/src/services/profile-startup.ts +87 -7
  50. package/src/services/profiles.ts +36 -23
  51. package/src/services/settings-file.ts +158 -4
  52. package/src/services/settings-manager.ts +19 -5
  53. package/src/services/skills-manager.ts +46 -0
  54. package/src/services/tool-probe.ts +272 -45
  55. package/src/services/toolchain.ts +39 -0
  56. package/src/services/update-engine.ts +5 -0
  57. package/src/services/update-plan.ts +9 -0
  58. package/src/tui.tsx +48 -21
  59. package/src/ui/App.tsx +58 -197
  60. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  61. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  62. package/src/ui/adapters/settingsAdapter.ts +0 -21
  63. package/src/ui/adapters/skillsAdapter.ts +27 -8
  64. package/src/ui/adapters/stylesAdapter.ts +15 -11
  65. package/src/ui/components/CategoryHeader.tsx +7 -1
  66. package/src/ui/components/EmptyFilterState.tsx +2 -2
  67. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  68. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  69. package/src/ui/components/TabBar.tsx +93 -21
  70. package/src/ui/components/layout/FooterHints.tsx +6 -27
  71. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  72. package/src/ui/components/layout/index.ts +1 -5
  73. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  74. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  75. package/src/ui/components/modals/MessageModal.tsx +3 -15
  76. package/src/ui/components/modals/ModalContainer.tsx +99 -60
  77. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  78. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  79. package/src/ui/components/primitives/MetaText.tsx +1 -1
  80. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  81. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  82. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  83. package/src/ui/components/primitives/index.ts +4 -3
  84. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  85. package/src/ui/input/KeyRouter.tsx +180 -0
  86. package/src/ui/input/key-dispatch.ts +149 -0
  87. package/src/ui/keymap.ts +898 -0
  88. package/src/ui/renderers/cliToolRenderers.tsx +128 -66
  89. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  90. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  91. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  92. package/src/ui/renderers/modelRenderers.tsx +12 -13
  93. package/src/ui/renderers/modelVisuals.tsx +0 -2
  94. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  95. package/src/ui/renderers/profileRenderers.tsx +32 -37
  96. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  97. package/src/ui/renderers/skillRenderers.tsx +237 -190
  98. package/src/ui/renderers/styleRenderers.tsx +13 -2
  99. package/src/ui/runtime/shutdown.ts +268 -0
  100. package/src/ui/screens/AliasScreen.tsx +118 -100
  101. package/src/ui/screens/CliToolsScreen.tsx +98 -87
  102. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  103. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  104. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  105. package/src/ui/screens/McpScreen.tsx +47 -33
  106. package/src/ui/screens/ModelsScreen.tsx +59 -104
  107. package/src/ui/screens/PluginsScreen.tsx +123 -127
  108. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  109. package/src/ui/screens/SkillsScreen.tsx +184 -204
  110. package/src/ui/screens/StylesScreen.tsx +104 -157
  111. package/src/ui/state/AppContext.tsx +6 -0
  112. package/src/ui/state/DimensionsContext.tsx +1 -10
  113. package/src/ui/state/reducer.ts +5 -8
  114. package/src/ui/state/types.ts +11 -9
  115. package/src/ui/text-width.ts +341 -0
  116. package/src/ui/theme-mode.ts +34 -71
  117. package/src/ui/theme.ts +50 -28
  118. package/src/utils/request-gate.ts +37 -0
  119. package/src/ui/components/ScopeIndicator.tsx +0 -63
  120. package/src/ui/components/SearchInput.tsx +0 -57
  121. package/src/ui/hooks/index.ts +0 -2
  122. package/src/ui/hooks/useKeyboard.ts +0 -24
  123. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -42,7 +42,7 @@ import {
42
42
  import os from "node:os";
43
43
  import path from "node:path";
44
44
  import semver from "semver";
45
- import type { CliTool } from "../data/cli-tools.js";
45
+ import { CLAUDE_CODE_NPM_PACKAGE, type CliTool } from "../data/cli-tools.js";
46
46
  import type {
47
47
  BinInstaller,
48
48
  BinRequirement,
@@ -50,6 +50,15 @@ import type {
50
50
  } from "../types/index.js";
51
51
  import type { Command } from "../utils/command-utils.js";
52
52
  import type { InstallMethod } from "./cli-tool-commands.js";
53
+ import {
54
+ readPackageVersionForBin,
55
+ readVersionDirForBin,
56
+ versionFromVersionedPath,
57
+ } from "./cli-tool-version.js";
58
+ import {
59
+ claudeNativeUpgradeCommand,
60
+ npmPrefixInstallCommand,
61
+ } from "./toolchain.js";
53
62
 
54
63
  // ── Types ────────────────────────────────────────────────────────────────────
55
64
 
@@ -84,6 +93,10 @@ export type ToolFactSource =
84
93
  | "pypi"
85
94
  | "npm-registry"
86
95
  | "go-proxy"
96
+ /** the package.json of the npm/bun package the PATH binary lives in. */
97
+ | "package-json"
98
+ /** the file name of a versioned install (`…/versions/2.1.276`). */
99
+ | "version-dir"
87
100
  | "check-command";
88
101
 
89
102
  export interface ToolFacts {
@@ -124,6 +137,24 @@ export interface ToolFacts {
124
137
  * carried to both front ends.
125
138
  */
126
139
  adopted?: string;
140
+ /**
141
+ * The command that upgrades the file PATH runs, when it differs from the
142
+ * spec's own installer command. Absent otherwise — and then the ordinary
143
+ * builder (`binCommandForState`, `cliToolUpdate`) is right.
144
+ *
145
+ * Set in two cases, both where the owner is known but is not what the spec's
146
+ * installer would write to:
147
+ * - an npm global install under ANOTHER prefix than the one `npm root -g`
148
+ * reports: `npm install -g --prefix <prefix> <pkg>@<latest|pin>`;
149
+ * - Claude Code's native installer: `claude update` (`claude install <pin>`
150
+ * when pinned).
151
+ *
152
+ * Built here, once, and carried to both front ends — `magus update` through
153
+ * its plan item, the TUI through its row — so the two run the same argv.
154
+ * It already aims at the only target the classifier can plan for this spec:
155
+ * the pin when there is one, the newest version otherwise.
156
+ */
157
+ upgradeCommand?: Command;
127
158
  /** e.g. "timed out after 8s", "registry returned 404". */
128
159
  failure?: string;
129
160
  /** Where each fact came from, for diagnostics. */
@@ -188,6 +219,16 @@ function withFailure(text: string, failure: string | undefined): string {
188
219
  return failure ? `${text} (${failure})` : text;
189
220
  }
190
221
 
222
+ /** The `not-managed` sentence. Names the file and its owner, never the version. */
223
+ function notManagedNote(spec: BinRequirement, facts: ToolFacts): string {
224
+ return facts.managed === false
225
+ ? `on PATH at ${facts.resolvedPath ?? "?"}, which ${facts.owner ?? spec.via} does not own — upgrading through ${spec.via} would install a second copy`
226
+ : withFailure(
227
+ `could not tell whether ${spec.via} owns ${facts.resolvedPath ?? spec.name}`,
228
+ facts.failure,
229
+ );
230
+ }
231
+
191
232
  /**
192
233
  * THE tool classifier. Pure. The first matching rule wins, and each branch
193
234
  * returns, so reading top to bottom is reading the precedence.
@@ -198,6 +239,13 @@ function withFailure(text: string, failure: string | undefined): string {
198
239
  * held formula is a no-op. Both would be replanned forever. The pin rules that
199
240
  * plan no work (at-pin, unorderable, ahead-of-pin) come before the vetoes,
200
241
  * because ownership cannot make a "nothing to do" answer wrong.
242
+ *
243
+ * One ownership answer comes earlier still: a file KNOWN to be nobody's the
244
+ * spec's installer reaches (`managed === false`) is `not-managed` even when no
245
+ * version could be read. That is where an unreadable version usually comes
246
+ * from, and "does not own it" is the sentence the user can act on. The pin
247
+ * rules are not displaced by it — they need a version to compare, and there is
248
+ * none.
201
249
  */
202
250
  export function classifyTool(
203
251
  spec: BinRequirement,
@@ -221,8 +269,23 @@ export function classifyTool(
221
269
  };
222
270
  }
223
271
 
224
- // 2. Present, but we could not read what version runs.
272
+ // 2. Present, owned by nobody the spec's installer is — and no version read.
273
+ // The ownership answer is the actionable one: the version is unreadable
274
+ // BECAUSE no installer's record covers the file (and check commands are off
275
+ // while planning), so "could not read its version" would hide the reason.
276
+ // Only a DEFINITE `false`; an undetermined owner (null) is not a finding.
225
277
  const installed = facts.installed;
278
+ if (installed === null && facts.managed === false) {
279
+ return {
280
+ ...base,
281
+ state: "unknown",
282
+ reason: "not-managed",
283
+ target: null,
284
+ note: notManagedNote(spec, facts),
285
+ };
286
+ }
287
+
288
+ // 3. Present, but we could not read what version runs.
226
289
  if (installed === null) {
227
290
  return {
228
291
  ...base,
@@ -236,7 +299,7 @@ export function classifyTool(
236
299
  };
237
300
  }
238
301
 
239
- // 3–5. Pin rules that plan no work.
302
+ // 4–6. Pin rules that plan no work.
240
303
  const vsPin = pin ? compareToolVersions(installed, pin) : undefined;
241
304
  if (pin && vsPin === 0) {
242
305
  return { ...base, state: "current", reason: "at-pin", target: null };
@@ -261,24 +324,18 @@ export function classifyTool(
261
324
  };
262
325
  }
263
326
 
264
- // 6. The veto: the upgrade would not replace the file PATH runs.
327
+ // 7. The veto: the upgrade would not replace the file PATH runs.
265
328
  if (facts.managed !== true) {
266
329
  return {
267
330
  ...base,
268
331
  state: "unknown",
269
332
  reason: "not-managed",
270
333
  target: null,
271
- note:
272
- facts.managed === false
273
- ? `on PATH at ${facts.resolvedPath ?? "?"}, which ${facts.owner ?? spec.via} does not own — upgrading through ${spec.via} would install a second copy`
274
- : withFailure(
275
- `could not tell whether ${spec.via} owns ${facts.resolvedPath ?? spec.name}`,
276
- facts.failure,
277
- ),
334
+ note: notManagedNote(spec, facts),
278
335
  };
279
336
  }
280
337
 
281
- // 7. The veto: the installer will refuse to move it.
338
+ // 8. The veto: the installer will refuse to move it.
282
339
  if (facts.heldBack) {
283
340
  return {
284
341
  ...base,
@@ -289,7 +346,7 @@ export function classifyTool(
289
346
  };
290
347
  }
291
348
 
292
- // 8–9. Pinned and behind the pin (every other pinned case returned above).
349
+ // 9–10. Pinned and behind the pin (every other pinned case returned above).
293
350
  // From here on a row can plan work, so an adoption must travel with it.
294
351
  const adopted = facts.adopted ? { adopted: facts.adopted } : {};
295
352
  if (pin) {
@@ -312,7 +369,7 @@ export function classifyTool(
312
369
  };
313
370
  }
314
371
 
315
- // 10–11. The owning installer's own verdict.
372
+ // 11–12. The owning installer's own verdict.
316
373
  if (facts.behind === true) {
317
374
  return {
318
375
  ...base,
@@ -329,7 +386,7 @@ export function classifyTool(
329
386
  return { ...base, state: "current", reason: "up-to-date", target: null };
330
387
  }
331
388
 
332
- // 12. Nothing to compare against.
389
+ // 13. Nothing to compare against.
333
390
  const latest = facts.latest;
334
391
  if (latest === null) {
335
392
  return {
@@ -341,7 +398,7 @@ export function classifyTool(
341
398
  };
342
399
  }
343
400
 
344
- // 13. Our own comparison.
401
+ // 14. Our own comparison.
345
402
  const cmp = compareToolVersions(installed, latest);
346
403
  if (cmp === null) {
347
404
  return {
@@ -598,6 +655,33 @@ export function isInside(resolved: string, ownerDir: string): boolean {
598
655
  return file.startsWith(dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`);
599
656
  }
600
657
 
658
+ /**
659
+ * The npm prefix whose global tree holds `resolved`, read from the path alone:
660
+ * `<prefix>/lib/node_modules/<pkg>/…` → `<prefix>`. Null when the file is not
661
+ * inside that package's directory under any `lib/node_modules`.
662
+ *
663
+ * This is npm's own global layout, so it names the owner even when the npm
664
+ * first on PATH reports another root — one machine with nvm, Homebrew and
665
+ * `~/.local` Node installs has three. The package segment must be `pkg`
666
+ * itself: a file of some other package is not what installing `pkg` replaces.
667
+ *
668
+ * A Homebrew keg is excluded. A formula bundles its package under
669
+ * `Cellar/<formula>/<version>/libexec/lib/node_modules/…`, which has the same
670
+ * shape, but brew owns that tree; npm writing into it would leave the keg's
671
+ * version and its contents disagreeing.
672
+ */
673
+ export function npmGlobalPrefixOf(
674
+ resolved: string,
675
+ pkg: string,
676
+ ): string | null {
677
+ const marker = `${path.sep}${path.join("lib", "node_modules", pkg)}${path.sep}`;
678
+ const at = resolved.indexOf(marker);
679
+ if (at < 0) return null;
680
+ const prefix = resolved.slice(0, at) || path.sep;
681
+ if (prefix.split(path.sep).includes("Cellar")) return null;
682
+ return prefix;
683
+ }
684
+
601
685
  // ── IO seam ──────────────────────────────────────────────────────────────────
602
686
 
603
687
  export interface ExecResult {
@@ -1261,44 +1345,145 @@ async function probePip(
1261
1345
  if (!pinned) await pypiLatest(s, pkg, f);
1262
1346
  }
1263
1347
 
1348
+ /** Version from the package directory, latest from the registry (unpinned only). */
1349
+ async function readNodePackage(
1350
+ s: ProbeSession,
1351
+ pkg: string,
1352
+ f: ToolFacts,
1353
+ pkgDir: string,
1354
+ source: "bun-global" | "npm-global",
1355
+ pinned: boolean,
1356
+ ): Promise<void> {
1357
+ f.installed = await packageJsonVersion(s.io, pkgDir);
1358
+ if (f.installed) addSource(f, source);
1359
+ if (!pinned) await npmLatest(s, pkg, f);
1360
+ }
1361
+
1264
1362
  async function probeNodeGlobal(
1265
1363
  s: ProbeSession,
1266
1364
  spec: BinRequirement,
1267
1365
  f: ToolFacts,
1268
1366
  resolvedPath: string,
1269
- pinned: boolean,
1270
1367
  ): Promise<void> {
1271
1368
  const pkg = spec.package ?? spec.name;
1272
- let root: string;
1369
+ const pin = pinOf(spec);
1370
+ const pinned = pin !== undefined;
1273
1371
  if (spec.via === "bun") {
1274
- root = path.join(
1372
+ const root = path.join(
1275
1373
  s.io.env.BUN_INSTALL ?? path.join(s.io.homedir, ".bun"),
1276
1374
  "install",
1277
1375
  "global",
1278
1376
  "node_modules",
1279
1377
  );
1280
1378
  f.owner = `bun (global ${root})`;
1281
- } else {
1282
- const npmRoot = await s.npmRoot();
1379
+ const { inside, real } = await containedIn(
1380
+ s,
1381
+ resolvedPath,
1382
+ path.join(root, pkg),
1383
+ );
1384
+ f.managed = inside;
1385
+ if (inside && real)
1386
+ await readNodePackage(s, pkg, f, real, "bun-global", pinned);
1387
+ return;
1388
+ }
1389
+
1390
+ // The root the npm first on PATH installs into.
1391
+ const npmRoot = await s.npmRoot();
1392
+ if ("dir" in npmRoot) {
1393
+ f.owner = `npm (root ${npmRoot.dir})`;
1394
+ const { inside, real } = await containedIn(
1395
+ s,
1396
+ resolvedPath,
1397
+ path.join(npmRoot.dir, pkg),
1398
+ );
1399
+ if (inside && real) {
1400
+ f.managed = true;
1401
+ await readNodePackage(s, pkg, f, real, "npm-global", pinned);
1402
+ return;
1403
+ }
1404
+ }
1405
+
1406
+ // Not under that root — but perhaps under ANOTHER npm's. The file's own
1407
+ // path names the prefix that owns it, and `--prefix` upgrades exactly that
1408
+ // tree, so this is npm-managed, never the "second copy" veto.
1409
+ const prefix = npmGlobalPrefixOf(resolvedPath, pkg);
1410
+ if (prefix === null) {
1283
1411
  if ("failure" in npmRoot) {
1284
1412
  f.managed = null;
1285
1413
  noteFailure(f, npmRoot.failure);
1286
- return;
1287
- }
1288
- root = npmRoot.dir;
1289
- f.owner = `npm (root ${root})`;
1414
+ } else f.managed = false;
1415
+ return;
1290
1416
  }
1291
- const { inside, real } = await containedIn(
1292
- s,
1293
- resolvedPath,
1294
- path.join(root, pkg),
1295
- );
1296
- f.managed = inside;
1297
- if (!inside || !real) return;
1298
- f.installed = await packageJsonVersion(s.io, real);
1299
- if (f.installed)
1300
- addSource(f, spec.via === "bun" ? "bun-global" : "npm-global");
1301
- if (!pinned) await npmLatest(s, pkg, f);
1417
+ f.managed = true;
1418
+ f.owner = `npm (prefix ${prefix})`;
1419
+ // Reached with the default root only when containment could not resolve the
1420
+ // package directory; the plain command is right then, and --prefix is noise.
1421
+ if (!(await isNpmDefaultPrefix(s, npmRoot, prefix))) {
1422
+ f.upgradeCommand = npmPrefixInstallCommand(prefix, pkg, pin);
1423
+ }
1424
+ // `resolvedPath` is already real, so every ancestor of it is too.
1425
+ const pkgDir = path.join(prefix, "lib", "node_modules", pkg);
1426
+ await readNodePackage(s, pkg, f, pkgDir, "npm-global", pinned);
1427
+ }
1428
+
1429
+ /** Does `prefix` hold the global root `npm root -g` reported? False when it failed. */
1430
+ async function isNpmDefaultPrefix(
1431
+ s: ProbeSession,
1432
+ npmRoot: Located,
1433
+ prefix: string,
1434
+ ): Promise<boolean> {
1435
+ if (!("dir" in npmRoot)) return false;
1436
+ const root = path.resolve(prefix, "lib", "node_modules");
1437
+ if (path.resolve(npmRoot.dir) === root) return true;
1438
+ const real = await s.io.realpath(npmRoot.dir);
1439
+ return real !== null && path.resolve(real) === root;
1440
+ }
1441
+
1442
+ /**
1443
+ * Claude Code's native installer keeps every release side by side under
1444
+ * `<data>/claude/versions/<version>` and points PATH (`~/.local/bin/claude`) at
1445
+ * one. That file is Claude Code's own, updated by `claude update`: no package
1446
+ * manager owns it, and the npm spec the catalogue carries would only install a
1447
+ * second copy beside it. So this runs BEFORE the spec's installer is asked.
1448
+ *
1449
+ * Recognised only at the installer's own directory — `~/.local/share`, or
1450
+ * `$XDG_DATA_HOME` when set — and only for a file named by a bare release
1451
+ * number. The version is that name; the latest is npm's `latest` dist-tag for
1452
+ * Claude Code, the channel the native installer tracks by default.
1453
+ *
1454
+ * True when it recognised the layout; `f` then holds the whole answer.
1455
+ */
1456
+ async function probeClaudeNative(
1457
+ s: ProbeSession,
1458
+ spec: BinRequirement,
1459
+ f: ToolFacts,
1460
+ resolvedPath: string,
1461
+ ): Promise<boolean> {
1462
+ // The cheap, pure shape test first, so no other tool pays for the IO below.
1463
+ const version = versionFromVersionedPath(resolvedPath);
1464
+ if (!version) return false;
1465
+ const dir = path.resolve(path.dirname(resolvedPath));
1466
+ if (path.basename(path.dirname(dir)) !== "claude") return false;
1467
+ const dataHomes = [
1468
+ path.join(s.io.homedir, ".local", "share"),
1469
+ ...(s.io.env.XDG_DATA_HOME ? [s.io.env.XDG_DATA_HOME] : []),
1470
+ ];
1471
+ let owned = false;
1472
+ for (const home of dataHomes) {
1473
+ const versions = path.resolve(home, "claude", "versions");
1474
+ owned = versions === dir || (await s.io.realpath(versions)) === dir;
1475
+ if (owned) break;
1476
+ }
1477
+ if (!owned) return false;
1478
+
1479
+ const pin = pinOf(spec);
1480
+ f.managed = true;
1481
+ f.owner = `Claude Code's native installer (${dir})`;
1482
+ f.installed = version;
1483
+ addSource(f, "version-dir");
1484
+ f.upgradeCommand = claudeNativeUpgradeCommand(pin);
1485
+ if (pin === undefined) await npmLatest(s, CLAUDE_CODE_NPM_PACKAGE, f);
1486
+ return true;
1302
1487
  }
1303
1488
 
1304
1489
  async function probeGo(
@@ -1375,6 +1560,10 @@ async function probeInto(
1375
1560
  f.present = true;
1376
1561
  f.resolvedPath = resolvedPath;
1377
1562
 
1563
+ // A file Claude Code's native installer owns answers for itself, whatever
1564
+ // installer the spec names.
1565
+ if (await probeClaudeNative(s, spec, f, resolvedPath)) return;
1566
+
1378
1567
  const pinned = pinOf(spec) !== undefined;
1379
1568
  const via: BinInstaller = spec.via;
1380
1569
  switch (via) {
@@ -1386,18 +1575,56 @@ async function probeInto(
1386
1575
  break;
1387
1576
  case "bun":
1388
1577
  case "npm":
1389
- await probeNodeGlobal(s, spec, f, resolvedPath, pinned);
1578
+ await probeNodeGlobal(s, spec, f, resolvedPath);
1390
1579
  break;
1391
1580
  case "go":
1392
1581
  await probeGo(s, spec, f, resolvedPath, pinned);
1393
1582
  break;
1394
1583
  }
1395
1584
 
1396
- // The method's own record was silent: read the running file itself — but
1397
- // only where the caller opted into running a check command (see there).
1585
+ // The method's own record was silent. Before anything runs, read what the
1586
+ // installed files already say about themselves.
1587
+ if (f.installed === null) versionFromFiles(f, resolvedPath);
1588
+
1589
+ // Still nothing: read the running file itself — but only where the caller
1590
+ // opted into running a check command (see there).
1398
1591
  if (f.installed === null) await versionFromCheck(s, spec, f, onPath);
1399
1592
  }
1400
1593
 
1594
+ /**
1595
+ * The installed version, from files next to the binary — never by running it.
1596
+ *
1597
+ * The method's record is keyed to where THIS spec's installer keeps things, so
1598
+ * it is silent whenever the tool on PATH came some other way: magus installed
1599
+ * with bun while the catalogue says npm, a pnpm or yarn global. (An npm global
1600
+ * under another prefix and Claude Code's native installer are recognised as
1601
+ * OWNED before this runs.) Each of those still says what it is. A binary inside
1602
+ * `node_modules` belongs to a package whose `package.json` carries the version;
1603
+ * a versioned install names the file after it. Neither says who OWNS the file,
1604
+ * so `managed` is left as the method found it — the classifier still refuses to
1605
+ * plan an upgrade it cannot land — but the row can show a version instead of
1606
+ * "could not be read".
1607
+ *
1608
+ * Reads the real filesystem rather than the IO seam: `resolvedPath` already
1609
+ * came through the seam's `realpath`, and a path a test invented does not
1610
+ * exist, so this answers null there exactly as a missing record does.
1611
+ */
1612
+ function versionFromFiles(f: ToolFacts, resolvedPath: string): void {
1613
+ if (resolvedPath.split(path.sep).includes("node_modules")) {
1614
+ const version = readPackageVersionForBin(resolvedPath);
1615
+ if (version) {
1616
+ f.installed = version;
1617
+ addSource(f, "package-json");
1618
+ return;
1619
+ }
1620
+ }
1621
+ const version = readVersionDirForBin(resolvedPath);
1622
+ if (version) {
1623
+ f.installed = version;
1624
+ addSource(f, "version-dir");
1625
+ }
1626
+ }
1627
+
1401
1628
  /** A copy, with the verdict fields cleared: a timed-out tool is never "behind". */
1402
1629
  function timedOut(f: ToolFacts, deadlineMs: number): ToolFacts {
1403
1630
  return {
@@ -1568,11 +1795,10 @@ function methodFromPath(binPath: string): InstallMethod | null {
1568
1795
  if (binPath.includes("/.bun/")) return "bun";
1569
1796
  if (binPath.includes("/homebrew/") || binPath.includes("/Cellar/"))
1570
1797
  return "brew";
1571
- if (
1572
- binPath.includes("/.local/share/claude") ||
1573
- binPath.includes("/.local/bin/claude")
1574
- )
1575
- return "npm";
1798
+ // Claude Code's native installer. `~/.local/bin/claude` itself proves
1799
+ // nothing — an npm with prefix `~/.local` links there too — so the link's
1800
+ // target decides.
1801
+ if (binPath.includes("/.local/share/claude/")) return "native";
1576
1802
  if (binPath.includes("/.nvm/") || binPath.includes("/node_modules/"))
1577
1803
  return "npm";
1578
1804
  if (binPath.includes("/.local/share/pnpm") || binPath.includes("/pnpm/"))
@@ -1624,7 +1850,8 @@ export async function detectInstallMethods(
1624
1850
  for (const binPath of paths) {
1625
1851
  let method = methodFromPath(binPath);
1626
1852
  const linkTarget = (await io.readlink(binPath)) ?? "";
1627
- if (!method && linkTarget) method = methodFromPath(linkTarget);
1853
+ if (!method && linkTarget)
1854
+ method = methodFromPath(path.resolve(path.dirname(binPath), linkTarget));
1628
1855
  if (method === "brew" && !brewFormula) {
1629
1856
  brewFormula = extractBrewFormula(binPath, linkTarget);
1630
1857
  }
@@ -316,6 +316,45 @@ export function binUpgradeCommand(
316
316
  }
317
317
  }
318
318
 
319
+ /**
320
+ * npm's global install INTO one named prefix: `npm install -g --prefix <prefix>
321
+ * <pkg>@<version>`.
322
+ *
323
+ * For a tool whose file lives under `<prefix>/lib/node_modules/<pkg>/` when the
324
+ * npm first on PATH reports a different global root — several Node installs
325
+ * side by side (nvm, Homebrew, `~/.local`). Plain `npm install -g` would write a
326
+ * second copy into ITS root, which PATH never runs.
327
+ *
328
+ * `--prefix` and not "run `<prefix>/bin/npm`": npm puts a global install next
329
+ * to the NODE executable that runs it (or wherever an `.npmrc` `prefix` says),
330
+ * not next to the npm script, so the script's own location proves nothing. The
331
+ * flag names the tree outright.
332
+ */
333
+ export function npmPrefixInstallCommand(
334
+ prefix: string,
335
+ pkg: string,
336
+ version = "latest",
337
+ ): Command {
338
+ return {
339
+ cmd: "npm",
340
+ args: ["install", "-g", "--prefix", prefix, `${pkg}@${version}`],
341
+ };
342
+ }
343
+
344
+ /**
345
+ * Claude Code's own updater, for a copy its native installer put on PATH
346
+ * (`~/.local/share/claude/versions/<version>`). No package manager owns that
347
+ * file, so `npm install -g` would only add a second copy beside it.
348
+ *
349
+ * Unpinned: `claude update`. Pinned: `claude install <pin>`, which installs
350
+ * exactly that native build — `claude update` would go past the pin.
351
+ */
352
+ export function claudeNativeUpgradeCommand(pin?: string): Command {
353
+ return pin
354
+ ? { cmd: "claude", args: ["install", pin.replace(/^v/i, "")] }
355
+ : { cmd: "claude", args: ["update"] };
356
+ }
357
+
319
358
  /**
320
359
  * The command that moves an ALREADY-INSTALLED binary to exactly its pin.
321
360
  *
@@ -554,12 +554,17 @@ export async function updatePluginInScope(
554
554
  * TUI's CLI Tools row so the two front ends cannot run different commands for
555
555
  * the same row. The CLI's plan report renders this same function, so what it
556
556
  * shows is what runs.
557
+ *
558
+ * An upgrade whose probe built its own command (`item.upgradeCommand`: another
559
+ * npm prefix, Claude Code's updater) runs that — the value the TUI row runs.
557
560
  */
558
561
  export function binCommandFor(
559
562
  item: BinUpdateItem,
560
563
  spec: ResolvedBin,
561
564
  ): Command | null {
562
565
  if (!binNeedsWork(item)) return null;
566
+ if (item.action === "upgrade" && item.upgradeCommand)
567
+ return item.upgradeCommand;
563
568
  return binCommandForState(
564
569
  item.action === "install" ? "install" : "upgrade",
565
570
  item.target,
@@ -29,6 +29,7 @@
29
29
  */
30
30
 
31
31
  import type { ProfileSkillRef, ResolvedBin } from "../types/index.js";
32
+ import type { Command } from "../utils/command-utils.js";
32
33
  import type { PluginScope } from "./claude-cli.js";
33
34
  import {
34
35
  type PluginInfo,
@@ -796,6 +797,13 @@ export interface BinUpdateItem {
796
797
  * afterwards.
797
798
  */
798
799
  adopted?: string;
800
+ /**
801
+ * {@link ToolFacts.upgradeCommand}: the argv that upgrades the file PATH
802
+ * runs when the spec's own installer would not (another npm prefix, Claude
803
+ * Code's updater). An `upgrade` runs this instead of the spec's command —
804
+ * the same value the TUI's row runs on Enter.
805
+ */
806
+ upgradeCommand?: Command;
799
807
  /** The exact pin, when the profile declares one. */
800
808
  version?: string;
801
809
  /** The version PATH runs, or null when absent / unreadable. */
@@ -851,6 +859,7 @@ export function planBinUpdates(
851
859
  };
852
860
  if (status.note) item.note = status.note;
853
861
  if (status.adopted) item.adopted = status.adopted;
862
+ if (f?.upgradeCommand) item.upgradeCommand = f.upgradeCommand;
854
863
  if (bin.version) item.version = bin.version;
855
864
  return item;
856
865
  });
package/src/tui.tsx CHANGED
@@ -10,6 +10,7 @@
10
10
  import { RGBA, type ThemeMode, createCliRenderer } from "@opentui/core";
11
11
  import { createRoot } from "@opentui/react";
12
12
  import { App } from "./ui/App.js";
13
+ import { installShutdown } from "./ui/runtime/shutdown.js";
13
14
  import { setThemeMode } from "./ui/theme-mode.js";
14
15
  import { type ThemeEnv, resolveTheme } from "./ui/theme-resolve.js";
15
16
 
@@ -57,6 +58,49 @@ export async function startTui({
57
58
  backgroundColor: RGBA.defaultBackground(),
58
59
  useMouse: true,
59
60
  enableMouseMovement: false,
61
+ // BOTH, so the renderer registers no signal handler and never quits on
62
+ // Ctrl+C by itself: `installShutdown` below is the ONE teardown owner.
63
+ // Left at their defaults the renderer is a second owner — it destroys
64
+ // itself first on a signal and the React tree is torn down after the
65
+ // terminal was already restored (measured; see ui/runtime/shutdown.ts).
66
+ exitSignals: [],
67
+ exitOnCtrlC: false,
68
+ // OFF, so a click never gives a renderable keyboard focus. With it on, one
69
+ // click in a scrollbox made that box a second key receiver: OpenTUI hands
70
+ // every key to the focused element as well as to the KeyRouter, so arrows
71
+ // and PgUp/PgDn acted twice (measured on the test renderer: scrollTop 0 → 2
72
+ // after a click, 0 → 0 with this off). The router is the one key owner;
73
+ // InputModal focuses its own <input>, which is the only focus magus needs.
74
+ autoFocus: false,
75
+ });
76
+
77
+ // Wired immediately, before anything that can wait: theme resolution below
78
+ // can probe the terminal for 250ms, and a signal or a throw landing in that
79
+ // window must restore the terminal too.
80
+ //
81
+ // SIGINT/SIGTERM/SIGHUP/… restore the terminal and exit 128 + signum; an
82
+ // uncaught exception or unhandled rejection restores it, prints the error,
83
+ // and exits 1. `shutdown(0)` is the clean quit.
84
+ const root = createRoot(renderer);
85
+ const shutdown = installShutdown(renderer, root);
86
+ const quit = (): void => {
87
+ void shutdown(0);
88
+ };
89
+
90
+ // Ctrl+C is a KEYPRESS here, not a signal: raw mode clears ISIG, so the tty
91
+ // delivers 0x03 and SIGINT is never raised. Without this listener
92
+ // (`exitOnCtrlC: false` above) Ctrl+C would do nothing at all.
93
+ //
94
+ // It is registered on the renderer before React mounts, so it runs ahead of
95
+ // every `useKeyboard` handler — and stopping propagation means no screen or
96
+ // modal ever sees the key. That is what makes Ctrl+C quit from under any
97
+ // modal, including the undismissable profile gate, and from a screen whose
98
+ // render crashed.
99
+ renderer.keyInput.on("keypress", (key) => {
100
+ if (key.ctrl && key.name === "c") {
101
+ key.stopPropagation();
102
+ quit();
103
+ }
60
104
  });
61
105
 
62
106
  // Mouse selection → system clipboard, on release. OSC 52 survives SSH;
@@ -86,27 +130,10 @@ export async function startTui({
86
130
  });
87
131
  setThemeMode(mode);
88
132
 
89
- const root = createRoot(renderer);
90
-
91
- // Cleanup function to restore terminal
92
- const cleanup = () => {
93
- root.unmount();
94
- renderer.destroy(); // CRITICAL: Never use process.exit() directly
95
- };
96
-
97
- // Handle cleanup on exit signals
98
- const handleExit = () => {
99
- cleanup();
100
- // Exit after cleanup completes
101
- process.exit(0);
102
- };
103
-
104
- process.on("SIGINT", handleExit);
105
- process.on("SIGTERM", handleExit);
106
-
107
- // Render the OpenTUI app with exit handler
108
- root.render(<App onExit={handleExit} />);
133
+ // `q`, Escape on the home tab and the profile gate's quit all end here.
134
+ root.render(<App onExit={quit} />);
109
135
 
110
- // Wait indefinitely (app runs until user exits)
136
+ // Wait indefinitely: every exit goes through `shutdown`, which ends the
137
+ // process itself.
111
138
  await new Promise(() => {});
112
139
  }