magus-cli 7.3.1 → 7.4.1
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/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/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -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__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/stale-load-repair.test.ts +5 -4
- 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__/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 +120 -15
- package/src/cli/install.ts +35 -4
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/data/cli-tools.ts +9 -1
- package/src/services/claude-settings.ts +434 -198
- package/src/services/cli-tool-commands.ts +45 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/gitignore-service.ts +39 -22
- 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 +164 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +316 -44
- 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/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 +107 -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 +304 -0
- package/src/ui/input/key-dispatch.ts +197 -0
- package/src/ui/keymap.ts +905 -0
- package/src/ui/renderers/cliToolRenderers.tsx +192 -92
- 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 +173 -101
- 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 +129 -129
- 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,22 @@ import {
|
|
|
28
28
|
pickLoadedEntry,
|
|
29
29
|
repoRootForPath,
|
|
30
30
|
} from "./plugin-resolution.js";
|
|
31
|
+
import {
|
|
32
|
+
SettingsParseError,
|
|
33
|
+
SettingsShapeError,
|
|
34
|
+
describeJsonKind,
|
|
35
|
+
isJsonObject,
|
|
36
|
+
readSettingsFile,
|
|
37
|
+
writableList,
|
|
38
|
+
writableMap,
|
|
39
|
+
} from "./settings-file.js";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What every reader below throws for a file that exists but does not parse.
|
|
43
|
+
* Defined beside `readSettingsFile`, which all four readers delegate to, and
|
|
44
|
+
* re-exported here because this is the module callers read settings through.
|
|
45
|
+
*/
|
|
46
|
+
export { SettingsParseError } from "./settings-file.js";
|
|
31
47
|
|
|
32
48
|
const CLAUDE_DIR = ".claude";
|
|
33
49
|
const SETTINGS_FILE = "settings.json";
|
|
@@ -44,8 +60,17 @@ export function getClaudeDir(projectPath?: string): string {
|
|
|
44
60
|
return path.join(base, CLAUDE_DIR);
|
|
45
61
|
}
|
|
46
62
|
|
|
63
|
+
/**
|
|
64
|
+
* `~/.claude`, resolved per call.
|
|
65
|
+
*
|
|
66
|
+
* `HOME` first, then `os.homedir()` — Node's own rule for `os.homedir()`, done
|
|
67
|
+
* by hand because Bun does not follow it: under Bun 1.4 `os.homedir()` answers
|
|
68
|
+
* the home the process started with and ignores a later `process.env.HOME`
|
|
69
|
+
* (measured; Node follows the change). Reading `HOME` at call time is what lets
|
|
70
|
+
* a caller — a test above all — point the global settings somewhere else.
|
|
71
|
+
*/
|
|
47
72
|
export function getGlobalClaudeDir(): string {
|
|
48
|
-
return path.join(os.homedir(), CLAUDE_DIR);
|
|
73
|
+
return path.join(process.env.HOME || os.homedir(), CLAUDE_DIR);
|
|
49
74
|
}
|
|
50
75
|
|
|
51
76
|
export async function ensureClaudeDir(projectPath?: string): Promise<string> {
|
|
@@ -54,24 +79,76 @@ export async function ensureClaudeDir(projectPath?: string): Promise<string> {
|
|
|
54
79
|
return claudeDir;
|
|
55
80
|
}
|
|
56
81
|
|
|
82
|
+
/*
|
|
83
|
+
* THE FOUR READERS: a missing file reads as `{}`; a file that exists but does
|
|
84
|
+
* not parse throws `SettingsParseError`, never `{}`.
|
|
85
|
+
*
|
|
86
|
+
* They used to swallow every error and answer `{}`. Each has a writer beside
|
|
87
|
+
* it, and every read-modify-write in magus is `read → change one key →
|
|
88
|
+
* write`, so a settings file a human was mid-edit in came back as `{}` plus
|
|
89
|
+
* one key — and was written over, taking the rest of the configuration with
|
|
90
|
+
* it. Throwing from the read is what makes every one of those callers refuse:
|
|
91
|
+
* nothing reaches the write, so the file stays byte-for-byte as it was.
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/*
|
|
95
|
+
* Each file's path has one getter, used by its reader AND by every writer that
|
|
96
|
+
* checks a map before writing into it (`writableMap`), so a refusal names the
|
|
97
|
+
* file that was actually read.
|
|
98
|
+
*/
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* THE FOUR WRITERS all start here: the file as it is NOW must still be one this
|
|
102
|
+
* module would read, or nothing is written.
|
|
103
|
+
*
|
|
104
|
+
* Every caller reads before it writes, but not necessarily just before —
|
|
105
|
+
* `magus install` reads the local settings, puts a prompt per missing variable
|
|
106
|
+
* on screen, and writes when the last one is answered. Claude Code writes that
|
|
107
|
+
* same file while those prompts are up (a permission rule, whenever someone
|
|
108
|
+
* answers "don't ask again"), and `writeJson` would replace it with a document
|
|
109
|
+
* built from the pre-prompt read.
|
|
110
|
+
*
|
|
111
|
+
* Checking at write time is what makes the refusal hold for every writer instead
|
|
112
|
+
* of only the ones that happened to re-read — the same rule `writeManifest`
|
|
113
|
+
* applies to `.claude/profiles.json`. The read is for the throw alone; the
|
|
114
|
+
* contents are about to be replaced.
|
|
115
|
+
*
|
|
116
|
+
* It closes the gap, it does not close the race: the check and the write are two
|
|
117
|
+
* syscalls. The window is microseconds rather than however long a human takes to
|
|
118
|
+
* answer a prompt.
|
|
119
|
+
*/
|
|
120
|
+
async function refuseUnwritableFile(filePath: string): Promise<void> {
|
|
121
|
+
await readSettingsFile(filePath);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** <project>/.claude/settings.json */
|
|
125
|
+
export function getSettingsPath(projectPath?: string): string {
|
|
126
|
+
return path.join(getClaudeDir(projectPath), SETTINGS_FILE);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** <project>/.claude/settings.local.json */
|
|
130
|
+
export function getLocalSettingsPath(projectPath?: string): string {
|
|
131
|
+
return path.join(getClaudeDir(projectPath), LOCAL_SETTINGS_FILE);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** ~/.claude/settings.json */
|
|
135
|
+
export function getGlobalSettingsPath(): string {
|
|
136
|
+
return path.join(getGlobalClaudeDir(), SETTINGS_FILE);
|
|
137
|
+
}
|
|
138
|
+
|
|
57
139
|
export async function readSettings(
|
|
58
140
|
projectPath?: string,
|
|
59
141
|
): Promise<ClaudeSettings> {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return await fs.readJson(settingsPath);
|
|
64
|
-
}
|
|
65
|
-
} catch {
|
|
66
|
-
// Return empty settings on error
|
|
67
|
-
}
|
|
68
|
-
return {};
|
|
142
|
+
return (await readSettingsFile(
|
|
143
|
+
getSettingsPath(projectPath),
|
|
144
|
+
)) as ClaudeSettings;
|
|
69
145
|
}
|
|
70
146
|
|
|
71
147
|
export async function writeSettings(
|
|
72
148
|
settings: ClaudeSettings,
|
|
73
149
|
projectPath?: string,
|
|
74
150
|
): Promise<void> {
|
|
151
|
+
await refuseUnwritableFile(getSettingsPath(projectPath));
|
|
75
152
|
const claudeDir = await ensureClaudeDir(projectPath);
|
|
76
153
|
const settingsPath = path.join(claudeDir, SETTINGS_FILE);
|
|
77
154
|
await fs.writeJson(settingsPath, settings, { spaces: 2 });
|
|
@@ -80,21 +157,16 @@ export async function writeSettings(
|
|
|
80
157
|
export async function readLocalSettings(
|
|
81
158
|
projectPath?: string,
|
|
82
159
|
): Promise<ClaudeLocalSettings> {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return await fs.readJson(localPath);
|
|
87
|
-
}
|
|
88
|
-
} catch {
|
|
89
|
-
// Return empty settings on error
|
|
90
|
-
}
|
|
91
|
-
return {};
|
|
160
|
+
return (await readSettingsFile(
|
|
161
|
+
getLocalSettingsPath(projectPath),
|
|
162
|
+
)) as ClaudeLocalSettings;
|
|
92
163
|
}
|
|
93
164
|
|
|
94
165
|
export async function writeLocalSettings(
|
|
95
166
|
settings: ClaudeLocalSettings,
|
|
96
167
|
projectPath?: string,
|
|
97
168
|
): Promise<void> {
|
|
169
|
+
await refuseUnwritableFile(getLocalSettingsPath(projectPath));
|
|
98
170
|
const claudeDir = await ensureClaudeDir(projectPath);
|
|
99
171
|
const localPath = path.join(claudeDir, LOCAL_SETTINGS_FILE);
|
|
100
172
|
await fs.writeJson(localPath, settings, { spaces: 2 });
|
|
@@ -109,15 +181,9 @@ export function getMcpConfigPath(projectPath?: string): string {
|
|
|
109
181
|
export async function readMcpConfig(
|
|
110
182
|
projectPath?: string,
|
|
111
183
|
): Promise<McpConfigFile> {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return await fs.readJson(mcpPath);
|
|
116
|
-
}
|
|
117
|
-
} catch {
|
|
118
|
-
// Return empty config on error
|
|
119
|
-
}
|
|
120
|
-
return {};
|
|
184
|
+
return (await readSettingsFile(
|
|
185
|
+
getMcpConfigPath(projectPath),
|
|
186
|
+
)) as McpConfigFile;
|
|
121
187
|
}
|
|
122
188
|
|
|
123
189
|
export async function writeMcpConfig(
|
|
@@ -125,24 +191,18 @@ export async function writeMcpConfig(
|
|
|
125
191
|
projectPath?: string,
|
|
126
192
|
): Promise<void> {
|
|
127
193
|
const mcpPath = getMcpConfigPath(projectPath);
|
|
194
|
+
await refuseUnwritableFile(mcpPath);
|
|
128
195
|
await fs.writeJson(mcpPath, config, { spaces: 2 });
|
|
129
196
|
}
|
|
130
197
|
|
|
131
198
|
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 {};
|
|
199
|
+
return (await readSettingsFile(getGlobalSettingsPath())) as ClaudeSettings;
|
|
141
200
|
}
|
|
142
201
|
|
|
143
202
|
export async function writeGlobalSettings(
|
|
144
203
|
settings: ClaudeSettings,
|
|
145
204
|
): Promise<void> {
|
|
205
|
+
await refuseUnwritableFile(getGlobalSettingsPath());
|
|
146
206
|
await fs.ensureDir(getGlobalClaudeDir());
|
|
147
207
|
const settingsPath = path.join(getGlobalClaudeDir(), SETTINGS_FILE);
|
|
148
208
|
await fs.writeJson(settingsPath, settings, { spaces: 2 });
|
|
@@ -160,15 +220,27 @@ export async function addMcpServer(
|
|
|
160
220
|
// biome-ignore lint/performance/noDelete: this object is serialised into .mcp.json; the env key must be absent, not present as undefined
|
|
161
221
|
delete configWithoutEnv.env;
|
|
162
222
|
|
|
163
|
-
//
|
|
223
|
+
// Read BOTH files, and check every map written into, before writing either:
|
|
224
|
+
// if one does not parse or has the wrong shape, the whole operation refuses,
|
|
225
|
+
// rather than leaving .mcp.json changed and the local settings not.
|
|
164
226
|
const mcpConfig = await readMcpConfig(projectPath);
|
|
165
|
-
|
|
166
|
-
|
|
227
|
+
const localSettings = await readLocalSettings(projectPath);
|
|
228
|
+
const localPath = getLocalSettingsPath(projectPath);
|
|
229
|
+
const mcpServers =
|
|
230
|
+
writableMap(mcpConfig, "mcpServers", getMcpConfigPath(projectPath)) ?? {};
|
|
231
|
+
const enabledServers =
|
|
232
|
+
writableList(localSettings, "enabledMcpjsonServers", localPath) ?? [];
|
|
233
|
+
const env =
|
|
234
|
+
Object.keys(envVars).length > 0
|
|
235
|
+
? (writableMap(localSettings, "env", localPath) ?? {})
|
|
236
|
+
: undefined;
|
|
237
|
+
|
|
238
|
+
// Add to .mcp.json (without env vars)
|
|
239
|
+
mcpConfig.mcpServers = mcpServers;
|
|
240
|
+
mcpServers[name] = configWithoutEnv;
|
|
167
241
|
await writeMcpConfig(mcpConfig, projectPath);
|
|
168
242
|
|
|
169
243
|
// Enable in settings.local.json and add env vars
|
|
170
|
-
const localSettings = await readLocalSettings(projectPath);
|
|
171
|
-
const enabledServers = localSettings.enabledMcpjsonServers || [];
|
|
172
244
|
if (!enabledServers.includes(name)) {
|
|
173
245
|
enabledServers.push(name);
|
|
174
246
|
}
|
|
@@ -176,12 +248,12 @@ export async function addMcpServer(
|
|
|
176
248
|
localSettings.enableAllProjectMcpServers = true;
|
|
177
249
|
|
|
178
250
|
// Add env vars to settings.local.json
|
|
179
|
-
if (
|
|
180
|
-
localSettings.env =
|
|
251
|
+
if (env) {
|
|
252
|
+
localSettings.env = env;
|
|
181
253
|
for (const [key, value] of Object.entries(envVars)) {
|
|
182
254
|
// Only add non-reference values (references like ${VAR} don't need to be stored)
|
|
183
255
|
if (!value.startsWith("${") || !value.endsWith("}")) {
|
|
184
|
-
|
|
256
|
+
env[key] = value;
|
|
185
257
|
}
|
|
186
258
|
}
|
|
187
259
|
}
|
|
@@ -193,18 +265,32 @@ export async function removeMcpServer(
|
|
|
193
265
|
name: string,
|
|
194
266
|
projectPath?: string,
|
|
195
267
|
): Promise<void> {
|
|
196
|
-
//
|
|
268
|
+
// Both reads and both shape checks first, for the same reason as in
|
|
269
|
+
// addMcpServer.
|
|
197
270
|
const mcpConfig = await readMcpConfig(projectPath);
|
|
198
|
-
|
|
199
|
-
|
|
271
|
+
const localSettings = await readLocalSettings(projectPath);
|
|
272
|
+
const mcpServers = writableMap(
|
|
273
|
+
mcpConfig,
|
|
274
|
+
"mcpServers",
|
|
275
|
+
getMcpConfigPath(projectPath),
|
|
276
|
+
);
|
|
277
|
+
const enabledServers = writableList(
|
|
278
|
+
localSettings,
|
|
279
|
+
"enabledMcpjsonServers",
|
|
280
|
+
getLocalSettingsPath(projectPath),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// Remove from .mcp.json
|
|
284
|
+
if (mcpServers) {
|
|
285
|
+
delete mcpServers[name];
|
|
200
286
|
}
|
|
201
287
|
await writeMcpConfig(mcpConfig, projectPath);
|
|
202
288
|
|
|
203
289
|
// Remove from settings.local.json
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
290
|
+
if (enabledServers) {
|
|
291
|
+
localSettings.enabledMcpjsonServers = enabledServers.filter(
|
|
292
|
+
(s) => s !== name,
|
|
293
|
+
);
|
|
208
294
|
await writeLocalSettings(localSettings, projectPath);
|
|
209
295
|
}
|
|
210
296
|
}
|
|
@@ -242,12 +328,11 @@ export async function enablePlugin(
|
|
|
242
328
|
projectPath?: string,
|
|
243
329
|
): Promise<void> {
|
|
244
330
|
const settings = await readSettings(projectPath);
|
|
245
|
-
settings.enabledPlugins =
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
}
|
|
331
|
+
settings.enabledPlugins =
|
|
332
|
+
writableMap(settings, "enabledPlugins", getSettingsPath(projectPath)) ?? {};
|
|
333
|
+
// `false`, never a deleted key: Claude Code resolves enabledPlugins per id with
|
|
334
|
+
// fall-through, so a missing project entry lets a user-scope `true` win.
|
|
335
|
+
settings.enabledPlugins[pluginId] = enabled;
|
|
251
336
|
await writeSettings(settings, projectPath);
|
|
252
337
|
}
|
|
253
338
|
|
|
@@ -398,7 +483,12 @@ export async function enableLocalPlugin(
|
|
|
398
483
|
projectPath?: string,
|
|
399
484
|
): Promise<void> {
|
|
400
485
|
const settings = await readLocalSettings(projectPath);
|
|
401
|
-
settings.enabledPlugins =
|
|
486
|
+
settings.enabledPlugins =
|
|
487
|
+
writableMap(
|
|
488
|
+
settings,
|
|
489
|
+
"enabledPlugins",
|
|
490
|
+
getLocalSettingsPath(projectPath),
|
|
491
|
+
) ?? {};
|
|
402
492
|
if (enabled) {
|
|
403
493
|
settings.enabledPlugins[pluginId] = true;
|
|
404
494
|
} else {
|
|
@@ -413,7 +503,12 @@ export async function saveLocalInstalledPluginVersion(
|
|
|
413
503
|
projectPath?: string,
|
|
414
504
|
): Promise<void> {
|
|
415
505
|
const settings = await readLocalSettings(projectPath);
|
|
416
|
-
settings.installedPluginVersions =
|
|
506
|
+
settings.installedPluginVersions =
|
|
507
|
+
writableMap(
|
|
508
|
+
settings,
|
|
509
|
+
"installedPluginVersions",
|
|
510
|
+
getLocalSettingsPath(projectPath),
|
|
511
|
+
) ?? {};
|
|
417
512
|
settings.installedPluginVersions[pluginId] = version;
|
|
418
513
|
await writeLocalSettings(settings, projectPath);
|
|
419
514
|
|
|
@@ -431,11 +526,14 @@ export async function removeLocalInstalledPluginVersion(
|
|
|
431
526
|
projectPath?: string,
|
|
432
527
|
): Promise<void> {
|
|
433
528
|
const settings = await readLocalSettings(projectPath);
|
|
434
|
-
|
|
435
|
-
|
|
529
|
+
const localPath = getLocalSettingsPath(projectPath);
|
|
530
|
+
const versions = writableMap(settings, "installedPluginVersions", localPath);
|
|
531
|
+
const enabled = writableMap(settings, "enabledPlugins", localPath);
|
|
532
|
+
if (versions) {
|
|
533
|
+
delete versions[pluginId];
|
|
436
534
|
}
|
|
437
|
-
if (
|
|
438
|
-
delete
|
|
535
|
+
if (enabled) {
|
|
536
|
+
delete enabled[pluginId];
|
|
439
537
|
}
|
|
440
538
|
await writeLocalSettings(settings, projectPath);
|
|
441
539
|
|
|
@@ -556,7 +654,8 @@ export async function setMcpEnvVar(
|
|
|
556
654
|
projectPath?: string,
|
|
557
655
|
): Promise<void> {
|
|
558
656
|
const localSettings = await readLocalSettings(projectPath);
|
|
559
|
-
localSettings.env =
|
|
657
|
+
localSettings.env =
|
|
658
|
+
writableMap(localSettings, "env", getLocalSettingsPath(projectPath)) ?? {};
|
|
560
659
|
localSettings.env[name] = value;
|
|
561
660
|
await writeLocalSettings(localSettings, projectPath);
|
|
562
661
|
}
|
|
@@ -567,8 +666,13 @@ export async function removeMcpEnvVar(
|
|
|
567
666
|
projectPath?: string,
|
|
568
667
|
): Promise<void> {
|
|
569
668
|
const localSettings = await readLocalSettings(projectPath);
|
|
570
|
-
|
|
571
|
-
|
|
669
|
+
const env = writableMap(
|
|
670
|
+
localSettings,
|
|
671
|
+
"env",
|
|
672
|
+
getLocalSettingsPath(projectPath),
|
|
673
|
+
);
|
|
674
|
+
if (env) {
|
|
675
|
+
delete env[name];
|
|
572
676
|
await writeLocalSettings(localSettings, projectPath);
|
|
573
677
|
}
|
|
574
678
|
}
|
|
@@ -652,7 +756,8 @@ export async function enableGlobalPlugin(
|
|
|
652
756
|
enabled: boolean,
|
|
653
757
|
): Promise<void> {
|
|
654
758
|
const settings = await readGlobalSettings();
|
|
655
|
-
settings.enabledPlugins =
|
|
759
|
+
settings.enabledPlugins =
|
|
760
|
+
writableMap(settings, "enabledPlugins", getGlobalSettingsPath()) ?? {};
|
|
656
761
|
if (enabled) {
|
|
657
762
|
settings.enabledPlugins[pluginId] = true;
|
|
658
763
|
} else {
|
|
@@ -683,7 +788,9 @@ export async function saveGlobalInstalledPluginVersion(
|
|
|
683
788
|
version: string,
|
|
684
789
|
): Promise<void> {
|
|
685
790
|
const settings = await readGlobalSettings();
|
|
686
|
-
settings.installedPluginVersions =
|
|
791
|
+
settings.installedPluginVersions =
|
|
792
|
+
writableMap(settings, "installedPluginVersions", getGlobalSettingsPath()) ??
|
|
793
|
+
{};
|
|
687
794
|
settings.installedPluginVersions[pluginId] = version;
|
|
688
795
|
await writeGlobalSettings(settings);
|
|
689
796
|
|
|
@@ -695,11 +802,14 @@ export async function removeGlobalInstalledPluginVersion(
|
|
|
695
802
|
pluginId: string,
|
|
696
803
|
): Promise<void> {
|
|
697
804
|
const settings = await readGlobalSettings();
|
|
698
|
-
|
|
699
|
-
|
|
805
|
+
const globalPath = getGlobalSettingsPath();
|
|
806
|
+
const versions = writableMap(settings, "installedPluginVersions", globalPath);
|
|
807
|
+
const enabled = writableMap(settings, "enabledPlugins", globalPath);
|
|
808
|
+
if (versions) {
|
|
809
|
+
delete versions[pluginId];
|
|
700
810
|
}
|
|
701
|
-
if (
|
|
702
|
-
delete
|
|
811
|
+
if (enabled) {
|
|
812
|
+
delete enabled[pluginId];
|
|
703
813
|
}
|
|
704
814
|
await writeGlobalSettings(settings);
|
|
705
815
|
|
|
@@ -1052,13 +1162,13 @@ export async function migrateMarketplaceRename(
|
|
|
1052
1162
|
}
|
|
1053
1163
|
|
|
1054
1164
|
// 4. known_marketplaces.json — rename old keys + physical directory cleanup
|
|
1055
|
-
const
|
|
1165
|
+
const marketplacesDir = path.join(
|
|
1056
1166
|
os.homedir(),
|
|
1057
1167
|
".claude",
|
|
1058
1168
|
"plugins",
|
|
1059
1169
|
"marketplaces",
|
|
1060
1170
|
);
|
|
1061
|
-
const newDir = path.join(
|
|
1171
|
+
const newDir = path.join(marketplacesDir, NEW_MARKETPLACE_NAME);
|
|
1062
1172
|
|
|
1063
1173
|
try {
|
|
1064
1174
|
const known = await readKnownMarketplaces();
|
|
@@ -1100,7 +1210,7 @@ export async function migrateMarketplaceRename(
|
|
|
1100
1210
|
|
|
1101
1211
|
// 4b. Rename/remove old physical directories (runs even if keys were already migrated)
|
|
1102
1212
|
for (const oldName of OLD_MARKETPLACE_NAMES) {
|
|
1103
|
-
const oldDir = path.join(
|
|
1213
|
+
const oldDir = path.join(marketplacesDir, oldName);
|
|
1104
1214
|
try {
|
|
1105
1215
|
if (await fs.pathExists(oldDir)) {
|
|
1106
1216
|
if (!(await fs.pathExists(newDir))) {
|
|
@@ -1139,29 +1249,32 @@ export async function migrateMarketplaceRename(
|
|
|
1139
1249
|
|
|
1140
1250
|
// 5. installed_plugins.json — rename plugin ID keys
|
|
1141
1251
|
try {
|
|
1142
|
-
const registry = await readInstalledPluginsRegistry();
|
|
1143
1252
|
let regCount = 0;
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
const
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
newPlugins[newKey]
|
|
1253
|
+
await updateRegistryFile((registry) => {
|
|
1254
|
+
const newPlugins: typeof registry.plugins = {};
|
|
1255
|
+
for (const [pluginId, entries] of Object.entries(registry.plugins)) {
|
|
1256
|
+
const oldName = OLD_MARKETPLACE_NAMES.find((n) =>
|
|
1257
|
+
pluginId.endsWith(`@${n}`),
|
|
1258
|
+
);
|
|
1259
|
+
if (oldName) {
|
|
1260
|
+
const pluginName = pluginId.slice(0, pluginId.lastIndexOf("@"));
|
|
1261
|
+
const newKey = `${pluginName}@${NEW_MARKETPLACE_NAME}`;
|
|
1262
|
+
if (!newPlugins[newKey]) {
|
|
1263
|
+
newPlugins[newKey] = entries;
|
|
1264
|
+
}
|
|
1265
|
+
regCount++;
|
|
1266
|
+
} else {
|
|
1267
|
+
newPlugins[pluginId] = entries;
|
|
1154
1268
|
}
|
|
1155
|
-
regCount++;
|
|
1156
|
-
} else {
|
|
1157
|
-
newPlugins[pluginId] = entries;
|
|
1158
1269
|
}
|
|
1159
|
-
|
|
1160
|
-
if (regCount > 0) {
|
|
1270
|
+
if (regCount === 0) return false;
|
|
1161
1271
|
registry.plugins = newPlugins;
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1272
|
+
return true;
|
|
1273
|
+
});
|
|
1274
|
+
// After the write, never inside the mutation: a refused write (a corrupt
|
|
1275
|
+
// file, or one another session changed) must not report a migration that
|
|
1276
|
+
// did not land.
|
|
1277
|
+
result.registryMigrated = regCount;
|
|
1165
1278
|
} catch {
|
|
1166
1279
|
/* skip if unreadable */
|
|
1167
1280
|
}
|
|
@@ -1416,7 +1529,17 @@ export async function resolveCacheProvenance(
|
|
|
1416
1529
|
}
|
|
1417
1530
|
|
|
1418
1531
|
/**
|
|
1419
|
-
* Read installed_plugins.json registry
|
|
1532
|
+
* Read installed_plugins.json registry.
|
|
1533
|
+
*
|
|
1534
|
+
* Missing reads as an empty registry — nothing is lost by starting from one.
|
|
1535
|
+
* A file that EXISTS and cannot be used throws, exactly as the settings readers
|
|
1536
|
+
* do: this is the record of what every project on the machine has installed,
|
|
1537
|
+
* and "unreadable" is not the same answer as "nothing installed".
|
|
1538
|
+
*
|
|
1539
|
+
* It used to answer an empty registry for both, and the registry writers below
|
|
1540
|
+
* then wrote that empty copy back with one plugin in it — so a registry a
|
|
1541
|
+
* Claude Code write had truncated came back holding a single row, and every
|
|
1542
|
+
* other plugin on the machine read as uninstalled.
|
|
1420
1543
|
*
|
|
1421
1544
|
* @param root the plugins directory to read from — see {@link pluginsDir}
|
|
1422
1545
|
*/
|
|
@@ -1424,20 +1547,7 @@ export async function readInstalledPluginsRegistry(
|
|
|
1424
1547
|
root: string = pluginsDir(),
|
|
1425
1548
|
): Promise<InstalledPluginsRegistry> {
|
|
1426
1549
|
const file = installedPluginsFile(root);
|
|
1427
|
-
|
|
1428
|
-
const parsed = parseRegistryBytes(raw);
|
|
1429
|
-
if (parsed) return parsed;
|
|
1430
|
-
if (raw !== null) {
|
|
1431
|
-
// Corrupt rather than absent — keep a copy before any caller writes over it.
|
|
1432
|
-
try {
|
|
1433
|
-
const backup = await registryBackupPath(file, "corrupt");
|
|
1434
|
-
await fs.copy(file, backup);
|
|
1435
|
-
console.warn(`Corrupted registry backed up to: ${backup}`);
|
|
1436
|
-
} catch {
|
|
1437
|
-
// Ignore backup errors
|
|
1438
|
-
}
|
|
1439
|
-
}
|
|
1440
|
-
return { version: 2, plugins: {} };
|
|
1550
|
+
return requireRegistry(file, await readRegistryBytes(file));
|
|
1441
1551
|
}
|
|
1442
1552
|
|
|
1443
1553
|
/**
|
|
@@ -1457,18 +1567,104 @@ async function readRegistryBytes(file: string): Promise<string | null> {
|
|
|
1457
1567
|
}
|
|
1458
1568
|
}
|
|
1459
1569
|
|
|
1460
|
-
/**
|
|
1461
|
-
function
|
|
1570
|
+
/** An empty, valid registry: what an absent file reads as. */
|
|
1571
|
+
function emptyRegistry(): InstalledPluginsRegistry {
|
|
1572
|
+
return { version: 2, plugins: {} };
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/**
|
|
1576
|
+
* Refuse a registry document that cannot be read AT ALL — it does not parse, or
|
|
1577
|
+
* its top level is not a JSON object — with the same refusal every settings file
|
|
1578
|
+
* gets, naming the file. Absent or whitespace-only passes (and answers `null`).
|
|
1579
|
+
*
|
|
1580
|
+
* The weaker of the two checks, and deliberately so: it is what a WRITER asks
|
|
1581
|
+
* before renaming over a file, where the question is only "is this a document
|
|
1582
|
+
* anything could have read", not "does it match the shape this version of magus
|
|
1583
|
+
* models". {@link requireRegistry} is the stronger one, asked by every reader.
|
|
1584
|
+
*/
|
|
1585
|
+
function refuseUnreadableRegistry(
|
|
1586
|
+
file: string,
|
|
1462
1587
|
raw: string | null,
|
|
1463
|
-
):
|
|
1464
|
-
if (raw === null) return null;
|
|
1588
|
+
): Record<string, unknown> | null {
|
|
1589
|
+
if (raw === null || raw.trim() === "") return null;
|
|
1590
|
+
|
|
1591
|
+
let parsed: unknown;
|
|
1465
1592
|
try {
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1593
|
+
parsed = JSON.parse(raw);
|
|
1594
|
+
} catch (error) {
|
|
1595
|
+
throw new SettingsParseError(file, error);
|
|
1596
|
+
}
|
|
1597
|
+
if (!isJsonObject(parsed)) {
|
|
1598
|
+
throw new SettingsShapeError(
|
|
1599
|
+
file,
|
|
1600
|
+
"is not a JSON object",
|
|
1601
|
+
`its top level is ${describeJsonKind(parsed)}`,
|
|
1602
|
+
);
|
|
1471
1603
|
}
|
|
1604
|
+
return parsed;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/**
|
|
1608
|
+
* Parse + structure check, or throw.
|
|
1609
|
+
*
|
|
1610
|
+
* THE one place a registry document is judged for READING, so no two readers
|
|
1611
|
+
* can disagree about whether the file on disk is usable. Absent or
|
|
1612
|
+
* whitespace-only reads as an empty registry; anything else that is not
|
|
1613
|
+
* `{version, plugins: {…}}` throws a {@link SettingsParseError} — a
|
|
1614
|
+
* `SettingsShapeError` when it parsed but is the wrong shape — naming the file,
|
|
1615
|
+
* so every read-modify-write refuses at the same point and nothing reaches a
|
|
1616
|
+
* write.
|
|
1617
|
+
*/
|
|
1618
|
+
function requireRegistry(
|
|
1619
|
+
file: string,
|
|
1620
|
+
raw: string | null,
|
|
1621
|
+
): InstalledPluginsRegistry {
|
|
1622
|
+
const parsed = refuseUnreadableRegistry(file, raw);
|
|
1623
|
+
if (parsed === null) return emptyRegistry();
|
|
1624
|
+
|
|
1625
|
+
if (!parsed.version || !isJsonObject(parsed.plugins)) {
|
|
1626
|
+
throw new SettingsShapeError(
|
|
1627
|
+
file,
|
|
1628
|
+
"is not a plugin registry",
|
|
1629
|
+
`expected a "version" and a "plugins" object, got ${
|
|
1630
|
+
parsed.plugins === undefined
|
|
1631
|
+
? 'no "plugins" key'
|
|
1632
|
+
: `"plugins" as ${describeJsonKind(parsed.plugins)}`
|
|
1633
|
+
}`,
|
|
1634
|
+
);
|
|
1635
|
+
}
|
|
1636
|
+
return parsed as unknown as InstalledPluginsRegistry;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
/**
|
|
1640
|
+
* Read the registry, change it, write it back — the ONE read-modify-write every
|
|
1641
|
+
* registry writer goes through.
|
|
1642
|
+
*
|
|
1643
|
+
* Both halves of the safety live here rather than in each writer: the read
|
|
1644
|
+
* refuses a document we cannot parse ({@link requireRegistry}), and the write
|
|
1645
|
+
* carries the exact bytes that were read as a precondition, so a Claude Code
|
|
1646
|
+
* install landing in between is refused instead of being silently dropped by our
|
|
1647
|
+
* snapshot (see {@link writeInstalledPluginsRegistry}).
|
|
1648
|
+
*
|
|
1649
|
+
* `mutate` answers whether there is anything to write: `false` writes nothing
|
|
1650
|
+
* and leaves the file untouched.
|
|
1651
|
+
*
|
|
1652
|
+
* {@link repairStaleLoad} is the one writer that does this by hand, because it
|
|
1653
|
+
* takes an undo backup between the read and the write and reports its refusals
|
|
1654
|
+
* as a result object instead of throwing. It reads and checks through the same
|
|
1655
|
+
* {@link requireRegistry}.
|
|
1656
|
+
*/
|
|
1657
|
+
async function updateRegistryFile(
|
|
1658
|
+
mutate: (registry: InstalledPluginsRegistry) => Promise<boolean> | boolean,
|
|
1659
|
+
root: string = pluginsDir(),
|
|
1660
|
+
): Promise<void> {
|
|
1661
|
+
const file = installedPluginsFile(root);
|
|
1662
|
+
// The BYTES first, and the snapshot parsed from those same bytes: they have
|
|
1663
|
+
// to describe one moment for the precondition to mean anything.
|
|
1664
|
+
const before = await readRegistryBytes(file);
|
|
1665
|
+
const registry = requireRegistry(file, before);
|
|
1666
|
+
if (!(await mutate(registry))) return;
|
|
1667
|
+
await writeInstalledPluginsRegistry(registry, root, before);
|
|
1472
1668
|
}
|
|
1473
1669
|
|
|
1474
1670
|
/** How many `installed_plugins.json.backup.*` files are kept. */
|
|
@@ -1547,10 +1743,23 @@ export class RegistryChangedError extends Error {
|
|
|
1547
1743
|
*
|
|
1548
1744
|
* ATOMIC — temp file in the same directory, then rename. This file is read by
|
|
1549
1745
|
* every Claude Code session on the machine, so a write interrupted partway
|
|
1550
|
-
* leaves a truncated JSON document
|
|
1551
|
-
*
|
|
1552
|
-
*
|
|
1553
|
-
*
|
|
1746
|
+
* leaves a truncated JSON document nothing can read: every plugin on the machine
|
|
1747
|
+
* would read as not installed. `rename(2)` within one directory cannot be
|
|
1748
|
+
* observed half-done, so a reader sees either the old file or the new one.
|
|
1749
|
+
*
|
|
1750
|
+
* TWO preconditions, both checked as the last act before the rename, and the
|
|
1751
|
+
* second is not implied by the first:
|
|
1752
|
+
*
|
|
1753
|
+
* 1. the file is the one the caller read (`expectBytes`);
|
|
1754
|
+
* 2. the file on disk is still a document we can read at all — it parses, and
|
|
1755
|
+
* into an object.
|
|
1756
|
+
*
|
|
1757
|
+
* Byte-equality does not cover (2). A caller holding the raw bytes of a corrupt
|
|
1758
|
+
* file passes the compare exactly, and the rename then replaces a registry
|
|
1759
|
+
* nobody could read with one holding only the plugin being written — every other
|
|
1760
|
+
* project's installs gone. The strict read in {@link requireRegistry} is the
|
|
1761
|
+
* first line and stops every read-modify-write; this is the second, and it holds
|
|
1762
|
+
* for a direct caller too, including one that passes no `expectBytes`.
|
|
1554
1763
|
*
|
|
1555
1764
|
* ATOMICITY IS NOT ISOLATION, and conflating the two is how this silently lost
|
|
1556
1765
|
* other sessions' work. A read-modify-write rewrites the WHOLE file from the
|
|
@@ -1586,10 +1795,12 @@ export async function writeInstalledPluginsRegistry(
|
|
|
1586
1795
|
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
1587
1796
|
try {
|
|
1588
1797
|
await fs.writeJson(tmp, registry, { spaces: 2 });
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1798
|
+
// One read, both preconditions — they have to judge the same moment.
|
|
1799
|
+
const current = await readRegistryBytes(file);
|
|
1800
|
+
if (expectBytes !== undefined && current !== expectBytes) {
|
|
1801
|
+
throw new RegistryChangedError(file);
|
|
1592
1802
|
}
|
|
1803
|
+
refuseUnreadableRegistry(file, current);
|
|
1593
1804
|
await fs.rename(tmp, file);
|
|
1594
1805
|
} catch (error) {
|
|
1595
1806
|
await fs.remove(tmp).catch(() => {});
|
|
@@ -1625,65 +1836,73 @@ export async function updateInstalledPluginsRegistry(
|
|
|
1625
1836
|
projectPath?: string,
|
|
1626
1837
|
): Promise<void> {
|
|
1627
1838
|
try {
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
const { marketplace } = parsed;
|
|
1839
|
+
await updateRegistryFile(async (registry) => {
|
|
1840
|
+
// Get marketplace from plugin ID
|
|
1841
|
+
const parsed = parsePluginId(pluginId);
|
|
1842
|
+
if (!parsed) {
|
|
1843
|
+
console.warn(
|
|
1844
|
+
`Invalid plugin ID: ${pluginId}, skipping registry update`,
|
|
1845
|
+
);
|
|
1846
|
+
return false;
|
|
1847
|
+
}
|
|
1638
1848
|
|
|
1639
|
-
|
|
1640
|
-
// This ensures the cache has the latest plugin version
|
|
1641
|
-
const copiedFrom = await copyPluginToCache(pluginId, version, marketplace);
|
|
1849
|
+
const { marketplace } = parsed;
|
|
1642
1850
|
|
|
1643
|
-
|
|
1644
|
-
|
|
1851
|
+
// Copy plugin files from source to cache
|
|
1852
|
+
// This ensures the cache has the latest plugin version
|
|
1853
|
+
const copiedFrom = await copyPluginToCache(
|
|
1854
|
+
pluginId,
|
|
1855
|
+
version,
|
|
1856
|
+
marketplace,
|
|
1857
|
+
);
|
|
1645
1858
|
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
registry.plugins[pluginId] = [];
|
|
1649
|
-
}
|
|
1859
|
+
const installPath = getPluginCachePath(pluginId, version, marketplace);
|
|
1860
|
+
const now = new Date().toISOString();
|
|
1650
1861
|
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
return entry.projectPath === projectPath;
|
|
1656
|
-
});
|
|
1862
|
+
// Initialize plugin array if it doesn't exist
|
|
1863
|
+
if (!registry.plugins[pluginId]) {
|
|
1864
|
+
registry.plugins[pluginId] = [];
|
|
1865
|
+
}
|
|
1657
1866
|
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
existingIndex >= 0
|
|
1665
|
-
? registry.plugins[pluginId][existingIndex].installedAt
|
|
1666
|
-
: now,
|
|
1667
|
-
lastUpdated: now,
|
|
1668
|
-
gitCommitSha: await resolveCacheProvenance(
|
|
1669
|
-
copiedFrom,
|
|
1670
|
-
existingIndex >= 0
|
|
1671
|
-
? registry.plugins[pluginId][existingIndex].gitCommitSha
|
|
1672
|
-
: undefined,
|
|
1673
|
-
),
|
|
1674
|
-
};
|
|
1867
|
+
// Find existing entry for this scope and project
|
|
1868
|
+
const existingIndex = registry.plugins[pluginId].findIndex((entry) => {
|
|
1869
|
+
if (entry.scope !== scope) return false;
|
|
1870
|
+
if (scope === "user") return true;
|
|
1871
|
+
return entry.projectPath === projectPath;
|
|
1872
|
+
});
|
|
1675
1873
|
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1874
|
+
const entry: InstalledPluginEntry = {
|
|
1875
|
+
scope,
|
|
1876
|
+
projectPath,
|
|
1877
|
+
installPath,
|
|
1878
|
+
version,
|
|
1879
|
+
installedAt:
|
|
1880
|
+
existingIndex >= 0
|
|
1881
|
+
? registry.plugins[pluginId][existingIndex].installedAt
|
|
1882
|
+
: now,
|
|
1883
|
+
lastUpdated: now,
|
|
1884
|
+
gitCommitSha: await resolveCacheProvenance(
|
|
1885
|
+
copiedFrom,
|
|
1886
|
+
existingIndex >= 0
|
|
1887
|
+
? registry.plugins[pluginId][existingIndex].gitCommitSha
|
|
1888
|
+
: undefined,
|
|
1889
|
+
),
|
|
1890
|
+
};
|
|
1683
1891
|
|
|
1684
|
-
|
|
1892
|
+
if (existingIndex >= 0) {
|
|
1893
|
+
// Update existing entry
|
|
1894
|
+
registry.plugins[pluginId][existingIndex] = entry;
|
|
1895
|
+
} else {
|
|
1896
|
+
// Add new entry
|
|
1897
|
+
registry.plugins[pluginId].push(entry);
|
|
1898
|
+
}
|
|
1899
|
+
return true;
|
|
1900
|
+
});
|
|
1685
1901
|
} catch (error) {
|
|
1686
|
-
//
|
|
1902
|
+
// Say what was refused and why, then let the plugin operation carry on:
|
|
1903
|
+
// the install itself succeeded, and the registry is Claude Code's file to
|
|
1904
|
+
// repair. What must not happen is writing over it, which is why the read
|
|
1905
|
+
// throws rather than answering an empty registry.
|
|
1687
1906
|
console.warn(
|
|
1688
1907
|
`Failed to update registry for ${pluginId}:`,
|
|
1689
1908
|
error instanceof Error ? error.message : "Unknown error",
|
|
@@ -1700,23 +1919,24 @@ export async function removeFromInstalledPluginsRegistry(
|
|
|
1700
1919
|
projectPath?: string,
|
|
1701
1920
|
): Promise<void> {
|
|
1702
1921
|
try {
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1922
|
+
await updateRegistryFile((registry) => {
|
|
1923
|
+
if (!registry.plugins[pluginId]) return false;
|
|
1924
|
+
|
|
1925
|
+
// Remove entry matching scope and projectPath
|
|
1926
|
+
registry.plugins[pluginId] = registry.plugins[pluginId].filter(
|
|
1927
|
+
(entry) => {
|
|
1928
|
+
if (entry.scope !== scope) return true;
|
|
1929
|
+
if (scope === "user") return false;
|
|
1930
|
+
return entry.projectPath !== projectPath;
|
|
1931
|
+
},
|
|
1932
|
+
);
|
|
1706
1933
|
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
return
|
|
1934
|
+
// Remove plugin key if no entries remain
|
|
1935
|
+
if (registry.plugins[pluginId].length === 0) {
|
|
1936
|
+
delete registry.plugins[pluginId];
|
|
1937
|
+
}
|
|
1938
|
+
return true;
|
|
1712
1939
|
});
|
|
1713
|
-
|
|
1714
|
-
// Remove plugin key if no entries remain
|
|
1715
|
-
if (registry.plugins[pluginId].length === 0) {
|
|
1716
|
-
delete registry.plugins[pluginId];
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
await writeInstalledPluginsRegistry(registry);
|
|
1720
1940
|
} catch (error) {
|
|
1721
1941
|
// Log warning but don't block plugin operation
|
|
1722
1942
|
console.warn(
|
|
@@ -2050,12 +2270,17 @@ export async function repairStaleLoad(
|
|
|
2050
2270
|
// precondition the write is checked against, so they have to describe one
|
|
2051
2271
|
// moment — see `writeInstalledPluginsRegistry`.
|
|
2052
2272
|
const before = await readRegistryBytes(file);
|
|
2053
|
-
|
|
2054
|
-
|
|
2273
|
+
if (before === null) return fail(`${file} does not exist`);
|
|
2274
|
+
let registry: InstalledPluginsRegistry;
|
|
2275
|
+
try {
|
|
2276
|
+
registry = requireRegistry(file, before);
|
|
2277
|
+
} catch (error) {
|
|
2278
|
+
// The same check every other reader makes; a repair reports it as a result
|
|
2279
|
+
// rather than throwing, because it is one item in a plan of many.
|
|
2055
2280
|
return fail(
|
|
2056
|
-
|
|
2057
|
-
?
|
|
2058
|
-
|
|
2281
|
+
`${file} is not a readable registry — refusing to write over it: ${
|
|
2282
|
+
error instanceof Error ? error.message : String(error)
|
|
2283
|
+
}`,
|
|
2059
2284
|
);
|
|
2060
2285
|
}
|
|
2061
2286
|
const rows = registry.plugins[pluginId];
|
|
@@ -2148,7 +2373,18 @@ export async function repairStaleLoad(
|
|
|
2148
2373
|
|
|
2149
2374
|
// Re-read from DISK, not from the object just mutated: the assertion has to
|
|
2150
2375
|
// judge the file the loader will read, which also proves the write landed.
|
|
2151
|
-
|
|
2376
|
+
// A refusal here is reported like every other outcome — this function answers
|
|
2377
|
+
// with a result, and a plan of repairs must not be ended by a throw from one.
|
|
2378
|
+
let after: InstalledPluginsRegistry;
|
|
2379
|
+
try {
|
|
2380
|
+
after = await readInstalledPluginsRegistry(root);
|
|
2381
|
+
} catch (error) {
|
|
2382
|
+
return fail(
|
|
2383
|
+
`could not re-read ${file} to confirm the repair: ${
|
|
2384
|
+
error instanceof Error ? error.message : String(error)
|
|
2385
|
+
}`,
|
|
2386
|
+
);
|
|
2387
|
+
}
|
|
2152
2388
|
const loaded =
|
|
2153
2389
|
pickLoadedEntry(after.plugins[pluginId], projectPath)?.version ?? null;
|
|
2154
2390
|
if (loaded === target) {
|