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
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
import os from "node:os";
|
|
43
43
|
import path from "node:path";
|
|
44
44
|
import semver from "semver";
|
|
45
|
-
import type
|
|
45
|
+
import { CLAUDE_CODE_NPM_PACKAGE, type CliTool } from "../data/cli-tools.js";
|
|
46
46
|
import type {
|
|
47
47
|
BinInstaller,
|
|
48
48
|
BinRequirement,
|
|
@@ -50,6 +50,15 @@ import type {
|
|
|
50
50
|
} from "../types/index.js";
|
|
51
51
|
import type { Command } from "../utils/command-utils.js";
|
|
52
52
|
import type { InstallMethod } from "./cli-tool-commands.js";
|
|
53
|
+
import {
|
|
54
|
+
readPackageVersionForBin,
|
|
55
|
+
readVersionDirForBin,
|
|
56
|
+
versionFromVersionedPath,
|
|
57
|
+
} from "./cli-tool-version.js";
|
|
58
|
+
import {
|
|
59
|
+
claudeNativeUpgradeCommand,
|
|
60
|
+
npmPrefixInstallCommand,
|
|
61
|
+
} from "./toolchain.js";
|
|
53
62
|
|
|
54
63
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
55
64
|
|
|
@@ -84,6 +93,10 @@ export type ToolFactSource =
|
|
|
84
93
|
| "pypi"
|
|
85
94
|
| "npm-registry"
|
|
86
95
|
| "go-proxy"
|
|
96
|
+
/** the package.json of the npm/bun package the PATH binary lives in. */
|
|
97
|
+
| "package-json"
|
|
98
|
+
/** the file name of a versioned install (`…/versions/2.1.276`). */
|
|
99
|
+
| "version-dir"
|
|
87
100
|
| "check-command";
|
|
88
101
|
|
|
89
102
|
export interface ToolFacts {
|
|
@@ -124,6 +137,24 @@ export interface ToolFacts {
|
|
|
124
137
|
* carried to both front ends.
|
|
125
138
|
*/
|
|
126
139
|
adopted?: string;
|
|
140
|
+
/**
|
|
141
|
+
* The command that upgrades the file PATH runs, when it differs from the
|
|
142
|
+
* spec's own installer command. Absent otherwise — and then the ordinary
|
|
143
|
+
* builder (`binCommandForState`, `cliToolUpdate`) is right.
|
|
144
|
+
*
|
|
145
|
+
* Set in two cases, both where the owner is known but is not what the spec's
|
|
146
|
+
* installer would write to:
|
|
147
|
+
* - an npm global install under ANOTHER prefix than the one `npm root -g`
|
|
148
|
+
* reports: `npm install -g --prefix <prefix> <pkg>@<latest|pin>`;
|
|
149
|
+
* - Claude Code's native installer: `claude update` (`claude install <pin>`
|
|
150
|
+
* when pinned).
|
|
151
|
+
*
|
|
152
|
+
* Built here, once, and carried to both front ends — `magus update` through
|
|
153
|
+
* its plan item, the TUI through its row — so the two run the same argv.
|
|
154
|
+
* It already aims at the only target the classifier can plan for this spec:
|
|
155
|
+
* the pin when there is one, the newest version otherwise.
|
|
156
|
+
*/
|
|
157
|
+
upgradeCommand?: Command;
|
|
127
158
|
/** e.g. "timed out after 8s", "registry returned 404". */
|
|
128
159
|
failure?: string;
|
|
129
160
|
/** Where each fact came from, for diagnostics. */
|
|
@@ -188,6 +219,16 @@ function withFailure(text: string, failure: string | undefined): string {
|
|
|
188
219
|
return failure ? `${text} (${failure})` : text;
|
|
189
220
|
}
|
|
190
221
|
|
|
222
|
+
/** The `not-managed` sentence. Names the file and its owner, never the version. */
|
|
223
|
+
function notManagedNote(spec: BinRequirement, facts: ToolFacts): string {
|
|
224
|
+
return facts.managed === false
|
|
225
|
+
? `on PATH at ${facts.resolvedPath ?? "?"}, which ${facts.owner ?? spec.via} does not own — upgrading through ${spec.via} would install a second copy`
|
|
226
|
+
: withFailure(
|
|
227
|
+
`could not tell whether ${spec.via} owns ${facts.resolvedPath ?? spec.name}`,
|
|
228
|
+
facts.failure,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
191
232
|
/**
|
|
192
233
|
* THE tool classifier. Pure. The first matching rule wins, and each branch
|
|
193
234
|
* returns, so reading top to bottom is reading the precedence.
|
|
@@ -198,6 +239,13 @@ function withFailure(text: string, failure: string | undefined): string {
|
|
|
198
239
|
* held formula is a no-op. Both would be replanned forever. The pin rules that
|
|
199
240
|
* plan no work (at-pin, unorderable, ahead-of-pin) come before the vetoes,
|
|
200
241
|
* because ownership cannot make a "nothing to do" answer wrong.
|
|
242
|
+
*
|
|
243
|
+
* One ownership answer comes earlier still: a file KNOWN to be nobody's the
|
|
244
|
+
* spec's installer reaches (`managed === false`) is `not-managed` even when no
|
|
245
|
+
* version could be read. That is where an unreadable version usually comes
|
|
246
|
+
* from, and "does not own it" is the sentence the user can act on. The pin
|
|
247
|
+
* rules are not displaced by it — they need a version to compare, and there is
|
|
248
|
+
* none.
|
|
201
249
|
*/
|
|
202
250
|
export function classifyTool(
|
|
203
251
|
spec: BinRequirement,
|
|
@@ -221,8 +269,23 @@ export function classifyTool(
|
|
|
221
269
|
};
|
|
222
270
|
}
|
|
223
271
|
|
|
224
|
-
// 2. Present,
|
|
272
|
+
// 2. Present, owned by nobody the spec's installer is — and no version read.
|
|
273
|
+
// The ownership answer is the actionable one: the version is unreadable
|
|
274
|
+
// BECAUSE no installer's record covers the file (and check commands are off
|
|
275
|
+
// while planning), so "could not read its version" would hide the reason.
|
|
276
|
+
// Only a DEFINITE `false`; an undetermined owner (null) is not a finding.
|
|
225
277
|
const installed = facts.installed;
|
|
278
|
+
if (installed === null && facts.managed === false) {
|
|
279
|
+
return {
|
|
280
|
+
...base,
|
|
281
|
+
state: "unknown",
|
|
282
|
+
reason: "not-managed",
|
|
283
|
+
target: null,
|
|
284
|
+
note: notManagedNote(spec, facts),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 3. Present, but we could not read what version runs.
|
|
226
289
|
if (installed === null) {
|
|
227
290
|
return {
|
|
228
291
|
...base,
|
|
@@ -236,7 +299,7 @@ export function classifyTool(
|
|
|
236
299
|
};
|
|
237
300
|
}
|
|
238
301
|
|
|
239
|
-
//
|
|
302
|
+
// 4–6. Pin rules that plan no work.
|
|
240
303
|
const vsPin = pin ? compareToolVersions(installed, pin) : undefined;
|
|
241
304
|
if (pin && vsPin === 0) {
|
|
242
305
|
return { ...base, state: "current", reason: "at-pin", target: null };
|
|
@@ -261,24 +324,18 @@ export function classifyTool(
|
|
|
261
324
|
};
|
|
262
325
|
}
|
|
263
326
|
|
|
264
|
-
//
|
|
327
|
+
// 7. The veto: the upgrade would not replace the file PATH runs.
|
|
265
328
|
if (facts.managed !== true) {
|
|
266
329
|
return {
|
|
267
330
|
...base,
|
|
268
331
|
state: "unknown",
|
|
269
332
|
reason: "not-managed",
|
|
270
333
|
target: null,
|
|
271
|
-
note:
|
|
272
|
-
facts.managed === false
|
|
273
|
-
? `on PATH at ${facts.resolvedPath ?? "?"}, which ${facts.owner ?? spec.via} does not own — upgrading through ${spec.via} would install a second copy`
|
|
274
|
-
: withFailure(
|
|
275
|
-
`could not tell whether ${spec.via} owns ${facts.resolvedPath ?? spec.name}`,
|
|
276
|
-
facts.failure,
|
|
277
|
-
),
|
|
334
|
+
note: notManagedNote(spec, facts),
|
|
278
335
|
};
|
|
279
336
|
}
|
|
280
337
|
|
|
281
|
-
//
|
|
338
|
+
// 8. The veto: the installer will refuse to move it.
|
|
282
339
|
if (facts.heldBack) {
|
|
283
340
|
return {
|
|
284
341
|
...base,
|
|
@@ -289,7 +346,7 @@ export function classifyTool(
|
|
|
289
346
|
};
|
|
290
347
|
}
|
|
291
348
|
|
|
292
|
-
//
|
|
349
|
+
// 9–10. Pinned and behind the pin (every other pinned case returned above).
|
|
293
350
|
// From here on a row can plan work, so an adoption must travel with it.
|
|
294
351
|
const adopted = facts.adopted ? { adopted: facts.adopted } : {};
|
|
295
352
|
if (pin) {
|
|
@@ -312,7 +369,7 @@ export function classifyTool(
|
|
|
312
369
|
};
|
|
313
370
|
}
|
|
314
371
|
|
|
315
|
-
//
|
|
372
|
+
// 11–12. The owning installer's own verdict.
|
|
316
373
|
if (facts.behind === true) {
|
|
317
374
|
return {
|
|
318
375
|
...base,
|
|
@@ -329,7 +386,7 @@ export function classifyTool(
|
|
|
329
386
|
return { ...base, state: "current", reason: "up-to-date", target: null };
|
|
330
387
|
}
|
|
331
388
|
|
|
332
|
-
//
|
|
389
|
+
// 13. Nothing to compare against.
|
|
333
390
|
const latest = facts.latest;
|
|
334
391
|
if (latest === null) {
|
|
335
392
|
return {
|
|
@@ -341,7 +398,7 @@ export function classifyTool(
|
|
|
341
398
|
};
|
|
342
399
|
}
|
|
343
400
|
|
|
344
|
-
//
|
|
401
|
+
// 14. Our own comparison.
|
|
345
402
|
const cmp = compareToolVersions(installed, latest);
|
|
346
403
|
if (cmp === null) {
|
|
347
404
|
return {
|
|
@@ -598,6 +655,33 @@ export function isInside(resolved: string, ownerDir: string): boolean {
|
|
|
598
655
|
return file.startsWith(dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`);
|
|
599
656
|
}
|
|
600
657
|
|
|
658
|
+
/**
|
|
659
|
+
* The npm prefix whose global tree holds `resolved`, read from the path alone:
|
|
660
|
+
* `<prefix>/lib/node_modules/<pkg>/…` → `<prefix>`. Null when the file is not
|
|
661
|
+
* inside that package's directory under any `lib/node_modules`.
|
|
662
|
+
*
|
|
663
|
+
* This is npm's own global layout, so it names the owner even when the npm
|
|
664
|
+
* first on PATH reports another root — one machine with nvm, Homebrew and
|
|
665
|
+
* `~/.local` Node installs has three. The package segment must be `pkg`
|
|
666
|
+
* itself: a file of some other package is not what installing `pkg` replaces.
|
|
667
|
+
*
|
|
668
|
+
* A Homebrew keg is excluded. A formula bundles its package under
|
|
669
|
+
* `Cellar/<formula>/<version>/libexec/lib/node_modules/…`, which has the same
|
|
670
|
+
* shape, but brew owns that tree; npm writing into it would leave the keg's
|
|
671
|
+
* version and its contents disagreeing.
|
|
672
|
+
*/
|
|
673
|
+
export function npmGlobalPrefixOf(
|
|
674
|
+
resolved: string,
|
|
675
|
+
pkg: string,
|
|
676
|
+
): string | null {
|
|
677
|
+
const marker = `${path.sep}${path.join("lib", "node_modules", pkg)}${path.sep}`;
|
|
678
|
+
const at = resolved.indexOf(marker);
|
|
679
|
+
if (at < 0) return null;
|
|
680
|
+
const prefix = resolved.slice(0, at) || path.sep;
|
|
681
|
+
if (prefix.split(path.sep).includes("Cellar")) return null;
|
|
682
|
+
return prefix;
|
|
683
|
+
}
|
|
684
|
+
|
|
601
685
|
// ── IO seam ──────────────────────────────────────────────────────────────────
|
|
602
686
|
|
|
603
687
|
export interface ExecResult {
|
|
@@ -1261,44 +1345,189 @@ async function probePip(
|
|
|
1261
1345
|
if (!pinned) await pypiLatest(s, pkg, f);
|
|
1262
1346
|
}
|
|
1263
1347
|
|
|
1348
|
+
/** Version from the package directory, latest from the registry (unpinned only). */
|
|
1349
|
+
async function readNodePackage(
|
|
1350
|
+
s: ProbeSession,
|
|
1351
|
+
pkg: string,
|
|
1352
|
+
f: ToolFacts,
|
|
1353
|
+
pkgDir: string,
|
|
1354
|
+
source: "bun-global" | "npm-global",
|
|
1355
|
+
pinned: boolean,
|
|
1356
|
+
): Promise<void> {
|
|
1357
|
+
f.installed = await packageJsonVersion(s.io, pkgDir);
|
|
1358
|
+
if (f.installed) addSource(f, source);
|
|
1359
|
+
if (!pinned) await npmLatest(s, pkg, f);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1264
1362
|
async function probeNodeGlobal(
|
|
1265
1363
|
s: ProbeSession,
|
|
1266
1364
|
spec: BinRequirement,
|
|
1267
1365
|
f: ToolFacts,
|
|
1268
1366
|
resolvedPath: string,
|
|
1269
|
-
pinned: boolean,
|
|
1270
1367
|
): Promise<void> {
|
|
1271
1368
|
const pkg = spec.package ?? spec.name;
|
|
1272
|
-
|
|
1369
|
+
const pin = pinOf(spec);
|
|
1370
|
+
const pinned = pin !== undefined;
|
|
1273
1371
|
if (spec.via === "bun") {
|
|
1274
|
-
root = path.join(
|
|
1372
|
+
const root = path.join(
|
|
1275
1373
|
s.io.env.BUN_INSTALL ?? path.join(s.io.homedir, ".bun"),
|
|
1276
1374
|
"install",
|
|
1277
1375
|
"global",
|
|
1278
1376
|
"node_modules",
|
|
1279
1377
|
);
|
|
1280
1378
|
f.owner = `bun (global ${root})`;
|
|
1281
|
-
|
|
1282
|
-
|
|
1379
|
+
const { inside, real } = await containedIn(
|
|
1380
|
+
s,
|
|
1381
|
+
resolvedPath,
|
|
1382
|
+
path.join(root, pkg),
|
|
1383
|
+
);
|
|
1384
|
+
f.managed = inside;
|
|
1385
|
+
if (inside && real)
|
|
1386
|
+
await readNodePackage(s, pkg, f, real, "bun-global", pinned);
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// The root the npm first on PATH installs into.
|
|
1391
|
+
const npmRoot = await s.npmRoot();
|
|
1392
|
+
if ("dir" in npmRoot) {
|
|
1393
|
+
f.owner = `npm (root ${npmRoot.dir})`;
|
|
1394
|
+
const { inside, real } = await containedIn(
|
|
1395
|
+
s,
|
|
1396
|
+
resolvedPath,
|
|
1397
|
+
path.join(npmRoot.dir, pkg),
|
|
1398
|
+
);
|
|
1399
|
+
if (inside && real) {
|
|
1400
|
+
f.managed = true;
|
|
1401
|
+
await readNodePackage(s, pkg, f, real, "npm-global", pinned);
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Not under that root — but perhaps under ANOTHER npm's. The file's own
|
|
1407
|
+
// path names the prefix that owns it, and `--prefix` upgrades exactly that
|
|
1408
|
+
// tree, so this is npm-managed, never the "second copy" veto.
|
|
1409
|
+
const prefix = npmGlobalPrefixOf(resolvedPath, pkg);
|
|
1410
|
+
if (prefix === null) {
|
|
1283
1411
|
if ("failure" in npmRoot) {
|
|
1284
1412
|
f.managed = null;
|
|
1285
1413
|
noteFailure(f, npmRoot.failure);
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
root = npmRoot.dir;
|
|
1289
|
-
f.owner = `npm (root ${root})`;
|
|
1414
|
+
} else f.managed = false;
|
|
1415
|
+
return;
|
|
1290
1416
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1417
|
+
f.managed = true;
|
|
1418
|
+
f.owner = `npm (prefix ${prefix})`;
|
|
1419
|
+
// Reached with the default root only when containment could not resolve the
|
|
1420
|
+
// package directory; the plain command is right then, and --prefix is noise.
|
|
1421
|
+
if (!(await isNpmDefaultPrefix(s, npmRoot, prefix))) {
|
|
1422
|
+
f.upgradeCommand = npmPrefixInstallCommand(prefix, pkg, pin);
|
|
1423
|
+
}
|
|
1424
|
+
// `resolvedPath` is already real, so every ancestor of it is too.
|
|
1425
|
+
const pkgDir = path.join(prefix, "lib", "node_modules", pkg);
|
|
1426
|
+
await readNodePackage(s, pkg, f, pkgDir, "npm-global", pinned);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/** Does `prefix` hold the global root `npm root -g` reported? False when it failed. */
|
|
1430
|
+
async function isNpmDefaultPrefix(
|
|
1431
|
+
s: ProbeSession,
|
|
1432
|
+
npmRoot: Located,
|
|
1433
|
+
prefix: string,
|
|
1434
|
+
): Promise<boolean> {
|
|
1435
|
+
if (!("dir" in npmRoot)) return false;
|
|
1436
|
+
const root = path.resolve(prefix, "lib", "node_modules");
|
|
1437
|
+
if (path.resolve(npmRoot.dir) === root) return true;
|
|
1438
|
+
const real = await s.io.realpath(npmRoot.dir);
|
|
1439
|
+
return real !== null && path.resolve(real) === root;
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Is `onPath` — the entry PATH actually runs, before any link is followed —
|
|
1444
|
+
* the launcher the native installer maintains beside `dataHome`?
|
|
1445
|
+
*
|
|
1446
|
+
* The launcher lives one level up from the data home, in `bin`:
|
|
1447
|
+
* `~/.local/share` → `~/.local/bin/claude`. The comparison is on the
|
|
1448
|
+
* DIRECTORIES, never on the files: both the launcher and a hand-made link
|
|
1449
|
+
* resolve into `versions/<version>`, which is precisely what has to be told
|
|
1450
|
+
* apart. Their real paths are compared too, so a symlinked `~/.local` still
|
|
1451
|
+
* matches.
|
|
1452
|
+
*/
|
|
1453
|
+
async function isNativeLauncher(
|
|
1454
|
+
s: ProbeSession,
|
|
1455
|
+
onPath: string,
|
|
1456
|
+
dataHome: string,
|
|
1457
|
+
): Promise<boolean> {
|
|
1458
|
+
const launcherDir = path.resolve(dataHome, "..", "bin");
|
|
1459
|
+
const entryDir = path.resolve(path.dirname(onPath));
|
|
1460
|
+
if (launcherDir === entryDir) return true;
|
|
1461
|
+
const [launcherReal, entryReal] = await Promise.all([
|
|
1462
|
+
s.io.realpath(launcherDir),
|
|
1463
|
+
s.io.realpath(entryDir),
|
|
1464
|
+
]);
|
|
1465
|
+
return (
|
|
1466
|
+
launcherReal !== null &&
|
|
1467
|
+
entryReal !== null &&
|
|
1468
|
+
path.resolve(launcherReal) === path.resolve(entryReal)
|
|
1295
1469
|
);
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/**
|
|
1473
|
+
* Claude Code's native installer keeps every release side by side under
|
|
1474
|
+
* `<data>/claude/versions/<version>` and points PATH (`~/.local/bin/claude`) at
|
|
1475
|
+
* one. That file is Claude Code's own, updated by `claude update`: no package
|
|
1476
|
+
* manager owns it, and the npm spec the catalogue carries would only install a
|
|
1477
|
+
* second copy beside it. So this runs BEFORE the spec's installer is asked.
|
|
1478
|
+
*
|
|
1479
|
+
* Recognised only at the installer's own directory — `~/.local/share`, or
|
|
1480
|
+
* `$XDG_DATA_HOME` when set — and only for a file named by a bare release
|
|
1481
|
+
* number. The version is that name; the latest is npm's `latest` dist-tag for
|
|
1482
|
+
* Claude Code, the channel the native installer tracks by default.
|
|
1483
|
+
*
|
|
1484
|
+
* **Resolving into `versions/` is not enough: the entry PATH runs has to BE the
|
|
1485
|
+
* native launcher.** A hand-made link earlier on PATH — `/usr/local/bin/claude`
|
|
1486
|
+
* pointed at a release directory by hand, a wrapper someone wrote — resolves
|
|
1487
|
+
* into exactly the same place, and planning `claude update` for it would be a
|
|
1488
|
+
* plan that changes nothing: the updater rewrites the launcher, and PATH goes
|
|
1489
|
+
* on running the other file at the old version. Every run would find it behind
|
|
1490
|
+
* and plan the same no-op again. So the check is on `onPath`, the unresolved
|
|
1491
|
+
* entry, and a copy that is not the launcher falls through to the spec's own
|
|
1492
|
+
* installer — which reports it as a second copy it does not own.
|
|
1493
|
+
*
|
|
1494
|
+
* True when it recognised the layout; `f` then holds the whole answer.
|
|
1495
|
+
*/
|
|
1496
|
+
async function probeClaudeNative(
|
|
1497
|
+
s: ProbeSession,
|
|
1498
|
+
spec: BinRequirement,
|
|
1499
|
+
f: ToolFacts,
|
|
1500
|
+
onPath: string,
|
|
1501
|
+
resolvedPath: string,
|
|
1502
|
+
): Promise<boolean> {
|
|
1503
|
+
// The cheap, pure shape test first, so no other tool pays for the IO below.
|
|
1504
|
+
const version = versionFromVersionedPath(resolvedPath);
|
|
1505
|
+
if (!version) return false;
|
|
1506
|
+
const dir = path.resolve(path.dirname(resolvedPath));
|
|
1507
|
+
if (path.basename(path.dirname(dir)) !== "claude") return false;
|
|
1508
|
+
const dataHomes = [
|
|
1509
|
+
path.join(s.io.homedir, ".local", "share"),
|
|
1510
|
+
...(s.io.env.XDG_DATA_HOME ? [s.io.env.XDG_DATA_HOME] : []),
|
|
1511
|
+
];
|
|
1512
|
+
let owned = false;
|
|
1513
|
+
for (const home of dataHomes) {
|
|
1514
|
+
const versions = path.resolve(home, "claude", "versions");
|
|
1515
|
+
if (versions !== dir && (await s.io.realpath(versions)) !== dir) continue;
|
|
1516
|
+
// The directory is the installer's. Only the launcher it maintains
|
|
1517
|
+
// there speaks for it.
|
|
1518
|
+
owned = await isNativeLauncher(s, onPath, home);
|
|
1519
|
+
if (owned) break;
|
|
1520
|
+
}
|
|
1521
|
+
if (!owned) return false;
|
|
1522
|
+
|
|
1523
|
+
const pin = pinOf(spec);
|
|
1524
|
+
f.managed = true;
|
|
1525
|
+
f.owner = `Claude Code's native installer (${dir})`;
|
|
1526
|
+
f.installed = version;
|
|
1527
|
+
addSource(f, "version-dir");
|
|
1528
|
+
f.upgradeCommand = claudeNativeUpgradeCommand(pin);
|
|
1529
|
+
if (pin === undefined) await npmLatest(s, CLAUDE_CODE_NPM_PACKAGE, f);
|
|
1530
|
+
return true;
|
|
1302
1531
|
}
|
|
1303
1532
|
|
|
1304
1533
|
async function probeGo(
|
|
@@ -1375,6 +1604,11 @@ async function probeInto(
|
|
|
1375
1604
|
f.present = true;
|
|
1376
1605
|
f.resolvedPath = resolvedPath;
|
|
1377
1606
|
|
|
1607
|
+
// A file Claude Code's native installer owns answers for itself, whatever
|
|
1608
|
+
// installer the spec names. `onPath` as well as `resolvedPath`: ownership
|
|
1609
|
+
// turns on which file PATH runs, not only on where it points.
|
|
1610
|
+
if (await probeClaudeNative(s, spec, f, onPath, resolvedPath)) return;
|
|
1611
|
+
|
|
1378
1612
|
const pinned = pinOf(spec) !== undefined;
|
|
1379
1613
|
const via: BinInstaller = spec.via;
|
|
1380
1614
|
switch (via) {
|
|
@@ -1386,18 +1620,56 @@ async function probeInto(
|
|
|
1386
1620
|
break;
|
|
1387
1621
|
case "bun":
|
|
1388
1622
|
case "npm":
|
|
1389
|
-
await probeNodeGlobal(s, spec, f, resolvedPath
|
|
1623
|
+
await probeNodeGlobal(s, spec, f, resolvedPath);
|
|
1390
1624
|
break;
|
|
1391
1625
|
case "go":
|
|
1392
1626
|
await probeGo(s, spec, f, resolvedPath, pinned);
|
|
1393
1627
|
break;
|
|
1394
1628
|
}
|
|
1395
1629
|
|
|
1396
|
-
// The method's own record was silent
|
|
1397
|
-
//
|
|
1630
|
+
// The method's own record was silent. Before anything runs, read what the
|
|
1631
|
+
// installed files already say about themselves.
|
|
1632
|
+
if (f.installed === null) versionFromFiles(f, resolvedPath);
|
|
1633
|
+
|
|
1634
|
+
// Still nothing: read the running file itself — but only where the caller
|
|
1635
|
+
// opted into running a check command (see there).
|
|
1398
1636
|
if (f.installed === null) await versionFromCheck(s, spec, f, onPath);
|
|
1399
1637
|
}
|
|
1400
1638
|
|
|
1639
|
+
/**
|
|
1640
|
+
* The installed version, from files next to the binary — never by running it.
|
|
1641
|
+
*
|
|
1642
|
+
* The method's record is keyed to where THIS spec's installer keeps things, so
|
|
1643
|
+
* it is silent whenever the tool on PATH came some other way: magus installed
|
|
1644
|
+
* with bun while the catalogue says npm, a pnpm or yarn global. (An npm global
|
|
1645
|
+
* under another prefix and Claude Code's native installer are recognised as
|
|
1646
|
+
* OWNED before this runs.) Each of those still says what it is. A binary inside
|
|
1647
|
+
* `node_modules` belongs to a package whose `package.json` carries the version;
|
|
1648
|
+
* a versioned install names the file after it. Neither says who OWNS the file,
|
|
1649
|
+
* so `managed` is left as the method found it — the classifier still refuses to
|
|
1650
|
+
* plan an upgrade it cannot land — but the row can show a version instead of
|
|
1651
|
+
* "could not be read".
|
|
1652
|
+
*
|
|
1653
|
+
* Reads the real filesystem rather than the IO seam: `resolvedPath` already
|
|
1654
|
+
* came through the seam's `realpath`, and a path a test invented does not
|
|
1655
|
+
* exist, so this answers null there exactly as a missing record does.
|
|
1656
|
+
*/
|
|
1657
|
+
function versionFromFiles(f: ToolFacts, resolvedPath: string): void {
|
|
1658
|
+
if (resolvedPath.split(path.sep).includes("node_modules")) {
|
|
1659
|
+
const version = readPackageVersionForBin(resolvedPath);
|
|
1660
|
+
if (version) {
|
|
1661
|
+
f.installed = version;
|
|
1662
|
+
addSource(f, "package-json");
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
const version = readVersionDirForBin(resolvedPath);
|
|
1667
|
+
if (version) {
|
|
1668
|
+
f.installed = version;
|
|
1669
|
+
addSource(f, "version-dir");
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1401
1673
|
/** A copy, with the verdict fields cleared: a timed-out tool is never "behind". */
|
|
1402
1674
|
function timedOut(f: ToolFacts, deadlineMs: number): ToolFacts {
|
|
1403
1675
|
return {
|
|
@@ -1568,11 +1840,10 @@ function methodFromPath(binPath: string): InstallMethod | null {
|
|
|
1568
1840
|
if (binPath.includes("/.bun/")) return "bun";
|
|
1569
1841
|
if (binPath.includes("/homebrew/") || binPath.includes("/Cellar/"))
|
|
1570
1842
|
return "brew";
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
)
|
|
1575
|
-
return "npm";
|
|
1843
|
+
// Claude Code's native installer. `~/.local/bin/claude` itself proves
|
|
1844
|
+
// nothing — an npm with prefix `~/.local` links there too — so the link's
|
|
1845
|
+
// target decides.
|
|
1846
|
+
if (binPath.includes("/.local/share/claude/")) return "native";
|
|
1576
1847
|
if (binPath.includes("/.nvm/") || binPath.includes("/node_modules/"))
|
|
1577
1848
|
return "npm";
|
|
1578
1849
|
if (binPath.includes("/.local/share/pnpm") || binPath.includes("/pnpm/"))
|
|
@@ -1624,7 +1895,8 @@ export async function detectInstallMethods(
|
|
|
1624
1895
|
for (const binPath of paths) {
|
|
1625
1896
|
let method = methodFromPath(binPath);
|
|
1626
1897
|
const linkTarget = (await io.readlink(binPath)) ?? "";
|
|
1627
|
-
if (!method && linkTarget)
|
|
1898
|
+
if (!method && linkTarget)
|
|
1899
|
+
method = methodFromPath(path.resolve(path.dirname(binPath), linkTarget));
|
|
1628
1900
|
if (method === "brew" && !brewFormula) {
|
|
1629
1901
|
brewFormula = extractBrewFormula(binPath, linkTarget);
|
|
1630
1902
|
}
|
|
@@ -316,6 +316,45 @@ export function binUpgradeCommand(
|
|
|
316
316
|
}
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
+
/**
|
|
320
|
+
* npm's global install INTO one named prefix: `npm install -g --prefix <prefix>
|
|
321
|
+
* <pkg>@<version>`.
|
|
322
|
+
*
|
|
323
|
+
* For a tool whose file lives under `<prefix>/lib/node_modules/<pkg>/` when the
|
|
324
|
+
* npm first on PATH reports a different global root — several Node installs
|
|
325
|
+
* side by side (nvm, Homebrew, `~/.local`). Plain `npm install -g` would write a
|
|
326
|
+
* second copy into ITS root, which PATH never runs.
|
|
327
|
+
*
|
|
328
|
+
* `--prefix` and not "run `<prefix>/bin/npm`": npm puts a global install next
|
|
329
|
+
* to the NODE executable that runs it (or wherever an `.npmrc` `prefix` says),
|
|
330
|
+
* not next to the npm script, so the script's own location proves nothing. The
|
|
331
|
+
* flag names the tree outright.
|
|
332
|
+
*/
|
|
333
|
+
export function npmPrefixInstallCommand(
|
|
334
|
+
prefix: string,
|
|
335
|
+
pkg: string,
|
|
336
|
+
version = "latest",
|
|
337
|
+
): Command {
|
|
338
|
+
return {
|
|
339
|
+
cmd: "npm",
|
|
340
|
+
args: ["install", "-g", "--prefix", prefix, `${pkg}@${version}`],
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Claude Code's own updater, for a copy its native installer put on PATH
|
|
346
|
+
* (`~/.local/share/claude/versions/<version>`). No package manager owns that
|
|
347
|
+
* file, so `npm install -g` would only add a second copy beside it.
|
|
348
|
+
*
|
|
349
|
+
* Unpinned: `claude update`. Pinned: `claude install <pin>`, which installs
|
|
350
|
+
* exactly that native build — `claude update` would go past the pin.
|
|
351
|
+
*/
|
|
352
|
+
export function claudeNativeUpgradeCommand(pin?: string): Command {
|
|
353
|
+
return pin
|
|
354
|
+
? { cmd: "claude", args: ["install", pin.replace(/^v/i, "")] }
|
|
355
|
+
: { cmd: "claude", args: ["update"] };
|
|
356
|
+
}
|
|
357
|
+
|
|
319
358
|
/**
|
|
320
359
|
* The command that moves an ALREADY-INSTALLED binary to exactly its pin.
|
|
321
360
|
*
|
|
@@ -554,12 +554,17 @@ export async function updatePluginInScope(
|
|
|
554
554
|
* TUI's CLI Tools row so the two front ends cannot run different commands for
|
|
555
555
|
* the same row. The CLI's plan report renders this same function, so what it
|
|
556
556
|
* shows is what runs.
|
|
557
|
+
*
|
|
558
|
+
* An upgrade whose probe built its own command (`item.upgradeCommand`: another
|
|
559
|
+
* npm prefix, Claude Code's updater) runs that — the value the TUI row runs.
|
|
557
560
|
*/
|
|
558
561
|
export function binCommandFor(
|
|
559
562
|
item: BinUpdateItem,
|
|
560
563
|
spec: ResolvedBin,
|
|
561
564
|
): Command | null {
|
|
562
565
|
if (!binNeedsWork(item)) return null;
|
|
566
|
+
if (item.action === "upgrade" && item.upgradeCommand)
|
|
567
|
+
return item.upgradeCommand;
|
|
563
568
|
return binCommandForState(
|
|
564
569
|
item.action === "install" ? "install" : "upgrade",
|
|
565
570
|
item.target,
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import type { ProfileSkillRef, ResolvedBin } from "../types/index.js";
|
|
32
|
+
import type { Command } from "../utils/command-utils.js";
|
|
32
33
|
import type { PluginScope } from "./claude-cli.js";
|
|
33
34
|
import {
|
|
34
35
|
type PluginInfo,
|
|
@@ -796,6 +797,13 @@ export interface BinUpdateItem {
|
|
|
796
797
|
* afterwards.
|
|
797
798
|
*/
|
|
798
799
|
adopted?: string;
|
|
800
|
+
/**
|
|
801
|
+
* {@link ToolFacts.upgradeCommand}: the argv that upgrades the file PATH
|
|
802
|
+
* runs when the spec's own installer would not (another npm prefix, Claude
|
|
803
|
+
* Code's updater). An `upgrade` runs this instead of the spec's command —
|
|
804
|
+
* the same value the TUI's row runs on Enter.
|
|
805
|
+
*/
|
|
806
|
+
upgradeCommand?: Command;
|
|
799
807
|
/** The exact pin, when the profile declares one. */
|
|
800
808
|
version?: string;
|
|
801
809
|
/** The version PATH runs, or null when absent / unreadable. */
|
|
@@ -851,6 +859,7 @@ export function planBinUpdates(
|
|
|
851
859
|
};
|
|
852
860
|
if (status.note) item.note = status.note;
|
|
853
861
|
if (status.adopted) item.adopted = status.adopted;
|
|
862
|
+
if (f?.upgradeCommand) item.upgradeCommand = f.upgradeCommand;
|
|
854
863
|
if (bin.version) item.version = bin.version;
|
|
855
864
|
return item;
|
|
856
865
|
});
|