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
@@ -28,6 +28,18 @@ import {
28
28
  pickLoadedEntry,
29
29
  repoRootForPath,
30
30
  } from "./plugin-resolution.js";
31
+ import {
32
+ readSettingsFile,
33
+ writableList,
34
+ writableMap,
35
+ } from "./settings-file.js";
36
+
37
+ /**
38
+ * What every reader below throws for a file that exists but does not parse.
39
+ * Defined beside `readSettingsFile`, which all four readers delegate to, and
40
+ * re-exported here because this is the module callers read settings through.
41
+ */
42
+ export { SettingsParseError } from "./settings-file.js";
31
43
 
32
44
  const CLAUDE_DIR = ".claude";
33
45
  const SETTINGS_FILE = "settings.json";
@@ -44,8 +56,17 @@ export function getClaudeDir(projectPath?: string): string {
44
56
  return path.join(base, CLAUDE_DIR);
45
57
  }
46
58
 
59
+ /**
60
+ * `~/.claude`, resolved per call.
61
+ *
62
+ * `HOME` first, then `os.homedir()` — Node's own rule for `os.homedir()`, done
63
+ * by hand because Bun does not follow it: under Bun 1.4 `os.homedir()` answers
64
+ * the home the process started with and ignores a later `process.env.HOME`
65
+ * (measured; Node follows the change). Reading `HOME` at call time is what lets
66
+ * a caller — a test above all — point the global settings somewhere else.
67
+ */
47
68
  export function getGlobalClaudeDir(): string {
48
- return path.join(os.homedir(), CLAUDE_DIR);
69
+ return path.join(process.env.HOME || os.homedir(), CLAUDE_DIR);
49
70
  }
50
71
 
51
72
  export async function ensureClaudeDir(projectPath?: string): Promise<string> {
@@ -54,18 +75,45 @@ export async function ensureClaudeDir(projectPath?: string): Promise<string> {
54
75
  return claudeDir;
55
76
  }
56
77
 
78
+ /*
79
+ * THE FOUR READERS: a missing file reads as `{}`; a file that exists but does
80
+ * not parse throws `SettingsParseError`, never `{}`.
81
+ *
82
+ * They used to swallow every error and answer `{}`. Each has a writer beside
83
+ * it, and every read-modify-write in magus is `read → change one key →
84
+ * write`, so a settings file a human was mid-edit in came back as `{}` plus
85
+ * one key — and was written over, taking the rest of the configuration with
86
+ * it. Throwing from the read is what makes every one of those callers refuse:
87
+ * nothing reaches the write, so the file stays byte-for-byte as it was.
88
+ */
89
+
90
+ /*
91
+ * Each file's path has one getter, used by its reader AND by every writer that
92
+ * checks a map before writing into it (`writableMap`), so a refusal names the
93
+ * file that was actually read.
94
+ */
95
+
96
+ /** <project>/.claude/settings.json */
97
+ export function getSettingsPath(projectPath?: string): string {
98
+ return path.join(getClaudeDir(projectPath), SETTINGS_FILE);
99
+ }
100
+
101
+ /** <project>/.claude/settings.local.json */
102
+ export function getLocalSettingsPath(projectPath?: string): string {
103
+ return path.join(getClaudeDir(projectPath), LOCAL_SETTINGS_FILE);
104
+ }
105
+
106
+ /** ~/.claude/settings.json */
107
+ export function getGlobalSettingsPath(): string {
108
+ return path.join(getGlobalClaudeDir(), SETTINGS_FILE);
109
+ }
110
+
57
111
  export async function readSettings(
58
112
  projectPath?: string,
59
113
  ): Promise<ClaudeSettings> {
60
- const settingsPath = path.join(getClaudeDir(projectPath), SETTINGS_FILE);
61
- try {
62
- if (await fs.pathExists(settingsPath)) {
63
- return await fs.readJson(settingsPath);
64
- }
65
- } catch {
66
- // Return empty settings on error
67
- }
68
- return {};
114
+ return (await readSettingsFile(
115
+ getSettingsPath(projectPath),
116
+ )) as ClaudeSettings;
69
117
  }
70
118
 
71
119
  export async function writeSettings(
@@ -80,15 +128,9 @@ export async function writeSettings(
80
128
  export async function readLocalSettings(
81
129
  projectPath?: string,
82
130
  ): Promise<ClaudeLocalSettings> {
83
- const localPath = path.join(getClaudeDir(projectPath), LOCAL_SETTINGS_FILE);
84
- try {
85
- if (await fs.pathExists(localPath)) {
86
- return await fs.readJson(localPath);
87
- }
88
- } catch {
89
- // Return empty settings on error
90
- }
91
- return {};
131
+ return (await readSettingsFile(
132
+ getLocalSettingsPath(projectPath),
133
+ )) as ClaudeLocalSettings;
92
134
  }
93
135
 
94
136
  export async function writeLocalSettings(
@@ -109,15 +151,9 @@ export function getMcpConfigPath(projectPath?: string): string {
109
151
  export async function readMcpConfig(
110
152
  projectPath?: string,
111
153
  ): Promise<McpConfigFile> {
112
- const mcpPath = getMcpConfigPath(projectPath);
113
- try {
114
- if (await fs.pathExists(mcpPath)) {
115
- return await fs.readJson(mcpPath);
116
- }
117
- } catch {
118
- // Return empty config on error
119
- }
120
- return {};
154
+ return (await readSettingsFile(
155
+ getMcpConfigPath(projectPath),
156
+ )) as McpConfigFile;
121
157
  }
122
158
 
123
159
  export async function writeMcpConfig(
@@ -129,15 +165,7 @@ export async function writeMcpConfig(
129
165
  }
130
166
 
131
167
  export async function readGlobalSettings(): Promise<ClaudeSettings> {
132
- const settingsPath = path.join(getGlobalClaudeDir(), SETTINGS_FILE);
133
- try {
134
- if (await fs.pathExists(settingsPath)) {
135
- return await fs.readJson(settingsPath);
136
- }
137
- } catch {
138
- // Return empty settings on error
139
- }
140
- return {};
168
+ return (await readSettingsFile(getGlobalSettingsPath())) as ClaudeSettings;
141
169
  }
142
170
 
143
171
  export async function writeGlobalSettings(
@@ -160,15 +188,27 @@ export async function addMcpServer(
160
188
  // biome-ignore lint/performance/noDelete: this object is serialised into .mcp.json; the env key must be absent, not present as undefined
161
189
  delete configWithoutEnv.env;
162
190
 
163
- // Add to .mcp.json (without env vars)
191
+ // Read BOTH files, and check every map written into, before writing either:
192
+ // if one does not parse or has the wrong shape, the whole operation refuses,
193
+ // rather than leaving .mcp.json changed and the local settings not.
164
194
  const mcpConfig = await readMcpConfig(projectPath);
165
- mcpConfig.mcpServers = mcpConfig.mcpServers || {};
166
- mcpConfig.mcpServers[name] = configWithoutEnv;
195
+ const localSettings = await readLocalSettings(projectPath);
196
+ const localPath = getLocalSettingsPath(projectPath);
197
+ const mcpServers =
198
+ writableMap(mcpConfig, "mcpServers", getMcpConfigPath(projectPath)) ?? {};
199
+ const enabledServers =
200
+ writableList(localSettings, "enabledMcpjsonServers", localPath) ?? [];
201
+ const env =
202
+ Object.keys(envVars).length > 0
203
+ ? (writableMap(localSettings, "env", localPath) ?? {})
204
+ : undefined;
205
+
206
+ // Add to .mcp.json (without env vars)
207
+ mcpConfig.mcpServers = mcpServers;
208
+ mcpServers[name] = configWithoutEnv;
167
209
  await writeMcpConfig(mcpConfig, projectPath);
168
210
 
169
211
  // Enable in settings.local.json and add env vars
170
- const localSettings = await readLocalSettings(projectPath);
171
- const enabledServers = localSettings.enabledMcpjsonServers || [];
172
212
  if (!enabledServers.includes(name)) {
173
213
  enabledServers.push(name);
174
214
  }
@@ -176,12 +216,12 @@ export async function addMcpServer(
176
216
  localSettings.enableAllProjectMcpServers = true;
177
217
 
178
218
  // Add env vars to settings.local.json
179
- if (Object.keys(envVars).length > 0) {
180
- localSettings.env = localSettings.env || {};
219
+ if (env) {
220
+ localSettings.env = env;
181
221
  for (const [key, value] of Object.entries(envVars)) {
182
222
  // Only add non-reference values (references like ${VAR} don't need to be stored)
183
223
  if (!value.startsWith("${") || !value.endsWith("}")) {
184
- localSettings.env[key] = value;
224
+ env[key] = value;
185
225
  }
186
226
  }
187
227
  }
@@ -193,18 +233,32 @@ export async function removeMcpServer(
193
233
  name: string,
194
234
  projectPath?: string,
195
235
  ): Promise<void> {
196
- // Remove from .mcp.json
236
+ // Both reads and both shape checks first, for the same reason as in
237
+ // addMcpServer.
197
238
  const mcpConfig = await readMcpConfig(projectPath);
198
- if (mcpConfig.mcpServers) {
199
- delete mcpConfig.mcpServers[name];
239
+ const localSettings = await readLocalSettings(projectPath);
240
+ const mcpServers = writableMap(
241
+ mcpConfig,
242
+ "mcpServers",
243
+ getMcpConfigPath(projectPath),
244
+ );
245
+ const enabledServers = writableList(
246
+ localSettings,
247
+ "enabledMcpjsonServers",
248
+ getLocalSettingsPath(projectPath),
249
+ );
250
+
251
+ // Remove from .mcp.json
252
+ if (mcpServers) {
253
+ delete mcpServers[name];
200
254
  }
201
255
  await writeMcpConfig(mcpConfig, projectPath);
202
256
 
203
257
  // Remove from settings.local.json
204
- const localSettings = await readLocalSettings(projectPath);
205
- if (localSettings.enabledMcpjsonServers) {
206
- localSettings.enabledMcpjsonServers =
207
- localSettings.enabledMcpjsonServers.filter((s) => s !== name);
258
+ if (enabledServers) {
259
+ localSettings.enabledMcpjsonServers = enabledServers.filter(
260
+ (s) => s !== name,
261
+ );
208
262
  await writeLocalSettings(localSettings, projectPath);
209
263
  }
210
264
  }
@@ -242,12 +296,11 @@ export async function enablePlugin(
242
296
  projectPath?: string,
243
297
  ): Promise<void> {
244
298
  const settings = await readSettings(projectPath);
245
- settings.enabledPlugins = settings.enabledPlugins || {};
246
- if (enabled) {
247
- settings.enabledPlugins[pluginId] = true;
248
- } else {
249
- delete settings.enabledPlugins[pluginId];
250
- }
299
+ settings.enabledPlugins =
300
+ writableMap(settings, "enabledPlugins", getSettingsPath(projectPath)) ?? {};
301
+ // `false`, never a deleted key: Claude Code resolves enabledPlugins per id with
302
+ // fall-through, so a missing project entry lets a user-scope `true` win.
303
+ settings.enabledPlugins[pluginId] = enabled;
251
304
  await writeSettings(settings, projectPath);
252
305
  }
253
306
 
@@ -398,7 +451,12 @@ export async function enableLocalPlugin(
398
451
  projectPath?: string,
399
452
  ): Promise<void> {
400
453
  const settings = await readLocalSettings(projectPath);
401
- settings.enabledPlugins = settings.enabledPlugins || {};
454
+ settings.enabledPlugins =
455
+ writableMap(
456
+ settings,
457
+ "enabledPlugins",
458
+ getLocalSettingsPath(projectPath),
459
+ ) ?? {};
402
460
  if (enabled) {
403
461
  settings.enabledPlugins[pluginId] = true;
404
462
  } else {
@@ -413,7 +471,12 @@ export async function saveLocalInstalledPluginVersion(
413
471
  projectPath?: string,
414
472
  ): Promise<void> {
415
473
  const settings = await readLocalSettings(projectPath);
416
- settings.installedPluginVersions = settings.installedPluginVersions || {};
474
+ settings.installedPluginVersions =
475
+ writableMap(
476
+ settings,
477
+ "installedPluginVersions",
478
+ getLocalSettingsPath(projectPath),
479
+ ) ?? {};
417
480
  settings.installedPluginVersions[pluginId] = version;
418
481
  await writeLocalSettings(settings, projectPath);
419
482
 
@@ -431,11 +494,14 @@ export async function removeLocalInstalledPluginVersion(
431
494
  projectPath?: string,
432
495
  ): Promise<void> {
433
496
  const settings = await readLocalSettings(projectPath);
434
- if (settings.installedPluginVersions) {
435
- delete settings.installedPluginVersions[pluginId];
497
+ const localPath = getLocalSettingsPath(projectPath);
498
+ const versions = writableMap(settings, "installedPluginVersions", localPath);
499
+ const enabled = writableMap(settings, "enabledPlugins", localPath);
500
+ if (versions) {
501
+ delete versions[pluginId];
436
502
  }
437
- if (settings.enabledPlugins) {
438
- delete settings.enabledPlugins[pluginId];
503
+ if (enabled) {
504
+ delete enabled[pluginId];
439
505
  }
440
506
  await writeLocalSettings(settings, projectPath);
441
507
 
@@ -556,7 +622,8 @@ export async function setMcpEnvVar(
556
622
  projectPath?: string,
557
623
  ): Promise<void> {
558
624
  const localSettings = await readLocalSettings(projectPath);
559
- localSettings.env = localSettings.env || {};
625
+ localSettings.env =
626
+ writableMap(localSettings, "env", getLocalSettingsPath(projectPath)) ?? {};
560
627
  localSettings.env[name] = value;
561
628
  await writeLocalSettings(localSettings, projectPath);
562
629
  }
@@ -567,8 +634,13 @@ export async function removeMcpEnvVar(
567
634
  projectPath?: string,
568
635
  ): Promise<void> {
569
636
  const localSettings = await readLocalSettings(projectPath);
570
- if (localSettings.env) {
571
- delete localSettings.env[name];
637
+ const env = writableMap(
638
+ localSettings,
639
+ "env",
640
+ getLocalSettingsPath(projectPath),
641
+ );
642
+ if (env) {
643
+ delete env[name];
572
644
  await writeLocalSettings(localSettings, projectPath);
573
645
  }
574
646
  }
@@ -652,7 +724,8 @@ export async function enableGlobalPlugin(
652
724
  enabled: boolean,
653
725
  ): Promise<void> {
654
726
  const settings = await readGlobalSettings();
655
- settings.enabledPlugins = settings.enabledPlugins || {};
727
+ settings.enabledPlugins =
728
+ writableMap(settings, "enabledPlugins", getGlobalSettingsPath()) ?? {};
656
729
  if (enabled) {
657
730
  settings.enabledPlugins[pluginId] = true;
658
731
  } else {
@@ -683,7 +756,9 @@ export async function saveGlobalInstalledPluginVersion(
683
756
  version: string,
684
757
  ): Promise<void> {
685
758
  const settings = await readGlobalSettings();
686
- settings.installedPluginVersions = settings.installedPluginVersions || {};
759
+ settings.installedPluginVersions =
760
+ writableMap(settings, "installedPluginVersions", getGlobalSettingsPath()) ??
761
+ {};
687
762
  settings.installedPluginVersions[pluginId] = version;
688
763
  await writeGlobalSettings(settings);
689
764
 
@@ -695,11 +770,14 @@ export async function removeGlobalInstalledPluginVersion(
695
770
  pluginId: string,
696
771
  ): Promise<void> {
697
772
  const settings = await readGlobalSettings();
698
- if (settings.installedPluginVersions) {
699
- delete settings.installedPluginVersions[pluginId];
773
+ const globalPath = getGlobalSettingsPath();
774
+ const versions = writableMap(settings, "installedPluginVersions", globalPath);
775
+ const enabled = writableMap(settings, "enabledPlugins", globalPath);
776
+ if (versions) {
777
+ delete versions[pluginId];
700
778
  }
701
- if (settings.enabledPlugins) {
702
- delete settings.enabledPlugins[pluginId];
779
+ if (enabled) {
780
+ delete enabled[pluginId];
703
781
  }
704
782
  await writeGlobalSettings(settings);
705
783
 
@@ -37,8 +37,10 @@ import {
37
37
  * to follow what is actually on the machine, or it installs a second copy
38
38
  * beside the first and the screen then reports a conflict it created itself.
39
39
  *
40
- * `pnpm`, `yarn` and `unknown` have no `BinInstaller` counterpart, which is why
41
- * the two unions stay separate rather than one being widened into the other.
40
+ * `pnpm`, `yarn`, `native` and `unknown` have no `BinInstaller` counterpart,
41
+ * which is why the two unions stay separate rather than one being widened into
42
+ * the other. `native` is a tool's own installer — today Claude Code's, which
43
+ * keeps its releases under `~/.local/share/claude/versions/` and updates itself.
42
44
  */
43
45
  export type InstallMethod =
44
46
  | "npm"
@@ -47,6 +49,7 @@ export type InstallMethod =
47
49
  | "yarn"
48
50
  | "brew"
49
51
  | "pip"
52
+ | "native"
50
53
  | "unknown";
51
54
 
52
55
  /**
@@ -75,6 +78,10 @@ export function cliToolInstall(tool: CliTool): Command {
75
78
  * the detector could not attribute the binary on PATH to any package manager,
76
79
  * and guessing there would install a second copy alongside the one already
77
80
  * there. Callers fall back to installing.
81
+ *
82
+ * `native` is null too: a tool's own updater is not derivable from the method
83
+ * name. The probe builds it (`ToolFacts.upgradeCommand`, e.g. `claude update`)
84
+ * and the row takes it from there, ahead of this function.
78
85
  */
79
86
  export function cliToolUpdate(
80
87
  tool: CliTool,
@@ -82,12 +89,13 @@ export function cliToolUpdate(
82
89
  brewFormula?: string,
83
90
  ): Command | null {
84
91
  switch (method) {
85
- // The three the resolver does not model. `pnpm`/`yarn` are not
92
+ // The ones the resolver does not model. `pnpm`/`yarn` are not
86
93
  // BinInstallers because magus never chooses them — it only finds them.
87
94
  case "pnpm":
88
95
  return { cmd: "pnpm", args: ["install", "-g", tool.packageName] };
89
96
  case "yarn":
90
97
  return { cmd: "yarn", args: ["global", "add", tool.packageName] };
98
+ case "native":
91
99
  case "unknown":
92
100
  return null;
93
101
  default:
@@ -107,8 +115,9 @@ export function cliToolUpdate(
107
115
  /**
108
116
  * Remove a tool, following the method it was actually installed with.
109
117
  *
110
- * Null means "no command to offer": `unknown` for the reason above, and `go`
111
- * because a `go install` binary has no uninstaller to call.
118
+ * Null means "no command to offer": `unknown` for the reason above, `native`
119
+ * because a self-installed tool ships no uninstall command, and `go` because a
120
+ * `go install` binary has no uninstaller to call.
112
121
  */
113
122
  export function cliToolUninstall(
114
123
  tool: CliTool,
@@ -120,6 +129,7 @@ export function cliToolUninstall(
120
129
  return { cmd: "pnpm", args: ["remove", "-g", tool.packageName] };
121
130
  case "yarn":
122
131
  return { cmd: "yarn", args: ["global", "remove", tool.packageName] };
132
+ case "native":
123
133
  case "unknown":
124
134
  return null;
125
135
  default:
@@ -0,0 +1,101 @@
1
+ import { readFileSync, realpathSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * The version of the npm/bun package a binary on PATH belongs to.
6
+ *
7
+ * A global install leaves `<prefix>/bin/<tool>` as a symlink into
8
+ * `<prefix>/lib/node_modules/<package>/…` (npm) or
9
+ * `~/.bun/install/global/node_modules/<package>/…` (bun). The package's own
10
+ * `package.json` is therefore the authority on what is installed, and reading
11
+ * it needs neither an installer record nor running the tool.
12
+ *
13
+ * The walk starts from the binary's REAL location — the symlink is resolved
14
+ * first, chain and all — because the directory the symlink sits in can belong
15
+ * to some unrelated project whose `package.json` would answer wrongly. The
16
+ * nearest `package.json` carrying both `name` and `version` wins; the partial
17
+ * ones a build leaves in `dist/` (`{"type":"module"}`) are skipped.
18
+ *
19
+ * Never throws: a missing binary, a dangling link, an unreadable or unparseable
20
+ * manifest, and "no package at all" are all `null`.
21
+ */
22
+ export function readPackageVersionForBin(binPath: string): string | null {
23
+ let real: string;
24
+ try {
25
+ real = realpathSync(binPath);
26
+ } catch {
27
+ return null;
28
+ }
29
+
30
+ let dir = path.dirname(real);
31
+ for (;;) {
32
+ const version = qualifyingVersion(path.join(dir, "package.json"));
33
+ if (version !== undefined) return version;
34
+ const parent = path.dirname(dir);
35
+ if (parent === dir) return null;
36
+ dir = parent;
37
+ }
38
+ }
39
+
40
+ /** A bare release number, as a versioned install directory names its files. */
41
+ const RELEASE = /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
42
+
43
+ /**
44
+ * The version a VERSIONED INSTALL names its binary by, or `null`.
45
+ *
46
+ * Some installers keep every release side by side and point PATH at one:
47
+ * Claude Code's native installer links `~/.local/bin/claude` to
48
+ * `~/.local/share/claude/versions/2.1.276`. There is no package.json there to
49
+ * read, but the file's own name IS the version — accepted only when the real
50
+ * path's parent directory is literally `versions` and the name is a bare
51
+ * release number, so an ordinary binary called `1.0.0` is not misread.
52
+ *
53
+ * Reads no file and runs nothing; never throws.
54
+ */
55
+ export function readVersionDirForBin(binPath: string): string | null {
56
+ let real: string;
57
+ try {
58
+ real = realpathSync(binPath);
59
+ } catch {
60
+ return null;
61
+ }
62
+ return versionFromVersionedPath(real);
63
+ }
64
+
65
+ /**
66
+ * The same reading as {@link readVersionDirForBin}, from a path that is ALREADY
67
+ * real — pure string work, no filesystem. The probe resolves paths through its
68
+ * own IO seam and must not reach past it to the real disk.
69
+ */
70
+ export function versionFromVersionedPath(realPath: string): string | null {
71
+ const name = path.basename(realPath);
72
+ if (path.basename(path.dirname(realPath)) !== "versions") return null;
73
+ return RELEASE.test(name) ? name.replace(/^v/, "") : null;
74
+ }
75
+
76
+ /**
77
+ * The manifest's version when it names both a package and a version.
78
+ *
79
+ * `undefined` means "not a package root, keep walking"; `null` means "this is
80
+ * the package root, and it cannot be read" — the walk stops there rather than
81
+ * letting an enclosing workspace answer for a package it does not own.
82
+ */
83
+ function qualifyingVersion(file: string): string | null | undefined {
84
+ let text: string;
85
+ try {
86
+ text = readFileSync(file, "utf8");
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ let manifest: unknown;
91
+ try {
92
+ manifest = JSON.parse(text);
93
+ } catch {
94
+ return null;
95
+ }
96
+ if (manifest === null || typeof manifest !== "object") return undefined;
97
+ const { name, version } = manifest as Record<string, unknown>;
98
+ if (typeof name !== "string" || name === "") return undefined;
99
+ if (typeof version !== "string" || version === "") return undefined;
100
+ return version;
101
+ }
@@ -18,16 +18,21 @@
18
18
  * absolute path recorded today is a dead path after the next upgrade, and
19
19
  * the failure is invisible (a hook that cannot start is a hook that
20
20
  * silently does not route).
21
- * 3. **User scope, through `updateSettingsFile`.** NOT
22
- * `readGlobalSettings`/`writeGlobalSettings`: those swallow a parse error
23
- * and return `{}`, so a settings file a human is mid-edit in would be
24
- * overwritten with one hook and nothing else. `updateSettingsFile` refuses
25
- * a file it cannot parse.
21
+ * 3. **User scope, through `updateSettingsFile`.** It refuses a file it
22
+ * cannot parse, so a settings file a human is mid-edit in is never
23
+ * overwritten with one hook and nothing else. The path is the resolved
24
+ * Claude config dir (`CLAUDE_CONFIG_DIR`), which
25
+ * `readGlobalSettings`/`writeGlobalSettings` (always `~/.claude`) ignore.
26
26
  */
27
27
 
28
28
  import path from "node:path";
29
29
  import { claudeConfigDirOrNull } from "../utils/config-dir.js";
30
- import { readSettingsFile, updateSettingsFile } from "./settings-file.js";
30
+ import {
31
+ readSettingsFile,
32
+ updateSettingsFile,
33
+ writableList,
34
+ writableMap,
35
+ } from "./settings-file.js";
31
36
 
32
37
  /** The command Claude Code runs. Bare — see rule 2 above. */
33
38
  export const AGENT_MODEL_HOOK_COMMAND = "magus hook agent-model";
@@ -196,6 +201,18 @@ export async function registerAgentModelHook(
196
201
  if (isHookRegistered(await readSettingsFile(settingsPath))) return "already";
197
202
 
198
203
  await updateSettingsFile(settingsPath, (settings) => {
204
+ // The user's own hooks live in `hooks.PreToolUse`. A malformed one is
205
+ // refused here, never replaced: `ensureHookRegistered` alone swaps a
206
+ // non-object `hooks` or a non-array `PreToolUse` for an empty one, and this
207
+ // write would then drop everything that was in it.
208
+ const hooks = writableMap(settings, "hooks", settingsPath);
209
+ if (hooks) {
210
+ writableList(
211
+ hooks as Record<string, unknown>,
212
+ "PreToolUse",
213
+ settingsPath,
214
+ );
215
+ }
199
216
  return ensureHookRegistered(settings).settings;
200
217
  });
201
218
  return "registered";