magus-cli 7.5.3 → 7.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/blackbox-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/key-focus-guard.test.tsx +260 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-name-on-save.test.ts +443 -0
- package/src/__tests__/profile-skills.test.ts +3 -3
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-startup.test.ts +1 -1
- package/src/__tests__/profile-state.test.ts +27 -22
- package/src/__tests__/prompt-eof.test.ts +245 -0
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +271 -66
- package/src/cli/doctor.ts +4 -3
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +19 -5
- package/src/cli/router.ts +1 -0
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +120 -6
- package/src/cli/update.ts +6 -4
- package/src/services/claude-settings.ts +28 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +10 -7
- package/src/services/profile-activate.ts +41 -19
- package/src/services/profile-adopt.ts +9 -2
- package/src/services/profile-startup.ts +206 -5
- package/src/services/profile-state.ts +8 -7
- package/src/services/tracked-live-files.ts +24 -3
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +96 -56
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/modals/ModalContainer.tsx +3 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +21 -3
- package/src/ui/hooks/useProfileStartupModal.ts +214 -15
- package/src/ui/hooks/useStaleLoadModal.ts +5 -1
- package/src/ui/input/KeyRouter.tsx +43 -2
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +28 -41
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -373,7 +373,7 @@ describe("what the user is offered", () => {
|
|
|
373
373
|
// "Create <id> from this project" described the mechanism. The reason to
|
|
374
374
|
// pick this row is that it is the only one that keeps what is turned on.
|
|
375
375
|
expect(buildProfileOptions(inactive)[0]?.label).toBe(
|
|
376
|
-
|
|
376
|
+
"Save what you have as a new profile",
|
|
377
377
|
);
|
|
378
378
|
});
|
|
379
379
|
|
|
@@ -145,7 +145,7 @@ describe("strict active-profile check", () => {
|
|
|
145
145
|
profile: "other",
|
|
146
146
|
restored: false,
|
|
147
147
|
});
|
|
148
|
-
expect(prep.lines[0]).toContain('
|
|
148
|
+
expect(prep.lines[0]).toContain('Using profile "other"');
|
|
149
149
|
expect((await readActiveMarker(project))?.profile).toBe("other");
|
|
150
150
|
expect(await activeProfile(project)).toBe("other");
|
|
151
151
|
});
|
|
@@ -224,11 +224,11 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
224
224
|
|
|
225
225
|
expect(prep.problems).toEqual([]);
|
|
226
226
|
expect(prep.lines).toContain(
|
|
227
|
-
'Changes Claude Code made
|
|
227
|
+
'Changes Claude Code made were added to profile "team"',
|
|
228
228
|
);
|
|
229
229
|
expect(prep.lines).toContain('Added code-search@magus to profile "team"');
|
|
230
230
|
expect(prep.lines).toContain(
|
|
231
|
-
'
|
|
231
|
+
'Updated profile "team" to the current profile format',
|
|
232
232
|
);
|
|
233
233
|
// In profiles.json...
|
|
234
234
|
const team = (await readManifest(project)).profiles.team;
|
|
@@ -265,9 +265,8 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
265
265
|
enabledPlugins: { "dev@magus": true, "code-search@magus": true },
|
|
266
266
|
permissions: { allow: ["Bash(bun test:*)"] },
|
|
267
267
|
});
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
);
|
|
268
|
+
// Said in the user's terms; where the backup went is checked on disk above.
|
|
269
|
+
expect(prep.lines).toContain("A backup of the old profile format was kept");
|
|
271
270
|
expect(await readdir(join(project, ".claude", "profiles"))).toEqual([
|
|
272
271
|
"active.json",
|
|
273
272
|
]);
|
|
@@ -316,7 +315,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
316
315
|
// stands, the key is named, and the older copy is in the backup.
|
|
317
316
|
expect(team?.models?.agents).toEqual({ "dev:architect": "smart" });
|
|
318
317
|
expect(prep.lines).toContain(
|
|
319
|
-
'Profile "team": models differs between the
|
|
318
|
+
'Profile "team": models differs between the old profile format and profiles.json — kept profiles.json\'s value (the old one is backed up)',
|
|
320
319
|
);
|
|
321
320
|
const live = await fs.readJson(settingsPath());
|
|
322
321
|
expect(live.enabledPlugins["code-search@magus"]).toBe(true);
|
|
@@ -426,7 +425,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
426
425
|
// Removals are not applied, and the run says so, once.
|
|
427
426
|
expect(
|
|
428
427
|
prep.lines.filter((l) =>
|
|
429
|
-
l.includes("a removal made
|
|
428
|
+
l.includes("a removal made in the old format is not applied"),
|
|
430
429
|
),
|
|
431
430
|
).toHaveLength(1);
|
|
432
431
|
const live = await fs.readJson(settingsPath());
|
|
@@ -478,7 +477,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
478
477
|
"mcpServers.db",
|
|
479
478
|
]) {
|
|
480
479
|
expect(prep.lines).toContain(
|
|
481
|
-
`Profile "team": ${at} differs between the
|
|
480
|
+
`Profile "team": ${at} differs between the old profile format and profiles.json — kept profiles.json's value (the old one is backed up)`,
|
|
482
481
|
);
|
|
483
482
|
}
|
|
484
483
|
// The older copy really is in the backup.
|
|
@@ -577,7 +576,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
577
576
|
const prep = await prepareProfileState(project);
|
|
578
577
|
|
|
579
578
|
expect(prep.problems).toHaveLength(1);
|
|
580
|
-
expect(prep.problems[0]).toContain("did not
|
|
579
|
+
expect(prep.problems[0]).toContain("did not update the old profile format");
|
|
581
580
|
expect(
|
|
582
581
|
(await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink(),
|
|
583
582
|
).toBe(true);
|
|
@@ -623,7 +622,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
623
622
|
}
|
|
624
623
|
|
|
625
624
|
expect(await snapshot()).toEqual(before);
|
|
626
|
-
expect(printed()).toContain("
|
|
625
|
+
expect(printed()).toContain("uses an old magus profile format");
|
|
627
626
|
expect(await fs.pathExists(activeMarkerPath(project))).toBe(false);
|
|
628
627
|
});
|
|
629
628
|
|
|
@@ -637,11 +636,9 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
637
636
|
const counts = await reportProfileState(project, false);
|
|
638
637
|
|
|
639
638
|
const out = printed();
|
|
640
|
-
expect(out).toContain(
|
|
641
|
-
'No active-profile marker, but the live config matches profile "other" exactly',
|
|
642
|
-
);
|
|
639
|
+
expect(out).toContain('This project\'s plugins match profile "other"');
|
|
643
640
|
// The contradicting line is gone, and so is the failure.
|
|
644
|
-
expect(out).not.toContain("
|
|
641
|
+
expect(out).not.toContain("match none of the profiles");
|
|
645
642
|
expect(counts).toEqual({ fixable: 0, unfixable: 0 });
|
|
646
643
|
// Report only: the marker is not restored.
|
|
647
644
|
expect(await readActiveMarker(project)).toBeNull();
|
|
@@ -699,8 +696,14 @@ describe("generated files git still tracks: written, and warned about", () => {
|
|
|
699
696
|
const nothingStaged = () =>
|
|
700
697
|
expect(git(project, "diff", "--cached", "--name-only").stdout).toBe("");
|
|
701
698
|
|
|
702
|
-
|
|
703
|
-
|
|
699
|
+
// The notice is short lines: the problem, then the command on its own line.
|
|
700
|
+
const PROBLEM = "git tracks .claude/settings.json, but magus rewrites it";
|
|
701
|
+
const FIX = "git rm --cached .claude/settings.json, then commit";
|
|
702
|
+
/** Both lines, each printed exactly once. */
|
|
703
|
+
const warnedOnce = (out: string) => {
|
|
704
|
+
expect(out.split(PROBLEM).length - 1).toBe(1);
|
|
705
|
+
expect(out.split(FIX).length - 1).toBe(1);
|
|
706
|
+
};
|
|
704
707
|
|
|
705
708
|
test("B7: activating over a tracked settings.json writes it and names the fix", async () => {
|
|
706
709
|
await commitTrackedSettings();
|
|
@@ -710,7 +713,8 @@ describe("generated files git still tracks: written, and warned about", () => {
|
|
|
710
713
|
});
|
|
711
714
|
|
|
712
715
|
expect(result.tracked).toEqual([".claude/settings.json"]);
|
|
713
|
-
expect(activationNotes(result)).toContain(
|
|
716
|
+
expect(activationNotes(result)).toContain(PROBLEM);
|
|
717
|
+
expect(activationNotes(result)).toContain(FIX);
|
|
714
718
|
expect((await fs.readJson(settingsPath())).enabledPlugins).toEqual({
|
|
715
719
|
"dev@magus": true,
|
|
716
720
|
"terminal@magus": false,
|
|
@@ -761,7 +765,7 @@ describe("generated files git still tracks: written, and warned about", () => {
|
|
|
761
765
|
process.chdir(cwd);
|
|
762
766
|
}
|
|
763
767
|
expect(outcome.exitCode).toBe(0);
|
|
764
|
-
|
|
768
|
+
warnedOnce(printed());
|
|
765
769
|
expect((await readActiveMarker(project))?.profile).toBe("other");
|
|
766
770
|
nothingStaged();
|
|
767
771
|
});
|
|
@@ -772,7 +776,7 @@ describe("generated files git still tracks: written, and warned about", () => {
|
|
|
772
776
|
// A named profile that does not exist: the command stops right after the
|
|
773
777
|
// warning, before anything reaches the network.
|
|
774
778
|
expect(await runUpdateCommand(["no-such-profile"], project)).toBe(1);
|
|
775
|
-
|
|
779
|
+
warnedOnce(printed());
|
|
776
780
|
nothingStaged();
|
|
777
781
|
});
|
|
778
782
|
|
|
@@ -824,7 +828,7 @@ describe("generated files git still tracks: written, and warned about", () => {
|
|
|
824
828
|
expect(await activeProfile(project)).toBe(
|
|
825
829
|
Object.keys(manifest.profiles)[0],
|
|
826
830
|
);
|
|
827
|
-
|
|
831
|
+
warnedOnce(printed());
|
|
828
832
|
nothingStaged();
|
|
829
833
|
});
|
|
830
834
|
|
|
@@ -833,7 +837,8 @@ describe("generated files git still tracks: written, and warned about", () => {
|
|
|
833
837
|
await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
|
|
834
838
|
const counts = await reportProfileState(project, false);
|
|
835
839
|
expect(counts.unfixable).toBe(0);
|
|
836
|
-
expect(printed()).toContain(`• ${
|
|
840
|
+
expect(printed()).toContain(`• ${PROBLEM}`);
|
|
841
|
+
expect(printed()).toContain(FIX);
|
|
837
842
|
});
|
|
838
843
|
|
|
839
844
|
test("B7: writeSettings and writeMcpConfig write a tracked live file", async () => {
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI prompts at end of input.
|
|
3
|
+
*
|
|
4
|
+
* Written blind — from the spec and the export signatures of src/cli/prompt.ts
|
|
5
|
+
* only. `promptValue(question)` and `askYesNo(question)` take no stream, so they
|
|
6
|
+
* read the process's own stdin: each case runs them in a CHILD `bun` process
|
|
7
|
+
* whose stdin is a pipe this test writes to and then closes. The child writes
|
|
8
|
+
* its outcome to a result file; the prompt text it prints is ignored.
|
|
9
|
+
*
|
|
10
|
+
* REQ-B1 stdin ends before a line is answered → promptValue rejects with
|
|
11
|
+
* PromptAborted (instanceof, and isPromptAborted(err) is true),
|
|
12
|
+
* instead of waiting forever.
|
|
13
|
+
* REQ-B2 same for askYesNo.
|
|
14
|
+
* REQ-B3 stdin that stays open a while and THEN ends still rejects.
|
|
15
|
+
* REQ-B4 a line followed by a newline is still returned; promptValue trims it.
|
|
16
|
+
* REQ-B5 askYesNo still answers a line followed by a newline.
|
|
17
|
+
*
|
|
18
|
+
* Each child has HOME, XDG_* and the cwd inside a temp dir and runs with
|
|
19
|
+
* `--no-env-file`, so nothing here reads or writes the real home or a project.
|
|
20
|
+
*/
|
|
21
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
22
|
+
import {
|
|
23
|
+
mkdir,
|
|
24
|
+
mkdtemp,
|
|
25
|
+
readFile,
|
|
26
|
+
realpath,
|
|
27
|
+
rm,
|
|
28
|
+
writeFile,
|
|
29
|
+
} from "node:fs/promises";
|
|
30
|
+
import { tmpdir } from "node:os";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
|
|
33
|
+
const PROMPT_MODULE = join(import.meta.dir, "..", "cli", "prompt.ts");
|
|
34
|
+
|
|
35
|
+
/** Bounded wait for a child. A hang past this is the "waits forever" bug. */
|
|
36
|
+
const CHILD_TIMEOUT_MS = 8_000;
|
|
37
|
+
|
|
38
|
+
const CHILD = `
|
|
39
|
+
const [fn, question, out] = process.argv.slice(2);
|
|
40
|
+
const mod = await import(${JSON.stringify(PROMPT_MODULE)});
|
|
41
|
+
let result;
|
|
42
|
+
try {
|
|
43
|
+
const value = await mod[fn](question);
|
|
44
|
+
result = { outcome: "resolved", value };
|
|
45
|
+
} catch (err) {
|
|
46
|
+
result = {
|
|
47
|
+
outcome: "rejected",
|
|
48
|
+
name: err?.name ?? null,
|
|
49
|
+
isPromptAbortedClass: err instanceof mod.PromptAborted,
|
|
50
|
+
isPromptAborted: mod.isPromptAborted(err),
|
|
51
|
+
message: String(err?.message ?? err),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
await Bun.write(out, JSON.stringify(result));
|
|
55
|
+
process.exit(0);
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
type Outcome =
|
|
59
|
+
| { outcome: "resolved"; value: unknown }
|
|
60
|
+
| {
|
|
61
|
+
outcome: "rejected";
|
|
62
|
+
name: string | null;
|
|
63
|
+
isPromptAbortedClass: boolean;
|
|
64
|
+
isPromptAborted: boolean;
|
|
65
|
+
message: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
interface ChildRun {
|
|
69
|
+
result: Outcome | null;
|
|
70
|
+
timedOut: boolean;
|
|
71
|
+
exitCode: number | null;
|
|
72
|
+
stderr: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let root: string;
|
|
76
|
+
let childScript: string;
|
|
77
|
+
let runs = 0;
|
|
78
|
+
|
|
79
|
+
beforeAll(async () => {
|
|
80
|
+
root = await realpath(await mkdtemp(join(tmpdir(), "prompt-eof-")));
|
|
81
|
+
childScript = join(root, "child.ts");
|
|
82
|
+
await writeFile(childScript, CHILD);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterAll(async () => {
|
|
86
|
+
await rm(root, { recursive: true, force: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
async function runPrompt(
|
|
90
|
+
fn: "promptValue" | "askYesNo",
|
|
91
|
+
input: string,
|
|
92
|
+
opts: { holdOpenMs?: number } = {},
|
|
93
|
+
): Promise<ChildRun> {
|
|
94
|
+
const dir = join(root, `run-${++runs}`);
|
|
95
|
+
const home = join(dir, "home");
|
|
96
|
+
await mkdir(home, { recursive: true });
|
|
97
|
+
const out = join(dir, "result.json");
|
|
98
|
+
|
|
99
|
+
const proc = Bun.spawn(
|
|
100
|
+
[process.execPath, "--no-env-file", childScript, fn, "Question? ", out],
|
|
101
|
+
{
|
|
102
|
+
cwd: dir,
|
|
103
|
+
stdin: "pipe",
|
|
104
|
+
stdout: "pipe",
|
|
105
|
+
stderr: "pipe",
|
|
106
|
+
env: {
|
|
107
|
+
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
|
108
|
+
HOME: home,
|
|
109
|
+
XDG_CONFIG_HOME: join(dir, "xdg-config"),
|
|
110
|
+
XDG_CACHE_HOME: join(dir, "xdg-cache"),
|
|
111
|
+
XDG_DATA_HOME: join(dir, "xdg-data"),
|
|
112
|
+
XDG_STATE_HOME: join(dir, "xdg-state"),
|
|
113
|
+
TMPDIR: tmpdir(),
|
|
114
|
+
NO_COLOR: "1",
|
|
115
|
+
TERM: "dumb",
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
if (input.length > 0) proc.stdin.write(input);
|
|
121
|
+
await proc.stdin.flush();
|
|
122
|
+
if (opts.holdOpenMs) await Bun.sleep(opts.holdOpenMs);
|
|
123
|
+
await proc.stdin.end();
|
|
124
|
+
|
|
125
|
+
let timedOut = false;
|
|
126
|
+
const timer = setTimeout(() => {
|
|
127
|
+
timedOut = true;
|
|
128
|
+
proc.kill();
|
|
129
|
+
}, CHILD_TIMEOUT_MS);
|
|
130
|
+
const exitCode = await proc.exited;
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
const stderr = await new Response(proc.stderr).text();
|
|
133
|
+
|
|
134
|
+
let result: Outcome | null = null;
|
|
135
|
+
try {
|
|
136
|
+
result = JSON.parse(await readFile(out, "utf8")) as Outcome;
|
|
137
|
+
} catch {
|
|
138
|
+
result = null;
|
|
139
|
+
}
|
|
140
|
+
return { result, timedOut, exitCode, stderr };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function expectAborted(run: ChildRun): void {
|
|
144
|
+
expect({
|
|
145
|
+
timedOut: run.timedOut,
|
|
146
|
+
stderr: run.timedOut ? run.stderr : "",
|
|
147
|
+
}).toEqual({
|
|
148
|
+
timedOut: false,
|
|
149
|
+
stderr: "",
|
|
150
|
+
});
|
|
151
|
+
if (run.result === null) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`child produced no result (exit ${run.exitCode}):\n${run.stderr}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
expect(run.result.outcome).toBe("rejected");
|
|
157
|
+
if (run.result.outcome !== "rejected") return;
|
|
158
|
+
expect(run.result.name).toBe("PromptAborted");
|
|
159
|
+
expect(run.result.isPromptAbortedClass).toBe(true);
|
|
160
|
+
expect(run.result.isPromptAborted).toBe(true);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function expectResolved(run: ChildRun, value: unknown): void {
|
|
164
|
+
expect(run.timedOut).toBe(false);
|
|
165
|
+
if (run.result === null) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`child produced no result (exit ${run.exitCode}):\n${run.stderr}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
expect(run.result).toEqual({ outcome: "resolved", value });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
describe("promptValue at end of input", () => {
|
|
174
|
+
test(
|
|
175
|
+
"REQ-B1 rejects with PromptAborted when stdin closes with nothing on it",
|
|
176
|
+
async () => {
|
|
177
|
+
expectAborted(await runPrompt("promptValue", ""));
|
|
178
|
+
},
|
|
179
|
+
CHILD_TIMEOUT_MS + 4_000,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
test(
|
|
183
|
+
"REQ-B3 rejects with PromptAborted when stdin stays open, then closes unanswered",
|
|
184
|
+
async () => {
|
|
185
|
+
expectAborted(await runPrompt("promptValue", "", { holdOpenMs: 300 }));
|
|
186
|
+
},
|
|
187
|
+
CHILD_TIMEOUT_MS + 4_000,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
test(
|
|
191
|
+
"REQ-B4 returns a newline-terminated line, trimmed",
|
|
192
|
+
async () => {
|
|
193
|
+
expectResolved(
|
|
194
|
+
await runPrompt("promptValue", " team-profile \n"),
|
|
195
|
+
"team-profile",
|
|
196
|
+
);
|
|
197
|
+
},
|
|
198
|
+
CHILD_TIMEOUT_MS + 4_000,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
test(
|
|
202
|
+
"REQ-B4 returns the first line when stdin then ends",
|
|
203
|
+
async () => {
|
|
204
|
+
expectResolved(
|
|
205
|
+
await runPrompt("promptValue", "hello\n", { holdOpenMs: 100 }),
|
|
206
|
+
"hello",
|
|
207
|
+
);
|
|
208
|
+
},
|
|
209
|
+
CHILD_TIMEOUT_MS + 4_000,
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("askYesNo at end of input", () => {
|
|
214
|
+
test(
|
|
215
|
+
"REQ-B2 rejects with PromptAborted when stdin closes with nothing on it",
|
|
216
|
+
async () => {
|
|
217
|
+
expectAborted(await runPrompt("askYesNo", ""));
|
|
218
|
+
},
|
|
219
|
+
CHILD_TIMEOUT_MS + 4_000,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
test(
|
|
223
|
+
"REQ-B3 rejects with PromptAborted when stdin stays open, then closes unanswered",
|
|
224
|
+
async () => {
|
|
225
|
+
expectAborted(await runPrompt("askYesNo", "", { holdOpenMs: 300 }));
|
|
226
|
+
},
|
|
227
|
+
CHILD_TIMEOUT_MS + 4_000,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
test(
|
|
231
|
+
'REQ-B5 answers "y" + newline with true',
|
|
232
|
+
async () => {
|
|
233
|
+
expectResolved(await runPrompt("askYesNo", "y\n"), true);
|
|
234
|
+
},
|
|
235
|
+
CHILD_TIMEOUT_MS + 4_000,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
test(
|
|
239
|
+
'REQ-B5 answers "n" + newline with false',
|
|
240
|
+
async () => {
|
|
241
|
+
expectResolved(await runPrompt("askYesNo", "n\n"), false);
|
|
242
|
+
},
|
|
243
|
+
CHILD_TIMEOUT_MS + 4_000,
|
|
244
|
+
);
|
|
245
|
+
});
|
|
@@ -8,41 +8,37 @@ import { planPluginUpdates, scopeIsBehind } from "../services/update-plan.js";
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* The scope keys (u / p / l) and the Enter scope picker both decide between
|
|
11
|
-
* install, update and uninstall. That decision used to read
|
|
12
|
-
*
|
|
13
|
-
* which is the registry-backed answer to "did Claude Code actually install
|
|
14
|
-
* this".
|
|
11
|
+
* install, update and uninstall. That decision used to read the settings flag
|
|
12
|
+
* alone and ignore `scope.version`.
|
|
15
13
|
*
|
|
16
|
-
*
|
|
14
|
+
* The two disagree in a real, common state: a plugin listed in
|
|
17
15
|
* `enabledPlugins` with no entry in `installed_plugins.json` for the current
|
|
18
|
-
* project path
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* project path — installed, with its files missing (hasMissingFiles). Pressing
|
|
17
|
+
* its scope key ran `claude plugin uninstall`. The user asked to repair the
|
|
18
|
+
* thing the UI told them was broken, and it was removed instead.
|
|
21
19
|
*/
|
|
22
20
|
|
|
23
21
|
describe("isInstalledInScope", () => {
|
|
24
|
-
test("
|
|
25
|
-
expect(isInstalledInScope({
|
|
22
|
+
test("installed with a registry version is installed", () => {
|
|
23
|
+
expect(isInstalledInScope({ installed: true, version: "3.0.2" })).toBe(
|
|
24
|
+
true,
|
|
25
|
+
);
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
test("
|
|
28
|
+
test("installed with NO version is still installed — files missing", () => {
|
|
29
29
|
// dev@magus, measured: `.claude/settings.json` had "dev@magus": true while
|
|
30
30
|
// installed_plugins.json had no entry resolving for the project path.
|
|
31
|
-
|
|
31
|
+
// Installed is the settings fact; the missing version is the broken
|
|
32
|
+
// sub-state, and the scope key still resolves it to `install` (below).
|
|
33
|
+
expect(isInstalledInScope({ installed: true })).toBe(true);
|
|
32
34
|
});
|
|
33
35
|
|
|
34
|
-
test("a registry version with the flag off
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
// The hazard the sibling test above guards — pressing a scope key on
|
|
42
|
-
// enabled-with-no-version and having it run `uninstall` — is untouched:
|
|
43
|
-
// that case has no version, so it is still not installed, and still
|
|
44
|
-
// resolves to `install`.
|
|
45
|
-
expect(isInstalledInScope({ enabled: false, version: "3.0.2" })).toBe(true);
|
|
36
|
+
test("a registry version with the settings flag off is NOT installed", () => {
|
|
37
|
+
// A registry row alone never makes Claude Code load a plugin, and it can
|
|
38
|
+
// be inherited from another checkout of the same repository.
|
|
39
|
+
expect(isInstalledInScope({ installed: false, version: "3.0.2" })).toBe(
|
|
40
|
+
false,
|
|
41
|
+
);
|
|
46
42
|
});
|
|
47
43
|
|
|
48
44
|
test("an absent scope is not installed", () => {
|
|
@@ -50,7 +46,9 @@ describe("isInstalledInScope", () => {
|
|
|
50
46
|
});
|
|
51
47
|
|
|
52
48
|
test('"0.0.0" counts as installed — Anthropic official plugins record it', () => {
|
|
53
|
-
expect(isInstalledInScope({
|
|
49
|
+
expect(isInstalledInScope({ installed: true, version: "0.0.0" })).toBe(
|
|
50
|
+
true,
|
|
51
|
+
);
|
|
54
52
|
});
|
|
55
53
|
});
|
|
56
54
|
|
|
@@ -59,34 +57,34 @@ describe("resolveScopeAction", () => {
|
|
|
59
57
|
// takes from the classifier (`scopeIsBehind`). It used to be the catalog
|
|
60
58
|
// version, compared here with raw string inequality — a fourth rule.
|
|
61
59
|
|
|
62
|
-
test("REGRESSION:
|
|
60
|
+
test("REGRESSION: files missing resolves to install, never uninstall", () => {
|
|
63
61
|
// This is the reported bug. Before the fix this returned "uninstall".
|
|
64
|
-
expect(resolveScopeAction({
|
|
62
|
+
expect(resolveScopeAction({ installed: true }, false)).toBe("install");
|
|
65
63
|
// Whatever the classifier says: the safety branch comes first.
|
|
66
|
-
expect(resolveScopeAction({
|
|
64
|
+
expect(resolveScopeAction({ installed: true }, true)).toBe("install");
|
|
67
65
|
});
|
|
68
66
|
|
|
69
|
-
test("a scope that was never
|
|
67
|
+
test("a scope that was never installed resolves to install", () => {
|
|
70
68
|
expect(resolveScopeAction(undefined, false)).toBe("install");
|
|
71
|
-
expect(resolveScopeAction({
|
|
69
|
+
expect(resolveScopeAction({ installed: false }, false)).toBe("install");
|
|
72
70
|
});
|
|
73
71
|
|
|
74
72
|
test("a healthy install at the latest version resolves to uninstall (toggle off)", () => {
|
|
75
|
-
expect(
|
|
76
|
-
"
|
|
77
|
-
);
|
|
73
|
+
expect(
|
|
74
|
+
resolveScopeAction({ installed: true, version: "3.0.2" }, false),
|
|
75
|
+
).toBe("uninstall");
|
|
78
76
|
});
|
|
79
77
|
|
|
80
78
|
test("a healthy install behind the catalog resolves to update", () => {
|
|
81
|
-
expect(
|
|
82
|
-
"
|
|
83
|
-
);
|
|
79
|
+
expect(
|
|
80
|
+
resolveScopeAction({ installed: true, version: "2.9.0" }, true),
|
|
81
|
+
).toBe("update");
|
|
84
82
|
});
|
|
85
83
|
});
|
|
86
84
|
|
|
87
85
|
/** The screen's path: classify, then ask `scopeIsBehind` for the pressed scope. */
|
|
88
86
|
function keyFor(installed: string, catalogVersion: string | null) {
|
|
89
|
-
const scope = {
|
|
87
|
+
const scope = { installed: true, version: installed };
|
|
90
88
|
const [status] = planPluginUpdates(
|
|
91
89
|
{ "x@magus": "latest" },
|
|
92
90
|
new Map([
|
|
@@ -99,7 +97,6 @@ function keyFor(installed: string, catalogVersion: string | null) {
|
|
|
99
97
|
description: "",
|
|
100
98
|
marketplace: "magus",
|
|
101
99
|
marketplaceDisplay: "Magus",
|
|
102
|
-
enabled: true,
|
|
103
100
|
projectScope: scope,
|
|
104
101
|
} as PluginInfo,
|
|
105
102
|
],
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which registry row a question reads — the pure halves of two fixes whose
|
|
3
|
+
* end-to-end cases are in installed-version-scope.test.tsx.
|
|
4
|
+
*
|
|
5
|
+
* - `ownRegistryEntry`: "does THIS project hold a row at this scope?" — the
|
|
6
|
+
* apply engine's guard before `update`. It used to be answered by the lookup
|
|
7
|
+
* that falls back to inherited rows and then the user row.
|
|
8
|
+
* - `contentDriftEntry`: the row whose commit a content-drift check compares.
|
|
9
|
+
* It used to be the project lookup for every plugin, installed there or not.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
import { ownRegistryEntry } from "../services/claude-settings.js";
|
|
14
|
+
import {
|
|
15
|
+
type PluginInfo,
|
|
16
|
+
contentDriftEntry,
|
|
17
|
+
} from "../services/plugin-manager.js";
|
|
18
|
+
import type { InstalledPluginEntry } from "../types/index.js";
|
|
19
|
+
|
|
20
|
+
const REPO = "/work/repo";
|
|
21
|
+
const HERE = "/work/repo/sub";
|
|
22
|
+
|
|
23
|
+
const row = (over: Partial<InstalledPluginEntry>): InstalledPluginEntry => ({
|
|
24
|
+
scope: "user",
|
|
25
|
+
installPath: "/cache",
|
|
26
|
+
version: "1.0.0",
|
|
27
|
+
installedAt: "",
|
|
28
|
+
lastUpdated: "",
|
|
29
|
+
...over,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("ownRegistryEntry — this project's own row, exactly", () => {
|
|
33
|
+
test("never falls back to the user row for project or local scope", () => {
|
|
34
|
+
const rows = [row({ scope: "user" })];
|
|
35
|
+
expect(ownRegistryEntry(rows, "project", HERE)).toBeUndefined();
|
|
36
|
+
expect(ownRegistryEntry(rows, "local", HERE)).toBeUndefined();
|
|
37
|
+
expect(ownRegistryEntry(rows, "user", HERE)?.scope).toBe("user");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("never takes a row inherited from the repository root", () => {
|
|
41
|
+
const rows = [row({ scope: "project", projectPath: REPO })];
|
|
42
|
+
expect(ownRegistryEntry(rows, "project", HERE)).toBeUndefined();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("matches scope and path exactly — a local row is not a project row", () => {
|
|
46
|
+
const local = row({ scope: "local", projectPath: HERE, version: "3.0.0" });
|
|
47
|
+
const project = row({
|
|
48
|
+
scope: "project",
|
|
49
|
+
projectPath: `${HERE}/`,
|
|
50
|
+
version: "2.0.0",
|
|
51
|
+
});
|
|
52
|
+
expect(ownRegistryEntry([local], "project", HERE)).toBeUndefined();
|
|
53
|
+
expect(ownRegistryEntry([local, project], "project", HERE)?.version).toBe(
|
|
54
|
+
"2.0.0",
|
|
55
|
+
);
|
|
56
|
+
expect(ownRegistryEntry([local, project], "local", HERE)?.version).toBe(
|
|
57
|
+
"3.0.0",
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("contentDriftEntry — the row behind an installed scope", () => {
|
|
63
|
+
const USER = row({ scope: "user", version: "0.9.0", gitCommitSha: "user" });
|
|
64
|
+
const INHERITED = row({
|
|
65
|
+
scope: "project",
|
|
66
|
+
projectPath: REPO,
|
|
67
|
+
version: "0.8.1",
|
|
68
|
+
gitCommitSha: "inherited",
|
|
69
|
+
});
|
|
70
|
+
const OWN = row({
|
|
71
|
+
scope: "project",
|
|
72
|
+
projectPath: HERE,
|
|
73
|
+
version: "0.9.0",
|
|
74
|
+
gitCommitSha: "own",
|
|
75
|
+
});
|
|
76
|
+
const plugin = (over: Partial<PluginInfo>): PluginInfo => ({
|
|
77
|
+
id: "probe@magus",
|
|
78
|
+
name: "probe",
|
|
79
|
+
version: "0.9.0",
|
|
80
|
+
description: "",
|
|
81
|
+
marketplace: "magus",
|
|
82
|
+
marketplaceDisplay: "Magus",
|
|
83
|
+
installedVersion: "0.9.0",
|
|
84
|
+
...over,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("installed at user, beside an inherited project row of another version: the user row", () => {
|
|
88
|
+
const entry = contentDriftEntry(
|
|
89
|
+
plugin({
|
|
90
|
+
userScope: { installed: true, version: "0.9.0" },
|
|
91
|
+
projectScope: { installed: false, version: "0.8.1" },
|
|
92
|
+
}),
|
|
93
|
+
[USER, INHERITED],
|
|
94
|
+
HERE,
|
|
95
|
+
[REPO],
|
|
96
|
+
);
|
|
97
|
+
expect(entry?.gitCommitSha).toBe("user");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("installed at both at the same version: this directory's row first", () => {
|
|
101
|
+
const entry = contentDriftEntry(
|
|
102
|
+
plugin({
|
|
103
|
+
userScope: { installed: true, version: "0.9.0" },
|
|
104
|
+
projectScope: { installed: true, version: "0.9.0" },
|
|
105
|
+
}),
|
|
106
|
+
[USER, OWN],
|
|
107
|
+
HERE,
|
|
108
|
+
[REPO],
|
|
109
|
+
);
|
|
110
|
+
expect(entry?.gitCommitSha).toBe("own");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("no installed scope with a version: no row", () => {
|
|
114
|
+
const entry = contentDriftEntry(
|
|
115
|
+
plugin({
|
|
116
|
+
installedVersion: undefined,
|
|
117
|
+
userScope: { installed: true },
|
|
118
|
+
projectScope: { installed: false, version: "0.8.1" },
|
|
119
|
+
}),
|
|
120
|
+
[INHERITED],
|
|
121
|
+
HERE,
|
|
122
|
+
[REPO],
|
|
123
|
+
);
|
|
124
|
+
expect(entry).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
});
|