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
@@ -37,8 +37,10 @@ import {
37
37
  * to follow what is actually on the machine, or it installs a second copy
38
38
  * beside the first and the screen then reports a conflict it created itself.
39
39
  *
40
- * `pnpm`, `yarn` and `unknown` have no `BinInstaller` counterpart, which is why
41
- * the two unions stay separate rather than one being widened into the other.
40
+ * `pnpm`, `yarn`, `native` and `unknown` have no `BinInstaller` counterpart,
41
+ * which is why the two unions stay separate rather than one being widened into
42
+ * the other. `native` is a tool's own installer — today Claude Code's, which
43
+ * keeps its releases under `~/.local/share/claude/versions/` and updates itself.
42
44
  */
43
45
  export type InstallMethod =
44
46
  | "npm"
@@ -47,6 +49,7 @@ export type InstallMethod =
47
49
  | "yarn"
48
50
  | "brew"
49
51
  | "pip"
52
+ | "native"
50
53
  | "unknown";
51
54
 
52
55
  /**
@@ -75,6 +78,10 @@ export function cliToolInstall(tool: CliTool): Command {
75
78
  * the detector could not attribute the binary on PATH to any package manager,
76
79
  * and guessing there would install a second copy alongside the one already
77
80
  * there. Callers fall back to installing.
81
+ *
82
+ * `native` is null too: a tool's own updater is not derivable from the method
83
+ * name. The probe builds it (`ToolFacts.upgradeCommand`, e.g. `claude update`)
84
+ * and the row takes it from there, ahead of this function.
78
85
  */
79
86
  export function cliToolUpdate(
80
87
  tool: CliTool,
@@ -82,12 +89,13 @@ export function cliToolUpdate(
82
89
  brewFormula?: string,
83
90
  ): Command | null {
84
91
  switch (method) {
85
- // The three the resolver does not model. `pnpm`/`yarn` are not
92
+ // The ones the resolver does not model. `pnpm`/`yarn` are not
86
93
  // BinInstallers because magus never chooses them — it only finds them.
87
94
  case "pnpm":
88
95
  return { cmd: "pnpm", args: ["install", "-g", tool.packageName] };
89
96
  case "yarn":
90
97
  return { cmd: "yarn", args: ["global", "add", tool.packageName] };
98
+ case "native":
91
99
  case "unknown":
92
100
  return null;
93
101
  default:
@@ -107,8 +115,9 @@ export function cliToolUpdate(
107
115
  /**
108
116
  * Remove a tool, following the method it was actually installed with.
109
117
  *
110
- * Null means "no command to offer": `unknown` for the reason above, and `go`
111
- * because a `go install` binary has no uninstaller to call.
118
+ * Null means "no command to offer": `unknown` for the reason above, `native`
119
+ * because a self-installed tool ships no uninstall command, and `go` because a
120
+ * `go install` binary has no uninstaller to call.
112
121
  */
113
122
  export function cliToolUninstall(
114
123
  tool: CliTool,
@@ -120,6 +129,7 @@ export function cliToolUninstall(
120
129
  return { cmd: "pnpm", args: ["remove", "-g", tool.packageName] };
121
130
  case "yarn":
122
131
  return { cmd: "yarn", args: ["global", "remove", tool.packageName] };
132
+ case "native":
123
133
  case "unknown":
124
134
  return null;
125
135
  default:
@@ -131,3 +141,33 @@ export function cliToolUninstall(
131
141
  });
132
142
  }
133
143
  }
144
+
145
+ /**
146
+ * Why magus cannot remove an install made this way — or null when it can.
147
+ *
148
+ * Derived from {@link cliToolUninstall}, never from a second list of methods:
149
+ * "is there a command" and "why is there no command" have to answer about the
150
+ * same thing, or the conflict fix offers a removal it then skips. That is the
151
+ * defect this exists for — the fix used to list every install as removable,
152
+ * `continue` past the ones with no command, and report success.
153
+ *
154
+ * The text is a clause, to be read after "the <method> install stays:".
155
+ */
156
+ export function uninstallBlocker(
157
+ tool: CliTool,
158
+ method: InstallMethod,
159
+ brewFormula?: string,
160
+ ): string | null {
161
+ if (cliToolUninstall(tool, method, brewFormula) !== null) return null;
162
+ switch (method) {
163
+ case "native":
164
+ // Claude Code's native installer. It updates itself and ships no
165
+ // uninstall command; inventing one would mean deleting another
166
+ // program's files by hand.
167
+ return "its own installer ships no uninstall command";
168
+ case "unknown":
169
+ return "magus could not tell which installer put it there";
170
+ default:
171
+ return `magus has no uninstall command for a ${method} install`;
172
+ }
173
+ }
@@ -0,0 +1,101 @@
1
+ import { readFileSync, realpathSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * The version of the npm/bun package a binary on PATH belongs to.
6
+ *
7
+ * A global install leaves `<prefix>/bin/<tool>` as a symlink into
8
+ * `<prefix>/lib/node_modules/<package>/…` (npm) or
9
+ * `~/.bun/install/global/node_modules/<package>/…` (bun). The package's own
10
+ * `package.json` is therefore the authority on what is installed, and reading
11
+ * it needs neither an installer record nor running the tool.
12
+ *
13
+ * The walk starts from the binary's REAL location — the symlink is resolved
14
+ * first, chain and all — because the directory the symlink sits in can belong
15
+ * to some unrelated project whose `package.json` would answer wrongly. The
16
+ * nearest `package.json` carrying both `name` and `version` wins; the partial
17
+ * ones a build leaves in `dist/` (`{"type":"module"}`) are skipped.
18
+ *
19
+ * Never throws: a missing binary, a dangling link, an unreadable or unparseable
20
+ * manifest, and "no package at all" are all `null`.
21
+ */
22
+ export function readPackageVersionForBin(binPath: string): string | null {
23
+ let real: string;
24
+ try {
25
+ real = realpathSync(binPath);
26
+ } catch {
27
+ return null;
28
+ }
29
+
30
+ let dir = path.dirname(real);
31
+ for (;;) {
32
+ const version = qualifyingVersion(path.join(dir, "package.json"));
33
+ if (version !== undefined) return version;
34
+ const parent = path.dirname(dir);
35
+ if (parent === dir) return null;
36
+ dir = parent;
37
+ }
38
+ }
39
+
40
+ /** A bare release number, as a versioned install directory names its files. */
41
+ const RELEASE = /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
42
+
43
+ /**
44
+ * The version a VERSIONED INSTALL names its binary by, or `null`.
45
+ *
46
+ * Some installers keep every release side by side and point PATH at one:
47
+ * Claude Code's native installer links `~/.local/bin/claude` to
48
+ * `~/.local/share/claude/versions/2.1.276`. There is no package.json there to
49
+ * read, but the file's own name IS the version — accepted only when the real
50
+ * path's parent directory is literally `versions` and the name is a bare
51
+ * release number, so an ordinary binary called `1.0.0` is not misread.
52
+ *
53
+ * Reads no file and runs nothing; never throws.
54
+ */
55
+ export function readVersionDirForBin(binPath: string): string | null {
56
+ let real: string;
57
+ try {
58
+ real = realpathSync(binPath);
59
+ } catch {
60
+ return null;
61
+ }
62
+ return versionFromVersionedPath(real);
63
+ }
64
+
65
+ /**
66
+ * The same reading as {@link readVersionDirForBin}, from a path that is ALREADY
67
+ * real — pure string work, no filesystem. The probe resolves paths through its
68
+ * own IO seam and must not reach past it to the real disk.
69
+ */
70
+ export function versionFromVersionedPath(realPath: string): string | null {
71
+ const name = path.basename(realPath);
72
+ if (path.basename(path.dirname(realPath)) !== "versions") return null;
73
+ return RELEASE.test(name) ? name.replace(/^v/, "") : null;
74
+ }
75
+
76
+ /**
77
+ * The manifest's version when it names both a package and a version.
78
+ *
79
+ * `undefined` means "not a package root, keep walking"; `null` means "this is
80
+ * the package root, and it cannot be read" — the walk stops there rather than
81
+ * letting an enclosing workspace answer for a package it does not own.
82
+ */
83
+ function qualifyingVersion(file: string): string | null | undefined {
84
+ let text: string;
85
+ try {
86
+ text = readFileSync(file, "utf8");
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ let manifest: unknown;
91
+ try {
92
+ manifest = JSON.parse(text);
93
+ } catch {
94
+ return null;
95
+ }
96
+ if (manifest === null || typeof manifest !== "object") return undefined;
97
+ const { name, version } = manifest as Record<string, unknown>;
98
+ if (typeof name !== "string" || name === "") return undefined;
99
+ if (typeof version !== "string" || version === "") return undefined;
100
+ return version;
101
+ }
@@ -3,6 +3,11 @@
3
3
  * the Gitignore screen needs in one call, and exposes a small set of
4
4
  * action helpers (apply safe fixes, apply one destructive fix, apply a
5
5
  * built-in template).
6
+ *
7
+ * `.claude/gitignore.json` is read through `settings-file.ts`, the same refusal
8
+ * every settings file gets: applying a template REWRITES that file, so a copy
9
+ * we could not parse must never be the one it is rewritten from — see
10
+ * {@link readProjectManifest}.
6
11
  */
7
12
 
8
13
  import { existsSync } from "node:fs";
@@ -22,6 +27,7 @@ import {
22
27
  applySafeFixes as coreApplySafeFixes,
23
28
  } from "./gitignore-fixer.js";
24
29
  import { resolveGitignore } from "./gitignore-resolver.js";
30
+ import { readSettingsFile, writableList } from "./settings-file.js";
25
31
 
26
32
  export interface GitignoreState {
27
33
  resolved: ResolvedManifest;
@@ -97,7 +103,9 @@ export async function applyTemplate(
97
103
  if (!tpl) throw new Error(`Unknown template: ${templateName}`);
98
104
 
99
105
  const projectManifestPath = join(cwd, ".claude", "gitignore.json");
100
- const existing = await readManifestOrEmpty(projectManifestPath);
106
+ // Before anything is merged: a manifest we cannot read is refused here, so
107
+ // the file is never rewritten from a misreading of it.
108
+ const existing = await readProjectManifest(projectManifestPath);
101
109
 
102
110
  const existingIgnore = new Set(existing.ignore);
103
111
  const existingTrack = new Set(existing.track);
@@ -143,25 +151,34 @@ export async function applyTemplate(
143
151
  };
144
152
  }
145
153
 
146
- async function readManifestOrEmpty(path: string): Promise<GitignoreManifest> {
147
- if (!existsSync(path)) return { ignore: [], track: [] };
148
- try {
149
- const raw = await readFile(path, "utf8");
150
- const parsed = JSON.parse(raw);
151
- if (!isManifest(parsed)) return { ignore: [], track: [] };
152
- return parsed;
153
- } catch {
154
- return { ignore: [], track: [] };
155
- }
156
- }
157
-
158
- function isManifest(x: unknown): x is GitignoreManifest {
159
- if (!x || typeof x !== "object") return false;
160
- const m = x as Record<string, unknown>;
161
- return (
162
- Array.isArray(m.ignore) &&
163
- m.ignore.every((p) => typeof p === "string") &&
164
- Array.isArray(m.track) &&
165
- m.track.every((p) => typeof p === "string")
166
- );
154
+ /**
155
+ * The project manifest, as something safe to merge into and write back.
156
+ *
157
+ * Missing or empty is an empty manifest — nothing is lost by starting from one.
158
+ * A file that EXISTS and cannot be used is a `SettingsParseError` naming it, so
159
+ * the merge never runs and the file stays byte-for-byte as it was:
160
+ *
161
+ * - it does not parse, or its top level is not a JSON object
162
+ * ({@link readSettingsFile});
163
+ * - `ignore`, `track` or `extends` is present but is not an array
164
+ * ({@link writableList}).
165
+ *
166
+ * Reading any of those as "no entries" is what made `t` (apply template)
167
+ * destructive: a trailing comma left by a hand edit read as an empty manifest,
168
+ * the template merged into nothing, and the write replaced the user's own rules
169
+ * with the template's. A dropped `secrets/` line is how a secret reaches a
170
+ * commit, so this refuses rather than guesses.
171
+ *
172
+ * Entries INSIDE those arrays are carried through as they are, not filtered:
173
+ * the merge only appends, so anything already in the file comes back out of it
174
+ * unchanged. Dropping an entry we do not recognise would be the same data loss
175
+ * by a smaller door.
176
+ */
177
+ async function readProjectManifest(path: string): Promise<GitignoreManifest> {
178
+ const raw = await readSettingsFile(path);
179
+ return {
180
+ ignore: writableList(raw, "ignore", path) ?? [],
181
+ track: writableList(raw, "track", path) ?? [],
182
+ extends: writableList(raw, "extends", path),
183
+ } as GitignoreManifest;
167
184
  }
@@ -18,16 +18,21 @@
18
18
  * absolute path recorded today is a dead path after the next upgrade, and
19
19
  * the failure is invisible (a hook that cannot start is a hook that
20
20
  * silently does not route).
21
- * 3. **User scope, through `updateSettingsFile`.** NOT
22
- * `readGlobalSettings`/`writeGlobalSettings`: those swallow a parse error
23
- * and return `{}`, so a settings file a human is mid-edit in would be
24
- * overwritten with one hook and nothing else. `updateSettingsFile` refuses
25
- * a file it cannot parse.
21
+ * 3. **User scope, through `updateSettingsFile`.** It refuses a file it
22
+ * cannot parse, so a settings file a human is mid-edit in is never
23
+ * overwritten with one hook and nothing else. The path is the resolved
24
+ * Claude config dir (`CLAUDE_CONFIG_DIR`), which
25
+ * `readGlobalSettings`/`writeGlobalSettings` (always `~/.claude`) ignore.
26
26
  */
27
27
 
28
28
  import path from "node:path";
29
29
  import { claudeConfigDirOrNull } from "../utils/config-dir.js";
30
- import { readSettingsFile, updateSettingsFile } from "./settings-file.js";
30
+ import {
31
+ readSettingsFile,
32
+ updateSettingsFile,
33
+ writableList,
34
+ writableMap,
35
+ } from "./settings-file.js";
31
36
 
32
37
  /** The command Claude Code runs. Bare — see rule 2 above. */
33
38
  export const AGENT_MODEL_HOOK_COMMAND = "magus hook agent-model";
@@ -196,6 +201,18 @@ export async function registerAgentModelHook(
196
201
  if (isHookRegistered(await readSettingsFile(settingsPath))) return "already";
197
202
 
198
203
  await updateSettingsFile(settingsPath, (settings) => {
204
+ // The user's own hooks live in `hooks.PreToolUse`. A malformed one is
205
+ // refused here, never replaced: `ensureHookRegistered` alone swaps a
206
+ // non-object `hooks` or a non-array `PreToolUse` for an empty one, and this
207
+ // write would then drop everything that was in it.
208
+ const hooks = writableMap(settings, "hooks", settingsPath);
209
+ if (hooks) {
210
+ writableList(
211
+ hooks as Record<string, unknown>,
212
+ "PreToolUse",
213
+ settingsPath,
214
+ );
215
+ }
199
216
  return ensureHookRegistered(settings).settings;
200
217
  });
201
218
  return "registered";
@@ -11,13 +11,23 @@ import path from "node:path";
11
11
  import fs from "fs-extra";
12
12
  import type { ProfileManifest, ProfileManifestEntry } from "../types/index.js";
13
13
  import { validateModelsConfig } from "./models-core.js";
14
+ import {
15
+ SettingsShapeError,
16
+ describeJsonKind,
17
+ isJsonObject,
18
+ readSettingsFile,
19
+ } from "./settings-file.js";
14
20
 
15
- const PROFILES_FILE = "profiles.json";
21
+ /**
22
+ * The manifest's path relative to the project root, for messages that name the
23
+ * file without an absolute path — which wraps, and a wrapped path reads as two.
24
+ */
25
+ export const MANIFEST_FILE = path.join(".claude", "profiles.json");
16
26
 
17
27
  /** Project-scoped manifest path: <projectPath|cwd>/.claude/profiles.json */
18
28
  export function getManifestPath(projectPath?: string): string {
19
29
  const base = projectPath ?? process.cwd();
20
- return path.join(base, ".claude", PROFILES_FILE);
30
+ return path.join(base, MANIFEST_FILE);
21
31
  }
22
32
 
23
33
  /** An empty, valid v2 manifest. */
@@ -26,65 +36,132 @@ export function emptyManifest(): ProfileManifest {
26
36
  }
27
37
 
28
38
  /**
29
- * Normalize any parsed profiles.json (v1 or v2) into a v2 manifest.
30
- * Unknown/missing shapes degrade to an empty manifest rather than throwing —
31
- * validation is the caller's job via {@link validateManifest}.
39
+ * A profiles file's contents once its shape has been checked: a JSON object
40
+ * whose `profiles` maps each id to an object. Only {@link readProfilesFile}
41
+ * produces one, so holding one means the check ran.
42
+ */
43
+ export type ProfilesFile = Record<string, unknown> & {
44
+ profiles: Record<string, Record<string, unknown>>;
45
+ };
46
+
47
+ const NOT_A_PROFILES_FILE = "is not a valid profiles file";
48
+
49
+ /**
50
+ * Read a profiles file — the project's `.claude/profiles.json` or the user's
51
+ * `~/.claude/profiles.json` — and check its shape.
52
+ *
53
+ * THE one shape check. `readManifest`, `readProfiles` and both writers all come
54
+ * through here, so the two views of the same file cannot disagree about
55
+ * whether it holds profiles. They used to: for `"profiles": 5` one read "none"
56
+ * and the other passed the 5 through.
57
+ *
58
+ * Missing, empty, `{}` and `{"version": 2}` read as no profiles. Everything
59
+ * else that is not a map of profile objects THROWS `SettingsShapeError` naming
60
+ * the file (a `SettingsParseError`, so every existing catch reports it):
61
+ *
62
+ * - `profiles` present but not an object (`5`, `[]`, `null`);
63
+ * - any entry under `profiles` that is not an object;
64
+ * - `profiles` absent while the file holds any key besides `version`.
65
+ *
66
+ * The last is the one that bites. `{"version": 2, "profile": {…}}` — one
67
+ * letter off — read as "no profiles", which is the state in which magus offers
68
+ * to save what is turned on as the first profile, and saving writes this file.
69
+ * The real profile would have been replaced by a generated one.
32
70
  */
33
- export function migrateToV2(raw: unknown): ProfileManifest {
34
- if (!raw || typeof raw !== "object") return emptyManifest();
35
- const obj = raw as Record<string, unknown>;
71
+ export async function readProfilesFile(
72
+ filePath: string,
73
+ ): Promise<ProfilesFile> {
74
+ const raw = await readSettingsFile(filePath);
75
+ const refuse = (detail: string): never => {
76
+ throw new SettingsShapeError(filePath, NOT_A_PROFILES_FILE, detail);
77
+ };
36
78
 
37
- const profilesRaw =
38
- obj.profiles && typeof obj.profiles === "object"
39
- ? (obj.profiles as Record<string, unknown>)
40
- : {};
79
+ if (!("profiles" in raw)) {
80
+ const others = Object.keys(raw).filter((key) => key !== "version");
81
+ if (others.length > 0) {
82
+ refuse(
83
+ `it has no "profiles" key, but has ${others.map((key) => `"${key}"`).join(", ")}`,
84
+ );
85
+ }
86
+ return { ...raw, profiles: {} };
87
+ }
41
88
 
42
- const profiles: Record<string, ProfileManifestEntry> = {};
43
- for (const [id, entry] of Object.entries(profilesRaw)) {
44
- if (entry && typeof entry === "object") {
45
- // v1 and v2 entries are both accepted as-is; v1's plugins map
46
- // (Record<string, boolean>) is valid under v2's widened type.
47
- profiles[id] = entry as ProfileManifestEntry;
89
+ const { profiles } = raw;
90
+ if (!isJsonObject(profiles)) {
91
+ return refuse(`"profiles" is ${describeJsonKind(profiles)}, not an object`);
92
+ }
93
+ for (const [id, entry] of Object.entries(profiles)) {
94
+ if (!isJsonObject(entry)) {
95
+ refuse(`profile "${id}" is ${describeJsonKind(entry)}, not an object`);
48
96
  }
49
97
  }
98
+ return raw as ProfilesFile;
99
+ }
50
100
 
51
- const manifest: ProfileManifest = { version: 2, profiles };
101
+ /**
102
+ * Normalize a checked profiles file (v1 or v2) into a v2 manifest.
103
+ *
104
+ * Takes a {@link ProfilesFile}, never raw JSON: deciding that a wrong shape is
105
+ * "empty" is how a damaged file got overwritten, so there is no garbage branch
106
+ * here to decide it. The shape check refuses first.
107
+ */
108
+ export function migrateToV2(file: ProfilesFile): ProfileManifest {
109
+ // v1 and v2 entries are both accepted as-is; v1's plugins map
110
+ // (Record<string, boolean>) is valid under v2's widened type. What an entry
111
+ // holds is `validateManifest`'s to report, not this function's to drop.
112
+ const manifest: ProfileManifest = {
113
+ version: 2,
114
+ profiles: file.profiles as unknown as Record<string, ProfileManifestEntry>,
115
+ };
52
116
  // Only carry strictVersions when enabled, so a committed manifest that
53
117
  // doesn't opt in stays minimal (no spurious `"strictVersions": false`).
54
- if (obj.strictVersions === true) manifest.strictVersions = true;
118
+ if (file.strictVersions === true) manifest.strictVersions = true;
55
119
  // Carried through even when it names no existing profile: dropping it here
56
120
  // would silently "fix" a typo that `validateManifest` is meant to report,
57
121
  // and the commands that read it all check the id resolves first.
58
- if (typeof obj.default === "string" && obj.default !== "") {
59
- manifest.default = obj.default;
122
+ if (typeof file.default === "string" && file.default !== "") {
123
+ manifest.default = file.default;
60
124
  }
61
125
  return manifest;
62
126
  }
63
127
 
64
128
  /**
65
- * Read and migrate the project manifest. Returns an empty v2 manifest if the
66
- * file is absent or unreadable.
129
+ * Read and migrate the project manifest.
130
+ *
131
+ * A missing (or empty) file is an empty v2 manifest: a repo with no profiles.
132
+ * A file that exists and does not parse, or has the wrong shape, THROWS
133
+ * `SettingsParseError` carrying its path — see {@link readProfilesFile}.
134
+ *
135
+ * It used to answer an empty manifest for both, and "no profiles" is not a
136
+ * harmless answer here. It is exactly the state in which magus offers to save
137
+ * what is turned on as a first profile, and saving WRITES this file — so a
138
+ * manifest someone was mid-edit in came back as one generated profile and
139
+ * nothing else. Throwing stops every caller before its write.
67
140
  */
68
141
  export async function readManifest(
69
142
  projectPath?: string,
70
143
  ): Promise<ProfileManifest> {
71
- const filePath = getManifestPath(projectPath);
72
- try {
73
- if (await fs.pathExists(filePath)) {
74
- return migrateToV2(await fs.readJson(filePath));
75
- }
76
- } catch {
77
- // Corrupt/unreadable — fall through to empty.
78
- }
79
- return emptyManifest();
144
+ return migrateToV2(await readProfilesFile(getManifestPath(projectPath)));
80
145
  }
81
146
 
82
- /** Write the manifest back to disk (used by `profile sync`, tests). */
147
+ /**
148
+ * Write the manifest to disk.
149
+ *
150
+ * Refuses with `SettingsParseError`, leaving the file untouched, whenever the
151
+ * file already there would be refused by {@link readManifest} — unparseable or
152
+ * the wrong shape. Every caller read the manifest first, but not necessarily
153
+ * just before: the TUI's startup gate reads it at launch and writes when the
154
+ * user answers, which can be minutes later — long enough for someone to be
155
+ * mid-edit. Checking here makes the refusal hold for every writer, not only
156
+ * for the ones that happened to re-read.
157
+ */
83
158
  export async function writeManifest(
84
159
  manifest: ProfileManifest,
85
160
  projectPath?: string,
86
161
  ): Promise<void> {
87
162
  const filePath = getManifestPath(projectPath);
163
+ // Read for the throw alone; the contents are about to be replaced.
164
+ await readProfilesFile(filePath);
88
165
  await fs.ensureDir(path.dirname(filePath));
89
166
  await fs.writeJson(filePath, manifest, { spaces: 2 });
90
167
  }
@@ -207,10 +284,10 @@ export function validateManifest(
207
284
  * manifest file, or no entry for that profile) — all three are normal states,
208
285
  * and `mutate` is not called.
209
286
  *
210
- * THROWS when the manifest exists and cannot be read as JSON. `readManifest`
211
- * degrades a corrupt file to an empty manifest, which here would mean silently
212
- * recording nothing and reporting success — the caller has just written a live
213
- * setting the next `magus install` will revert, and would never learn.
287
+ * THROWS `SettingsParseError` when the manifest exists and does not parse.
288
+ * Treating that as "no entry" would mean silently recording nothing and
289
+ * reporting success — the caller has just written a live setting the next
290
+ * `magus install` will revert, and would never learn.
214
291
  */
215
292
  export async function updateProfileEntry(
216
293
  projectPath: string,
@@ -218,21 +295,9 @@ export async function updateProfileEntry(
218
295
  mutate: (entry: ProfileManifestEntry) => void,
219
296
  ): Promise<boolean> {
220
297
  if (!profile) return false;
221
- const filePath = getManifestPath(projectPath);
222
- if (!(await fs.pathExists(filePath))) return false;
223
-
224
- // Read directly rather than through readManifest: its `catch` is exactly
225
- // what turns a corrupt file into a silent no-op.
226
- let raw: unknown;
227
- try {
228
- raw = await fs.readJson(filePath);
229
- } catch (error) {
230
- throw new Error(
231
- `${filePath} is not valid JSON, refusing to write to it: ${String(error)}`,
232
- );
233
- }
298
+ if (!(await fs.pathExists(getManifestPath(projectPath)))) return false;
234
299
 
235
- const manifest = migrateToV2(raw);
300
+ const manifest = await readManifest(projectPath);
236
301
  const entry = manifest.profiles[profile];
237
302
  if (!entry) return false;
238
303
 
@@ -16,6 +16,7 @@ import {
16
16
  getLocalEnabledPlugins,
17
17
  getLocalInstalledPluginVersions,
18
18
  getProjectInstalledPluginVersions,
19
+ getSettingsPath,
19
20
  pickRegistryEntry,
20
21
  readInstalledPluginsRegistry,
21
22
  readSettings,
@@ -46,6 +47,7 @@ import {
46
47
  resolveMarketplacePlugins,
47
48
  } from "./marketplace-fetcher.js";
48
49
  import { resolveLoadedVersions } from "./plugin-resolution.js";
50
+ import { writableMap } from "./settings-file.js";
49
51
  export {
50
52
  fetchMarketplacePlugins,
51
53
  getMarketplaceFetchFailures,
@@ -733,7 +735,12 @@ export async function saveInstalledPluginVersion(
733
735
  projectPath?: string,
734
736
  ): Promise<void> {
735
737
  const settings = await readSettings(projectPath);
736
- settings.installedPluginVersions = settings.installedPluginVersions || {};
738
+ settings.installedPluginVersions =
739
+ writableMap(
740
+ settings,
741
+ "installedPluginVersions",
742
+ getSettingsPath(projectPath),
743
+ ) ?? {};
737
744
  settings.installedPluginVersions[pluginId] = version;
738
745
  await writeSettings(settings, projectPath);
739
746
 
@@ -799,11 +806,18 @@ export async function removeInstalledPluginVersion(
799
806
  projectPath?: string,
800
807
  ): Promise<void> {
801
808
  const settings = await readSettings(projectPath);
802
- if (settings.installedPluginVersions) {
803
- delete settings.installedPluginVersions[pluginId];
809
+ const settingsPath = getSettingsPath(projectPath);
810
+ const versions = writableMap(
811
+ settings,
812
+ "installedPluginVersions",
813
+ settingsPath,
814
+ );
815
+ const enabled = writableMap(settings, "enabledPlugins", settingsPath);
816
+ if (versions) {
817
+ delete versions[pluginId];
804
818
  }
805
- if (settings.enabledPlugins) {
806
- delete settings.enabledPlugins[pluginId];
819
+ if (enabled) {
820
+ delete enabled[pluginId];
807
821
  }
808
822
  await writeSettings(settings, projectPath);
809
823
 
@@ -165,11 +165,16 @@ export type AdoptSource = "project" | "user";
165
165
  export async function collectEnabledPlugins(
166
166
  projectPath: string,
167
167
  ): Promise<{ plugins: Record<string, boolean>; source: AdoptSource }> {
168
- const project = await getEnabledPlugins(projectPath).catch(() => ({}));
168
+ // No catch on either read. A project settings file that does not parse is
169
+ // not "no project plugins": falling through to user scope there would adopt
170
+ // one developer's global set as the team's contract, the exact thing the
171
+ // project-first order exists to prevent. The SettingsParseError names the
172
+ // file, and adoption stops.
173
+ const project = await getEnabledPlugins(projectPath);
169
174
  if (Object.values(project).some(Boolean)) {
170
175
  return { plugins: project, source: "project" };
171
176
  }
172
- const user = await getGlobalEnabledPlugins().catch(() => ({}));
177
+ const user = await getGlobalEnabledPlugins();
173
178
  return { plugins: user, source: "user" };
174
179
  }
175
180