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
|
@@ -20,16 +20,24 @@
|
|
|
20
20
|
* a set of declared plugins to a set of installed ones — they are different
|
|
21
21
|
* kinds of thing and a comparison between them cannot be acted on.
|
|
22
22
|
*
|
|
23
|
-
* ## What is left is a three-state question
|
|
23
|
+
* ## What is left is a three-state question, and one state with no question
|
|
24
24
|
*
|
|
25
|
-
* ok
|
|
26
|
-
* unmigrated
|
|
27
|
-
* inactive
|
|
25
|
+
* ok a profile is active; the manifest is in effect; ask nothing
|
|
26
|
+
* unmigrated no manifest — the project is not using profiles at all
|
|
27
|
+
* inactive a manifest with profiles, none of them linked into place
|
|
28
|
+
* unparseable a manifest that exists and is not valid JSON, or is valid
|
|
29
|
+
* JSON in the wrong shape (see `readProfilesFile`)
|
|
28
30
|
*
|
|
29
31
|
* `unmigrated` and `inactive` are both unusable, not merely untidy: with no
|
|
30
32
|
* active profile there is no profile state to read and nothing to write updates
|
|
31
33
|
* into, so magus cannot do its job. The caller must not offer to continue.
|
|
32
34
|
*
|
|
35
|
+
* `unparseable` is unusable for a different reason: magus cannot tell which of
|
|
36
|
+
* the other three it is in. It must never read as `unmigrated` — that state
|
|
37
|
+
* offers to save what is turned on as a new profile, and saving writes the
|
|
38
|
+
* manifest over whatever the file held. So it offers nothing but the way out,
|
|
39
|
+
* and names the file to fix.
|
|
40
|
+
*
|
|
33
41
|
* Detection only — nothing here writes. That is what makes it callable from the
|
|
34
42
|
* TUI's startup effect, and testable without a renderer.
|
|
35
43
|
*/
|
|
@@ -40,13 +48,18 @@ import type { ProfileManifest } from "../types/index.js";
|
|
|
40
48
|
// selectable option is presentation-neutral data, and both surfaces render the
|
|
41
49
|
// same list from these builders rather than each composing its own.
|
|
42
50
|
import type { SelectOption } from "../ui/state/types.js";
|
|
43
|
-
import { readManifest } from "./manifest.js";
|
|
51
|
+
import { MANIFEST_FILE, emptyManifest, readManifest } from "./manifest.js";
|
|
44
52
|
import type { ProfileChoice } from "./profile-activate.js";
|
|
45
53
|
import { freeProfileId } from "./profile-adopt.js";
|
|
54
|
+
import { SettingsParseError } from "./settings-file.js";
|
|
46
55
|
import { activeProfile, displaceableArtifacts } from "./symlink-manager.js";
|
|
47
56
|
|
|
48
57
|
/** Whether the project can be worked on, and if not, why not. */
|
|
49
|
-
export type ProfileStartupState =
|
|
58
|
+
export type ProfileStartupState =
|
|
59
|
+
| "ok"
|
|
60
|
+
| "unmigrated"
|
|
61
|
+
| "inactive"
|
|
62
|
+
| "unparseable";
|
|
50
63
|
|
|
51
64
|
/** A profile the user could activate. */
|
|
52
65
|
export interface ActivatableProfile {
|
|
@@ -88,6 +101,11 @@ export interface ProfileStartupNeed {
|
|
|
88
101
|
autoActivate: boolean;
|
|
89
102
|
/** The manifest as read, so the caller does not read it a second time. */
|
|
90
103
|
manifest: ProfileManifest;
|
|
104
|
+
/**
|
|
105
|
+
* Why the manifest could not be read. Set in the `unparseable` state only,
|
|
106
|
+
* where `manifest` is empty because nothing was read.
|
|
107
|
+
*/
|
|
108
|
+
parseError?: SettingsParseError;
|
|
91
109
|
}
|
|
92
110
|
|
|
93
111
|
/** True when the project is usable and there is nothing to ask. */
|
|
@@ -95,11 +113,38 @@ export function needsNothing(need: ProfileStartupNeed): boolean {
|
|
|
95
113
|
return need.state === "ok";
|
|
96
114
|
}
|
|
97
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The need for a manifest that does not parse: nothing to adopt, nothing to
|
|
118
|
+
* activate, so the only option built from it is the way out.
|
|
119
|
+
*
|
|
120
|
+
* Exported because detection is not the only place this is learned. The TUI
|
|
121
|
+
* gate reads at launch and writes when the user answers; a file that stops
|
|
122
|
+
* parsing in between surfaces as the write's refusal, and the gate has to turn
|
|
123
|
+
* into this rather than ask its stale question again.
|
|
124
|
+
*/
|
|
125
|
+
export function unparseableNeed(error: SettingsParseError): ProfileStartupNeed {
|
|
126
|
+
return {
|
|
127
|
+
state: "unparseable",
|
|
128
|
+
adopt: null,
|
|
129
|
+
activate: [],
|
|
130
|
+
declaredDefault: null,
|
|
131
|
+
autoActivate: false,
|
|
132
|
+
manifest: emptyManifest(),
|
|
133
|
+
parseError: error,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
98
137
|
/** Inspect the project and decide what, if anything, must happen first. */
|
|
99
138
|
export async function detectProfileStartupNeed(
|
|
100
139
|
projectPath: string,
|
|
101
140
|
): Promise<ProfileStartupNeed> {
|
|
102
|
-
|
|
141
|
+
let manifest: ProfileManifest;
|
|
142
|
+
try {
|
|
143
|
+
manifest = await readManifest(projectPath);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (!(error instanceof SettingsParseError)) throw error;
|
|
146
|
+
return unparseableNeed(error);
|
|
147
|
+
}
|
|
103
148
|
const ids = Object.keys(manifest.profiles);
|
|
104
149
|
|
|
105
150
|
if (ids.length === 0) {
|
|
@@ -202,11 +247,43 @@ export const ACTIVATE_PREFIX = "activate:";
|
|
|
202
247
|
* break opportunity — MEASURED on screen, `.claude/settings.json` wrapped as
|
|
203
248
|
* `.` then `claude/settings.json` on the next line. A path split across a line
|
|
204
249
|
* break reads as two different paths, neither of them real.
|
|
250
|
+
*
|
|
251
|
+
* The `unparseable` state is the one exception, because the file IS the
|
|
252
|
+
* message: "fix it" is useless without saying which. So the path goes on a
|
|
253
|
+
* line of its own, project-relative, far shorter than the narrowest modal —
|
|
254
|
+
* nothing on that line ever needs to wrap.
|
|
205
255
|
*/
|
|
206
256
|
export function describeNeed(need: ProfileStartupNeed): {
|
|
207
257
|
title: string;
|
|
208
258
|
message: string;
|
|
209
259
|
} {
|
|
260
|
+
if (need.state === "unparseable") {
|
|
261
|
+
// The error's own parts, without the absolute path its message leads
|
|
262
|
+
// with: what is wrong ("is not valid JSON", "is not a valid profiles
|
|
263
|
+
// file") and the specifics — the parser's words, or the key with the
|
|
264
|
+
// wrong shape, which is how a one-letter typo gets found.
|
|
265
|
+
const error = need.parseError;
|
|
266
|
+
const lines = [
|
|
267
|
+
`This file ${error?.problem ?? "is not valid JSON"}:`,
|
|
268
|
+
"",
|
|
269
|
+
` ${MANIFEST_FILE}`,
|
|
270
|
+
];
|
|
271
|
+
if (error?.detail) {
|
|
272
|
+
lines.push(
|
|
273
|
+
"",
|
|
274
|
+
error.detail.charAt(0).toUpperCase() + error.detail.slice(1),
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
lines.push(
|
|
278
|
+
"",
|
|
279
|
+
"magus leaves it exactly as it is. Fix the JSON by hand, then start magus again.",
|
|
280
|
+
);
|
|
281
|
+
return {
|
|
282
|
+
title: "Cannot read this project's profiles",
|
|
283
|
+
message: lines.join("\n"),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
210
287
|
if (need.state === "unmigrated") {
|
|
211
288
|
return {
|
|
212
289
|
title: "magus uses profiles",
|
|
@@ -234,6 +311,9 @@ export function describeNeed(need: ProfileStartupNeed): {
|
|
|
234
311
|
* "Quit magus" is last instead. A question that cannot be answered with "no"
|
|
235
312
|
* still has to be answerable with "not in this app", and the way out has to be
|
|
236
313
|
* visible rather than a keystroke the user has to guess.
|
|
314
|
+
*
|
|
315
|
+
* For an `unparseable` need it is the ONLY row: there is nothing to adopt into
|
|
316
|
+
* and nothing to activate from a file magus cannot read.
|
|
237
317
|
*/
|
|
238
318
|
export function buildProfileOptions(need: ProfileStartupNeed): SelectOption[] {
|
|
239
319
|
const options: SelectOption[] = [];
|
package/src/services/profiles.ts
CHANGED
|
@@ -2,6 +2,7 @@ import os from "node:os";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import fs from "fs-extra";
|
|
4
4
|
import type { ProfileEntry, ProfileManifest } from "../types/index.js";
|
|
5
|
+
import { readProfilesFile } from "./manifest.js";
|
|
5
6
|
import { activeProfile } from "./symlink-manager.js";
|
|
6
7
|
|
|
7
8
|
const PROFILES_FILE = "profiles.json";
|
|
@@ -19,40 +20,52 @@ export function getProjectProfilesPath(projectPath?: string): string {
|
|
|
19
20
|
|
|
20
21
|
// ─── Low-level read/write ──────────────────────────────────────────────────────
|
|
21
22
|
|
|
23
|
+
function profilesPath(scope: "user" | "project", projectPath?: string): string {
|
|
24
|
+
return scope === "user"
|
|
25
|
+
? getUserProfilesPath()
|
|
26
|
+
: getProjectProfilesPath(projectPath);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read one scope's profiles file.
|
|
31
|
+
*
|
|
32
|
+
* Missing, empty, `{}` or `{"version": 2}` reads as no profiles. A file that
|
|
33
|
+
* does not parse, or parses into the wrong shape, throws `SettingsParseError`
|
|
34
|
+
* naming it — never "no profiles". Every writer below reads through here
|
|
35
|
+
* first, so an empty answer for a damaged file is how saving one profile used
|
|
36
|
+
* to write over all the others.
|
|
37
|
+
*
|
|
38
|
+
* The shape check is `readProfilesFile`'s, shared with `readManifest`: at
|
|
39
|
+
* project scope this is the same `.claude/profiles.json`, and the two readers
|
|
40
|
+
* must agree on what it holds.
|
|
41
|
+
*/
|
|
22
42
|
export async function readProfiles(
|
|
23
43
|
scope: "user" | "project",
|
|
24
44
|
projectPath?: string,
|
|
25
45
|
): Promise<ProfileManifest> {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// A v1 file differs only in what it omits, so it reads as a v2 entry
|
|
34
|
-
// with absent optional fields. Nothing here converts: `version: 2` is
|
|
35
|
-
// what the next write records, and every consumer already treats
|
|
36
|
-
// `createdAt`, `updatedAt` and a string plugin pin as optional.
|
|
37
|
-
if (data.version && data.profiles) {
|
|
38
|
-
return { ...data, version: 2 } as ProfileManifest;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
} catch {
|
|
42
|
-
// fall through to default
|
|
43
|
-
}
|
|
44
|
-
return { version: 2, profiles: {} };
|
|
46
|
+
const data = await readProfilesFile(profilesPath(scope, projectPath));
|
|
47
|
+
// A v1 file differs only in what it omits, so it reads as a v2 entry
|
|
48
|
+
// with absent optional fields. Nothing here converts: `version: 2` is
|
|
49
|
+
// what the next write records, and every consumer already treats
|
|
50
|
+
// `createdAt`, `updatedAt` and a string plugin pin as optional. Keys this
|
|
51
|
+
// reader does not know are carried, so a write puts them back.
|
|
52
|
+
return { ...data, version: 2 } as unknown as ProfileManifest;
|
|
45
53
|
}
|
|
46
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Write one scope's profiles file — refusing, and leaving it untouched, when
|
|
57
|
+
* the file already there would be refused by {@link readProfiles}. Every
|
|
58
|
+
* caller reads first, but this is the half that must never write over a file
|
|
59
|
+
* magus cannot read, so it checks for itself.
|
|
60
|
+
*/
|
|
47
61
|
async function writeProfiles(
|
|
48
62
|
scope: "user" | "project",
|
|
49
63
|
data: ProfileManifest,
|
|
50
64
|
projectPath?: string,
|
|
51
65
|
): Promise<void> {
|
|
52
|
-
const filePath =
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
: getProjectProfilesPath(projectPath);
|
|
66
|
+
const filePath = profilesPath(scope, projectPath);
|
|
67
|
+
// Read for the throw alone; the contents are about to be replaced.
|
|
68
|
+
await readProfilesFile(filePath);
|
|
56
69
|
await fs.ensureDir(path.dirname(filePath));
|
|
57
70
|
await fs.writeJson(filePath, data, { spaces: 2 });
|
|
58
71
|
}
|
|
@@ -13,15 +13,168 @@
|
|
|
13
13
|
* mid-edit. Treating it as `{}` and writing our one key back is how a
|
|
14
14
|
* user's whole configuration disappears. We refuse and say so.
|
|
15
15
|
*
|
|
16
|
+
* "Unparseable" includes a file that parses into something other than an
|
|
17
|
+
* object — `[]`, `null`, `5`, `"text"`. Every caller indexes the result as a
|
|
18
|
+
* map, so an array swallowed an enabled plugin as a property `JSON.stringify`
|
|
19
|
+
* never writes (the enable reported success and was gone), and `null` or a
|
|
20
|
+
* number crashed with a raw `TypeError`. Neither is a file we understand.
|
|
21
|
+
*
|
|
16
22
|
* A MISSING file is different from an unparseable one: nothing is lost by
|
|
17
23
|
* starting from `{}`, so that is what a missing (or empty) file reads as.
|
|
24
|
+
*
|
|
25
|
+
* Every Claude Code settings reader goes through `readSettingsFile` —
|
|
26
|
+
* `claude-settings.ts` delegates its four readers here, and the profiles
|
|
27
|
+
* readers in `manifest.ts` build on it — so there is one refusal, one wording
|
|
28
|
+
* and one error class for all of them.
|
|
18
29
|
*/
|
|
19
30
|
|
|
20
31
|
import path from "node:path";
|
|
21
32
|
import fs from "fs-extra";
|
|
22
33
|
|
|
23
34
|
/**
|
|
24
|
-
*
|
|
35
|
+
* A settings file that exists and cannot be used: it does not parse, or it
|
|
36
|
+
* parses into the wrong shape (see {@link SettingsShapeError}).
|
|
37
|
+
*
|
|
38
|
+
* Its own class so a caller can tell "this file is damaged" apart from every
|
|
39
|
+
* other failure and name the file to the user. The message is complete on its
|
|
40
|
+
* own — the path, what is wrong, and that magus refused — so a caller that
|
|
41
|
+
* prints `message` needs nothing else. `problem` and `detail` are its parts,
|
|
42
|
+
* for a caller that lays the file out on its own line instead.
|
|
43
|
+
*/
|
|
44
|
+
export class SettingsParseError extends Error {
|
|
45
|
+
readonly path: string;
|
|
46
|
+
/** What is wrong, as the rest of a sentence that starts with the file: "is not valid JSON". */
|
|
47
|
+
readonly problem: string;
|
|
48
|
+
/** The specifics: the parser's own words, or which part has the wrong shape. */
|
|
49
|
+
readonly detail: string;
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
settingsPath: string,
|
|
53
|
+
cause: unknown,
|
|
54
|
+
problem = "is not valid JSON",
|
|
55
|
+
) {
|
|
56
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
57
|
+
super(`${settingsPath} ${problem}, refusing to overwrite it: ${detail}`, {
|
|
58
|
+
cause,
|
|
59
|
+
});
|
|
60
|
+
this.name = "SettingsParseError";
|
|
61
|
+
this.path = settingsPath;
|
|
62
|
+
this.problem = problem;
|
|
63
|
+
this.detail = detail;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A settings or profiles file that is valid JSON, but not the shape the file
|
|
69
|
+
* must have — a top level that is not an object, or (for a profiles file) a
|
|
70
|
+
* `profiles` key that is not a map of objects.
|
|
71
|
+
*
|
|
72
|
+
* Refused exactly like a file that does not parse, and for the same reason:
|
|
73
|
+
* reading it as "empty" is what lets the next write replace it. A subclass so
|
|
74
|
+
* every existing `instanceof SettingsParseError` catch handles it unchanged.
|
|
75
|
+
*/
|
|
76
|
+
export class SettingsShapeError extends SettingsParseError {
|
|
77
|
+
constructor(settingsPath: string, problem: string, detail: string) {
|
|
78
|
+
super(settingsPath, detail, problem);
|
|
79
|
+
this.name = "SettingsShapeError";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** True for a JSON object: not `null`, not an array, not a primitive. */
|
|
84
|
+
export function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
85
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A parsed JSON value's kind with its article, for a refusal: "an array", "null". */
|
|
89
|
+
export function describeJsonKind(value: unknown): string {
|
|
90
|
+
if (value === null) return "null";
|
|
91
|
+
if (Array.isArray(value)) return "an array";
|
|
92
|
+
if (typeof value === "object") return "an object";
|
|
93
|
+
return `a ${typeof value}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The map at `settings[key]`, checked before the caller writes an entry into
|
|
98
|
+
* it: the map itself, or `undefined` when the key is absent. Present but not a
|
|
99
|
+
* JSON object THROWS {@link SettingsShapeError} naming the file and the key —
|
|
100
|
+
* so nothing reaches the write, and the file stays byte-for-byte as it was.
|
|
101
|
+
*
|
|
102
|
+
* The top-level check in {@link readSettingsFile} does not reach this far: the
|
|
103
|
+
* file IS an object, one of its maps is not. `settings.enabledPlugins || {}`
|
|
104
|
+
* kept an array, a plugin id set on an array is a property `JSON.stringify`
|
|
105
|
+
* never writes, and the enable reported success with nothing saved. On a
|
|
106
|
+
* string or a number the same assignment is a raw `TypeError`; on `null`,
|
|
107
|
+
* `|| {}` silently replaced the user's value.
|
|
108
|
+
*
|
|
109
|
+
* Every read-modify-write that writes into a map comes through here.
|
|
110
|
+
* `keyPath` names a nested key in the refusal ("permissions.allow"); it
|
|
111
|
+
* defaults to `key`.
|
|
112
|
+
*/
|
|
113
|
+
export function writableMap<S extends object, K extends keyof S & string>(
|
|
114
|
+
settings: S,
|
|
115
|
+
key: K,
|
|
116
|
+
settingsPath: string,
|
|
117
|
+
keyPath: string = key,
|
|
118
|
+
): CheckedMap<S[K]> {
|
|
119
|
+
return checkedEntry(
|
|
120
|
+
settings,
|
|
121
|
+
key,
|
|
122
|
+
settingsPath,
|
|
123
|
+
keyPath,
|
|
124
|
+
"object",
|
|
125
|
+
) as CheckedMap<S[K]>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A typed key keeps its type; an untyped one (`unknown`, from an open settings
|
|
130
|
+
* record) comes back as the object the check has just proved it is.
|
|
131
|
+
*/
|
|
132
|
+
type CheckedMap<V> = unknown extends V
|
|
133
|
+
? Record<string, unknown> | undefined
|
|
134
|
+
: V;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* {@link writableMap} for a list the caller pushes into or filters —
|
|
138
|
+
* `enabledMcpjsonServers`, a gitignore manifest's `ignore` and `track`. Present
|
|
139
|
+
* but not an array throws the same way.
|
|
140
|
+
*/
|
|
141
|
+
export function writableList<S extends object, K extends keyof S & string>(
|
|
142
|
+
settings: S,
|
|
143
|
+
key: K,
|
|
144
|
+
settingsPath: string,
|
|
145
|
+
): CheckedList<S[K]> {
|
|
146
|
+
return checkedEntry(settings, key, settingsPath, key, "array") as CheckedList<
|
|
147
|
+
S[K]
|
|
148
|
+
>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** {@link CheckedMap} for a list: an untyped key comes back as the array it is. */
|
|
152
|
+
type CheckedList<V> = unknown extends V ? unknown[] | undefined : V;
|
|
153
|
+
|
|
154
|
+
function checkedEntry<S extends object, K extends keyof S & string>(
|
|
155
|
+
settings: S,
|
|
156
|
+
key: K,
|
|
157
|
+
settingsPath: string,
|
|
158
|
+
keyPath: string,
|
|
159
|
+
shape: "object" | "array",
|
|
160
|
+
): S[K] {
|
|
161
|
+
const value = settings[key];
|
|
162
|
+
if (value === undefined) return value;
|
|
163
|
+
const fits = shape === "object" ? isJsonObject(value) : Array.isArray(value);
|
|
164
|
+
if (!fits) {
|
|
165
|
+
throw new SettingsShapeError(
|
|
166
|
+
settingsPath,
|
|
167
|
+
`has a malformed "${keyPath}"`,
|
|
168
|
+
`it is ${describeJsonKind(value)}, not a JSON ${shape}`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Parse `settingsPath`. Missing or empty reads as `{}`; unparseable throws
|
|
176
|
+
* {@link SettingsParseError}, and a top level that is not an object throws
|
|
177
|
+
* {@link SettingsShapeError}.
|
|
25
178
|
*
|
|
26
179
|
* Exported for callers that only need the read half — the refusal wording is
|
|
27
180
|
* the same either way, so nothing has to reimplement it.
|
|
@@ -34,13 +187,20 @@ export async function readSettingsFile(
|
|
|
34
187
|
const raw = (await fs.readFile(settingsPath, "utf8")).trim();
|
|
35
188
|
if (!raw) return {};
|
|
36
189
|
|
|
190
|
+
let parsed: unknown;
|
|
37
191
|
try {
|
|
38
|
-
|
|
192
|
+
parsed = JSON.parse(raw);
|
|
39
193
|
} catch (error) {
|
|
40
|
-
throw new
|
|
41
|
-
|
|
194
|
+
throw new SettingsParseError(settingsPath, error);
|
|
195
|
+
}
|
|
196
|
+
if (!isJsonObject(parsed)) {
|
|
197
|
+
throw new SettingsShapeError(
|
|
198
|
+
settingsPath,
|
|
199
|
+
"is not a JSON object",
|
|
200
|
+
`its top level is ${describeJsonKind(parsed)}`,
|
|
42
201
|
);
|
|
43
202
|
}
|
|
203
|
+
return parsed;
|
|
44
204
|
}
|
|
45
205
|
|
|
46
206
|
/**
|
|
@@ -3,11 +3,14 @@ import * as path from "node:path";
|
|
|
3
3
|
import type { SettingDefinition } from "../data/settings-catalog.js";
|
|
4
4
|
import type { ClaudeSettings } from "../types/index.js";
|
|
5
5
|
import {
|
|
6
|
+
getGlobalSettingsPath,
|
|
7
|
+
getSettingsPath,
|
|
6
8
|
readGlobalSettings,
|
|
7
9
|
readSettings,
|
|
8
10
|
writeGlobalSettings,
|
|
9
11
|
writeSettings,
|
|
10
12
|
} from "./claude-settings.js";
|
|
13
|
+
import { writableMap } from "./settings-file.js";
|
|
11
14
|
|
|
12
15
|
export type SettingScope = "user" | "project";
|
|
13
16
|
|
|
@@ -103,6 +106,9 @@ export async function writeSettingValue(
|
|
|
103
106
|
projectPath?: string,
|
|
104
107
|
): Promise<void> {
|
|
105
108
|
const settings = await loadSettings(scope, projectPath);
|
|
109
|
+
// Named in the refusal when a map this writes into has the wrong shape.
|
|
110
|
+
const settingsPath =
|
|
111
|
+
scope === "user" ? getGlobalSettingsPath() : getSettingsPath(projectPath);
|
|
106
112
|
|
|
107
113
|
if (setting.storage.type === "attribution") {
|
|
108
114
|
// Boolean toggle: "false" -> write { commit: "", pr: "" }, "true"/undefined -> delete key
|
|
@@ -125,8 +131,10 @@ export async function writeSettingValue(
|
|
|
125
131
|
delete settings.attribution;
|
|
126
132
|
}
|
|
127
133
|
} else if (setting.storage.type === "env") {
|
|
128
|
-
// Write to the "env" block in settings.json
|
|
129
|
-
|
|
134
|
+
// Write to the "env" block in settings.json. One that is present but not
|
|
135
|
+
// an object is refused, never replaced — replacing it deleted the user's
|
|
136
|
+
// value to make room for ours.
|
|
137
|
+
const env = writableMap(settings, "env", settingsPath) ?? {};
|
|
130
138
|
settings.env = env;
|
|
131
139
|
if (value === undefined || value === "" || value === setting.defaultValue) {
|
|
132
140
|
delete env[setting.storage.key];
|
|
@@ -149,11 +157,17 @@ export async function writeSettingValue(
|
|
|
149
157
|
settings[keys[0]] = parseSettingValue(value, setting.type);
|
|
150
158
|
}
|
|
151
159
|
} else {
|
|
152
|
-
// Nested key like "permissions.defaultMode"
|
|
160
|
+
// Nested key like "permissions.defaultMode". Each parent on the way is
|
|
161
|
+
// a map this writes into: absent is created, the wrong shape refused.
|
|
153
162
|
let obj: Record<string, unknown> = settings;
|
|
154
163
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
155
|
-
const
|
|
156
|
-
|
|
164
|
+
const next =
|
|
165
|
+
writableMap(
|
|
166
|
+
obj,
|
|
167
|
+
keys[i],
|
|
168
|
+
settingsPath,
|
|
169
|
+
keys.slice(0, i + 1).join("."),
|
|
170
|
+
) ?? {};
|
|
157
171
|
obj[keys[i]] = next;
|
|
158
172
|
obj = next;
|
|
159
173
|
}
|
|
@@ -664,6 +664,26 @@ function parseYamlFrontmatter(content: string): Partial<SkillFrontmatter> {
|
|
|
664
664
|
if (!frontmatterMatch) return {};
|
|
665
665
|
|
|
666
666
|
const yaml = frontmatterMatch[1];
|
|
667
|
+
|
|
668
|
+
// Real YAML first: a description is routinely a block scalar (`description: >-`
|
|
669
|
+
// with the text on the lines below), which the line reading further down returns
|
|
670
|
+
// as the literal ">-". Many community SKILL.md files are not valid YAML, though —
|
|
671
|
+
// an unquoted ": " inside a description is common — so a parse failure falls
|
|
672
|
+
// back to the line reading those files have always had.
|
|
673
|
+
try {
|
|
674
|
+
// `Bun.YAML` ships in the runtime (1.2.21+) but not in the Bun types this
|
|
675
|
+
// package resolves. A runtime without it throws here and takes the fallback.
|
|
676
|
+
const { YAML } = Bun as unknown as {
|
|
677
|
+
YAML: { parse(text: string): unknown };
|
|
678
|
+
};
|
|
679
|
+
const parsed: unknown = YAML.parse(yaml);
|
|
680
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
681
|
+
return parsed as Partial<SkillFrontmatter>;
|
|
682
|
+
}
|
|
683
|
+
} catch {
|
|
684
|
+
// Not valid YAML — read it line by line below.
|
|
685
|
+
}
|
|
686
|
+
|
|
667
687
|
const result: Record<string, unknown> = {};
|
|
668
688
|
|
|
669
689
|
for (const line of yaml.split("\n")) {
|
|
@@ -692,6 +712,32 @@ function parseYamlFrontmatter(content: string): Partial<SkillFrontmatter> {
|
|
|
692
712
|
return result as Partial<SkillFrontmatter>;
|
|
693
713
|
}
|
|
694
714
|
|
|
715
|
+
/**
|
|
716
|
+
* The frontmatter of an INSTALLED skill, read from its SKILL.md on disk.
|
|
717
|
+
*
|
|
718
|
+
* A local skill has no remote to fetch from, so this is the only place its
|
|
719
|
+
* description can come from. `name` falls back to the directory name and
|
|
720
|
+
* `description` to "" — the caller decides how to say "none". Never throws: an
|
|
721
|
+
* unreadable file answers `null`.
|
|
722
|
+
*/
|
|
723
|
+
export async function readLocalSkillFrontmatter(
|
|
724
|
+
skillMdPath: string,
|
|
725
|
+
fallbackName: string,
|
|
726
|
+
): Promise<SkillFrontmatter | null> {
|
|
727
|
+
let content: string;
|
|
728
|
+
try {
|
|
729
|
+
content = await fs.readFile(skillMdPath, "utf8");
|
|
730
|
+
} catch {
|
|
731
|
+
return null;
|
|
732
|
+
}
|
|
733
|
+
const parsed = parseYamlFrontmatter(content);
|
|
734
|
+
return {
|
|
735
|
+
...parsed,
|
|
736
|
+
name: parsed.name || fallbackName,
|
|
737
|
+
description: parsed.description || "",
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
695
741
|
export async function fetchSkillFrontmatter(
|
|
696
742
|
skill: SkillInfo,
|
|
697
743
|
): Promise<SkillFrontmatter> {
|