magus-cli 7.10.3 → 7.10.5
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__/blackbox-convergence.test.ts +0 -1
- package/src/__tests__/blackbox-parity.test.ts +2 -2
- package/src/__tests__/blackbox-tool-state.test.ts +8 -18
- package/src/__tests__/blind/c14-cli-row-label.test.ts +8 -3
- package/src/__tests__/blind/c15-probe-ownership.test.ts +11 -11
- package/src/__tests__/blind/c16-probe-adopts-node-global.test.ts +16 -12
- package/src/__tests__/blind/c17-enter-label.test.ts +42 -7
- package/src/__tests__/blind/c18-probe-adopts-homebrew-keg.test.ts +36 -19
- package/src/__tests__/blind/c19-probe-owner-version-shadow.test.ts +38 -35
- package/src/__tests__/cli-tool-commands.test.ts +2 -2
- package/src/__tests__/cli-tool-external-row.test.tsx +112 -0
- package/src/__tests__/cli-tools-adapter.test.ts +32 -22
- package/src/__tests__/cli-update-view.test.ts +124 -51
- package/src/__tests__/fixtures/bins.ts +9 -2
- package/src/__tests__/npm-package-name.test.ts +3 -2
- package/src/__tests__/profile-worktree-include.test.ts +212 -0
- package/src/__tests__/tool-adopt-brew-owner.test.ts +167 -0
- package/src/__tests__/tool-probe.test.ts +231 -144
- package/src/__tests__/tool-rows-followups.test.ts +315 -0
- package/src/__tests__/tool-rows-reproduction.test.ts +630 -0
- package/src/__tests__/tool-versions.test.ts +164 -0
- package/src/__tests__/toolchain.test.ts +0 -2
- package/src/__tests__/update-plan.test.ts +40 -9
- package/src/cli/update-view.ts +180 -76
- package/src/cli/update.ts +44 -37
- package/src/data/cli-tools.ts +5 -10
- package/src/data/tool-versions.ts +137 -0
- package/src/services/cli-tool-commands.ts +1 -2
- package/src/services/cli-tool-version.ts +10 -88
- package/src/services/profile-activate.ts +9 -0
- package/src/services/profile-live.ts +26 -0
- package/src/services/profile-skills.ts +14 -4
- package/src/services/profile-state.ts +18 -0
- package/src/services/resolver.ts +9 -15
- package/src/services/tool-probe.ts +537 -691
- package/src/services/toolchain.ts +10 -4
- package/src/services/update-engine.ts +9 -15
- package/src/services/update-plan.ts +49 -21
- package/src/types/index.ts +0 -2
- package/src/ui/adapters/cliToolsAdapter.ts +31 -22
- package/src/ui/renderers/cliToolRenderers.tsx +15 -21
- package/src/ui/state/types.ts +0 -1
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +0 -239
package/src/cli/update.ts
CHANGED
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
} from "../services/update-engine.js";
|
|
78
78
|
import {
|
|
79
79
|
type UpdatePlan,
|
|
80
|
+
binHeldBack,
|
|
80
81
|
planBinUpdates,
|
|
81
82
|
planHasWork,
|
|
82
83
|
planIsBehind,
|
|
@@ -98,7 +99,7 @@ import {
|
|
|
98
99
|
header,
|
|
99
100
|
info,
|
|
100
101
|
note as noteLine,
|
|
101
|
-
|
|
102
|
+
nothingToApplyLines,
|
|
102
103
|
ok as okLine,
|
|
103
104
|
pendingHeadline,
|
|
104
105
|
planReport,
|
|
@@ -106,7 +107,6 @@ import {
|
|
|
106
107
|
stepRecord,
|
|
107
108
|
summaryLine,
|
|
108
109
|
uncheckedGateNotice,
|
|
109
|
-
unknownToolsNotice,
|
|
110
110
|
warn as warnLine,
|
|
111
111
|
} from "./update-view.js";
|
|
112
112
|
import { setUpOtherWorktrees } from "./worktrees.js";
|
|
@@ -447,18 +447,25 @@ async function updateProfile(
|
|
|
447
447
|
const items = planBinUpdates(tools, facts);
|
|
448
448
|
const n = (a: string) => items.filter((i) => i.action === a).length;
|
|
449
449
|
const behind = n("upgrade");
|
|
450
|
-
const
|
|
450
|
+
const elsewhere = n("external");
|
|
451
|
+
// A held-back tool was checked: brew says it is behind and pinned.
|
|
452
|
+
const held = items.filter(binHeldBack).length;
|
|
453
|
+
const unchecked = n("unknown") - held;
|
|
451
454
|
const missing = n("install");
|
|
452
455
|
steps.binaries.detail = [
|
|
453
456
|
`${items.length} checked`,
|
|
454
457
|
behind > 0 ? `${behind} behind` : "",
|
|
455
|
-
|
|
458
|
+
held > 0 ? `${held} held back` : "",
|
|
459
|
+
elsewhere > 0 ? `${elsewhere} installed elsewhere` : "",
|
|
460
|
+
unchecked > 0 ? `${unchecked} not checked` : "",
|
|
456
461
|
missing > 0 ? `${missing} missing` : "",
|
|
457
462
|
declaredOnly > 0 ? `${declaredOnly} managed by their declaration` : "",
|
|
458
463
|
]
|
|
459
464
|
.filter(Boolean)
|
|
460
465
|
.join(" · ");
|
|
461
|
-
|
|
466
|
+
// A warning only for a tool that is missing. Installed elsewhere is not a
|
|
467
|
+
// problem, and a tool that could not be checked is counted, not alarmed.
|
|
468
|
+
if (missing > 0) steps.binaries.state = "warn";
|
|
462
469
|
return facts;
|
|
463
470
|
});
|
|
464
471
|
|
|
@@ -505,21 +512,11 @@ async function updateProfile(
|
|
|
505
512
|
for (const line of planReport(plan)) console.log(line);
|
|
506
513
|
|
|
507
514
|
const counts = summarizePlan(plan);
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
// No plugin equivalent of `toolUnknownLine`. Every unverified plugin is
|
|
514
|
-
// already a row in the table above, under a heading that names its state,
|
|
515
|
-
// so a trailing "N plugin(s) could not be checked" restated the table in
|
|
516
|
-
// prose and — since the grouping landed — miscounted it: the count is raw
|
|
517
|
-
// `unknown`, which now includes rows filed under Current. A tool has no
|
|
518
|
-
// such row, which is why that notice stays and names them.
|
|
519
|
-
if (counts.unknownTools > 0) {
|
|
520
|
-
console.log("");
|
|
521
|
-
console.log(toolUnknownLine());
|
|
522
|
-
}
|
|
515
|
+
// No trailing "N could not be checked" notice, for plugins or tools. Every
|
|
516
|
+
// unverified plugin is already a row under a heading that names its state,
|
|
517
|
+
// and every tool is counted by state in the CLI section's header — so such
|
|
518
|
+
// a notice only restated the table in prose, and for a tool installed
|
|
519
|
+
// another way it claimed a failure that never happened.
|
|
523
520
|
|
|
524
521
|
// `--check` gates on planIsBehind, not planHasWork. An already-installed
|
|
525
522
|
// skill is re-fetched on every apply because its manifest ref carries no
|
|
@@ -528,17 +525,29 @@ async function updateProfile(
|
|
|
528
525
|
//
|
|
529
526
|
// An `unknown` does not fail the gate, for plugins and tools alike (see
|
|
530
527
|
// planIsBehind). The same argument applies to both: a plugin whose
|
|
531
|
-
// marketplace publishes no version
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
528
|
+
// marketplace publishes no version is unknown permanently, so failing on it
|
|
529
|
+
// is the skill case again — this profile's 5 unversioned official plugins
|
|
530
|
+
// made `--check` exit 1 on every run. A tool installed another way is not
|
|
531
|
+
// unknown at all, and never fails it; nor does a held-back one, which only
|
|
532
|
+
// the user can release. A tool or plugin something says is
|
|
533
|
+
// BEHIND still does fail it. Because a failed check now passes silently by
|
|
534
|
+
// exit code, the unchecked count is printed beside the verdict.
|
|
537
535
|
if (flags.check) {
|
|
538
536
|
console.log("");
|
|
539
537
|
if (!planIsBehind(plan)) {
|
|
540
|
-
if (counts.unknown > 0)
|
|
541
|
-
|
|
538
|
+
if (counts.unknown > 0)
|
|
539
|
+
console.log(
|
|
540
|
+
uncheckedGateNotice(counts.unknownPlugins, counts.unknownTools),
|
|
541
|
+
);
|
|
542
|
+
// A held-back tool is behind, so "nothing behind" would be false; it
|
|
543
|
+
// does not fail the gate because only the user can release the pin.
|
|
544
|
+
console.log(
|
|
545
|
+
okLine(
|
|
546
|
+
counts.heldBack > 0
|
|
547
|
+
? `Nothing missing, and nothing behind except ${counts.heldBack} held back.`
|
|
548
|
+
: "Nothing missing and nothing behind.",
|
|
549
|
+
),
|
|
550
|
+
);
|
|
542
551
|
return 0;
|
|
543
552
|
}
|
|
544
553
|
console.error(
|
|
@@ -563,21 +572,17 @@ async function updateProfile(
|
|
|
563
572
|
Date.now() - startedAt,
|
|
564
573
|
0,
|
|
565
574
|
counts.kept,
|
|
575
|
+
counts.external,
|
|
576
|
+
counts.heldBack,
|
|
566
577
|
))
|
|
567
578
|
console.log(line);
|
|
568
579
|
// `planHasWork` counts an `unknown` plugin as work, because from the
|
|
569
580
|
// planner's side "we could not check this" is genuinely not "this is
|
|
570
581
|
// fine". But nothing is APPLIED for an unknown — the apply loop skips it —
|
|
571
582
|
// so reporting on `planHasWork` alone claimed a run had happened when it
|
|
572
|
-
// had not. Say what is actually true instead
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
console.log(nothingToApplyNotice(counts.unknownPlugins));
|
|
576
|
-
}
|
|
577
|
-
if (counts.unknownTools > 0) console.log(toolUnknownLine());
|
|
578
|
-
return 0;
|
|
579
|
-
}
|
|
580
|
-
console.log(okLine("Everything is up to date."));
|
|
583
|
+
// had not. Say what is actually true instead: "Everything is up to date"
|
|
584
|
+
// only when nothing went unchecked and nothing is held back.
|
|
585
|
+
for (const line of nothingToApplyLines(counts)) console.log(line);
|
|
581
586
|
return 0;
|
|
582
587
|
}
|
|
583
588
|
|
|
@@ -697,6 +702,8 @@ async function updateProfile(
|
|
|
697
702
|
Date.now() - startedAt,
|
|
698
703
|
outcome.unchanged.length,
|
|
699
704
|
counts.kept,
|
|
705
|
+
counts.external,
|
|
706
|
+
counts.heldBack,
|
|
700
707
|
))
|
|
701
708
|
console.log(line);
|
|
702
709
|
|
package/src/data/cli-tools.ts
CHANGED
|
@@ -21,8 +21,12 @@ interface CatalogueDisplay {
|
|
|
21
21
|
category: "ai-coding" | "utility";
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* There is no `checkCommand` field either: how magus asks a tool its version
|
|
26
|
+
* is `data/tool-versions.ts`, the one table, so each tool's version command
|
|
27
|
+
* exists once.
|
|
28
|
+
*/
|
|
24
29
|
export interface CliTool extends CatalogueDisplay {
|
|
25
|
-
checkCommand: string;
|
|
26
30
|
/**
|
|
27
31
|
* Reuses `BinInstaller` rather than restating its members, so the
|
|
28
32
|
* correspondence with the resolver in `services/toolchain.ts` — which is
|
|
@@ -62,7 +66,6 @@ export const cliTools: CatalogueEntry[] = [
|
|
|
62
66
|
displayName: "magus",
|
|
63
67
|
description:
|
|
64
68
|
"TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
65
|
-
checkCommand: "magus --version",
|
|
66
69
|
website: "https://github.com/MadAppGang/magus/tree/main/tools/magus",
|
|
67
70
|
category: "ai-coding",
|
|
68
71
|
packageManager: "bun",
|
|
@@ -91,7 +94,6 @@ export const cliTools: CatalogueEntry[] = [
|
|
|
91
94
|
name: "claude",
|
|
92
95
|
displayName: "Claude Code",
|
|
93
96
|
description: "Anthropic official agentic coding tool",
|
|
94
|
-
checkCommand: "claude --version",
|
|
95
97
|
website: "https://claude.ai/code",
|
|
96
98
|
category: "ai-coding",
|
|
97
99
|
packageManager: "npm",
|
|
@@ -102,7 +104,6 @@ export const cliTools: CatalogueEntry[] = [
|
|
|
102
104
|
displayName: "OpenAI Codex",
|
|
103
105
|
description:
|
|
104
106
|
"Lightweight coding agent from OpenAI that runs in your terminal",
|
|
105
|
-
checkCommand: "codex --version",
|
|
106
107
|
website: "https://github.com/openai/codex",
|
|
107
108
|
category: "ai-coding",
|
|
108
109
|
packageManager: "npm",
|
|
@@ -112,7 +113,6 @@ export const cliTools: CatalogueEntry[] = [
|
|
|
112
113
|
name: "gemini",
|
|
113
114
|
displayName: "Gemini CLI",
|
|
114
115
|
description: "Google AI agent with 1M token context, free tier available",
|
|
115
|
-
checkCommand: "gemini --version",
|
|
116
116
|
website: "https://github.com/google-gemini/gemini-cli",
|
|
117
117
|
category: "ai-coding",
|
|
118
118
|
packageManager: "npm",
|
|
@@ -122,7 +122,6 @@ export const cliTools: CatalogueEntry[] = [
|
|
|
122
122
|
name: "qwen",
|
|
123
123
|
displayName: "Qwen Code",
|
|
124
124
|
description: "Alibaba coding agent optimized for Qwen3-Coder models",
|
|
125
|
-
checkCommand: "qwen --version",
|
|
126
125
|
website: "https://github.com/QwenLM/qwen-code",
|
|
127
126
|
category: "ai-coding",
|
|
128
127
|
packageManager: "npm",
|
|
@@ -133,7 +132,6 @@ export const cliTools: CatalogueEntry[] = [
|
|
|
133
132
|
displayName: "Cline",
|
|
134
133
|
description:
|
|
135
134
|
"Autonomous coding agent with plan & act, checkpoints, browser use",
|
|
136
|
-
checkCommand: "cline --version",
|
|
137
135
|
website: "https://cline.bot",
|
|
138
136
|
category: "ai-coding",
|
|
139
137
|
packageManager: "npm",
|
|
@@ -143,7 +141,6 @@ export const cliTools: CatalogueEntry[] = [
|
|
|
143
141
|
name: "opencode",
|
|
144
142
|
displayName: "OpenCode",
|
|
145
143
|
description: "Open source AI coding agent trusted by 400k+ developers",
|
|
146
|
-
checkCommand: "opencode --version",
|
|
147
144
|
website: "https://opencode.ai",
|
|
148
145
|
category: "ai-coding",
|
|
149
146
|
packageManager: "npm",
|
|
@@ -153,7 +150,6 @@ export const cliTools: CatalogueEntry[] = [
|
|
|
153
150
|
name: "aider",
|
|
154
151
|
displayName: "Aider",
|
|
155
152
|
description: "AI pair programming - works with Claude, GPT-4, local models",
|
|
156
|
-
checkCommand: "aider --version",
|
|
157
153
|
website: "https://aider.chat",
|
|
158
154
|
category: "ai-coding",
|
|
159
155
|
packageManager: "pip",
|
|
@@ -163,7 +159,6 @@ export const cliTools: CatalogueEntry[] = [
|
|
|
163
159
|
name: "crush",
|
|
164
160
|
displayName: "Crush",
|
|
165
161
|
description: "AI coding assistant from Charm with terminal-native UX",
|
|
166
|
-
checkCommand: "crush --version",
|
|
167
162
|
website: "https://github.com/charmbracelet/crush",
|
|
168
163
|
category: "ai-coding",
|
|
169
164
|
packageManager: "npm",
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-versions.ts — how magus asks a CLI tool its version. ONE table.
|
|
3
|
+
*
|
|
4
|
+
* A version is read from exactly two places: the installer's own record (brew
|
|
5
|
+
* list, the npm/bun global package, uv/pipx, go build info), or the command
|
|
6
|
+
* below. Nothing else. magus used to guess when the installer was silent — a
|
|
7
|
+
* `package.json` above a `node_modules` path, a `versions/<n>` folder name, the
|
|
8
|
+
* first version-like token of whatever a plugin's `check` string printed — and
|
|
9
|
+
* a guess is how a row shows a version nobody ran.
|
|
10
|
+
*
|
|
11
|
+
* Because magus wrote every argv here, the probe may run it while PLANNING,
|
|
12
|
+
* including `--dry-run` and `--check`. A plugin manifest's `requires.bin[].check`
|
|
13
|
+
* is a string a third party wrote, and the update probe never runs it.
|
|
14
|
+
*
|
|
15
|
+
* Every entry is pinned by a test to a line of real output captured from the
|
|
16
|
+
* tool (`__tests__/tool-versions.test.ts`). Each pattern is anchored at both
|
|
17
|
+
* ends and is tested against the FIRST non-empty line only, so a trailing
|
|
18
|
+
* "Update available: v0.36.2" notice can never be read as the version. A tool
|
|
19
|
+
* whose output was never captured has no entry: it still gets a version when
|
|
20
|
+
* its installer's record has one, and shows none otherwise.
|
|
21
|
+
*
|
|
22
|
+
* Imports nothing: the probe, the resolver and the catalogue all read it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface ToolVersionCommand {
|
|
26
|
+
/**
|
|
27
|
+
* Arguments after the executable. argv[0] is ALWAYS the tool's own name,
|
|
28
|
+
* resolved by PATH — so the version read is the version of the file PATH
|
|
29
|
+
* runs.
|
|
30
|
+
*/
|
|
31
|
+
readonly args: readonly string[];
|
|
32
|
+
/**
|
|
33
|
+
* Tested against the first non-empty line of stdout (of stderr when stdout
|
|
34
|
+
* is empty). Group 1 is the version. Anchored at both ends.
|
|
35
|
+
*/
|
|
36
|
+
readonly parse: RegExp;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `X.Y.Z`, with an optional prerelease suffix. */
|
|
40
|
+
const V = String.raw`(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)`;
|
|
41
|
+
const anchored = (body: string): RegExp => new RegExp(`^${body}$`);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A `Map`, never a bare object: a plugin may declare a bin named after an
|
|
45
|
+
* Object.prototype key ("constructor"), and that must not find a "command".
|
|
46
|
+
*/
|
|
47
|
+
const TABLE: ReadonlyMap<string, ToolVersionCommand> = new Map<
|
|
48
|
+
string,
|
|
49
|
+
ToolVersionCommand
|
|
50
|
+
>([
|
|
51
|
+
// `claudish version 10.4.1`
|
|
52
|
+
[
|
|
53
|
+
"claudish",
|
|
54
|
+
{ args: ["--version"], parse: anchored(`claudish version v?${V}`) },
|
|
55
|
+
],
|
|
56
|
+
// `jq-1.8.2`; jq 1.6 prints `jq-1.6`. A dev build `jq-1.7.1-dirty` keeps its
|
|
57
|
+
// suffix, so it never compares equal to brew's 1.7.1.
|
|
58
|
+
[
|
|
59
|
+
"jq",
|
|
60
|
+
{
|
|
61
|
+
args: ["--version"],
|
|
62
|
+
parse: /^jq-(\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?)$/,
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
// `mnemex v0.36.1`, then update-notice lines the first-line rule ignores.
|
|
66
|
+
["mnemex", { args: ["--version"], parse: anchored(`mnemex v${V}`) }],
|
|
67
|
+
// `tmux 3.7c`. HEAD builds (`tmux next-3.7`, `tmux master`) give null on
|
|
68
|
+
// purpose: they have no release to compare with.
|
|
69
|
+
["tmux", { args: ["-V"], parse: /^tmux (\d+\.\d+[a-z]?)$/ }],
|
|
70
|
+
// `uv 0.11.15 (3cffe97c2 2026-05-18 aarch64-apple-darwin)`
|
|
71
|
+
["uv", { args: ["--version"], parse: anchored(`uv ${V}(?: \\(.*\\))?`) }],
|
|
72
|
+
// `zsh 5.9 (arm64-apple-darwin26.0)`
|
|
73
|
+
[
|
|
74
|
+
"zsh",
|
|
75
|
+
{
|
|
76
|
+
args: ["--version"],
|
|
77
|
+
parse: /^zsh (\d+\.\d+(?:\.\d+)*(?:-[0-9A-Za-z.]+)?) \(.+\)$/,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
// `magus v7.9.0`, then update-notice lines.
|
|
81
|
+
["magus", { args: ["--version"], parse: anchored(`magus v${V}`) }],
|
|
82
|
+
// `2.1.293 (Claude Code)`
|
|
83
|
+
[
|
|
84
|
+
"claude",
|
|
85
|
+
{ args: ["--version"], parse: anchored(`${V} \\(Claude Code\\)`) },
|
|
86
|
+
],
|
|
87
|
+
// `codex-cli 0.153.2`
|
|
88
|
+
["codex", { args: ["--version"], parse: anchored(`codex-cli ${V}`) }],
|
|
89
|
+
// `0.46.0`
|
|
90
|
+
["gemini", { args: ["--version"], parse: anchored(V) }],
|
|
91
|
+
// `1.17.11`
|
|
92
|
+
["opencode", { args: ["--version"], parse: anchored(V) }],
|
|
93
|
+
// `crush version v0.55.0`
|
|
94
|
+
["crush", { args: ["--version"], parse: anchored(`crush version v${V}`) }],
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
/** The version command magus defines for `name`, or undefined when it has none. */
|
|
98
|
+
export function toolVersionCommand(
|
|
99
|
+
name: string,
|
|
100
|
+
): ToolVersionCommand | undefined {
|
|
101
|
+
return TABLE.get(name);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The first non-empty line, trimmed; "" when there is none. */
|
|
105
|
+
function firstLine(text: string): string {
|
|
106
|
+
return (
|
|
107
|
+
text
|
|
108
|
+
.split("\n")
|
|
109
|
+
.find((l) => l.trim())
|
|
110
|
+
?.trim() ?? ""
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The version in a tool's output, or null when the first non-empty line is
|
|
116
|
+
* not one the pattern anchors. stdout is read; stderr only when stdout says
|
|
117
|
+
* nothing (tmux and some older tools print their version there).
|
|
118
|
+
*/
|
|
119
|
+
export function parseToolVersion(
|
|
120
|
+
cmd: ToolVersionCommand,
|
|
121
|
+
stdout: string,
|
|
122
|
+
stderr: string,
|
|
123
|
+
): string | null {
|
|
124
|
+
const line = firstLine(stdout) || firstLine(stderr);
|
|
125
|
+
if (!line) return null;
|
|
126
|
+
return cmd.parse.exec(line)?.[1] ?? null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The presence check argv for a tool magus maps itself (a catalogue entry, a
|
|
131
|
+
* profile cliTool): its version command when the table knows it, else
|
|
132
|
+
* `<name> --version`. Built here so the version command exists once.
|
|
133
|
+
*/
|
|
134
|
+
export function presenceArgv(name: string): string[] {
|
|
135
|
+
const cmd = toolVersionCommand(name);
|
|
136
|
+
return [name, ...(cmd ? cmd.args : ["--version"])];
|
|
137
|
+
}
|
|
@@ -83,7 +83,6 @@ export function ownedCliTool(
|
|
|
83
83
|
description,
|
|
84
84
|
website,
|
|
85
85
|
category,
|
|
86
|
-
checkCommand: owned.check.join(" "),
|
|
87
86
|
packageManager: managed.via,
|
|
88
87
|
packageName:
|
|
89
88
|
managed.package ?? managed.formula ?? managed.module ?? owned.name,
|
|
@@ -159,7 +158,7 @@ export type InstallMethod =
|
|
|
159
158
|
* Entries used to carry a literal `installCommand` string beside the
|
|
160
159
|
* `packageManager` and `packageName` that describe the same thing, so the two
|
|
161
160
|
* could disagree — and did. `aider` declared `packageName: "aider-chat"` and
|
|
162
|
-
* `
|
|
161
|
+
* checked its version with `aider --version`, and its `installCommand` installed
|
|
163
162
|
* `aider-install`: a bootstrapper whose version nothing in this screen ever
|
|
164
163
|
* looked at, while the version check queried `aider-chat`. Deriving makes that
|
|
165
164
|
* disagreement unrepresentable rather than merely fixed.
|
|
@@ -1,101 +1,23 @@
|
|
|
1
|
-
import { readFileSync, realpathSync } from "node:fs";
|
|
2
1
|
import path from "node:path";
|
|
3
2
|
|
|
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
3
|
/** A bare release number, as a versioned install directory names its files. */
|
|
41
4
|
const RELEASE = /^v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
|
42
5
|
|
|
43
6
|
/**
|
|
44
|
-
* The version a VERSIONED INSTALL names its binary by, or `null
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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.
|
|
7
|
+
* The version a VERSIONED INSTALL names its binary by, or `null`, from a path
|
|
8
|
+
* that is ALREADY real — pure string work, no filesystem. The probe resolves
|
|
9
|
+
* paths through its own IO seam and must not reach past it to the real disk.
|
|
52
10
|
*
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
11
|
+
* Claude Code's native installer keeps every release side by side and points
|
|
12
|
+
* PATH at one: `~/.local/bin/claude` links to
|
|
13
|
+
* `~/.local/share/claude/versions/2.1.276`. That installer's record IS the file
|
|
14
|
+
* name — accepted only when the parent directory is literally `versions` and
|
|
15
|
+
* the name is a bare release number, so an ordinary binary called `1.0.0` is
|
|
16
|
+
* not misread. The probe calls this only once it has confirmed the layout is
|
|
17
|
+
* that installer's own.
|
|
69
18
|
*/
|
|
70
19
|
export function versionFromVersionedPath(realPath: string): string | null {
|
|
71
20
|
const name = path.basename(realPath);
|
|
72
21
|
if (path.basename(path.dirname(realPath)) !== "versions") return null;
|
|
73
22
|
return RELEASE.test(name) ? name.replace(/^v/, "") : null;
|
|
74
23
|
}
|
|
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
|
-
}
|
|
@@ -42,6 +42,8 @@ import {
|
|
|
42
42
|
OBSOLETE_GITIGNORE_ENTRIES,
|
|
43
43
|
PROFILE_GITIGNORE_COMMENT,
|
|
44
44
|
PROFILE_GITIGNORE_ENTRIES,
|
|
45
|
+
PROFILE_WORKTREE_INCLUDE_COMMENT,
|
|
46
|
+
PROFILE_WORKTREE_INCLUDE_ENTRIES,
|
|
45
47
|
assertLiveFilesParse,
|
|
46
48
|
backupRoot,
|
|
47
49
|
backupStamp,
|
|
@@ -324,6 +326,13 @@ export async function activateProfile(
|
|
|
324
326
|
remove: conversion.converted ? OBSOLETE_GITIGNORE_ENTRIES : [],
|
|
325
327
|
},
|
|
326
328
|
);
|
|
329
|
+
// Claude Code copies git-ignored files into a worktree it creates only when
|
|
330
|
+
// `.worktreeinclude` lists them — see PROFILE_WORKTREE_INCLUDE_ENTRIES.
|
|
331
|
+
await updateManagedGitignoreBlock(
|
|
332
|
+
path.join(projectPath, ".worktreeinclude"),
|
|
333
|
+
PROFILE_WORKTREE_INCLUDE_COMMENT,
|
|
334
|
+
{ add: PROFILE_WORKTREE_INCLUDE_ENTRIES },
|
|
335
|
+
);
|
|
327
336
|
|
|
328
337
|
const backupDir = path.join(backupRoot(projectPath), backupStamp());
|
|
329
338
|
const displaced: DisplacedArtifact[] = [];
|
|
@@ -72,6 +72,32 @@ export const PROFILE_GITIGNORE_ENTRIES = [
|
|
|
72
72
|
export const PROFILE_GITIGNORE_COMMENT =
|
|
73
73
|
"# magus: generated profile config (source of truth: .claude/profiles.json)";
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Paths magus lists in a project's `.worktreeinclude`. Claude Code copies a
|
|
77
|
+
* file into a worktree it creates (`claude -w`, EnterWorktree, agent
|
|
78
|
+
* isolation) only when that file is both git-ignored and listed there, at
|
|
79
|
+
* creation time. magus git-ignores the set-up state above, so without this
|
|
80
|
+
* list every new worktree gets `.claude/profiles.json` from the commit and none
|
|
81
|
+
* of the project's plugins.
|
|
82
|
+
*
|
|
83
|
+
* The marker FILE is listed, not `.claude/profiles/`: the directory would also
|
|
84
|
+
* copy a crash-orphaned temp file. It holds only hashes and ids, so a
|
|
85
|
+
* byte-for-byte copy reconciles as unchanged in the worktree. Backups are never
|
|
86
|
+
* listed — they are this checkout's hand-written files, possibly credentials,
|
|
87
|
+
* and nothing in a worktree uses them. Each magus-installed skill folder gets
|
|
88
|
+
* its own line (see `profile-skills.ts`).
|
|
89
|
+
*/
|
|
90
|
+
export const PROFILE_WORKTREE_INCLUDE_ENTRIES = [
|
|
91
|
+
".claude/settings.json",
|
|
92
|
+
".mcp.json",
|
|
93
|
+
".claude/models.json",
|
|
94
|
+
".claude/profiles/active.json",
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
/** The comment heading magus's lines in a project's `.worktreeinclude`. */
|
|
98
|
+
export const PROFILE_WORKTREE_INCLUDE_COMMENT =
|
|
99
|
+
"# magus: copied into new worktrees so they start with this project's plugins (source of truth: .claude/profiles.json)";
|
|
100
|
+
|
|
75
101
|
/**
|
|
76
102
|
* Lines older releases wrote into magus's managed `.gitignore` block that must
|
|
77
103
|
* go when an older layout is converted: the blanket skills ignore hid committed
|
|
@@ -28,6 +28,7 @@ import type {
|
|
|
28
28
|
import { updateManagedGitignoreBlock } from "./conventions-manager.js";
|
|
29
29
|
import {
|
|
30
30
|
PROFILE_GITIGNORE_COMMENT,
|
|
31
|
+
PROFILE_WORKTREE_INCLUDE_COMMENT,
|
|
31
32
|
backupRoot,
|
|
32
33
|
backupStamp,
|
|
33
34
|
folderHash,
|
|
@@ -251,6 +252,9 @@ export async function inspectManagedSkills(
|
|
|
251
252
|
* `.gitignore` block: `before` are the folders the marker listed, `after` the
|
|
252
253
|
* ones it lists now. Lines are taken out of magus's block only — the same
|
|
253
254
|
* pattern a person wrote elsewhere in the file stays.
|
|
255
|
+
*
|
|
256
|
+
* The same lines go into magus's `.worktreeinclude` block, so a worktree
|
|
257
|
+
* Claude Code creates gets the folders the copied marker records.
|
|
254
258
|
*/
|
|
255
259
|
export async function updateSkillIgnoreLines(
|
|
256
260
|
projectPath: string,
|
|
@@ -258,13 +262,19 @@ export async function updateSkillIgnoreLines(
|
|
|
258
262
|
after: readonly string[],
|
|
259
263
|
): Promise<void> {
|
|
260
264
|
const keep = new Set(after);
|
|
265
|
+
const edits = {
|
|
266
|
+
add: [...keep].map(skillIgnoreLine),
|
|
267
|
+
remove: before.filter((f) => !keep.has(f)).map(skillIgnoreLine),
|
|
268
|
+
};
|
|
261
269
|
await updateManagedGitignoreBlock(
|
|
262
270
|
path.join(projectPath, ".gitignore"),
|
|
263
271
|
PROFILE_GITIGNORE_COMMENT,
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
272
|
+
edits,
|
|
273
|
+
);
|
|
274
|
+
await updateManagedGitignoreBlock(
|
|
275
|
+
path.join(projectPath, ".worktreeinclude"),
|
|
276
|
+
PROFILE_WORKTREE_INCLUDE_COMMENT,
|
|
277
|
+
edits,
|
|
268
278
|
);
|
|
269
279
|
}
|
|
270
280
|
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import path from "node:path";
|
|
29
29
|
import type { ProfileManifest, ProfileManifestEntry } from "../types/index.js";
|
|
30
30
|
import { selectProfileId } from "./active-closure.js";
|
|
31
|
+
import { updateManagedGitignoreBlock } from "./conventions-manager.js";
|
|
31
32
|
import { readManifest, updateProfileEntry, writeManifest } from "./manifest.js";
|
|
32
33
|
import {
|
|
33
34
|
type ReconcileResult,
|
|
@@ -48,6 +49,8 @@ import {
|
|
|
48
49
|
type ActiveMarker,
|
|
49
50
|
LIVE_FILE_KEYS,
|
|
50
51
|
MACHINE_LOCAL_SETTINGS_KEYS,
|
|
52
|
+
PROFILE_WORKTREE_INCLUDE_COMMENT,
|
|
53
|
+
PROFILE_WORKTREE_INCLUDE_ENTRIES,
|
|
51
54
|
activeProfile,
|
|
52
55
|
folderHash,
|
|
53
56
|
hasCurrentMarker,
|
|
@@ -69,6 +72,7 @@ import {
|
|
|
69
72
|
SkillClashError,
|
|
70
73
|
inspectManagedSkills,
|
|
71
74
|
skillFolder,
|
|
75
|
+
skillIgnoreLine,
|
|
72
76
|
} from "./profile-skills.js";
|
|
73
77
|
import { resolveExtends } from "./resolver.js";
|
|
74
78
|
import type { SettingsParseError } from "./settings-file.js";
|
|
@@ -371,6 +375,20 @@ export async function prepareProfileState(
|
|
|
371
375
|
out.lines.push(...activationNotes(result, { tracked: false }));
|
|
372
376
|
out.activations.push(result);
|
|
373
377
|
}
|
|
378
|
+
// A project set up by a release that never wrote `.worktreeinclude`
|
|
379
|
+
// re-activates rarely, so the gate lists the set-up state here. A no-op
|
|
380
|
+
// when every line is already there.
|
|
381
|
+
const marker = await readActiveMarker(projectPath);
|
|
382
|
+
await updateManagedGitignoreBlock(
|
|
383
|
+
path.join(projectPath, ".worktreeinclude"),
|
|
384
|
+
PROFILE_WORKTREE_INCLUDE_COMMENT,
|
|
385
|
+
{
|
|
386
|
+
add: [
|
|
387
|
+
...PROFILE_WORKTREE_INCLUDE_ENTRIES,
|
|
388
|
+
...Object.keys(marker?.skills ?? {}).map(skillIgnoreLine),
|
|
389
|
+
],
|
|
390
|
+
},
|
|
391
|
+
);
|
|
374
392
|
out.check = { state: "active", profile, restored: false };
|
|
375
393
|
}
|
|
376
394
|
return out;
|