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
|
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parseKeypress } from "@opentui/core";
|
|
4
4
|
import fs from "fs-extra";
|
|
5
|
+
import type { KeyPress } from "../ui/input/key-dispatch.js";
|
|
6
|
+
import { SCREEN_BINDINGS, matchBinding } from "../ui/keymap.js";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* A shifted key never arrives as an uppercase `name`.
|
|
@@ -81,21 +83,32 @@ describe("no binding compares name to an uppercase letter", () => {
|
|
|
81
83
|
expect(offenders).toEqual([]);
|
|
82
84
|
});
|
|
83
85
|
|
|
84
|
-
test("an uppercase binding is reachable only via the shift flag",
|
|
86
|
+
test("an uppercase binding is reachable only via the shift flag", () => {
|
|
85
87
|
// The positive half: Shift+U must actually route to update. Asserting only
|
|
86
88
|
// the absence of `=== "U"` would also pass if someone deleted the binding.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
// Keys are matched through the keymap now, so the check is on what a
|
|
90
|
+
// parsed keypress resolves to — and plain `u` must NOT resolve to update,
|
|
91
|
+
// which is the original bug the other way round.
|
|
92
|
+
const press = (raw: string): KeyPress => {
|
|
93
|
+
const k = parseKeypress(raw);
|
|
94
|
+
return {
|
|
95
|
+
name: k?.name ?? "",
|
|
96
|
+
sequence: k?.sequence ?? "",
|
|
97
|
+
ctrl: k?.ctrl === true,
|
|
98
|
+
meta: k?.meta === true,
|
|
99
|
+
shift: k?.shift === true,
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
expect(matchBinding(SCREEN_BINDINGS.plugins, press("U"))?.id).toBe(
|
|
103
|
+
"update",
|
|
90
104
|
);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
105
|
+
expect(matchBinding(SCREEN_BINDINGS.plugins, press("u"))?.id).toBe("scope");
|
|
106
|
+
// The registry's refresh, the one that silently did nothing.
|
|
107
|
+
expect(matchBinding(SCREEN_BINDINGS["mcp-registry"], press("R"))?.id).toBe(
|
|
108
|
+
"reload",
|
|
94
109
|
);
|
|
95
|
-
expect(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
// "u" branch swallows it again — which is exactly the original bug.
|
|
99
|
-
expect(update).toBeLessThan(userScope);
|
|
110
|
+
expect(
|
|
111
|
+
matchBinding(SCREEN_BINDINGS["mcp-registry"], press("r"))?.id,
|
|
112
|
+
).toBeUndefined();
|
|
100
113
|
});
|
|
101
114
|
});
|
package/src/cli/ansi.ts
CHANGED
|
@@ -346,12 +346,12 @@ export function truncate(text: string, n: number): string {
|
|
|
346
346
|
export const ramps = {
|
|
347
347
|
/**
|
|
348
348
|
* Work in progress to done. Purple is magus's brand, green is "settled",
|
|
349
|
-
* and the
|
|
349
|
+
* and the teal midtone is the chosen control point that stops a two-hue
|
|
350
350
|
* blend desaturating into grey at the halfway mark.
|
|
351
351
|
*/
|
|
352
352
|
progress: [
|
|
353
353
|
lighten(brand.accent, 0.15),
|
|
354
|
-
lighten(brand.
|
|
354
|
+
lighten(brand.info, 0.2),
|
|
355
355
|
lighten(brand.success, 0.25),
|
|
356
356
|
],
|
|
357
357
|
/**
|
package/src/cli/bootstrap.ts
CHANGED
|
@@ -31,12 +31,28 @@ import {
|
|
|
31
31
|
needsNothing,
|
|
32
32
|
parseProfileChoice,
|
|
33
33
|
} from "../services/profile-startup.js";
|
|
34
|
+
import { SettingsParseError } from "../services/settings-file.js";
|
|
34
35
|
import type { ProfileManifest } from "../types/index.js";
|
|
35
36
|
import { dim } from "./ansi.js";
|
|
36
37
|
import { confirm } from "./prompt.js";
|
|
37
38
|
import { selectDialog } from "./tui-prompt.js";
|
|
38
39
|
import { fail as failLine, note as noteLine } from "./update-view.js";
|
|
39
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Report a file that exists and cannot be used — it does not parse, or parses
|
|
43
|
+
* into the wrong shape — and that magus left it alone.
|
|
44
|
+
*
|
|
45
|
+
* One wording for every command that meets one — the message names the file
|
|
46
|
+
* and says what is wrong with it; the note says what the user has to do, since
|
|
47
|
+
* magus will not do it for them.
|
|
48
|
+
*/
|
|
49
|
+
export function reportUnparseable(error: SettingsParseError): void {
|
|
50
|
+
console.error(failLine(error.message));
|
|
51
|
+
console.error(
|
|
52
|
+
noteLine("Fix the JSON by hand; magus leaves the file as it is."),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
/**
|
|
41
57
|
* Write the adopted manifest, then put the project ON the profile it describes.
|
|
42
58
|
*
|
|
@@ -147,13 +163,22 @@ export interface EnsureManifestOptions {
|
|
|
147
163
|
* Return the project's manifest, creating one by adoption if it has none.
|
|
148
164
|
*
|
|
149
165
|
* Resolves to null when there is no manifest and none was created — the caller
|
|
150
|
-
* should exit non-zero. Anything it prints has already explained why.
|
|
166
|
+
* should exit non-zero. Anything it prints has already explained why. A
|
|
167
|
+
* manifest that exists and does not parse is one of those: it is reported, and
|
|
168
|
+
* never read as "none" — adoption below would write a new one over it.
|
|
151
169
|
*/
|
|
152
170
|
export async function ensureManifest(
|
|
153
171
|
projectPath: string,
|
|
154
172
|
opts: EnsureManifestOptions = {},
|
|
155
173
|
): Promise<ProfileManifest | null> {
|
|
156
|
-
|
|
174
|
+
let manifest: ProfileManifest;
|
|
175
|
+
try {
|
|
176
|
+
manifest = await readManifest(projectPath);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (!(error instanceof SettingsParseError)) throw error;
|
|
179
|
+
reportUnparseable(error);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
157
182
|
if (Object.keys(manifest.profiles).length > 0) return manifest;
|
|
158
183
|
|
|
159
184
|
const result = await adoptCurrentProject(projectPath);
|
|
@@ -226,6 +251,12 @@ export async function requireActiveProfile(
|
|
|
226
251
|
const need = await detectProfileStartupNeed(projectPath);
|
|
227
252
|
if (needsNothing(need)) return manifest;
|
|
228
253
|
|
|
254
|
+
// Nothing to ask: every option would read or write a file that does not parse.
|
|
255
|
+
if (need.parseError) {
|
|
256
|
+
reportUnparseable(need.parseError);
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
229
260
|
// The repo declares which profile it wants and there is nothing of the
|
|
230
261
|
// user's to replace — a fresh clone. Activate it and carry on, even under
|
|
231
262
|
// `--check` and with no TTY: this is the committed answer being applied, not
|
package/src/cli/doctor.ts
CHANGED
|
@@ -2,18 +2,27 @@
|
|
|
2
2
|
* `magus doctor [--fix]`
|
|
3
3
|
*
|
|
4
4
|
* Diagnoses (and with --fix, repairs) project health:
|
|
5
|
+
* 0. Global settings — `~/.claude/settings.json` parses.
|
|
5
6
|
* 1. Binary dependencies — every requires.bin / cliTools binary on PATH.
|
|
6
7
|
* This is the check that would have caught a dead `tmux-mcp`.
|
|
7
8
|
* 2. Profile symlink integrity — the active profile's links resolve.
|
|
8
9
|
* 3. Convention compliance — .gitignore + CLAUDE.md blocks (ported engine).
|
|
9
10
|
* 4. Model routing — config validity, hook registration, and drift.
|
|
10
11
|
* 5. Plugin loads — what Claude Code will load here vs what is installed.
|
|
12
|
+
*
|
|
13
|
+
* A file that exists and does not parse — the global settings, the project's
|
|
14
|
+
* `.claude/profiles.json` or its `.claude/settings.json` — is a finding in the
|
|
15
|
+
* section that meets it, never an abort: the rest of the report is still worth
|
|
16
|
+
* having. `--fix` repairs none of them, because magus never rewrites a file it
|
|
17
|
+
* cannot parse.
|
|
11
18
|
*/
|
|
12
19
|
|
|
13
20
|
import path from "node:path";
|
|
14
21
|
import fs from "fs-extra";
|
|
15
22
|
import {
|
|
23
|
+
SettingsParseError,
|
|
16
24
|
getProjectInstalledPluginVersions,
|
|
25
|
+
readGlobalSettings,
|
|
17
26
|
readInstalledPluginsRegistry,
|
|
18
27
|
readSettings,
|
|
19
28
|
} from "../services/claude-settings.js";
|
|
@@ -35,17 +44,35 @@ import {
|
|
|
35
44
|
} from "../services/plugin-resolution.js";
|
|
36
45
|
import { resolveAllProfiles } from "../services/resolver.js";
|
|
37
46
|
import { activeProfile, profileDir } from "../services/symlink-manager.js";
|
|
47
|
+
import type { ClaudeSettings, ProfileManifest } from "../types/index.js";
|
|
38
48
|
import { resolveExecutable } from "../utils/command-utils.js";
|
|
39
49
|
|
|
40
50
|
export async function runDoctorCommand(args: string[]): Promise<number> {
|
|
41
51
|
const fix = args.includes("--fix");
|
|
42
52
|
const projectPath = process.cwd();
|
|
43
53
|
let problems = 0;
|
|
54
|
+
// Findings `--fix` does not repair, counted apart so it cannot silence
|
|
55
|
+
// them — see doctorExitCode.
|
|
56
|
+
let unfixable = 0;
|
|
57
|
+
|
|
58
|
+
// 0. Global settings. Its own check, first, because most of doctor never
|
|
59
|
+
// reads that file: a project without model routing reached it nowhere and
|
|
60
|
+
// reported "No problems found" over a damaged one, while the one section
|
|
61
|
+
// that did — hook registration under --fix — aborted the whole run on it.
|
|
62
|
+
const globalSettingsError = await checkGlobalSettings();
|
|
63
|
+
if (globalSettingsError) unfixable++;
|
|
44
64
|
|
|
45
|
-
// 1. Binary dependencies.
|
|
46
|
-
|
|
65
|
+
// 1. Binary dependencies, which needs the manifest to know what to check.
|
|
66
|
+
let manifest: ProfileManifest | null = null;
|
|
67
|
+
try {
|
|
68
|
+
manifest = await readManifest(projectPath);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (!(error instanceof SettingsParseError)) throw error;
|
|
71
|
+
reportUnparseable("Profiles", error);
|
|
72
|
+
unfixable++;
|
|
73
|
+
}
|
|
47
74
|
let pluginIds: string[] = [];
|
|
48
|
-
if (Object.keys(manifest.profiles).length > 0) {
|
|
75
|
+
if (manifest && Object.keys(manifest.profiles).length > 0) {
|
|
49
76
|
const closure = await resolveAllProfiles(manifest);
|
|
50
77
|
pluginIds = Object.keys(closure.plugins);
|
|
51
78
|
if (closure.bins.length > 0) {
|
|
@@ -120,14 +147,43 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
|
|
|
120
147
|
}
|
|
121
148
|
|
|
122
149
|
// 4. Model routing.
|
|
123
|
-
|
|
150
|
+
const models = await checkModels(projectPath, fix, globalSettingsError);
|
|
151
|
+
problems += models.fixable;
|
|
152
|
+
unfixable += models.unfixable;
|
|
124
153
|
|
|
125
154
|
// 5. Stale plugin loads. Counted SEPARATELY, because `--fix` does not repair
|
|
126
155
|
// them — see doctorExitCode.
|
|
127
|
-
|
|
156
|
+
unfixable += await checkPluginLoads(projectPath);
|
|
157
|
+
|
|
158
|
+
if (problems === 0 && unfixable === 0) console.log("\n✓ No problems found.");
|
|
159
|
+
return doctorExitCode({ fixable: problems, unfixable, fix });
|
|
160
|
+
}
|
|
128
161
|
|
|
129
|
-
|
|
130
|
-
|
|
162
|
+
/**
|
|
163
|
+
* A file that exists and does not parse, as a finding under `heading`.
|
|
164
|
+
*
|
|
165
|
+
* The same three lines wherever one turns up, so a damaged file reads the same
|
|
166
|
+
* whichever section met it.
|
|
167
|
+
*/
|
|
168
|
+
function reportUnparseable(heading: string, error: SettingsParseError): void {
|
|
169
|
+
console.log(`\n${heading}:`);
|
|
170
|
+
console.log(` ✗ ${error.message}`);
|
|
171
|
+
console.log(" Fix the JSON by hand; magus leaves the file as it is.");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The global settings file, if it does not parse — reported, and returned so
|
|
176
|
+
* the hook check can say why it cannot answer instead of guessing.
|
|
177
|
+
*/
|
|
178
|
+
async function checkGlobalSettings(): Promise<SettingsParseError | null> {
|
|
179
|
+
try {
|
|
180
|
+
await readGlobalSettings();
|
|
181
|
+
return null;
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (!(error instanceof SettingsParseError)) throw error;
|
|
184
|
+
reportUnparseable("Global settings", error);
|
|
185
|
+
return error;
|
|
186
|
+
}
|
|
131
187
|
}
|
|
132
188
|
|
|
133
189
|
/**
|
|
@@ -177,7 +233,14 @@ export function doctorExitCode(args: {
|
|
|
177
233
|
*/
|
|
178
234
|
async function checkPluginLoads(projectPath: string): Promise<number> {
|
|
179
235
|
const registry = await readInstalledPluginsRegistry();
|
|
180
|
-
|
|
236
|
+
let settings: ClaudeSettings;
|
|
237
|
+
try {
|
|
238
|
+
settings = await readSettings(projectPath);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (!(error instanceof SettingsParseError)) throw error;
|
|
241
|
+
reportUnparseable("Plugin loads", error);
|
|
242
|
+
return 1;
|
|
243
|
+
}
|
|
181
244
|
const installed = await getProjectInstalledPluginVersions(
|
|
182
245
|
settings.installedPluginVersions || {},
|
|
183
246
|
projectPath,
|
|
@@ -249,13 +312,24 @@ async function checkPluginLoads(projectPath: string): Promise<number> {
|
|
|
249
312
|
* fixed from here (the values are a human's decision); an unregistered hook
|
|
250
313
|
* routes nothing no matter how good the config is; drift only matters once
|
|
251
314
|
* both of those are sound.
|
|
315
|
+
*
|
|
316
|
+
* `fixable` is what `--fix` can act on; `unfixable` is what it cannot, which
|
|
317
|
+
* is a hook whose settings file does not parse.
|
|
252
318
|
*/
|
|
253
|
-
async function checkModels(
|
|
319
|
+
async function checkModels(
|
|
320
|
+
projectPath: string,
|
|
321
|
+
fix: boolean,
|
|
322
|
+
/** From `checkGlobalSettings`: the global settings file does not parse. */
|
|
323
|
+
globalSettingsError: SettingsParseError | null,
|
|
324
|
+
): Promise<{ fixable: number; unfixable: number }> {
|
|
254
325
|
const { config, errors, path: file } = await readModelsConfig(projectPath);
|
|
255
|
-
if (config === null && errors.length === 0)
|
|
326
|
+
if (config === null && errors.length === 0) {
|
|
327
|
+
return { fixable: 0, unfixable: 0 };
|
|
328
|
+
}
|
|
256
329
|
|
|
257
330
|
console.log("\nModel routing:");
|
|
258
331
|
let problems = 0;
|
|
332
|
+
let unfixable = 0;
|
|
259
333
|
|
|
260
334
|
if (errors.length > 0) {
|
|
261
335
|
console.log(` ✗ ${file} is invalid — nothing is routed:`);
|
|
@@ -268,15 +342,32 @@ async function checkModels(projectPath: string, fix: boolean): Promise<number> {
|
|
|
268
342
|
console.log(" re-run `magus models use <preset>`) and check again.");
|
|
269
343
|
// An invalid config is worth exactly one problem, not one per message —
|
|
270
344
|
// the count drives the exit code, not the severity.
|
|
271
|
-
return 1;
|
|
345
|
+
return { fixable: 1, unfixable: 0 };
|
|
272
346
|
}
|
|
273
347
|
|
|
274
348
|
// The hook is what applies the routing. Without it the config is inert.
|
|
275
349
|
if (await isAgentModelHookRegistered()) {
|
|
276
350
|
console.log(" ✓ agent-model hook registered at user scope.");
|
|
277
351
|
} else if (fix) {
|
|
278
|
-
|
|
279
|
-
|
|
352
|
+
try {
|
|
353
|
+
await registerAgentModelHook();
|
|
354
|
+
console.log(" ✓ agent-model hook registered at user scope (fixed).");
|
|
355
|
+
} catch (error) {
|
|
356
|
+
// Registering writes the settings file, and one that does not parse is
|
|
357
|
+
// never written. This used to escape and abort the whole run.
|
|
358
|
+
if (!(error instanceof SettingsParseError)) throw error;
|
|
359
|
+
console.log(
|
|
360
|
+
` ✗ agent-model hook not registered: ${error.path} ${error.problem}, and magus does not write a file it cannot read.`,
|
|
361
|
+
);
|
|
362
|
+
unfixable++;
|
|
363
|
+
}
|
|
364
|
+
} else if (globalSettingsError) {
|
|
365
|
+
// `isAgentModelHookRegistered` reads an unparseable file as "not
|
|
366
|
+
// registered". Offering `--fix` for that would offer a write magus refuses.
|
|
367
|
+
console.log(
|
|
368
|
+
` ✗ agent-model hook: cannot tell whether it is registered — the global settings file ${globalSettingsError.problem} (see above).`,
|
|
369
|
+
);
|
|
370
|
+
unfixable++;
|
|
280
371
|
} else {
|
|
281
372
|
console.log(
|
|
282
373
|
" ✗ agent-model hook is not registered — the config is there, but nothing runs it.",
|
|
@@ -310,7 +401,7 @@ async function checkModels(projectPath: string, fix: boolean): Promise<number> {
|
|
|
310
401
|
|
|
311
402
|
for (const warning of status.warnings) console.log(` ⚠ ${warning}`);
|
|
312
403
|
|
|
313
|
-
return problems;
|
|
404
|
+
return { fixable: problems, unfixable };
|
|
314
405
|
}
|
|
315
406
|
|
|
316
407
|
/** Which of the active profile's expected symlinks are missing or dangling. */
|
package/src/cli/install.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import { installPlugin, isClaudeAvailable } from "../services/claude-cli.js";
|
|
17
17
|
import {
|
|
18
|
+
getLocalSettingsPath,
|
|
18
19
|
readInstalledPluginsRegistry,
|
|
19
20
|
readLocalSettings,
|
|
20
21
|
writeLocalSettings,
|
|
@@ -31,6 +32,7 @@ import { reportDisplaced } from "../services/profile-activate.js";
|
|
|
31
32
|
import { materializeProfile } from "../services/profile-materializer.js";
|
|
32
33
|
import { detectProfileDrift } from "../services/profile-sync.js";
|
|
33
34
|
import { resolveAllProfiles, resolveProfile } from "../services/resolver.js";
|
|
35
|
+
import { writableMap } from "../services/settings-file.js";
|
|
34
36
|
import { installSkill } from "../services/skills-manager.js";
|
|
35
37
|
import {
|
|
36
38
|
PROFILE_GITIGNORE_ENTRIES,
|
|
@@ -229,10 +231,14 @@ async function collectEnv(
|
|
|
229
231
|
}
|
|
230
232
|
|
|
231
233
|
const local = await readLocalSettings(projectPath);
|
|
232
|
-
|
|
234
|
+
// Checked before the first prompt, so a malformed `env` is refused before the
|
|
235
|
+
// user types anything, not after — and never replaced.
|
|
236
|
+
const env =
|
|
237
|
+
writableMap(local, "env", getLocalSettingsPath(projectPath)) ?? {};
|
|
238
|
+
local.env = env;
|
|
233
239
|
for (const name of status.missingRequired) {
|
|
234
240
|
const value = await promptValue(` ${name} = `);
|
|
235
|
-
if (value)
|
|
241
|
+
if (value) env[name] = value;
|
|
236
242
|
}
|
|
237
243
|
await writeLocalSettings(local, projectPath);
|
|
238
244
|
console.log("Saved to .claude/settings.local.json (personal, gitignored).");
|
package/src/cli/profile.ts
CHANGED
|
@@ -16,15 +16,32 @@ import { getManifestPath, readManifest } from "../services/manifest.js";
|
|
|
16
16
|
import { reportDisplaced } from "../services/profile-activate.js";
|
|
17
17
|
import { syncProfile } from "../services/profile-sync.js";
|
|
18
18
|
import { resolveProfile } from "../services/resolver.js";
|
|
19
|
+
import { SettingsParseError } from "../services/settings-file.js";
|
|
19
20
|
import {
|
|
20
21
|
PROFILE_GITIGNORE_ENTRIES,
|
|
21
22
|
activateProfile,
|
|
22
23
|
activeProfile,
|
|
23
24
|
profileDir,
|
|
24
25
|
} from "../services/symlink-manager.js";
|
|
25
|
-
import { ensureManifest } from "./bootstrap.js";
|
|
26
|
+
import { ensureManifest, reportUnparseable } from "./bootstrap.js";
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Every subcommand reads `.claude/profiles.json`, and one that does not parse
|
|
30
|
+
* is a report and exit 1 for all of them — named once here rather than guarded
|
|
31
|
+
* per subcommand. It never reads as "no profiles": `list` said exactly that,
|
|
32
|
+
* and `init` would then have written a new manifest over the file.
|
|
33
|
+
*/
|
|
27
34
|
export async function runProfileCommand(args: string[]): Promise<number> {
|
|
35
|
+
try {
|
|
36
|
+
return await dispatchProfileCommand(args);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (!(error instanceof SettingsParseError)) throw error;
|
|
39
|
+
reportUnparseable(error);
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function dispatchProfileCommand(args: string[]): Promise<number> {
|
|
28
45
|
const [sub, ...rest] = args;
|
|
29
46
|
const projectPath = process.cwd();
|
|
30
47
|
|
package/src/cli/router.ts
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { ThemeMode } from "@opentui/core";
|
|
10
|
+
import { SettingsParseError } from "../services/settings-file.js";
|
|
10
11
|
import { checkForUpdates } from "../services/version-check.js";
|
|
11
12
|
import { type ThemeEnv, parseThemeWord } from "../ui/theme-resolve.js";
|
|
13
|
+
import { reportUnparseable } from "./bootstrap.js";
|
|
12
14
|
import { runDoctorCommand } from "./doctor.js";
|
|
13
15
|
import { runInstallCommand } from "./install.js";
|
|
14
16
|
import { runModelsCommand } from "./models.js";
|
|
@@ -171,24 +173,35 @@ export async function route(
|
|
|
171
173
|
setThemeMode(mode);
|
|
172
174
|
}
|
|
173
175
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
176
|
+
try {
|
|
177
|
+
switch (command) {
|
|
178
|
+
case "update":
|
|
179
|
+
return HANDLED(await runUpdateCommand(rest));
|
|
180
|
+
case "upgrade":
|
|
181
|
+
return HANDLED(await runUpgradeCommand());
|
|
182
|
+
case "install":
|
|
183
|
+
return HANDLED(await runInstallCommand(rest));
|
|
184
|
+
case "profile":
|
|
185
|
+
return HANDLED(await runProfileCommand(rest));
|
|
186
|
+
case "models":
|
|
187
|
+
return HANDLED(await runModelsCommand(rest));
|
|
188
|
+
case "doctor":
|
|
189
|
+
return HANDLED(await runDoctorCommand(rest));
|
|
190
|
+
default:
|
|
191
|
+
// Bare `magus` or an unrecognized token → interactive TUI.
|
|
192
|
+
return themeFlag.theme === undefined
|
|
193
|
+
? LAUNCH_TUI
|
|
194
|
+
: { ...LAUNCH_TUI, theme: themeFlag.theme };
|
|
195
|
+
}
|
|
196
|
+
} catch (error) {
|
|
197
|
+
// A settings or manifest file that does not parse is the user's to fix, not a
|
|
198
|
+
// crash. Every writer already refuses to touch it; this is the one place that
|
|
199
|
+
// turns the refusal into a message for commands with no handling of their own.
|
|
200
|
+
if (error instanceof SettingsParseError) {
|
|
201
|
+
reportUnparseable(error);
|
|
202
|
+
return HANDLED(1);
|
|
203
|
+
}
|
|
204
|
+
throw error;
|
|
192
205
|
}
|
|
193
206
|
}
|
|
194
207
|
|
package/src/cli/update-view.ts
CHANGED
|
@@ -86,7 +86,7 @@ type ActionKey =
|
|
|
86
86
|
|
|
87
87
|
const ACTIONS: Record<ActionKey, ActionStyle> = {
|
|
88
88
|
current: { glyph: "=", label: "CURRENT", color: brand.muted, chrome: true },
|
|
89
|
-
install: { glyph: "+", label: "INSTALL", color: brand.
|
|
89
|
+
install: { glyph: "+", label: "INSTALL", color: brand.info },
|
|
90
90
|
// Green, the tone magus uses for an update everywhere else. Amber stays
|
|
91
91
|
// on `unknown` below, so the two states remain distinguishable: green is
|
|
92
92
|
// "a newer version exists", amber is "we could not tell".
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -6,19 +6,49 @@
|
|
|
6
6
|
* and advancing the tool itself moved here.
|
|
7
7
|
*
|
|
8
8
|
* Detects magus installs across bun + npm global, warns on duplicates
|
|
9
|
-
* (active vs shadowed), then re-installs `
|
|
10
|
-
* package manager owns the active binary. Resolves
|
|
9
|
+
* (active vs shadowed), then re-installs the npm package (`MAGUS_NPM_PACKAGE`)
|
|
10
|
+
* at latest with whichever package manager owns the active binary. Resolves
|
|
11
|
+
* with the child's exit code.
|
|
12
|
+
*
|
|
13
|
+
* The npm package is not the command. The binary on disk and `which magus`
|
|
14
|
+
* stay `magus`; only what is handed to the package manager uses the package
|
|
15
|
+
* name, because bare `magus` on npm is an unrelated package.
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
18
|
import { spawn } from "node:child_process";
|
|
14
19
|
import { constants } from "node:os";
|
|
20
|
+
import { MAGUS_NPM_PACKAGE } from "../data/npm-package.js";
|
|
21
|
+
import type { Command } from "../utils/command-utils.js";
|
|
22
|
+
|
|
23
|
+
/** The package managers `magus upgrade` detects and upgrades through. */
|
|
24
|
+
export type SelfUpgradeManager = "bun" | "npm";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The self-upgrade command for a package manager. Pure: no I/O, no PATH probe.
|
|
28
|
+
*/
|
|
29
|
+
export function selfUpgradeCommand(manager: SelfUpgradeManager): Command {
|
|
30
|
+
return {
|
|
31
|
+
cmd: manager,
|
|
32
|
+
args: ["install", "-g", `${MAGUS_NPM_PACKAGE}@latest`],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The command that removes a duplicate global install, for the hint printed
|
|
38
|
+
* when magus is installed through both package managers. No leading
|
|
39
|
+
* indentation — the caller indents.
|
|
40
|
+
*/
|
|
41
|
+
export function duplicateInstallRemoval(manager: SelfUpgradeManager): string {
|
|
42
|
+
return `${manager} uninstall -g ${MAGUS_NPM_PACKAGE}`;
|
|
43
|
+
}
|
|
15
44
|
|
|
16
45
|
export async function runUpgradeCommand(): Promise<number> {
|
|
17
46
|
const { execSync } = await import("node:child_process");
|
|
18
47
|
const { existsSync } = await import("node:fs");
|
|
19
48
|
|
|
20
49
|
// Detect all installations of magus across package managers
|
|
21
|
-
const installations: Array<{ manager:
|
|
50
|
+
const installations: Array<{ manager: SelfUpgradeManager; path: string }> =
|
|
51
|
+
[];
|
|
22
52
|
|
|
23
53
|
// Check bun global
|
|
24
54
|
try {
|
|
@@ -60,7 +90,7 @@ export async function runUpgradeCommand(): Promise<number> {
|
|
|
60
90
|
}
|
|
61
91
|
console.log("\nTo fix, keep one and remove the other:");
|
|
62
92
|
for (const inst of installations) {
|
|
63
|
-
console.log(` ${inst.manager}
|
|
93
|
+
console.log(` ${duplicateInstallRemoval(inst.manager)}`);
|
|
64
94
|
}
|
|
65
95
|
console.log();
|
|
66
96
|
}
|
|
@@ -76,13 +106,11 @@ export async function runUpgradeCommand(): Promise<number> {
|
|
|
76
106
|
// If which fails, default to npm
|
|
77
107
|
}
|
|
78
108
|
|
|
79
|
-
const
|
|
80
|
-
console.log(`Updating magus using ${
|
|
81
|
-
|
|
82
|
-
const installArgs = ["install", "-g", "magus@latest"];
|
|
109
|
+
const { cmd, args } = selfUpgradeCommand(usesBun ? "bun" : "npm");
|
|
110
|
+
console.log(`Updating magus using ${cmd}...`);
|
|
83
111
|
|
|
84
112
|
return new Promise<number>((resolve) => {
|
|
85
|
-
const proc = spawn(
|
|
113
|
+
const proc = spawn(cmd, args, {
|
|
86
114
|
stdio: "inherit",
|
|
87
115
|
shell: false, // Avoid shell for security (fixes DEP0190 warning)
|
|
88
116
|
});
|
package/src/data/cli-tools.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BinInstaller } from "../types/index.js";
|
|
2
|
+
import { MAGUS_NPM_PACKAGE } from "./npm-package.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* A CLI tool magus can install, check and update.
|
|
@@ -28,6 +29,14 @@ export interface CliTool {
|
|
|
28
29
|
packageName: string;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Claude Code's npm package. Named once because two readers need it: the
|
|
34
|
+
* catalogue entry below, and the probe, which asks this package's `latest`
|
|
35
|
+
* dist-tag for a copy the native installer put on PATH — that copy has no
|
|
36
|
+
* package.json to say which package it is.
|
|
37
|
+
*/
|
|
38
|
+
export const CLAUDE_CODE_NPM_PACKAGE = "@anthropic-ai/claude-code";
|
|
39
|
+
|
|
31
40
|
export const cliTools: CliTool[] = [
|
|
32
41
|
{
|
|
33
42
|
name: "magus",
|
|
@@ -38,7 +47,9 @@ export const cliTools: CliTool[] = [
|
|
|
38
47
|
website: "https://github.com/MadAppGang/magus/tree/main/tools/magus",
|
|
39
48
|
category: "ai-coding",
|
|
40
49
|
packageManager: "bun",
|
|
41
|
-
|
|
50
|
+
// The npm package (`magus-cli`), not the command above: bare `magus` on
|
|
51
|
+
// npm is an unrelated package.
|
|
52
|
+
packageName: MAGUS_NPM_PACKAGE,
|
|
42
53
|
},
|
|
43
54
|
{
|
|
44
55
|
name: "mnemex",
|
|
@@ -69,7 +80,7 @@ export const cliTools: CliTool[] = [
|
|
|
69
80
|
website: "https://claude.ai/code",
|
|
70
81
|
category: "ai-coding",
|
|
71
82
|
packageManager: "npm",
|
|
72
|
-
packageName:
|
|
83
|
+
packageName: CLAUDE_CODE_NPM_PACKAGE,
|
|
73
84
|
},
|
|
74
85
|
{
|
|
75
86
|
name: "codex",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import pkg from "../../package.json";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The npm package magus is published as. It is NOT the command.
|
|
5
|
+
*
|
|
6
|
+
* The command users type is `magus` (package.json `bin`). The npm package is
|
|
7
|
+
* `magus-cli`, because the bare `magus` name on npm belongs to an unrelated
|
|
8
|
+
* 2015 package, and npm never transfers names. Anything that talks to npm —
|
|
9
|
+
* the registry check, the self-upgrade, the CLI Tools row — must use this, and
|
|
10
|
+
* `git grep MAGUS_NPM_PACKAGE` lists every such call site.
|
|
11
|
+
*
|
|
12
|
+
* Static import so it resolves inside a `bun build --compile` binary — a
|
|
13
|
+
* dynamic require("../../package.json") returns nothing usable there.
|
|
14
|
+
*/
|
|
15
|
+
export const MAGUS_NPM_PACKAGE: string = (pkg as { name: string }).name;
|