magus-cli 7.3.0 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/package.json +4 -4
  2. package/scripts/build-binaries.ts +4 -2
  3. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  4. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  5. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  6. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  7. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  8. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  9. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  10. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  11. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  12. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  15. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  16. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  17. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  18. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  19. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  20. package/src/__tests__/blind/child-run.ts +114 -0
  21. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  22. package/src/__tests__/component-badge.test.ts +20 -36
  23. package/src/__tests__/footer-hints.test.ts +1 -1
  24. package/src/__tests__/manifest.test.ts +25 -17
  25. package/src/__tests__/marketplace-badge.test.ts +4 -4
  26. package/src/__tests__/models-screen-state.test.ts +36 -13
  27. package/src/__tests__/npm-package-name-api.test.ts +111 -0
  28. package/src/__tests__/npm-package-name.test.ts +280 -0
  29. package/src/__tests__/scope-squares.test.tsx +28 -114
  30. package/src/__tests__/shutdown.test.ts +578 -0
  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__/upgrade-spawn.test.ts +355 -0
  37. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  38. package/src/cli/ansi.ts +2 -2
  39. package/src/cli/bootstrap.ts +33 -2
  40. package/src/cli/doctor.ts +105 -14
  41. package/src/cli/install.ts +8 -2
  42. package/src/cli/profile.ts +18 -1
  43. package/src/cli/router.ts +31 -18
  44. package/src/cli/update-view.ts +1 -1
  45. package/src/cli/upgrade.ts +37 -9
  46. package/src/data/cli-tools.ts +13 -2
  47. package/src/data/npm-package.ts +15 -0
  48. package/src/services/claude-settings.ts +151 -73
  49. package/src/services/cli-tool-commands.ts +15 -5
  50. package/src/services/cli-tool-version.ts +101 -0
  51. package/src/services/hook-registration.ts +23 -6
  52. package/src/services/manifest.ts +117 -52
  53. package/src/services/plugin-manager.ts +19 -5
  54. package/src/services/profile-adopt.ts +7 -2
  55. package/src/services/profile-startup.ts +87 -7
  56. package/src/services/profiles.ts +36 -23
  57. package/src/services/settings-file.ts +158 -4
  58. package/src/services/settings-manager.ts +19 -5
  59. package/src/services/skills-manager.ts +46 -0
  60. package/src/services/tool-probe.ts +272 -45
  61. package/src/services/toolchain.ts +39 -0
  62. package/src/services/update-engine.ts +5 -0
  63. package/src/services/update-plan.ts +9 -0
  64. package/src/services/version-check.ts +20 -10
  65. package/src/tui.tsx +48 -21
  66. package/src/ui/App.tsx +58 -197
  67. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  68. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  69. package/src/ui/adapters/settingsAdapter.ts +0 -21
  70. package/src/ui/adapters/skillsAdapter.ts +27 -8
  71. package/src/ui/adapters/stylesAdapter.ts +15 -11
  72. package/src/ui/components/CategoryHeader.tsx +7 -1
  73. package/src/ui/components/EmptyFilterState.tsx +2 -2
  74. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  75. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  76. package/src/ui/components/TabBar.tsx +93 -21
  77. package/src/ui/components/layout/FooterHints.tsx +6 -27
  78. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  79. package/src/ui/components/layout/index.ts +1 -5
  80. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  81. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  82. package/src/ui/components/modals/MessageModal.tsx +3 -15
  83. package/src/ui/components/modals/ModalContainer.tsx +99 -60
  84. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  85. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  86. package/src/ui/components/primitives/MetaText.tsx +1 -1
  87. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  88. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  89. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  90. package/src/ui/components/primitives/index.ts +4 -3
  91. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  92. package/src/ui/input/KeyRouter.tsx +180 -0
  93. package/src/ui/input/key-dispatch.ts +149 -0
  94. package/src/ui/keymap.ts +898 -0
  95. package/src/ui/renderers/cliToolRenderers.tsx +128 -66
  96. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  97. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  98. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  99. package/src/ui/renderers/modelRenderers.tsx +12 -13
  100. package/src/ui/renderers/modelVisuals.tsx +0 -2
  101. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  102. package/src/ui/renderers/profileRenderers.tsx +32 -37
  103. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  104. package/src/ui/renderers/skillRenderers.tsx +237 -190
  105. package/src/ui/renderers/styleRenderers.tsx +13 -2
  106. package/src/ui/runtime/shutdown.ts +268 -0
  107. package/src/ui/screens/AliasScreen.tsx +118 -100
  108. package/src/ui/screens/CliToolsScreen.tsx +98 -87
  109. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  110. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  111. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  112. package/src/ui/screens/McpScreen.tsx +47 -33
  113. package/src/ui/screens/ModelsScreen.tsx +59 -104
  114. package/src/ui/screens/PluginsScreen.tsx +123 -127
  115. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  116. package/src/ui/screens/SkillsScreen.tsx +184 -204
  117. package/src/ui/screens/StylesScreen.tsx +104 -157
  118. package/src/ui/state/AppContext.tsx +6 -0
  119. package/src/ui/state/DimensionsContext.tsx +1 -10
  120. package/src/ui/state/reducer.ts +5 -8
  121. package/src/ui/state/types.ts +11 -9
  122. package/src/ui/text-width.ts +341 -0
  123. package/src/ui/theme-mode.ts +34 -71
  124. package/src/ui/theme.ts +50 -28
  125. package/src/utils/request-gate.ts +37 -0
  126. package/src/ui/components/ScopeIndicator.tsx +0 -63
  127. package/src/ui/components/SearchInput.tsx +0 -57
  128. package/src/ui/hooks/index.ts +0 -2
  129. package/src/ui/hooks/useKeyboard.ts +0 -24
  130. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -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
 
@@ -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
  }