magus-cli 7.3.0 → 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.
- package/package.json +4 -4
- package/scripts/build-binaries.ts +4 -2
- package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
- package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
- package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
- package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
- package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
- package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
- package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
- package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
- package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
- package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
- package/src/__tests__/blind/c5-keymap.test.ts +461 -0
- package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
- package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
- package/src/__tests__/blind/c8-text-width.test.ts +137 -0
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
- package/src/__tests__/blind/child-run.ts +114 -0
- package/src/__tests__/cli-tools-adapter.test.ts +5 -2
- package/src/__tests__/component-badge.test.ts +20 -36
- package/src/__tests__/footer-hints.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +25 -17
- package/src/__tests__/marketplace-badge.test.ts +4 -4
- package/src/__tests__/models-screen-state.test.ts +36 -13
- package/src/__tests__/npm-package-name-api.test.ts +111 -0
- package/src/__tests__/npm-package-name.test.ts +280 -0
- package/src/__tests__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/styles-manager.test.ts +4 -4
- package/src/__tests__/styles-screen-state.test.ts +26 -12
- package/src/__tests__/tabbar-layout.test.ts +44 -32
- package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
- package/src/__tests__/tool-probe.test.ts +47 -22
- package/src/__tests__/upgrade-spawn.test.ts +355 -0
- package/src/__tests__/uppercase-keybindings.test.ts +25 -12
- package/src/cli/ansi.ts +2 -2
- package/src/cli/bootstrap.ts +33 -2
- package/src/cli/doctor.ts +105 -14
- package/src/cli/install.ts +8 -2
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/cli/upgrade.ts +37 -9
- package/src/data/cli-tools.ts +13 -2
- package/src/data/npm-package.ts +15 -0
- package/src/services/claude-settings.ts +151 -73
- package/src/services/cli-tool-commands.ts +15 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/hook-registration.ts +23 -6
- package/src/services/manifest.ts +117 -52
- package/src/services/plugin-manager.ts +19 -5
- package/src/services/profile-adopt.ts +7 -2
- package/src/services/profile-startup.ts +87 -7
- package/src/services/profiles.ts +36 -23
- package/src/services/settings-file.ts +158 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +272 -45
- package/src/services/toolchain.ts +39 -0
- package/src/services/update-engine.ts +5 -0
- package/src/services/update-plan.ts +9 -0
- package/src/services/version-check.ts +20 -10
- package/src/tui.tsx +48 -21
- package/src/ui/App.tsx +58 -197
- package/src/ui/adapters/cliToolsAdapter.ts +141 -12
- package/src/ui/adapters/pluginsAdapter.ts +10 -7
- package/src/ui/adapters/settingsAdapter.ts +0 -21
- package/src/ui/adapters/skillsAdapter.ts +27 -8
- package/src/ui/adapters/stylesAdapter.ts +15 -11
- package/src/ui/components/CategoryHeader.tsx +7 -1
- package/src/ui/components/EmptyFilterState.tsx +2 -2
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
- package/src/ui/components/TabBar.tsx +93 -21
- package/src/ui/components/layout/FooterHints.tsx +6 -27
- package/src/ui/components/layout/ScreenLayout.tsx +68 -42
- package/src/ui/components/layout/index.ts +1 -5
- package/src/ui/components/modals/ConfirmModal.tsx +2 -19
- package/src/ui/components/modals/HelpOverlay.tsx +186 -0
- package/src/ui/components/modals/MessageModal.tsx +3 -15
- package/src/ui/components/modals/ModalContainer.tsx +99 -60
- package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
- package/src/ui/components/primitives/MetaText.tsx +1 -1
- package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
- package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
- package/src/ui/components/primitives/SelectableRow.tsx +24 -1
- package/src/ui/components/primitives/index.ts +4 -3
- package/src/ui/hooks/useProfileStartupModal.ts +15 -3
- package/src/ui/input/KeyRouter.tsx +180 -0
- package/src/ui/input/key-dispatch.ts +149 -0
- package/src/ui/keymap.ts +898 -0
- package/src/ui/renderers/cliToolRenderers.tsx +128 -66
- package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
- package/src/ui/renderers/markdownRenderer.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +28 -14
- package/src/ui/renderers/modelRenderers.tsx +12 -13
- package/src/ui/renderers/modelVisuals.tsx +0 -2
- package/src/ui/renderers/pluginRenderers.tsx +47 -54
- package/src/ui/renderers/profileRenderers.tsx +32 -37
- package/src/ui/renderers/settingsRenderers.tsx +84 -55
- package/src/ui/renderers/skillRenderers.tsx +237 -190
- package/src/ui/renderers/styleRenderers.tsx +13 -2
- package/src/ui/runtime/shutdown.ts +268 -0
- package/src/ui/screens/AliasScreen.tsx +118 -100
- package/src/ui/screens/CliToolsScreen.tsx +98 -87
- package/src/ui/screens/EnvVarsScreen.tsx +44 -42
- package/src/ui/screens/GitignoreScreen.tsx +50 -55
- package/src/ui/screens/McpRegistryScreen.tsx +52 -86
- package/src/ui/screens/McpScreen.tsx +47 -33
- package/src/ui/screens/ModelsScreen.tsx +59 -104
- package/src/ui/screens/PluginsScreen.tsx +123 -127
- package/src/ui/screens/ProfilesScreen.tsx +49 -46
- package/src/ui/screens/SkillsScreen.tsx +184 -204
- package/src/ui/screens/StylesScreen.tsx +104 -157
- package/src/ui/state/AppContext.tsx +6 -0
- package/src/ui/state/DimensionsContext.tsx +1 -10
- package/src/ui/state/reducer.ts +5 -8
- package/src/ui/state/types.ts +11 -9
- package/src/ui/text-width.ts +341 -0
- package/src/ui/theme-mode.ts +34 -71
- package/src/ui/theme.ts +50 -28
- package/src/utils/request-gate.ts +37 -0
- package/src/ui/components/ScopeIndicator.tsx +0 -63
- package/src/ui/components/SearchInput.tsx +0 -57
- package/src/ui/hooks/index.ts +0 -2
- package/src/ui/hooks/useKeyboard.ts +0 -24
- 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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
166
|
-
|
|
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 (
|
|
180
|
-
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
|
-
|
|
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
|
-
//
|
|
236
|
+
// Both reads and both shape checks first, for the same reason as in
|
|
237
|
+
// addMcpServer.
|
|
197
238
|
const mcpConfig = await readMcpConfig(projectPath);
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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 =
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
435
|
-
|
|
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 (
|
|
438
|
-
delete
|
|
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 =
|
|
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
|
-
|
|
571
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
699
|
-
|
|
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 (
|
|
702
|
-
delete
|
|
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,
|
|
41
|
-
* the two unions stay separate rather than one being widened into
|
|
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
|
|
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,
|
|
111
|
-
* because a
|
|
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`.**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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 {
|
|
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";
|