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.
- package/package.json +4 -4
- package/scripts/build-binaries.ts +4 -2
- package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
- package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
- package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
- package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
- package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
- package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
- package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
- package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
- package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
- package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
- package/src/__tests__/blind/c5-keymap.test.ts +461 -0
- package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
- package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
- package/src/__tests__/blind/c8-text-width.test.ts +137 -0
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
- package/src/__tests__/blind/child-run.ts +114 -0
- package/src/__tests__/cli-tools-adapter.test.ts +5 -2
- package/src/__tests__/component-badge.test.ts +20 -36
- package/src/__tests__/footer-hints.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +25 -17
- package/src/__tests__/marketplace-badge.test.ts +4 -4
- package/src/__tests__/models-screen-state.test.ts +36 -13
- package/src/__tests__/npm-package-name-api.test.ts +111 -0
- package/src/__tests__/npm-package-name.test.ts +280 -0
- package/src/__tests__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/styles-manager.test.ts +4 -4
- package/src/__tests__/styles-screen-state.test.ts +26 -12
- package/src/__tests__/tabbar-layout.test.ts +44 -32
- package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
- package/src/__tests__/tool-probe.test.ts +47 -22
- package/src/__tests__/upgrade-spawn.test.ts +355 -0
- package/src/__tests__/uppercase-keybindings.test.ts +25 -12
- package/src/cli/ansi.ts +2 -2
- package/src/cli/bootstrap.ts +33 -2
- package/src/cli/doctor.ts +105 -14
- package/src/cli/install.ts +8 -2
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/cli/upgrade.ts +37 -9
- package/src/data/cli-tools.ts +13 -2
- package/src/data/npm-package.ts +15 -0
- package/src/services/claude-settings.ts +151 -73
- package/src/services/cli-tool-commands.ts +15 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/hook-registration.ts +23 -6
- package/src/services/manifest.ts +117 -52
- package/src/services/plugin-manager.ts +19 -5
- package/src/services/profile-adopt.ts +7 -2
- package/src/services/profile-startup.ts +87 -7
- package/src/services/profiles.ts +36 -23
- package/src/services/settings-file.ts +158 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +272 -45
- package/src/services/toolchain.ts +39 -0
- package/src/services/update-engine.ts +5 -0
- package/src/services/update-plan.ts +9 -0
- package/src/services/version-check.ts +20 -10
- package/src/tui.tsx +48 -21
- package/src/ui/App.tsx +58 -197
- package/src/ui/adapters/cliToolsAdapter.ts +141 -12
- package/src/ui/adapters/pluginsAdapter.ts +10 -7
- package/src/ui/adapters/settingsAdapter.ts +0 -21
- package/src/ui/adapters/skillsAdapter.ts +27 -8
- package/src/ui/adapters/stylesAdapter.ts +15 -11
- package/src/ui/components/CategoryHeader.tsx +7 -1
- package/src/ui/components/EmptyFilterState.tsx +2 -2
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
- package/src/ui/components/TabBar.tsx +93 -21
- package/src/ui/components/layout/FooterHints.tsx +6 -27
- package/src/ui/components/layout/ScreenLayout.tsx +68 -42
- package/src/ui/components/layout/index.ts +1 -5
- package/src/ui/components/modals/ConfirmModal.tsx +2 -19
- package/src/ui/components/modals/HelpOverlay.tsx +186 -0
- package/src/ui/components/modals/MessageModal.tsx +3 -15
- package/src/ui/components/modals/ModalContainer.tsx +99 -60
- package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
- package/src/ui/components/primitives/MetaText.tsx +1 -1
- package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
- package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
- package/src/ui/components/primitives/SelectableRow.tsx +24 -1
- package/src/ui/components/primitives/index.ts +4 -3
- package/src/ui/hooks/useProfileStartupModal.ts +15 -3
- package/src/ui/input/KeyRouter.tsx +180 -0
- package/src/ui/input/key-dispatch.ts +149 -0
- package/src/ui/keymap.ts +898 -0
- package/src/ui/renderers/cliToolRenderers.tsx +128 -66
- package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
- package/src/ui/renderers/markdownRenderer.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +28 -14
- package/src/ui/renderers/modelRenderers.tsx +12 -13
- package/src/ui/renderers/modelVisuals.tsx +0 -2
- package/src/ui/renderers/pluginRenderers.tsx +47 -54
- package/src/ui/renderers/profileRenderers.tsx +32 -37
- package/src/ui/renderers/settingsRenderers.tsx +84 -55
- package/src/ui/renderers/skillRenderers.tsx +237 -190
- package/src/ui/renderers/styleRenderers.tsx +13 -2
- package/src/ui/runtime/shutdown.ts +268 -0
- package/src/ui/screens/AliasScreen.tsx +118 -100
- package/src/ui/screens/CliToolsScreen.tsx +98 -87
- package/src/ui/screens/EnvVarsScreen.tsx +44 -42
- package/src/ui/screens/GitignoreScreen.tsx +50 -55
- package/src/ui/screens/McpRegistryScreen.tsx +52 -86
- package/src/ui/screens/McpScreen.tsx +47 -33
- package/src/ui/screens/ModelsScreen.tsx +59 -104
- package/src/ui/screens/PluginsScreen.tsx +123 -127
- package/src/ui/screens/ProfilesScreen.tsx +49 -46
- package/src/ui/screens/SkillsScreen.tsx +184 -204
- package/src/ui/screens/StylesScreen.tsx +104 -157
- package/src/ui/state/AppContext.tsx +6 -0
- package/src/ui/state/DimensionsContext.tsx +1 -10
- package/src/ui/state/reducer.ts +5 -8
- package/src/ui/state/types.ts +11 -9
- package/src/ui/text-width.ts +341 -0
- package/src/ui/theme-mode.ts +34 -71
- package/src/ui/theme.ts +50 -28
- package/src/utils/request-gate.ts +37 -0
- package/src/ui/components/ScopeIndicator.tsx +0 -63
- package/src/ui/components/SearchInput.tsx +0 -57
- package/src/ui/hooks/index.ts +0 -2
- package/src/ui/hooks/useKeyboard.ts +0 -24
- package/src/ui/hooks/useKeyboardHandler.ts +0 -63
package/src/services/manifest.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
59
|
-
manifest.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.
|
|
66
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
803
|
-
|
|
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 (
|
|
806
|
-
delete
|
|
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
|
-
|
|
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()
|
|
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
|
|
26
|
-
* unmigrated
|
|
27
|
-
* inactive
|
|
25
|
+
* ok a profile is active; the manifest is in effect; ask nothing
|
|
26
|
+
* unmigrated no manifest — the project is not using profiles at all
|
|
27
|
+
* inactive a manifest with profiles, none of them linked into place
|
|
28
|
+
* unparseable a manifest that exists and is not valid JSON, or is valid
|
|
29
|
+
* JSON in the wrong shape (see `readProfilesFile`)
|
|
28
30
|
*
|
|
29
31
|
* `unmigrated` and `inactive` are both unusable, not merely untidy: with no
|
|
30
32
|
* active profile there is no profile state to read and nothing to write updates
|
|
31
33
|
* into, so magus cannot do its job. The caller must not offer to continue.
|
|
32
34
|
*
|
|
35
|
+
* `unparseable` is unusable for a different reason: magus cannot tell which of
|
|
36
|
+
* the other three it is in. It must never read as `unmigrated` — that state
|
|
37
|
+
* offers to save what is turned on as a new profile, and saving writes the
|
|
38
|
+
* manifest over whatever the file held. So it offers nothing but the way out,
|
|
39
|
+
* and names the file to fix.
|
|
40
|
+
*
|
|
33
41
|
* Detection only — nothing here writes. That is what makes it callable from the
|
|
34
42
|
* TUI's startup effect, and testable without a renderer.
|
|
35
43
|
*/
|
|
@@ -40,13 +48,18 @@ import type { ProfileManifest } from "../types/index.js";
|
|
|
40
48
|
// selectable option is presentation-neutral data, and both surfaces render the
|
|
41
49
|
// same list from these builders rather than each composing its own.
|
|
42
50
|
import type { SelectOption } from "../ui/state/types.js";
|
|
43
|
-
import { readManifest } from "./manifest.js";
|
|
51
|
+
import { MANIFEST_FILE, emptyManifest, readManifest } from "./manifest.js";
|
|
44
52
|
import type { ProfileChoice } from "./profile-activate.js";
|
|
45
53
|
import { freeProfileId } from "./profile-adopt.js";
|
|
54
|
+
import { SettingsParseError } from "./settings-file.js";
|
|
46
55
|
import { activeProfile, displaceableArtifacts } from "./symlink-manager.js";
|
|
47
56
|
|
|
48
57
|
/** Whether the project can be worked on, and if not, why not. */
|
|
49
|
-
export type ProfileStartupState =
|
|
58
|
+
export type ProfileStartupState =
|
|
59
|
+
| "ok"
|
|
60
|
+
| "unmigrated"
|
|
61
|
+
| "inactive"
|
|
62
|
+
| "unparseable";
|
|
50
63
|
|
|
51
64
|
/** A profile the user could activate. */
|
|
52
65
|
export interface ActivatableProfile {
|
|
@@ -88,6 +101,11 @@ export interface ProfileStartupNeed {
|
|
|
88
101
|
autoActivate: boolean;
|
|
89
102
|
/** The manifest as read, so the caller does not read it a second time. */
|
|
90
103
|
manifest: ProfileManifest;
|
|
104
|
+
/**
|
|
105
|
+
* Why the manifest could not be read. Set in the `unparseable` state only,
|
|
106
|
+
* where `manifest` is empty because nothing was read.
|
|
107
|
+
*/
|
|
108
|
+
parseError?: SettingsParseError;
|
|
91
109
|
}
|
|
92
110
|
|
|
93
111
|
/** True when the project is usable and there is nothing to ask. */
|
|
@@ -95,11 +113,38 @@ export function needsNothing(need: ProfileStartupNeed): boolean {
|
|
|
95
113
|
return need.state === "ok";
|
|
96
114
|
}
|
|
97
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The need for a manifest that does not parse: nothing to adopt, nothing to
|
|
118
|
+
* activate, so the only option built from it is the way out.
|
|
119
|
+
*
|
|
120
|
+
* Exported because detection is not the only place this is learned. The TUI
|
|
121
|
+
* gate reads at launch and writes when the user answers; a file that stops
|
|
122
|
+
* parsing in between surfaces as the write's refusal, and the gate has to turn
|
|
123
|
+
* into this rather than ask its stale question again.
|
|
124
|
+
*/
|
|
125
|
+
export function unparseableNeed(error: SettingsParseError): ProfileStartupNeed {
|
|
126
|
+
return {
|
|
127
|
+
state: "unparseable",
|
|
128
|
+
adopt: null,
|
|
129
|
+
activate: [],
|
|
130
|
+
declaredDefault: null,
|
|
131
|
+
autoActivate: false,
|
|
132
|
+
manifest: emptyManifest(),
|
|
133
|
+
parseError: error,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
98
137
|
/** Inspect the project and decide what, if anything, must happen first. */
|
|
99
138
|
export async function detectProfileStartupNeed(
|
|
100
139
|
projectPath: string,
|
|
101
140
|
): Promise<ProfileStartupNeed> {
|
|
102
|
-
|
|
141
|
+
let manifest: ProfileManifest;
|
|
142
|
+
try {
|
|
143
|
+
manifest = await readManifest(projectPath);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (!(error instanceof SettingsParseError)) throw error;
|
|
146
|
+
return unparseableNeed(error);
|
|
147
|
+
}
|
|
103
148
|
const ids = Object.keys(manifest.profiles);
|
|
104
149
|
|
|
105
150
|
if (ids.length === 0) {
|
|
@@ -202,11 +247,43 @@ export const ACTIVATE_PREFIX = "activate:";
|
|
|
202
247
|
* break opportunity — MEASURED on screen, `.claude/settings.json` wrapped as
|
|
203
248
|
* `.` then `claude/settings.json` on the next line. A path split across a line
|
|
204
249
|
* break reads as two different paths, neither of them real.
|
|
250
|
+
*
|
|
251
|
+
* The `unparseable` state is the one exception, because the file IS the
|
|
252
|
+
* message: "fix it" is useless without saying which. So the path goes on a
|
|
253
|
+
* line of its own, project-relative, far shorter than the narrowest modal —
|
|
254
|
+
* nothing on that line ever needs to wrap.
|
|
205
255
|
*/
|
|
206
256
|
export function describeNeed(need: ProfileStartupNeed): {
|
|
207
257
|
title: string;
|
|
208
258
|
message: string;
|
|
209
259
|
} {
|
|
260
|
+
if (need.state === "unparseable") {
|
|
261
|
+
// The error's own parts, without the absolute path its message leads
|
|
262
|
+
// with: what is wrong ("is not valid JSON", "is not a valid profiles
|
|
263
|
+
// file") and the specifics — the parser's words, or the key with the
|
|
264
|
+
// wrong shape, which is how a one-letter typo gets found.
|
|
265
|
+
const error = need.parseError;
|
|
266
|
+
const lines = [
|
|
267
|
+
`This file ${error?.problem ?? "is not valid JSON"}:`,
|
|
268
|
+
"",
|
|
269
|
+
` ${MANIFEST_FILE}`,
|
|
270
|
+
];
|
|
271
|
+
if (error?.detail) {
|
|
272
|
+
lines.push(
|
|
273
|
+
"",
|
|
274
|
+
error.detail.charAt(0).toUpperCase() + error.detail.slice(1),
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
lines.push(
|
|
278
|
+
"",
|
|
279
|
+
"magus leaves it exactly as it is. Fix the JSON by hand, then start magus again.",
|
|
280
|
+
);
|
|
281
|
+
return {
|
|
282
|
+
title: "Cannot read this project's profiles",
|
|
283
|
+
message: lines.join("\n"),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
210
287
|
if (need.state === "unmigrated") {
|
|
211
288
|
return {
|
|
212
289
|
title: "magus uses profiles",
|
|
@@ -234,6 +311,9 @@ export function describeNeed(need: ProfileStartupNeed): {
|
|
|
234
311
|
* "Quit magus" is last instead. A question that cannot be answered with "no"
|
|
235
312
|
* still has to be answerable with "not in this app", and the way out has to be
|
|
236
313
|
* visible rather than a keystroke the user has to guess.
|
|
314
|
+
*
|
|
315
|
+
* For an `unparseable` need it is the ONLY row: there is nothing to adopt into
|
|
316
|
+
* and nothing to activate from a file magus cannot read.
|
|
237
317
|
*/
|
|
238
318
|
export function buildProfileOptions(need: ProfileStartupNeed): SelectOption[] {
|
|
239
319
|
const options: SelectOption[] = [];
|
package/src/services/profiles.ts
CHANGED
|
@@ -2,6 +2,7 @@ import os from "node:os";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import fs from "fs-extra";
|
|
4
4
|
import type { ProfileEntry, ProfileManifest } from "../types/index.js";
|
|
5
|
+
import { readProfilesFile } from "./manifest.js";
|
|
5
6
|
import { activeProfile } from "./symlink-manager.js";
|
|
6
7
|
|
|
7
8
|
const PROFILES_FILE = "profiles.json";
|
|
@@ -19,40 +20,52 @@ export function getProjectProfilesPath(projectPath?: string): string {
|
|
|
19
20
|
|
|
20
21
|
// ─── Low-level read/write ──────────────────────────────────────────────────────
|
|
21
22
|
|
|
23
|
+
function profilesPath(scope: "user" | "project", projectPath?: string): string {
|
|
24
|
+
return scope === "user"
|
|
25
|
+
? getUserProfilesPath()
|
|
26
|
+
: getProjectProfilesPath(projectPath);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read one scope's profiles file.
|
|
31
|
+
*
|
|
32
|
+
* Missing, empty, `{}` or `{"version": 2}` reads as no profiles. A file that
|
|
33
|
+
* does not parse, or parses into the wrong shape, throws `SettingsParseError`
|
|
34
|
+
* naming it — never "no profiles". Every writer below reads through here
|
|
35
|
+
* first, so an empty answer for a damaged file is how saving one profile used
|
|
36
|
+
* to write over all the others.
|
|
37
|
+
*
|
|
38
|
+
* The shape check is `readProfilesFile`'s, shared with `readManifest`: at
|
|
39
|
+
* project scope this is the same `.claude/profiles.json`, and the two readers
|
|
40
|
+
* must agree on what it holds.
|
|
41
|
+
*/
|
|
22
42
|
export async function readProfiles(
|
|
23
43
|
scope: "user" | "project",
|
|
24
44
|
projectPath?: string,
|
|
25
45
|
): Promise<ProfileManifest> {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// A v1 file differs only in what it omits, so it reads as a v2 entry
|
|
34
|
-
// with absent optional fields. Nothing here converts: `version: 2` is
|
|
35
|
-
// what the next write records, and every consumer already treats
|
|
36
|
-
// `createdAt`, `updatedAt` and a string plugin pin as optional.
|
|
37
|
-
if (data.version && data.profiles) {
|
|
38
|
-
return { ...data, version: 2 } as ProfileManifest;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
} catch {
|
|
42
|
-
// fall through to default
|
|
43
|
-
}
|
|
44
|
-
return { version: 2, profiles: {} };
|
|
46
|
+
const data = await readProfilesFile(profilesPath(scope, projectPath));
|
|
47
|
+
// A v1 file differs only in what it omits, so it reads as a v2 entry
|
|
48
|
+
// with absent optional fields. Nothing here converts: `version: 2` is
|
|
49
|
+
// what the next write records, and every consumer already treats
|
|
50
|
+
// `createdAt`, `updatedAt` and a string plugin pin as optional. Keys this
|
|
51
|
+
// reader does not know are carried, so a write puts them back.
|
|
52
|
+
return { ...data, version: 2 } as unknown as ProfileManifest;
|
|
45
53
|
}
|
|
46
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Write one scope's profiles file — refusing, and leaving it untouched, when
|
|
57
|
+
* the file already there would be refused by {@link readProfiles}. Every
|
|
58
|
+
* caller reads first, but this is the half that must never write over a file
|
|
59
|
+
* magus cannot read, so it checks for itself.
|
|
60
|
+
*/
|
|
47
61
|
async function writeProfiles(
|
|
48
62
|
scope: "user" | "project",
|
|
49
63
|
data: ProfileManifest,
|
|
50
64
|
projectPath?: string,
|
|
51
65
|
): Promise<void> {
|
|
52
|
-
const filePath =
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
: getProjectProfilesPath(projectPath);
|
|
66
|
+
const filePath = profilesPath(scope, projectPath);
|
|
67
|
+
// Read for the throw alone; the contents are about to be replaced.
|
|
68
|
+
await readProfilesFile(filePath);
|
|
56
69
|
await fs.ensureDir(path.dirname(filePath));
|
|
57
70
|
await fs.writeJson(filePath, data, { spaces: 2 });
|
|
58
71
|
}
|