magus-cli 7.3.1 → 7.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- 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/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -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__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/stale-load-repair.test.ts +5 -4
- 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__/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 +120 -15
- package/src/cli/install.ts +35 -4
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/data/cli-tools.ts +9 -1
- package/src/services/claude-settings.ts +434 -198
- package/src/services/cli-tool-commands.ts +45 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/gitignore-service.ts +39 -22
- 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 +164 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +316 -44
- 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/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 +107 -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 +304 -0
- package/src/ui/input/key-dispatch.ts +197 -0
- package/src/ui/keymap.ts +905 -0
- package/src/ui/renderers/cliToolRenderers.tsx +192 -92
- 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 +173 -101
- 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 +129 -129
- 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
|
@@ -37,8 +37,10 @@ import {
|
|
|
37
37
|
* to follow what is actually on the machine, or it installs a second copy
|
|
38
38
|
* beside the first and the screen then reports a conflict it created itself.
|
|
39
39
|
*
|
|
40
|
-
* `pnpm`, `yarn` and `unknown` have no `BinInstaller` counterpart,
|
|
41
|
-
* the two unions stay separate rather than one being widened into
|
|
40
|
+
* `pnpm`, `yarn`, `native` and `unknown` have no `BinInstaller` counterpart,
|
|
41
|
+
* which is why the two unions stay separate rather than one being widened into
|
|
42
|
+
* the other. `native` is a tool's own installer — today Claude Code's, which
|
|
43
|
+
* keeps its releases under `~/.local/share/claude/versions/` and updates itself.
|
|
42
44
|
*/
|
|
43
45
|
export type InstallMethod =
|
|
44
46
|
| "npm"
|
|
@@ -47,6 +49,7 @@ export type InstallMethod =
|
|
|
47
49
|
| "yarn"
|
|
48
50
|
| "brew"
|
|
49
51
|
| "pip"
|
|
52
|
+
| "native"
|
|
50
53
|
| "unknown";
|
|
51
54
|
|
|
52
55
|
/**
|
|
@@ -75,6 +78,10 @@ export function cliToolInstall(tool: CliTool): Command {
|
|
|
75
78
|
* the detector could not attribute the binary on PATH to any package manager,
|
|
76
79
|
* and guessing there would install a second copy alongside the one already
|
|
77
80
|
* there. Callers fall back to installing.
|
|
81
|
+
*
|
|
82
|
+
* `native` is null too: a tool's own updater is not derivable from the method
|
|
83
|
+
* name. The probe builds it (`ToolFacts.upgradeCommand`, e.g. `claude update`)
|
|
84
|
+
* and the row takes it from there, ahead of this function.
|
|
78
85
|
*/
|
|
79
86
|
export function cliToolUpdate(
|
|
80
87
|
tool: CliTool,
|
|
@@ -82,12 +89,13 @@ export function cliToolUpdate(
|
|
|
82
89
|
brewFormula?: string,
|
|
83
90
|
): Command | null {
|
|
84
91
|
switch (method) {
|
|
85
|
-
// The
|
|
92
|
+
// The ones the resolver does not model. `pnpm`/`yarn` are not
|
|
86
93
|
// BinInstallers because magus never chooses them — it only finds them.
|
|
87
94
|
case "pnpm":
|
|
88
95
|
return { cmd: "pnpm", args: ["install", "-g", tool.packageName] };
|
|
89
96
|
case "yarn":
|
|
90
97
|
return { cmd: "yarn", args: ["global", "add", tool.packageName] };
|
|
98
|
+
case "native":
|
|
91
99
|
case "unknown":
|
|
92
100
|
return null;
|
|
93
101
|
default:
|
|
@@ -107,8 +115,9 @@ export function cliToolUpdate(
|
|
|
107
115
|
/**
|
|
108
116
|
* Remove a tool, following the method it was actually installed with.
|
|
109
117
|
*
|
|
110
|
-
* Null means "no command to offer": `unknown` for the reason above,
|
|
111
|
-
* because a
|
|
118
|
+
* Null means "no command to offer": `unknown` for the reason above, `native`
|
|
119
|
+
* because a self-installed tool ships no uninstall command, and `go` because a
|
|
120
|
+
* `go install` binary has no uninstaller to call.
|
|
112
121
|
*/
|
|
113
122
|
export function cliToolUninstall(
|
|
114
123
|
tool: CliTool,
|
|
@@ -120,6 +129,7 @@ export function cliToolUninstall(
|
|
|
120
129
|
return { cmd: "pnpm", args: ["remove", "-g", tool.packageName] };
|
|
121
130
|
case "yarn":
|
|
122
131
|
return { cmd: "yarn", args: ["global", "remove", tool.packageName] };
|
|
132
|
+
case "native":
|
|
123
133
|
case "unknown":
|
|
124
134
|
return null;
|
|
125
135
|
default:
|
|
@@ -131,3 +141,33 @@ export function cliToolUninstall(
|
|
|
131
141
|
});
|
|
132
142
|
}
|
|
133
143
|
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Why magus cannot remove an install made this way — or null when it can.
|
|
147
|
+
*
|
|
148
|
+
* Derived from {@link cliToolUninstall}, never from a second list of methods:
|
|
149
|
+
* "is there a command" and "why is there no command" have to answer about the
|
|
150
|
+
* same thing, or the conflict fix offers a removal it then skips. That is the
|
|
151
|
+
* defect this exists for — the fix used to list every install as removable,
|
|
152
|
+
* `continue` past the ones with no command, and report success.
|
|
153
|
+
*
|
|
154
|
+
* The text is a clause, to be read after "the <method> install stays:".
|
|
155
|
+
*/
|
|
156
|
+
export function uninstallBlocker(
|
|
157
|
+
tool: CliTool,
|
|
158
|
+
method: InstallMethod,
|
|
159
|
+
brewFormula?: string,
|
|
160
|
+
): string | null {
|
|
161
|
+
if (cliToolUninstall(tool, method, brewFormula) !== null) return null;
|
|
162
|
+
switch (method) {
|
|
163
|
+
case "native":
|
|
164
|
+
// Claude Code's native installer. It updates itself and ships no
|
|
165
|
+
// uninstall command; inventing one would mean deleting another
|
|
166
|
+
// program's files by hand.
|
|
167
|
+
return "its own installer ships no uninstall command";
|
|
168
|
+
case "unknown":
|
|
169
|
+
return "magus could not tell which installer put it there";
|
|
170
|
+
default:
|
|
171
|
+
return `magus has no uninstall command for a ${method} install`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The version of the npm/bun package a binary on PATH belongs to.
|
|
6
|
+
*
|
|
7
|
+
* A global install leaves `<prefix>/bin/<tool>` as a symlink into
|
|
8
|
+
* `<prefix>/lib/node_modules/<package>/…` (npm) or
|
|
9
|
+
* `~/.bun/install/global/node_modules/<package>/…` (bun). The package's own
|
|
10
|
+
* `package.json` is therefore the authority on what is installed, and reading
|
|
11
|
+
* it needs neither an installer record nor running the tool.
|
|
12
|
+
*
|
|
13
|
+
* The walk starts from the binary's REAL location — the symlink is resolved
|
|
14
|
+
* first, chain and all — because the directory the symlink sits in can belong
|
|
15
|
+
* to some unrelated project whose `package.json` would answer wrongly. The
|
|
16
|
+
* nearest `package.json` carrying both `name` and `version` wins; the partial
|
|
17
|
+
* ones a build leaves in `dist/` (`{"type":"module"}`) are skipped.
|
|
18
|
+
*
|
|
19
|
+
* Never throws: a missing binary, a dangling link, an unreadable or unparseable
|
|
20
|
+
* manifest, and "no package at all" are all `null`.
|
|
21
|
+
*/
|
|
22
|
+
export function readPackageVersionForBin(binPath: string): string | null {
|
|
23
|
+
let real: string;
|
|
24
|
+
try {
|
|
25
|
+
real = realpathSync(binPath);
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let dir = path.dirname(real);
|
|
31
|
+
for (;;) {
|
|
32
|
+
const version = qualifyingVersion(path.join(dir, "package.json"));
|
|
33
|
+
if (version !== undefined) return version;
|
|
34
|
+
const parent = path.dirname(dir);
|
|
35
|
+
if (parent === dir) return null;
|
|
36
|
+
dir = parent;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A bare release number, as a versioned install directory names its files. */
|
|
41
|
+
const RELEASE = /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The version a VERSIONED INSTALL names its binary by, or `null`.
|
|
45
|
+
*
|
|
46
|
+
* Some installers keep every release side by side and point PATH at one:
|
|
47
|
+
* Claude Code's native installer links `~/.local/bin/claude` to
|
|
48
|
+
* `~/.local/share/claude/versions/2.1.276`. There is no package.json there to
|
|
49
|
+
* read, but the file's own name IS the version — accepted only when the real
|
|
50
|
+
* path's parent directory is literally `versions` and the name is a bare
|
|
51
|
+
* release number, so an ordinary binary called `1.0.0` is not misread.
|
|
52
|
+
*
|
|
53
|
+
* Reads no file and runs nothing; never throws.
|
|
54
|
+
*/
|
|
55
|
+
export function readVersionDirForBin(binPath: string): string | null {
|
|
56
|
+
let real: string;
|
|
57
|
+
try {
|
|
58
|
+
real = realpathSync(binPath);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return versionFromVersionedPath(real);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The same reading as {@link readVersionDirForBin}, from a path that is ALREADY
|
|
67
|
+
* real — pure string work, no filesystem. The probe resolves paths through its
|
|
68
|
+
* own IO seam and must not reach past it to the real disk.
|
|
69
|
+
*/
|
|
70
|
+
export function versionFromVersionedPath(realPath: string): string | null {
|
|
71
|
+
const name = path.basename(realPath);
|
|
72
|
+
if (path.basename(path.dirname(realPath)) !== "versions") return null;
|
|
73
|
+
return RELEASE.test(name) ? name.replace(/^v/, "") : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The manifest's version when it names both a package and a version.
|
|
78
|
+
*
|
|
79
|
+
* `undefined` means "not a package root, keep walking"; `null` means "this is
|
|
80
|
+
* the package root, and it cannot be read" — the walk stops there rather than
|
|
81
|
+
* letting an enclosing workspace answer for a package it does not own.
|
|
82
|
+
*/
|
|
83
|
+
function qualifyingVersion(file: string): string | null | undefined {
|
|
84
|
+
let text: string;
|
|
85
|
+
try {
|
|
86
|
+
text = readFileSync(file, "utf8");
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
let manifest: unknown;
|
|
91
|
+
try {
|
|
92
|
+
manifest = JSON.parse(text);
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
if (manifest === null || typeof manifest !== "object") return undefined;
|
|
97
|
+
const { name, version } = manifest as Record<string, unknown>;
|
|
98
|
+
if (typeof name !== "string" || name === "") return undefined;
|
|
99
|
+
if (typeof version !== "string" || version === "") return undefined;
|
|
100
|
+
return version;
|
|
101
|
+
}
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
* the Gitignore screen needs in one call, and exposes a small set of
|
|
4
4
|
* action helpers (apply safe fixes, apply one destructive fix, apply a
|
|
5
5
|
* built-in template).
|
|
6
|
+
*
|
|
7
|
+
* `.claude/gitignore.json` is read through `settings-file.ts`, the same refusal
|
|
8
|
+
* every settings file gets: applying a template REWRITES that file, so a copy
|
|
9
|
+
* we could not parse must never be the one it is rewritten from — see
|
|
10
|
+
* {@link readProjectManifest}.
|
|
6
11
|
*/
|
|
7
12
|
|
|
8
13
|
import { existsSync } from "node:fs";
|
|
@@ -22,6 +27,7 @@ import {
|
|
|
22
27
|
applySafeFixes as coreApplySafeFixes,
|
|
23
28
|
} from "./gitignore-fixer.js";
|
|
24
29
|
import { resolveGitignore } from "./gitignore-resolver.js";
|
|
30
|
+
import { readSettingsFile, writableList } from "./settings-file.js";
|
|
25
31
|
|
|
26
32
|
export interface GitignoreState {
|
|
27
33
|
resolved: ResolvedManifest;
|
|
@@ -97,7 +103,9 @@ export async function applyTemplate(
|
|
|
97
103
|
if (!tpl) throw new Error(`Unknown template: ${templateName}`);
|
|
98
104
|
|
|
99
105
|
const projectManifestPath = join(cwd, ".claude", "gitignore.json");
|
|
100
|
-
|
|
106
|
+
// Before anything is merged: a manifest we cannot read is refused here, so
|
|
107
|
+
// the file is never rewritten from a misreading of it.
|
|
108
|
+
const existing = await readProjectManifest(projectManifestPath);
|
|
101
109
|
|
|
102
110
|
const existingIgnore = new Set(existing.ignore);
|
|
103
111
|
const existingTrack = new Set(existing.track);
|
|
@@ -143,25 +151,34 @@ export async function applyTemplate(
|
|
|
143
151
|
};
|
|
144
152
|
}
|
|
145
153
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
154
|
+
/**
|
|
155
|
+
* The project manifest, as something safe to merge into and write back.
|
|
156
|
+
*
|
|
157
|
+
* Missing or empty is an empty manifest — nothing is lost by starting from one.
|
|
158
|
+
* A file that EXISTS and cannot be used is a `SettingsParseError` naming it, so
|
|
159
|
+
* the merge never runs and the file stays byte-for-byte as it was:
|
|
160
|
+
*
|
|
161
|
+
* - it does not parse, or its top level is not a JSON object
|
|
162
|
+
* ({@link readSettingsFile});
|
|
163
|
+
* - `ignore`, `track` or `extends` is present but is not an array
|
|
164
|
+
* ({@link writableList}).
|
|
165
|
+
*
|
|
166
|
+
* Reading any of those as "no entries" is what made `t` (apply template)
|
|
167
|
+
* destructive: a trailing comma left by a hand edit read as an empty manifest,
|
|
168
|
+
* the template merged into nothing, and the write replaced the user's own rules
|
|
169
|
+
* with the template's. A dropped `secrets/` line is how a secret reaches a
|
|
170
|
+
* commit, so this refuses rather than guesses.
|
|
171
|
+
*
|
|
172
|
+
* Entries INSIDE those arrays are carried through as they are, not filtered:
|
|
173
|
+
* the merge only appends, so anything already in the file comes back out of it
|
|
174
|
+
* unchanged. Dropping an entry we do not recognise would be the same data loss
|
|
175
|
+
* by a smaller door.
|
|
176
|
+
*/
|
|
177
|
+
async function readProjectManifest(path: string): Promise<GitignoreManifest> {
|
|
178
|
+
const raw = await readSettingsFile(path);
|
|
179
|
+
return {
|
|
180
|
+
ignore: writableList(raw, "ignore", path) ?? [],
|
|
181
|
+
track: writableList(raw, "track", path) ?? [],
|
|
182
|
+
extends: writableList(raw, "extends", path),
|
|
183
|
+
} as GitignoreManifest;
|
|
167
184
|
}
|
|
@@ -18,16 +18,21 @@
|
|
|
18
18
|
* absolute path recorded today is a dead path after the next upgrade, and
|
|
19
19
|
* the failure is invisible (a hook that cannot start is a hook that
|
|
20
20
|
* silently does not route).
|
|
21
|
-
* 3. **User scope, through `updateSettingsFile`.**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* 3. **User scope, through `updateSettingsFile`.** It refuses a file it
|
|
22
|
+
* cannot parse, so a settings file a human is mid-edit in is never
|
|
23
|
+
* overwritten with one hook and nothing else. The path is the resolved
|
|
24
|
+
* Claude config dir (`CLAUDE_CONFIG_DIR`), which
|
|
25
|
+
* `readGlobalSettings`/`writeGlobalSettings` (always `~/.claude`) ignore.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import path from "node:path";
|
|
29
29
|
import { claudeConfigDirOrNull } from "../utils/config-dir.js";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
readSettingsFile,
|
|
32
|
+
updateSettingsFile,
|
|
33
|
+
writableList,
|
|
34
|
+
writableMap,
|
|
35
|
+
} from "./settings-file.js";
|
|
31
36
|
|
|
32
37
|
/** The command Claude Code runs. Bare — see rule 2 above. */
|
|
33
38
|
export const AGENT_MODEL_HOOK_COMMAND = "magus hook agent-model";
|
|
@@ -196,6 +201,18 @@ export async function registerAgentModelHook(
|
|
|
196
201
|
if (isHookRegistered(await readSettingsFile(settingsPath))) return "already";
|
|
197
202
|
|
|
198
203
|
await updateSettingsFile(settingsPath, (settings) => {
|
|
204
|
+
// The user's own hooks live in `hooks.PreToolUse`. A malformed one is
|
|
205
|
+
// refused here, never replaced: `ensureHookRegistered` alone swaps a
|
|
206
|
+
// non-object `hooks` or a non-array `PreToolUse` for an empty one, and this
|
|
207
|
+
// write would then drop everything that was in it.
|
|
208
|
+
const hooks = writableMap(settings, "hooks", settingsPath);
|
|
209
|
+
if (hooks) {
|
|
210
|
+
writableList(
|
|
211
|
+
hooks as Record<string, unknown>,
|
|
212
|
+
"PreToolUse",
|
|
213
|
+
settingsPath,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
199
216
|
return ensureHookRegistered(settings).settings;
|
|
200
217
|
});
|
|
201
218
|
return "registered";
|
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
|
|