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.
Files changed (127) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  3. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  4. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  5. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  6. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  7. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  8. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  9. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  10. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  11. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  12. package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
  15. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  16. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  17. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  18. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  19. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  20. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  21. package/src/__tests__/blind/child-run.ts +114 -0
  22. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  23. package/src/__tests__/component-badge.test.ts +20 -36
  24. package/src/__tests__/footer-hints.test.ts +1 -1
  25. package/src/__tests__/manifest.test.ts +25 -17
  26. package/src/__tests__/marketplace-badge.test.ts +4 -4
  27. package/src/__tests__/models-screen-state.test.ts +36 -13
  28. package/src/__tests__/scope-squares.test.tsx +28 -114
  29. package/src/__tests__/shutdown.test.ts +578 -0
  30. package/src/__tests__/stale-load-repair.test.ts +5 -4
  31. package/src/__tests__/styles-manager.test.ts +4 -4
  32. package/src/__tests__/styles-screen-state.test.ts +26 -12
  33. package/src/__tests__/tabbar-layout.test.ts +44 -32
  34. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  35. package/src/__tests__/tool-probe.test.ts +47 -22
  36. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  37. package/src/cli/ansi.ts +2 -2
  38. package/src/cli/bootstrap.ts +33 -2
  39. package/src/cli/doctor.ts +120 -15
  40. package/src/cli/install.ts +35 -4
  41. package/src/cli/profile.ts +18 -1
  42. package/src/cli/router.ts +31 -18
  43. package/src/cli/update-view.ts +1 -1
  44. package/src/data/cli-tools.ts +9 -1
  45. package/src/services/claude-settings.ts +434 -198
  46. package/src/services/cli-tool-commands.ts +45 -5
  47. package/src/services/cli-tool-version.ts +101 -0
  48. package/src/services/gitignore-service.ts +39 -22
  49. package/src/services/hook-registration.ts +23 -6
  50. package/src/services/manifest.ts +117 -52
  51. package/src/services/plugin-manager.ts +19 -5
  52. package/src/services/profile-adopt.ts +7 -2
  53. package/src/services/profile-startup.ts +87 -7
  54. package/src/services/profiles.ts +36 -23
  55. package/src/services/settings-file.ts +164 -4
  56. package/src/services/settings-manager.ts +19 -5
  57. package/src/services/skills-manager.ts +46 -0
  58. package/src/services/tool-probe.ts +316 -44
  59. package/src/services/toolchain.ts +39 -0
  60. package/src/services/update-engine.ts +5 -0
  61. package/src/services/update-plan.ts +9 -0
  62. package/src/tui.tsx +48 -21
  63. package/src/ui/App.tsx +58 -197
  64. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  65. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  66. package/src/ui/adapters/settingsAdapter.ts +0 -21
  67. package/src/ui/adapters/skillsAdapter.ts +27 -8
  68. package/src/ui/adapters/stylesAdapter.ts +15 -11
  69. package/src/ui/components/CategoryHeader.tsx +7 -1
  70. package/src/ui/components/EmptyFilterState.tsx +2 -2
  71. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  72. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  73. package/src/ui/components/TabBar.tsx +93 -21
  74. package/src/ui/components/layout/FooterHints.tsx +6 -27
  75. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  76. package/src/ui/components/layout/index.ts +1 -5
  77. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  78. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  79. package/src/ui/components/modals/MessageModal.tsx +3 -15
  80. package/src/ui/components/modals/ModalContainer.tsx +107 -60
  81. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  82. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  83. package/src/ui/components/primitives/MetaText.tsx +1 -1
  84. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  85. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  86. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  87. package/src/ui/components/primitives/index.ts +4 -3
  88. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  89. package/src/ui/input/KeyRouter.tsx +304 -0
  90. package/src/ui/input/key-dispatch.ts +197 -0
  91. package/src/ui/keymap.ts +905 -0
  92. package/src/ui/renderers/cliToolRenderers.tsx +192 -92
  93. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  94. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  95. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  96. package/src/ui/renderers/modelRenderers.tsx +12 -13
  97. package/src/ui/renderers/modelVisuals.tsx +0 -2
  98. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  99. package/src/ui/renderers/profileRenderers.tsx +32 -37
  100. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  101. package/src/ui/renderers/skillRenderers.tsx +237 -190
  102. package/src/ui/renderers/styleRenderers.tsx +13 -2
  103. package/src/ui/runtime/shutdown.ts +268 -0
  104. package/src/ui/screens/AliasScreen.tsx +118 -100
  105. package/src/ui/screens/CliToolsScreen.tsx +173 -101
  106. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  107. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  108. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  109. package/src/ui/screens/McpScreen.tsx +47 -33
  110. package/src/ui/screens/ModelsScreen.tsx +59 -104
  111. package/src/ui/screens/PluginsScreen.tsx +129 -129
  112. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  113. package/src/ui/screens/SkillsScreen.tsx +184 -204
  114. package/src/ui/screens/StylesScreen.tsx +104 -157
  115. package/src/ui/state/AppContext.tsx +6 -0
  116. package/src/ui/state/DimensionsContext.tsx +1 -10
  117. package/src/ui/state/reducer.ts +5 -8
  118. package/src/ui/state/types.ts +11 -9
  119. package/src/ui/text-width.ts +341 -0
  120. package/src/ui/theme-mode.ts +34 -71
  121. package/src/ui/theme.ts +50 -28
  122. package/src/utils/request-gate.ts +37 -0
  123. package/src/ui/components/ScopeIndicator.tsx +0 -63
  124. package/src/ui/components/SearchInput.tsx +0 -57
  125. package/src/ui/hooks/index.ts +0 -2
  126. package/src/ui/hooks/useKeyboard.ts +0 -24
  127. 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 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
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 = "ok" | "unmigrated" | "inactive";
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
- const manifest = await readManifest(projectPath);
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[] = [];
@@ -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 filePath =
27
- scope === "user"
28
- ? getUserProfilesPath()
29
- : getProjectProfilesPath(projectPath);
30
- try {
31
- if (await fs.pathExists(filePath)) {
32
- const data = await fs.readJson(filePath);
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
- scope === "user"
54
- ? getUserProfilesPath()
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
- * Parse `settingsPath`. Missing or empty reads as `{}`; unparseable throws.
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
- return JSON.parse(raw) as Record<string, unknown>;
192
+ parsed = JSON.parse(raw);
39
193
  } catch (error) {
40
- throw new Error(
41
- `${settingsPath} is not valid JSON, refusing to overwrite it: ${String(error)}`,
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
- const env = isRecord(settings.env) ? settings.env : {};
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 existing = obj[keys[i]];
156
- const next = isRecord(existing) ? existing : {};
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> {