magus-cli 7.7.0 → 7.9.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/README.md +1 -1
- package/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +45 -26
- package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
- package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
- package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
- package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
- package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
- package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
- package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
- package/src/__tests__/blind-780-cli-tools.test.ts +796 -0
- package/src/__tests__/blind-780-deprecated.test.ts +631 -0
- package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
- package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
- package/src/__tests__/blind-780-review2.test.ts +1178 -0
- package/src/__tests__/cli-apply-seams.test.ts +2 -67
- package/src/__tests__/cli-tool-commands.test.ts +131 -6
- package/src/__tests__/cli-tools-adapter.test.ts +52 -2
- package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
- package/src/__tests__/cli-update-view.test.ts +3 -3
- package/src/__tests__/deprecated-remove.test.ts +827 -0
- package/src/__tests__/deps-adapter.test.ts +191 -0
- package/src/__tests__/deps-check-hook.test.ts +220 -0
- package/src/__tests__/deps-plan.test.ts +896 -0
- package/src/__tests__/deps-report.test.ts +197 -0
- package/src/__tests__/doctor-bins.test.ts +134 -36
- package/src/__tests__/doctor-json.test.ts +261 -0
- package/src/__tests__/doctor.test.ts +159 -1
- package/src/__tests__/failure-popup-review2.test.tsx +896 -0
- package/src/__tests__/failure-popup.test.tsx +419 -0
- package/src/__tests__/final-fixes-780.test.tsx +740 -0
- package/src/__tests__/fixtures/bins.ts +55 -0
- package/src/__tests__/format-update-outcome.test.ts +6 -32
- package/src/__tests__/gitignore-profile.test.ts +1 -9
- package/src/__tests__/install-plan.test.ts +15 -1
- package/src/__tests__/marketplace-refresh.test.ts +9 -3
- package/src/__tests__/models-screen-state.test.ts +18 -40
- package/src/__tests__/no-shell-spawn.test.ts +77 -0
- package/src/__tests__/npm-package-name.test.ts +6 -9
- package/src/__tests__/plugin-requires.test.ts +138 -153
- package/src/__tests__/plugin-resolution.test.ts +52 -13
- package/src/__tests__/plugin-row-text.test.ts +30 -37
- package/src/__tests__/profile-dependencies.test.ts +7 -0
- package/src/__tests__/profile-startup-old-format.test.ts +0 -3
- package/src/__tests__/profile-startup.test.ts +40 -12
- package/src/__tests__/requires-probe.test.ts +858 -0
- package/src/__tests__/resolver.test.ts +129 -52
- package/src/__tests__/run.test.ts +108 -0
- package/src/__tests__/stale-load-repair.test.ts +31 -23
- package/src/__tests__/tool-probe.test.ts +1 -1
- package/src/__tests__/toolchain.test.ts +123 -52
- package/src/__tests__/update-apply.test.ts +13 -29
- package/src/__tests__/update-plan.test.ts +24 -18
- package/src/__tests__/useStaleLoadModal.test.ts +1 -57
- package/src/__tests__/version-unknown-state.test.ts +15 -17
- package/src/cli/ansi.ts +2 -7
- package/src/cli/deps-view.ts +213 -0
- package/src/cli/doctor.ts +320 -54
- package/src/cli/install.ts +55 -71
- package/src/cli/profile.ts +20 -2
- package/src/cli/router.ts +6 -2
- package/src/cli/update-view.ts +58 -15
- package/src/cli/update.ts +55 -12
- package/src/data/cli-tools.ts +24 -9
- package/src/services/active-closure.ts +3 -3
- package/src/services/catalog-cache-store.ts +8 -6
- package/src/services/claude-cli.ts +106 -19
- package/src/services/claude-settings.ts +88 -384
- package/src/services/cli-tool-commands.ts +104 -3
- package/src/services/deps-plan.ts +962 -0
- package/src/services/deps-report.ts +265 -0
- package/src/services/doctor-bins.ts +115 -43
- package/src/services/install-plan.ts +11 -1
- package/src/services/local-marketplace.ts +12 -206
- package/src/services/marketplace-refresh.ts +4 -4
- package/src/services/plugin-env-needs.ts +69 -0
- package/src/services/plugin-manager.ts +35 -37
- package/src/services/plugin-requires.ts +93 -162
- package/src/services/plugin-resolution.ts +34 -42
- package/src/services/profile-activate.ts +1 -2
- package/src/services/profile-startup.ts +18 -43
- package/src/services/profile-state.ts +72 -2
- package/src/services/resolver.ts +263 -59
- package/src/services/tool-probe.ts +18 -25
- package/src/services/toolchain.ts +117 -111
- package/src/services/update-engine.ts +345 -45
- package/src/services/update-plan.ts +296 -36
- package/src/shared/plugin/hooks/bun-missing.sh +39 -0
- package/src/shared/plugin/hooks/deps-check.ts +224 -0
- package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
- package/src/types/index.ts +60 -16
- package/src/ui/App.tsx +81 -171
- package/src/ui/adapters/cliToolsAdapter.ts +252 -12
- package/src/ui/adapters/depsAdapter.ts +129 -0
- package/src/ui/adapters/failureReport.ts +137 -0
- package/src/ui/adapters/pluginsAdapter.ts +297 -45
- package/src/ui/adapters/startupReports.ts +260 -0
- package/src/ui/components/modals/FailureListModal.tsx +231 -0
- package/src/ui/components/modals/MessageModal.tsx +7 -12
- package/src/ui/components/modals/ModalContainer.tsx +10 -5
- package/src/ui/components/modals/index.ts +1 -0
- package/src/ui/hooks/startupQueue.ts +56 -0
- package/src/ui/hooks/useGitignoreModal.ts +30 -52
- package/src/ui/hooks/useProfileStartupModal.ts +119 -142
- package/src/ui/hooks/useStaleLoadModal.ts +35 -65
- package/src/ui/keymap.ts +14 -1
- package/src/ui/renderers/cliToolRenderers.tsx +135 -2
- package/src/ui/renderers/mcpRenderers.tsx +110 -3
- package/src/ui/renderers/pluginRenderers.tsx +77 -6
- package/src/ui/renderers/pluginRowText.ts +25 -25
- package/src/ui/renderers/pluginStatusText.ts +18 -1
- package/src/ui/screens/AliasScreen.tsx +38 -45
- package/src/ui/screens/CliToolsScreen.tsx +199 -98
- package/src/ui/screens/EnvVarsScreen.tsx +17 -5
- package/src/ui/screens/GitignoreScreen.tsx +48 -67
- package/src/ui/screens/McpRegistryScreen.tsx +67 -38
- package/src/ui/screens/McpScreen.tsx +46 -19
- package/src/ui/screens/ModelsScreen.tsx +46 -86
- package/src/ui/screens/PluginsScreen.tsx +315 -259
- package/src/ui/screens/ProfilesScreen.tsx +79 -40
- package/src/ui/screens/SkillsScreen.tsx +83 -80
- package/src/ui/screens/StylesScreen.tsx +211 -275
- package/src/ui/state/AppContext.tsx +66 -44
- package/src/ui/state/DimensionsContext.tsx +3 -22
- package/src/ui/state/reducer.ts +12 -100
- package/src/ui/state/types.ts +48 -69
- package/src/utils/command-utils.ts +4 -5
- package/src/utils/run.ts +123 -52
- package/src/utils/string-utils.ts +9 -0
- package/src/utils/user-path.ts +60 -0
- package/src/__tests__/go-module.test.ts +0 -43
- package/src/__tests__/notices-rules.test.ts +0 -476
- package/src/__tests__/notices.test.tsx +0 -604
- package/src/__tests__/plugin-setup.test.ts +0 -1016
- package/src/__tests__/shell-script-callers.test.ts +0 -92
- package/src/__tests__/styles-status-line.test.ts +0 -72
- package/src/__tests__/version-snapshot.test.ts +0 -212
- package/src/services/plugin-setup.ts +0 -965
- package/src/services/version-snapshot.ts +0 -189
- package/src/utils/go-module.ts +0 -36
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* What a plugin's plugin.json says it needs: its `dependencies` (other plugins)
|
|
3
|
+
* and its `requires` (binaries on the machine).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* `requires` is parsed by `shared/plugin/hooks/lib/requires-probe.ts` and by
|
|
6
|
+
* nothing else — the same file every declaring plugin carries for its
|
|
7
|
+
* SessionStart deps check, so the hook and magus read one declaration one way.
|
|
8
|
+
* This module only locates the manifest Claude Code loads and hands it over.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import os from "node:os";
|
|
11
12
|
import path from "node:path";
|
|
12
13
|
import fs from "fs-extra";
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
import {
|
|
15
|
+
type Arch,
|
|
16
|
+
type Os,
|
|
17
|
+
type ParseResult,
|
|
18
|
+
type Requirement,
|
|
19
|
+
applicable,
|
|
20
|
+
currentPlatform,
|
|
21
|
+
onPath,
|
|
22
|
+
parseRequires,
|
|
23
|
+
selectOption,
|
|
24
|
+
} from "../shared/plugin/hooks/lib/requires-probe.js";
|
|
25
|
+
import type { SelectedBin } from "../types/index.js";
|
|
19
26
|
import { parsePluginId } from "../utils/string-utils.js";
|
|
20
27
|
import { readInstalledPluginsRegistry } from "./claude-settings.js";
|
|
21
28
|
import { getLocalMarketplacesInfo } from "./plugin-manager.js";
|
|
@@ -24,144 +31,6 @@ import { pickLoadedEntry } from "./plugin-resolution.js";
|
|
|
24
31
|
const MARKETPLACES_DIR = (): string =>
|
|
25
32
|
path.join(os.homedir(), ".claude", "plugins", "marketplaces");
|
|
26
33
|
|
|
27
|
-
const VALID_INSTALLERS: readonly BinInstaller[] = [
|
|
28
|
-
"bun",
|
|
29
|
-
"brew",
|
|
30
|
-
"npm",
|
|
31
|
-
"pip",
|
|
32
|
-
"go",
|
|
33
|
-
];
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Normalize a raw `requires.bin` entry into a complete BinRequirement,
|
|
37
|
-
* filling in defaults. Returns null for entries that can't be made valid
|
|
38
|
-
* (missing name, or an unrecognized installer).
|
|
39
|
-
*/
|
|
40
|
-
function normalizeBin(raw: unknown): BinRequirement | null {
|
|
41
|
-
if (!raw || typeof raw !== "object") return null;
|
|
42
|
-
const o = raw as Record<string, unknown>;
|
|
43
|
-
const name = typeof o.name === "string" ? o.name.trim() : "";
|
|
44
|
-
if (!name) return null;
|
|
45
|
-
|
|
46
|
-
const via = o.via as BinInstaller;
|
|
47
|
-
if (!VALID_INSTALLERS.includes(via)) return null;
|
|
48
|
-
|
|
49
|
-
const bin: BinRequirement = {
|
|
50
|
-
name,
|
|
51
|
-
via,
|
|
52
|
-
check:
|
|
53
|
-
typeof o.check === "string" && o.check.trim()
|
|
54
|
-
? o.check.trim()
|
|
55
|
-
: `${name} --version`,
|
|
56
|
-
};
|
|
57
|
-
if (typeof o.package === "string") bin.package = o.package;
|
|
58
|
-
if (typeof o.formula === "string") bin.formula = o.formula;
|
|
59
|
-
if (typeof o.module === "string") bin.module = o.module;
|
|
60
|
-
if (typeof o.version === "string") bin.version = o.version;
|
|
61
|
-
return bin;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Split a package/module spec into its base and optional version. Handles
|
|
65
|
-
* scoped npm (@scope/pkg@1.2.0) and go modules (host/path@latest). */
|
|
66
|
-
function splitVersion(spec: string): { base: string; version?: string } {
|
|
67
|
-
const at = spec.lastIndexOf("@");
|
|
68
|
-
// at > 0 avoids treating a leading npm scope "@scope/..." as a version.
|
|
69
|
-
if (at > 0) return { base: spec.slice(0, at), version: spec.slice(at + 1) };
|
|
70
|
-
return { base: spec };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const basename = (p: string): string => p.split("/").pop() ?? p;
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Map a plugin.json `setup` block (the established convention: browser-use uses
|
|
77
|
-
* `pip`, terminal uses `go` + `required`) to BinRequirements.
|
|
78
|
-
* setup: { bun|npm|pip: string[], go: string[], brew: string[], required: string[] }
|
|
79
|
-
*/
|
|
80
|
-
function parseSetup(setup: unknown): BinRequirement[] {
|
|
81
|
-
if (!setup || typeof setup !== "object") return [];
|
|
82
|
-
const s = setup as Record<string, unknown>;
|
|
83
|
-
const bins: BinRequirement[] = [];
|
|
84
|
-
|
|
85
|
-
const pkgManager = (key: "bun" | "npm" | "pip"): void => {
|
|
86
|
-
for (const spec of (s[key] as string[] | undefined) ?? []) {
|
|
87
|
-
if (typeof spec !== "string") continue;
|
|
88
|
-
const { base, version } = splitVersion(spec);
|
|
89
|
-
bins.push({
|
|
90
|
-
name: basename(base),
|
|
91
|
-
via: key,
|
|
92
|
-
package: base,
|
|
93
|
-
version,
|
|
94
|
-
check: `${basename(base)} --version`,
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
pkgManager("bun");
|
|
99
|
-
pkgManager("npm");
|
|
100
|
-
pkgManager("pip");
|
|
101
|
-
|
|
102
|
-
// Not basename(): a Go module's trailing /vN is its major version, not the
|
|
103
|
-
// binary. `tmux-mcp/v2` installs `tmux-mcp`; naming it `v2` here made every
|
|
104
|
-
// downstream presence check look for a binary that never exists.
|
|
105
|
-
for (const spec of (s.go as string[] | undefined) ?? []) {
|
|
106
|
-
if (typeof spec !== "string") continue;
|
|
107
|
-
const { base, version } = splitVersion(spec);
|
|
108
|
-
const name = goBinaryName(base);
|
|
109
|
-
bins.push({
|
|
110
|
-
name,
|
|
111
|
-
via: "go",
|
|
112
|
-
module: base,
|
|
113
|
-
version,
|
|
114
|
-
check: `${name} --version`,
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
for (const spec of (s.brew as string[] | undefined) ?? []) {
|
|
118
|
-
if (typeof spec !== "string") continue;
|
|
119
|
-
bins.push({
|
|
120
|
-
name: spec,
|
|
121
|
-
via: "brew",
|
|
122
|
-
formula: spec,
|
|
123
|
-
check: `${spec} --version`,
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
// `required` names a system tool that must be present; brew is the assumed
|
|
127
|
-
// installer (matches the "system tools via brew" decision).
|
|
128
|
-
for (const name of (s.required as string[] | undefined) ?? []) {
|
|
129
|
-
if (typeof name !== "string") continue;
|
|
130
|
-
bins.push({ name, via: "brew", formula: name, check: `${name} --version` });
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return bins;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Parse a plugin.json's binary requirements from BOTH the established `setup`
|
|
138
|
-
* block and the richer explicit `requires.bin` form. On a name collision the
|
|
139
|
-
* explicit `requires.bin` entry wins (it can override installer/check/version).
|
|
140
|
-
*/
|
|
141
|
-
export function parsePluginRequires(pluginJson: unknown): PluginRequires {
|
|
142
|
-
if (!pluginJson || typeof pluginJson !== "object") return {};
|
|
143
|
-
const obj = pluginJson as Record<string, unknown>;
|
|
144
|
-
|
|
145
|
-
const fromSetup = parseSetup(obj.setup);
|
|
146
|
-
|
|
147
|
-
const requires = obj.requires;
|
|
148
|
-
const binRaw =
|
|
149
|
-
requires && typeof requires === "object"
|
|
150
|
-
? (requires as Record<string, unknown>).bin
|
|
151
|
-
: undefined;
|
|
152
|
-
const fromRequires = Array.isArray(binRaw)
|
|
153
|
-
? binRaw.map(normalizeBin).filter((b): b is BinRequirement => b !== null)
|
|
154
|
-
: [];
|
|
155
|
-
|
|
156
|
-
// Merge, requires.bin winning by name.
|
|
157
|
-
const byName = new Map<string, BinRequirement>();
|
|
158
|
-
for (const b of fromSetup) byName.set(b.name, b);
|
|
159
|
-
for (const b of fromRequires) byName.set(b.name, b);
|
|
160
|
-
|
|
161
|
-
const bin = [...byName.values()];
|
|
162
|
-
return bin.length > 0 ? { bin } : {};
|
|
163
|
-
}
|
|
164
|
-
|
|
165
34
|
/**
|
|
166
35
|
* The plugin.json at a plugin directory, parsed — null when there is none or
|
|
167
36
|
* it does not parse.
|
|
@@ -191,12 +60,15 @@ export async function readPluginManifest(
|
|
|
191
60
|
return null;
|
|
192
61
|
}
|
|
193
62
|
|
|
194
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* `requires` from the plugin.json at a plugin directory, or null when there is
|
|
65
|
+
* no readable manifest there.
|
|
66
|
+
*/
|
|
195
67
|
export async function readPluginRequires(
|
|
196
68
|
pluginPath: string,
|
|
197
|
-
): Promise<
|
|
198
|
-
|
|
199
|
-
return
|
|
69
|
+
): Promise<ParseResult | null> {
|
|
70
|
+
const manifest = await readPluginManifest(pluginPath);
|
|
71
|
+
return manifest === null ? null : parseRequires(manifest);
|
|
200
72
|
}
|
|
201
73
|
|
|
202
74
|
/**
|
|
@@ -257,8 +129,8 @@ export interface PluginManifestSources {
|
|
|
257
129
|
* row its loader resolves there (`pickLoadedEntry`). Claude Code checks
|
|
258
130
|
* the dependencies of the version it loads, which may be older than the
|
|
259
131
|
* marketplace's latest;
|
|
260
|
-
* 2. its source in the local marketplace clone
|
|
261
|
-
*
|
|
132
|
+
* 2. its source in the local marketplace clone, for a plugin not installed
|
|
133
|
+
* yet;
|
|
262
134
|
* 3. any other installed copy — where a plugin whose marketplace entry
|
|
263
135
|
* points at another repository lives.
|
|
264
136
|
*/
|
|
@@ -337,14 +209,73 @@ export async function resolvePluginPath(
|
|
|
337
209
|
}
|
|
338
210
|
|
|
339
211
|
/**
|
|
340
|
-
*
|
|
341
|
-
*
|
|
212
|
+
* The `requires` of the copy of `pluginId` Claude Code loads in `projectPath`
|
|
213
|
+
* (else its marketplace clone), and the directory it was read from — the same
|
|
214
|
+
* plugin.json its SessionStart hook reads through ${CLAUDE_PLUGIN_ROOT}. Null
|
|
215
|
+
* when no plugin.json for it can be read on this machine.
|
|
216
|
+
*/
|
|
217
|
+
export async function resolvePluginRequires(
|
|
218
|
+
pluginId: string,
|
|
219
|
+
projectPath: string = process.cwd(),
|
|
220
|
+
sources: PluginManifestSources = {},
|
|
221
|
+
): Promise<{ dir: string; parse: ParseResult } | null> {
|
|
222
|
+
for (const dir of await pluginManifestDirs(pluginId, projectPath, sources)) {
|
|
223
|
+
const parse = await readPluginRequires(dir);
|
|
224
|
+
if (parse !== null) return { dir, parse };
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Each requirement that applies on this platform, with its option chosen for
|
|
231
|
+
* this machine — the first in declaration order whose prerequisite is on PATH
|
|
232
|
+
* or declared beside it ({@link selectOption}) — and the directory it was read
|
|
233
|
+
* from, which a `${CLAUDE_PLUGIN_ROOT}` check and a plugin-relative script
|
|
234
|
+
* resolve against.
|
|
235
|
+
*/
|
|
236
|
+
export function selectBins(
|
|
237
|
+
requirements: Requirement[],
|
|
238
|
+
root: string | undefined,
|
|
239
|
+
pathEnv: string = process.env.PATH ?? "",
|
|
240
|
+
platform: { os: Os; arch: Arch } | null = currentPlatform(),
|
|
241
|
+
): SelectedBin[] {
|
|
242
|
+
if (!platform) return [];
|
|
243
|
+
const live = applicable(requirements, platform.os);
|
|
244
|
+
const names = new Set(live.map((r) => r.name));
|
|
245
|
+
const has = (tool: string) =>
|
|
246
|
+
names.has(tool) || onPath(tool, pathEnv) !== null;
|
|
247
|
+
return live.map((r) => ({
|
|
248
|
+
...r,
|
|
249
|
+
chosen: selectOption(r, platform, has),
|
|
250
|
+
...(root ? { root } : {}),
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The requirements a plugin declares, selected for this machine, from the copy
|
|
256
|
+
* Claude Code loads in `projectPath`. Empty when the plugin cannot be located,
|
|
257
|
+
* declares none, or its declaration does not parse (doctor and the plugin's
|
|
258
|
+
* own SessionStart hook report that one).
|
|
342
259
|
*/
|
|
343
260
|
export async function resolvePluginBinRequirements(
|
|
344
261
|
pluginId: string,
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
return
|
|
262
|
+
projectPath: string = process.cwd(),
|
|
263
|
+
): Promise<SelectedBin[]> {
|
|
264
|
+
const found = await resolvePluginRequires(pluginId, projectPath);
|
|
265
|
+
if (!found?.parse.ok) return [];
|
|
266
|
+
return selectBins(found.parse.requirements, found.dir);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The requirement named `name` that `pluginId` declares — how a catalogue tool
|
|
271
|
+
* owned by a plugin is checked and installed — or null when that plugin's
|
|
272
|
+
* declaration cannot be read here or does not name it.
|
|
273
|
+
*/
|
|
274
|
+
export async function ownedRequirement(
|
|
275
|
+
pluginId: string,
|
|
276
|
+
name: string,
|
|
277
|
+
projectPath: string = process.cwd(),
|
|
278
|
+
): Promise<SelectedBin | null> {
|
|
279
|
+
const bins = await resolvePluginBinRequirements(pluginId, projectPath);
|
|
280
|
+
return bins.find((b) => b.name === name) ?? null;
|
|
350
281
|
}
|
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
* Ported from Claude Code 2.1.274, decompiled: the relevance filter `SF`
|
|
5
5
|
* (byte 178070986) and the selection function `Bls` (byte 178140984). The port
|
|
6
6
|
* was validated against the real loader over 123/123 plugin resolutions across
|
|
7
|
-
* 12 repositories and worktrees.
|
|
7
|
+
* 12 repositories and worktrees. Re-derived from 2.1.284 (`Aw` at byte
|
|
8
|
+
* 188165906, `t5t` at 188274660): the picker is unchanged, and the relevance
|
|
9
|
+
* filter now rejects a row with no `projectPath` and matches nothing by root when
|
|
10
|
+
* the current folder is in no repository (see {@link isRelevantInstallation}).
|
|
11
|
+
* Checked against `--debug-file` in a rootless folder (7/7) and in a repository
|
|
12
|
+
* (11/11). RE-VALIDATE IT WHEN CLAUDE CODE UPDATES —
|
|
8
13
|
* this file describes someone else's algorithm, so a new release can move it
|
|
9
14
|
* without anything here failing.
|
|
10
15
|
*
|
|
@@ -21,26 +26,14 @@
|
|
|
21
26
|
* This module holds NO magus policy. It answers the second question only, so
|
|
22
27
|
* that a disagreement between the two is visible rather than averaged away.
|
|
23
28
|
*
|
|
24
|
-
* KNOWN
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* repoRoot(entry.projectPath) === repoRoot(originalCwd)` with no guard on
|
|
33
|
-
* `projectPath` being absent at all. A project-scope row carrying no
|
|
34
|
-
* `projectPath` is rejected outright here (`isRelevantInstallation`), because
|
|
35
|
-
* what `repoRoot(undefined)` answers inside the binary was not measured and
|
|
36
|
-
* guessing it would put a row into the selection on a guess. Claude Code
|
|
37
|
-
* writes `projectPath` for every project-scope install, so the case is
|
|
38
|
-
* theoretical rather than observed.
|
|
39
|
-
*
|
|
40
|
-
* A THIRD divergence was removed rather than documented: two rootless paths used
|
|
41
|
-
* to compare unequal here, so in a directory belonging to no repository every
|
|
42
|
-
* rootless row was irrelevant to magus while the loader still counted it. See
|
|
43
|
-
* {@link isRelevantInstallation}.
|
|
29
|
+
* ONE KNOWN DIVERGENCE from the decompiled original: the binary additionally
|
|
30
|
+
* rejects an entry whose `installPath` fails a path-safety check
|
|
31
|
+
* (`suspect(installPath)`). Not ported — every `installPath` is treated as
|
|
32
|
+
* non-suspect here. That check only ever removes candidates, so skipping it can
|
|
33
|
+
* make this module name an entry the loader does not use, never miss one the
|
|
34
|
+
* loader does use. A row's `projectPath` is compared as the raw string the
|
|
35
|
+
* binary compares — normalising it once added candidates the loader rejects —
|
|
36
|
+
* so path handling is not a second divergence.
|
|
44
37
|
*/
|
|
45
38
|
|
|
46
39
|
import fs from "node:fs";
|
|
@@ -193,28 +186,27 @@ export function isRelevantInstallation(
|
|
|
193
186
|
if (isMachineWideScope(entry.scope)) return true;
|
|
194
187
|
if (!entry.projectPath) return false;
|
|
195
188
|
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
// NULL MATCHES NULL, because the original's `repoRoot(entry.projectPath) ===
|
|
203
|
-
// repoRoot(originalCwd)` does: two directories in no repository at all compare
|
|
204
|
-
// equal there, so every rootless row competes when Claude Code runs from a
|
|
205
|
-
// scratch directory, a home directory, or any plain non-git folder.
|
|
189
|
+
// Raw strings, exactly as the binary compares them. Normalising first looked
|
|
190
|
+
// harmless — "/p/a/" and "/p/a" are the same directory — but it is not a
|
|
191
|
+
// no-op: in a folder outside any repository the root comparison below
|
|
192
|
+
// matches nothing, so normalisation was the ONLY thing that could make that
|
|
193
|
+
// pair relevant, and it added a candidate the loader rejects.
|
|
206
194
|
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
195
|
+
// Only `cwd` is resolved, because it stands in for the binary's own working
|
|
196
|
+
// directory, which is always absolute and has no trailing slash; a caller
|
|
197
|
+
// handing over "./x" or "/p/a/" means that directory. The row's path is
|
|
198
|
+
// compared exactly as written, as the binary compares it.
|
|
199
|
+
if (entry.projectPath === path.resolve(cwd)) return true;
|
|
200
|
+
|
|
201
|
+
// A directory in no repository matches NOTHING by root. Claude Code 2.1.284
|
|
202
|
+
// guards the comparison (`Aw` at byte 188165906:
|
|
203
|
+
// `let r=Zx(n);return r!==null&&Zx(e.projectPath)===r`), so in a rootless
|
|
204
|
+
// folder only its own rows and the machine-wide ones count. 2.1.274 had no
|
|
205
|
+
// guard and two nulls compared equal, which this port used to copy — and so
|
|
206
|
+
// named, in every rootless folder, versions from strangers' rows that the
|
|
207
|
+
// loader never looks at. Measured on 2.1.284 with `--debug-file` in a rootless
|
|
208
|
+
// folder: 7 of 7 plugins matched this rule, 2 of 7 matched the old one.
|
|
209
|
+
if (cwdRoot === null) return false;
|
|
218
210
|
return repoRootForPath(entry.projectPath, cache) === cwdRoot;
|
|
219
211
|
}
|
|
220
212
|
|
|
@@ -209,8 +209,7 @@ export function releasedCommittedSkillNote(folder: string): string {
|
|
|
209
209
|
|
|
210
210
|
/**
|
|
211
211
|
* Lines describing what activation did beyond the switch itself, phrased for
|
|
212
|
-
* the terminal.
|
|
213
|
-
* ui/hooks/useProfileStartupModal.ts).
|
|
212
|
+
* the terminal.
|
|
214
213
|
*
|
|
215
214
|
* `tracked: false` leaves the tracked-path notices out, for the CLI, which
|
|
216
215
|
* prints them itself as a warning with the fix beneath it
|
|
@@ -69,11 +69,7 @@ import {
|
|
|
69
69
|
findEnclosingManifest,
|
|
70
70
|
readManifest,
|
|
71
71
|
} from "./manifest.js";
|
|
72
|
-
import {
|
|
73
|
-
type ActivationResult,
|
|
74
|
-
type ProfileChoice,
|
|
75
|
-
activationNotes,
|
|
76
|
-
} from "./profile-activate.js";
|
|
72
|
+
import type { ActivationResult, ProfileChoice } from "./profile-activate.js";
|
|
77
73
|
import { freeProfileId } from "./profile-adopt.js";
|
|
78
74
|
import {
|
|
79
75
|
activeProfile,
|
|
@@ -311,36 +307,29 @@ export async function oldFormatNeed(
|
|
|
311
307
|
};
|
|
312
308
|
}
|
|
313
309
|
|
|
314
|
-
/**
|
|
310
|
+
/** The TUI's startup: the question (if any), and what the gate refused. */
|
|
315
311
|
export interface ProfileStartup {
|
|
316
312
|
need: ProfileStartupNeed;
|
|
317
313
|
/**
|
|
318
|
-
* One line per thing the gate
|
|
319
|
-
*
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* One line per thing the gate REFUSED — the lines the CLI stops on. Kept
|
|
324
|
-
* apart from `notices` because a refusal can leave detection reading `ok`:
|
|
325
|
-
* an old layout whose settings file does not parse is not converted, yet
|
|
326
|
-
* still names its profile, so only this says the update did not happen.
|
|
314
|
+
* One line per thing the gate REFUSED — the lines the CLI stops on. A
|
|
315
|
+
* refusal can leave detection reading `ok`: an old layout whose settings
|
|
316
|
+
* file does not parse is not converted, yet still names its profile, so only
|
|
317
|
+
* this says the update did not happen.
|
|
327
318
|
*/
|
|
328
319
|
problems: string[];
|
|
329
320
|
/**
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
* load" in a line nothing marks as a warning. The TUI words them itself, in
|
|
334
|
-
* user terms and with a tone (`activationWarnings` in the startup hook).
|
|
321
|
+
* Every activation the gate ran — a re-render after profiles.json changed,
|
|
322
|
+
* an older format converted — so the TUI can say what one of them could
|
|
323
|
+
* not do. Absent is none.
|
|
335
324
|
*/
|
|
336
|
-
activations
|
|
325
|
+
activations?: ActivationResult[];
|
|
337
326
|
}
|
|
338
327
|
|
|
339
328
|
/**
|
|
340
329
|
* The TUI's startup: the same gate every CLI command runs, then detection.
|
|
341
330
|
*
|
|
342
|
-
* A failure in the gate never blocks the TUI — its message
|
|
343
|
-
* and detection still runs, so the user is asked whatever is left
|
|
331
|
+
* A failure in the gate never blocks the TUI — its message is kept in
|
|
332
|
+
* `problems` and detection still runs, so the user is asked whatever is left.
|
|
344
333
|
*/
|
|
345
334
|
export async function prepareProfileStartup(
|
|
346
335
|
projectPath: string,
|
|
@@ -354,21 +343,13 @@ export async function prepareProfileStartup(
|
|
|
354
343
|
if (!opts.convert) {
|
|
355
344
|
const format = await oldFormatNeed(projectPath);
|
|
356
345
|
if (format !== null) {
|
|
357
|
-
return { need: format,
|
|
346
|
+
return { need: format, problems: [] };
|
|
358
347
|
}
|
|
359
348
|
}
|
|
360
|
-
const notices: string[] = [];
|
|
361
349
|
const problems: string[] = [];
|
|
362
350
|
const activations: ActivationResult[] = [];
|
|
363
351
|
try {
|
|
364
352
|
const prep = await prepareProfileState(projectPath);
|
|
365
|
-
// The gate's lines, less the ones its activations contributed: those
|
|
366
|
-
// reach the TUI as `activations`, and passing both would say the same
|
|
367
|
-
// thing twice — once in the CLI's words, as if it were a success.
|
|
368
|
-
const fromActivations = new Set(
|
|
369
|
-
prep.activations.flatMap((a) => activationNotes(a, { tracked: false })),
|
|
370
|
-
);
|
|
371
|
-
notices.push(...prep.lines.filter((line) => !fromActivations.has(line)));
|
|
372
353
|
problems.push(...prep.problems);
|
|
373
354
|
activations.push(...prep.activations);
|
|
374
355
|
} catch (error) {
|
|
@@ -378,19 +359,18 @@ export async function prepareProfileStartup(
|
|
|
378
359
|
}
|
|
379
360
|
return {
|
|
380
361
|
need: await detectProfileStartupNeed(projectPath),
|
|
381
|
-
notices,
|
|
382
362
|
problems,
|
|
383
|
-
activations,
|
|
363
|
+
...(activations.length > 0 ? { activations } : {}),
|
|
384
364
|
};
|
|
385
365
|
}
|
|
386
366
|
|
|
387
367
|
/**
|
|
388
|
-
* What the TUI
|
|
389
|
-
*
|
|
368
|
+
* What the TUI does once "Update it now" has run: ask another question, carry
|
|
369
|
+
* on, or show an error modal.
|
|
390
370
|
*/
|
|
391
371
|
export type FormatUpdateOutcome =
|
|
392
372
|
| { kind: "ask"; need: ProfileStartupNeed }
|
|
393
|
-
| { kind: "updated"
|
|
373
|
+
| { kind: "updated" }
|
|
394
374
|
| { kind: "failed"; title: string; message: string };
|
|
395
375
|
|
|
396
376
|
/**
|
|
@@ -410,12 +390,7 @@ export function formatUpdateOutcome(
|
|
|
410
390
|
};
|
|
411
391
|
}
|
|
412
392
|
if (!needsNothing(result.need)) return { kind: "ask", need: result.need };
|
|
413
|
-
|
|
414
|
-
// (edits carried over, the old format's backup), and the result the user
|
|
415
|
-
// cares about is only that the update is done. What the conversion's
|
|
416
|
-
// activation warns about is NOT dropped: the caller words it from
|
|
417
|
-
// `result.activations`, as it does for any activation.
|
|
418
|
-
return { kind: "updated", notice: "Profile format updated" };
|
|
393
|
+
return { kind: "updated" };
|
|
419
394
|
}
|
|
420
395
|
|
|
421
396
|
// ─── What to offer, as data both surfaces render ─────────────────────────────
|
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import path from "node:path";
|
|
29
|
-
import type { ProfileManifest } from "../types/index.js";
|
|
30
|
-
import {
|
|
29
|
+
import type { ProfileManifest, ProfileManifestEntry } from "../types/index.js";
|
|
30
|
+
import { selectProfileId } from "./active-closure.js";
|
|
31
|
+
import { readManifest, updateProfileEntry, writeManifest } from "./manifest.js";
|
|
31
32
|
import {
|
|
32
33
|
type ReconcileResult,
|
|
33
34
|
absorbOlderLayout,
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
type ActiveMarker,
|
|
48
49
|
LIVE_FILE_KEYS,
|
|
49
50
|
MACHINE_LOCAL_SETTINGS_KEYS,
|
|
51
|
+
activeProfile,
|
|
50
52
|
folderHash,
|
|
51
53
|
hasCurrentMarker,
|
|
52
54
|
hasOlderLayout,
|
|
@@ -68,6 +70,7 @@ import {
|
|
|
68
70
|
inspectManagedSkills,
|
|
69
71
|
skillFolder,
|
|
70
72
|
} from "./profile-skills.js";
|
|
73
|
+
import { resolveExtends } from "./resolver.js";
|
|
71
74
|
import type { SettingsParseError } from "./settings-file.js";
|
|
72
75
|
|
|
73
76
|
export type ActiveProfileCheck =
|
|
@@ -652,3 +655,70 @@ export async function withCliBookkeeping<T>(
|
|
|
652
655
|
for (const line of reconciled.lines) opts.report?.(line);
|
|
653
656
|
}
|
|
654
657
|
}
|
|
658
|
+
|
|
659
|
+
/** Whether a profile entry, `extends` included, turns the plugin on. */
|
|
660
|
+
function profileLists(
|
|
661
|
+
entry: ProfileManifestEntry | undefined,
|
|
662
|
+
pluginId: string,
|
|
663
|
+
): boolean {
|
|
664
|
+
if (!entry) return false;
|
|
665
|
+
const value = resolveExtends(entry).plugins?.[pluginId];
|
|
666
|
+
return value !== undefined && value !== false;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Stop a profile listing `pluginId`, so its next activation does not turn the
|
|
671
|
+
* plugin back on. Answers whether the profile no longer lists it — read back
|
|
672
|
+
* from profiles.json after the write, never assumed.
|
|
673
|
+
*
|
|
674
|
+
* For a deprecated plugin `magus update` removes. The uninstall that runs first
|
|
675
|
+
* deletes the plugin from this project's settings (measured, Claude Code
|
|
676
|
+
* 2.1.284), and `reconcileActiveProfile` would absorb that into the ACTIVE
|
|
677
|
+
* profile at the next entry point — but only there, and only for the active
|
|
678
|
+
* one. The Plugins screen's `a` runs no reconcile afterwards, so the screen kept
|
|
679
|
+
* the plugin declared and planned the same removal again; `magus update <name>`
|
|
680
|
+
* plans a profile that is not active, whose live files nothing absorbs from;
|
|
681
|
+
* and a profile declaring a plugin that is installed nowhere has no settings
|
|
682
|
+
* change to absorb at all. So the entry is edited here, directly, through
|
|
683
|
+
* {@link updateProfileEntry} — the editor `magus models use` writes through.
|
|
684
|
+
* The reconcile that follows (`withCliBookkeeping`, or the next entry point)
|
|
685
|
+
* finds profiles.json and the live files agreeing, and regenerates from it.
|
|
686
|
+
*
|
|
687
|
+
* `profile` undefined means the active one, chosen as the Plugins screen and
|
|
688
|
+
* `magus update` choose it; null means no profile, which lists nothing.
|
|
689
|
+
*
|
|
690
|
+
* Deletes the entry's own pin. When `extends` supplies the plugin, the pin
|
|
691
|
+
* becomes `false`, which is how an entry turns an inherited plugin off.
|
|
692
|
+
* Throws `SettingsParseError` when profiles.json exists and does not parse.
|
|
693
|
+
*/
|
|
694
|
+
export async function removePluginFromProfile(
|
|
695
|
+
projectPath: string,
|
|
696
|
+
pluginId: string,
|
|
697
|
+
profile?: string | null,
|
|
698
|
+
): Promise<boolean> {
|
|
699
|
+
const manifest = await readManifest(projectPath);
|
|
700
|
+
const id =
|
|
701
|
+
profile === undefined
|
|
702
|
+
? selectProfileId(
|
|
703
|
+
Object.keys(manifest.profiles),
|
|
704
|
+
undefined,
|
|
705
|
+
await activeProfile(projectPath),
|
|
706
|
+
)
|
|
707
|
+
: profile;
|
|
708
|
+
if (!id || !profileLists(manifest.profiles[id], pluginId)) return true;
|
|
709
|
+
|
|
710
|
+
await updateProfileEntry(projectPath, id, (entry) => {
|
|
711
|
+
const plugins = { ...(entry.plugins ?? {}) };
|
|
712
|
+
delete plugins[pluginId];
|
|
713
|
+
if (profileLists({ ...entry, plugins }, pluginId))
|
|
714
|
+
plugins[pluginId] = false;
|
|
715
|
+
if (Object.keys(plugins).length > 0) entry.plugins = plugins;
|
|
716
|
+
// biome-ignore lint/performance/noDelete: absent, not undefined, in profiles.json
|
|
717
|
+
else delete entry.plugins;
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
return !profileLists(
|
|
721
|
+
(await readManifest(projectPath)).profiles[id],
|
|
722
|
+
pluginId,
|
|
723
|
+
);
|
|
724
|
+
}
|