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
package/src/cli/doctor.ts CHANGED
@@ -2,18 +2,27 @@
2
2
  * `magus doctor [--fix]`
3
3
  *
4
4
  * Diagnoses (and with --fix, repairs) project health:
5
+ * 0. Global settings — `~/.claude/settings.json` parses.
5
6
  * 1. Binary dependencies — every requires.bin / cliTools binary on PATH.
6
7
  * This is the check that would have caught a dead `tmux-mcp`.
7
8
  * 2. Profile symlink integrity — the active profile's links resolve.
8
9
  * 3. Convention compliance — .gitignore + CLAUDE.md blocks (ported engine).
9
10
  * 4. Model routing — config validity, hook registration, and drift.
10
11
  * 5. Plugin loads — what Claude Code will load here vs what is installed.
12
+ *
13
+ * A file that exists and does not parse — the global settings, the project's
14
+ * `.claude/profiles.json` or its `.claude/settings.json` — is a finding in the
15
+ * section that meets it, never an abort: the rest of the report is still worth
16
+ * having. `--fix` repairs none of them, because magus never rewrites a file it
17
+ * cannot parse.
11
18
  */
12
19
 
13
20
  import path from "node:path";
14
21
  import fs from "fs-extra";
15
22
  import {
23
+ SettingsParseError,
16
24
  getProjectInstalledPluginVersions,
25
+ readGlobalSettings,
17
26
  readInstalledPluginsRegistry,
18
27
  readSettings,
19
28
  } from "../services/claude-settings.js";
@@ -35,17 +44,35 @@ import {
35
44
  } from "../services/plugin-resolution.js";
36
45
  import { resolveAllProfiles } from "../services/resolver.js";
37
46
  import { activeProfile, profileDir } from "../services/symlink-manager.js";
47
+ import type { ClaudeSettings, ProfileManifest } from "../types/index.js";
38
48
  import { resolveExecutable } from "../utils/command-utils.js";
39
49
 
40
50
  export async function runDoctorCommand(args: string[]): Promise<number> {
41
51
  const fix = args.includes("--fix");
42
52
  const projectPath = process.cwd();
43
53
  let problems = 0;
54
+ // Findings `--fix` does not repair, counted apart so it cannot silence
55
+ // them — see doctorExitCode.
56
+ let unfixable = 0;
57
+
58
+ // 0. Global settings. Its own check, first, because most of doctor never
59
+ // reads that file: a project without model routing reached it nowhere and
60
+ // reported "No problems found" over a damaged one, while the one section
61
+ // that did — hook registration under --fix — aborted the whole run on it.
62
+ const globalSettingsError = await checkGlobalSettings();
63
+ if (globalSettingsError) unfixable++;
44
64
 
45
- // 1. Binary dependencies.
46
- const manifest = await readManifest(projectPath);
65
+ // 1. Binary dependencies, which needs the manifest to know what to check.
66
+ let manifest: ProfileManifest | null = null;
67
+ try {
68
+ manifest = await readManifest(projectPath);
69
+ } catch (error) {
70
+ if (!(error instanceof SettingsParseError)) throw error;
71
+ reportUnparseable("Profiles", error);
72
+ unfixable++;
73
+ }
47
74
  let pluginIds: string[] = [];
48
- if (Object.keys(manifest.profiles).length > 0) {
75
+ if (manifest && Object.keys(manifest.profiles).length > 0) {
49
76
  const closure = await resolveAllProfiles(manifest);
50
77
  pluginIds = Object.keys(closure.plugins);
51
78
  if (closure.bins.length > 0) {
@@ -120,14 +147,43 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
120
147
  }
121
148
 
122
149
  // 4. Model routing.
123
- problems += await checkModels(projectPath, fix);
150
+ const models = await checkModels(projectPath, fix, globalSettingsError);
151
+ problems += models.fixable;
152
+ unfixable += models.unfixable;
124
153
 
125
154
  // 5. Stale plugin loads. Counted SEPARATELY, because `--fix` does not repair
126
155
  // them — see doctorExitCode.
127
- const staleLoads = await checkPluginLoads(projectPath);
156
+ unfixable += await checkPluginLoads(projectPath);
157
+
158
+ if (problems === 0 && unfixable === 0) console.log("\n✓ No problems found.");
159
+ return doctorExitCode({ fixable: problems, unfixable, fix });
160
+ }
128
161
 
129
- if (problems === 0 && staleLoads === 0) console.log("\n✓ No problems found.");
130
- return doctorExitCode({ fixable: problems, unfixable: staleLoads, fix });
162
+ /**
163
+ * A file that exists and does not parse, as a finding under `heading`.
164
+ *
165
+ * The same three lines wherever one turns up, so a damaged file reads the same
166
+ * whichever section met it.
167
+ */
168
+ function reportUnparseable(heading: string, error: SettingsParseError): void {
169
+ console.log(`\n${heading}:`);
170
+ console.log(` ✗ ${error.message}`);
171
+ console.log(" Fix the JSON by hand; magus leaves the file as it is.");
172
+ }
173
+
174
+ /**
175
+ * The global settings file, if it does not parse — reported, and returned so
176
+ * the hook check can say why it cannot answer instead of guessing.
177
+ */
178
+ async function checkGlobalSettings(): Promise<SettingsParseError | null> {
179
+ try {
180
+ await readGlobalSettings();
181
+ return null;
182
+ } catch (error) {
183
+ if (!(error instanceof SettingsParseError)) throw error;
184
+ reportUnparseable("Global settings", error);
185
+ return error;
186
+ }
131
187
  }
132
188
 
133
189
  /**
@@ -177,7 +233,14 @@ export function doctorExitCode(args: {
177
233
  */
178
234
  async function checkPluginLoads(projectPath: string): Promise<number> {
179
235
  const registry = await readInstalledPluginsRegistry();
180
- const settings = await readSettings(projectPath);
236
+ let settings: ClaudeSettings;
237
+ try {
238
+ settings = await readSettings(projectPath);
239
+ } catch (error) {
240
+ if (!(error instanceof SettingsParseError)) throw error;
241
+ reportUnparseable("Plugin loads", error);
242
+ return 1;
243
+ }
181
244
  const installed = await getProjectInstalledPluginVersions(
182
245
  settings.installedPluginVersions || {},
183
246
  projectPath,
@@ -249,13 +312,24 @@ async function checkPluginLoads(projectPath: string): Promise<number> {
249
312
  * fixed from here (the values are a human's decision); an unregistered hook
250
313
  * routes nothing no matter how good the config is; drift only matters once
251
314
  * both of those are sound.
315
+ *
316
+ * `fixable` is what `--fix` can act on; `unfixable` is what it cannot, which
317
+ * is a hook whose settings file does not parse.
252
318
  */
253
- async function checkModels(projectPath: string, fix: boolean): Promise<number> {
319
+ async function checkModels(
320
+ projectPath: string,
321
+ fix: boolean,
322
+ /** From `checkGlobalSettings`: the global settings file does not parse. */
323
+ globalSettingsError: SettingsParseError | null,
324
+ ): Promise<{ fixable: number; unfixable: number }> {
254
325
  const { config, errors, path: file } = await readModelsConfig(projectPath);
255
- if (config === null && errors.length === 0) return 0;
326
+ if (config === null && errors.length === 0) {
327
+ return { fixable: 0, unfixable: 0 };
328
+ }
256
329
 
257
330
  console.log("\nModel routing:");
258
331
  let problems = 0;
332
+ let unfixable = 0;
259
333
 
260
334
  if (errors.length > 0) {
261
335
  console.log(` ✗ ${file} is invalid — nothing is routed:`);
@@ -268,15 +342,32 @@ async function checkModels(projectPath: string, fix: boolean): Promise<number> {
268
342
  console.log(" re-run `magus models use <preset>`) and check again.");
269
343
  // An invalid config is worth exactly one problem, not one per message —
270
344
  // the count drives the exit code, not the severity.
271
- return 1;
345
+ return { fixable: 1, unfixable: 0 };
272
346
  }
273
347
 
274
348
  // The hook is what applies the routing. Without it the config is inert.
275
349
  if (await isAgentModelHookRegistered()) {
276
350
  console.log(" ✓ agent-model hook registered at user scope.");
277
351
  } else if (fix) {
278
- await registerAgentModelHook();
279
- console.log(" ✓ agent-model hook registered at user scope (fixed).");
352
+ try {
353
+ await registerAgentModelHook();
354
+ console.log(" ✓ agent-model hook registered at user scope (fixed).");
355
+ } catch (error) {
356
+ // Registering writes the settings file, and one that does not parse is
357
+ // never written. This used to escape and abort the whole run.
358
+ if (!(error instanceof SettingsParseError)) throw error;
359
+ console.log(
360
+ ` ✗ agent-model hook not registered: ${error.path} ${error.problem}, and magus does not write a file it cannot read.`,
361
+ );
362
+ unfixable++;
363
+ }
364
+ } else if (globalSettingsError) {
365
+ // `isAgentModelHookRegistered` reads an unparseable file as "not
366
+ // registered". Offering `--fix` for that would offer a write magus refuses.
367
+ console.log(
368
+ ` ✗ agent-model hook: cannot tell whether it is registered — the global settings file ${globalSettingsError.problem} (see above).`,
369
+ );
370
+ unfixable++;
280
371
  } else {
281
372
  console.log(
282
373
  " ✗ agent-model hook is not registered — the config is there, but nothing runs it.",
@@ -310,7 +401,7 @@ async function checkModels(projectPath: string, fix: boolean): Promise<number> {
310
401
 
311
402
  for (const warning of status.warnings) console.log(` ⚠ ${warning}`);
312
403
 
313
- return problems;
404
+ return { fixable: problems, unfixable };
314
405
  }
315
406
 
316
407
  /** Which of the active profile's expected symlinks are missing or dangling. */
@@ -15,6 +15,7 @@
15
15
  import path from "node:path";
16
16
  import { installPlugin, isClaudeAvailable } from "../services/claude-cli.js";
17
17
  import {
18
+ getLocalSettingsPath,
18
19
  readInstalledPluginsRegistry,
19
20
  readLocalSettings,
20
21
  writeLocalSettings,
@@ -31,6 +32,7 @@ import { reportDisplaced } from "../services/profile-activate.js";
31
32
  import { materializeProfile } from "../services/profile-materializer.js";
32
33
  import { detectProfileDrift } from "../services/profile-sync.js";
33
34
  import { resolveAllProfiles, resolveProfile } from "../services/resolver.js";
35
+ import { writableMap } from "../services/settings-file.js";
34
36
  import { installSkill } from "../services/skills-manager.js";
35
37
  import {
36
38
  PROFILE_GITIGNORE_ENTRIES,
@@ -229,10 +231,14 @@ async function collectEnv(
229
231
  }
230
232
 
231
233
  const local = await readLocalSettings(projectPath);
232
- local.env = local.env ?? {};
234
+ // Checked before the first prompt, so a malformed `env` is refused before the
235
+ // user types anything, not after — and never replaced.
236
+ const env =
237
+ writableMap(local, "env", getLocalSettingsPath(projectPath)) ?? {};
238
+ local.env = env;
233
239
  for (const name of status.missingRequired) {
234
240
  const value = await promptValue(` ${name} = `);
235
- if (value) local.env[name] = value;
241
+ if (value) env[name] = value;
236
242
  }
237
243
  await writeLocalSettings(local, projectPath);
238
244
  console.log("Saved to .claude/settings.local.json (personal, gitignored).");
@@ -16,15 +16,32 @@ import { getManifestPath, readManifest } from "../services/manifest.js";
16
16
  import { reportDisplaced } from "../services/profile-activate.js";
17
17
  import { syncProfile } from "../services/profile-sync.js";
18
18
  import { resolveProfile } from "../services/resolver.js";
19
+ import { SettingsParseError } from "../services/settings-file.js";
19
20
  import {
20
21
  PROFILE_GITIGNORE_ENTRIES,
21
22
  activateProfile,
22
23
  activeProfile,
23
24
  profileDir,
24
25
  } from "../services/symlink-manager.js";
25
- import { ensureManifest } from "./bootstrap.js";
26
+ import { ensureManifest, reportUnparseable } from "./bootstrap.js";
26
27
 
28
+ /**
29
+ * Every subcommand reads `.claude/profiles.json`, and one that does not parse
30
+ * is a report and exit 1 for all of them — named once here rather than guarded
31
+ * per subcommand. It never reads as "no profiles": `list` said exactly that,
32
+ * and `init` would then have written a new manifest over the file.
33
+ */
27
34
  export async function runProfileCommand(args: string[]): Promise<number> {
35
+ try {
36
+ return await dispatchProfileCommand(args);
37
+ } catch (error) {
38
+ if (!(error instanceof SettingsParseError)) throw error;
39
+ reportUnparseable(error);
40
+ return 1;
41
+ }
42
+ }
43
+
44
+ async function dispatchProfileCommand(args: string[]): Promise<number> {
28
45
  const [sub, ...rest] = args;
29
46
  const projectPath = process.cwd();
30
47
 
package/src/cli/router.ts CHANGED
@@ -7,8 +7,10 @@
7
7
  */
8
8
 
9
9
  import type { ThemeMode } from "@opentui/core";
10
+ import { SettingsParseError } from "../services/settings-file.js";
10
11
  import { checkForUpdates } from "../services/version-check.js";
11
12
  import { type ThemeEnv, parseThemeWord } from "../ui/theme-resolve.js";
13
+ import { reportUnparseable } from "./bootstrap.js";
12
14
  import { runDoctorCommand } from "./doctor.js";
13
15
  import { runInstallCommand } from "./install.js";
14
16
  import { runModelsCommand } from "./models.js";
@@ -171,24 +173,35 @@ export async function route(
171
173
  setThemeMode(mode);
172
174
  }
173
175
 
174
- switch (command) {
175
- case "update":
176
- return HANDLED(await runUpdateCommand(rest));
177
- case "upgrade":
178
- return HANDLED(await runUpgradeCommand());
179
- case "install":
180
- return HANDLED(await runInstallCommand(rest));
181
- case "profile":
182
- return HANDLED(await runProfileCommand(rest));
183
- case "models":
184
- return HANDLED(await runModelsCommand(rest));
185
- case "doctor":
186
- return HANDLED(await runDoctorCommand(rest));
187
- default:
188
- // Bare `magus` or an unrecognized token → interactive TUI.
189
- return themeFlag.theme === undefined
190
- ? LAUNCH_TUI
191
- : { ...LAUNCH_TUI, theme: themeFlag.theme };
176
+ try {
177
+ switch (command) {
178
+ case "update":
179
+ return HANDLED(await runUpdateCommand(rest));
180
+ case "upgrade":
181
+ return HANDLED(await runUpgradeCommand());
182
+ case "install":
183
+ return HANDLED(await runInstallCommand(rest));
184
+ case "profile":
185
+ return HANDLED(await runProfileCommand(rest));
186
+ case "models":
187
+ return HANDLED(await runModelsCommand(rest));
188
+ case "doctor":
189
+ return HANDLED(await runDoctorCommand(rest));
190
+ default:
191
+ // Bare `magus` or an unrecognized token → interactive TUI.
192
+ return themeFlag.theme === undefined
193
+ ? LAUNCH_TUI
194
+ : { ...LAUNCH_TUI, theme: themeFlag.theme };
195
+ }
196
+ } catch (error) {
197
+ // A settings or manifest file that does not parse is the user's to fix, not a
198
+ // crash. Every writer already refuses to touch it; this is the one place that
199
+ // turns the refusal into a message for commands with no handling of their own.
200
+ if (error instanceof SettingsParseError) {
201
+ reportUnparseable(error);
202
+ return HANDLED(1);
203
+ }
204
+ throw error;
192
205
  }
193
206
  }
194
207
 
@@ -86,7 +86,7 @@ type ActionKey =
86
86
 
87
87
  const ACTIONS: Record<ActionKey, ActionStyle> = {
88
88
  current: { glyph: "=", label: "CURRENT", color: brand.muted, chrome: true },
89
- install: { glyph: "+", label: "INSTALL", color: brand.link },
89
+ install: { glyph: "+", label: "INSTALL", color: brand.info },
90
90
  // Green, the tone magus uses for an update everywhere else. Amber stays
91
91
  // on `unknown` below, so the two states remain distinguishable: green is
92
92
  // "a newer version exists", amber is "we could not tell".
@@ -29,6 +29,14 @@ export interface CliTool {
29
29
  packageName: string;
30
30
  }
31
31
 
32
+ /**
33
+ * Claude Code's npm package. Named once because two readers need it: the
34
+ * catalogue entry below, and the probe, which asks this package's `latest`
35
+ * dist-tag for a copy the native installer put on PATH — that copy has no
36
+ * package.json to say which package it is.
37
+ */
38
+ export const CLAUDE_CODE_NPM_PACKAGE = "@anthropic-ai/claude-code";
39
+
32
40
  export const cliTools: CliTool[] = [
33
41
  {
34
42
  name: "magus",
@@ -72,7 +80,7 @@ export const cliTools: CliTool[] = [
72
80
  website: "https://claude.ai/code",
73
81
  category: "ai-coding",
74
82
  packageManager: "npm",
75
- packageName: "@anthropic-ai/claude-code",
83
+ packageName: CLAUDE_CODE_NPM_PACKAGE,
76
84
  },
77
85
  {
78
86
  name: "codex",