magus-cli 7.5.2 → 7.5.4
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__/gitignore-check.test.ts +11 -29
- package/src/__tests__/gitignore-profile.test.ts +248 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/models-cli.test.ts +11 -0
- 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-skills.test.ts +17 -22
- 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-state.test.ts +152 -164
- 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 +239 -70
- package/src/cli/doctor.ts +7 -20
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +7 -3
- package/src/cli/router.ts +6 -4
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +118 -6
- package/src/cli/update.ts +10 -1
- package/src/services/claude-cli.ts +0 -24
- package/src/services/claude-settings.ts +28 -7
- package/src/services/gitignore-check.ts +37 -49
- package/src/services/gitignore-resolver.ts +10 -1
- package/src/services/gitignore-service.ts +67 -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 +41 -24
- package/src/services/profile-activate.ts +102 -127
- package/src/services/profile-startup.ts +160 -4
- package/src/services/profile-state.ts +7 -68
- package/src/services/tracked-live-files.ts +109 -74
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +41 -35
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +63 -17
- package/src/ui/hooks/useProfileStartupModal.ts +94 -9
- 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 +30 -52
- package/src/ui/screens/ProfilesScreen.tsx +3 -10
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -13,7 +13,7 @@ import { ensureManifest, profileGate } from "../cli/bootstrap.js";
|
|
|
13
13
|
import { reportProfileState, runDoctorCommand } from "../cli/doctor.js";
|
|
14
14
|
import { runModelsCommand } from "../cli/models.js";
|
|
15
15
|
import { route } from "../cli/router.js";
|
|
16
|
-
import {
|
|
16
|
+
import { runUpdateCommand } from "../cli/update.js";
|
|
17
17
|
import {
|
|
18
18
|
readSettings,
|
|
19
19
|
writeMcpConfig,
|
|
@@ -25,6 +25,7 @@ import type { ModelsConfig } from "../services/models-core.js";
|
|
|
25
25
|
import { reconcileActiveProfile } from "../services/profile-absorb.js";
|
|
26
26
|
import {
|
|
27
27
|
activateProfile,
|
|
28
|
+
activationNotes,
|
|
28
29
|
regenerateLiveFiles,
|
|
29
30
|
trackedGeneratedFiles,
|
|
30
31
|
} from "../services/profile-activate.js";
|
|
@@ -44,10 +45,6 @@ import {
|
|
|
44
45
|
switchToProfile,
|
|
45
46
|
} from "../services/profile-state.js";
|
|
46
47
|
import { applyUserProfile } from "../services/profiles.js";
|
|
47
|
-
import {
|
|
48
|
-
GitCheckError,
|
|
49
|
-
TrackedLiveFileError,
|
|
50
|
-
} from "../services/tracked-live-files.js";
|
|
51
48
|
import type { ProfileManifest } from "../types/index.js";
|
|
52
49
|
|
|
53
50
|
const MANIFEST: ProfileManifest = {
|
|
@@ -148,7 +145,7 @@ describe("strict active-profile check", () => {
|
|
|
148
145
|
profile: "other",
|
|
149
146
|
restored: false,
|
|
150
147
|
});
|
|
151
|
-
expect(prep.lines[0]).toContain('
|
|
148
|
+
expect(prep.lines[0]).toContain('Using profile "other"');
|
|
152
149
|
expect((await readActiveMarker(project))?.profile).toBe("other");
|
|
153
150
|
expect(await activeProfile(project)).toBe("other");
|
|
154
151
|
});
|
|
@@ -227,11 +224,11 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
227
224
|
|
|
228
225
|
expect(prep.problems).toEqual([]);
|
|
229
226
|
expect(prep.lines).toContain(
|
|
230
|
-
'Changes Claude Code made
|
|
227
|
+
'Changes Claude Code made were added to profile "team"',
|
|
231
228
|
);
|
|
232
229
|
expect(prep.lines).toContain('Added code-search@magus to profile "team"');
|
|
233
230
|
expect(prep.lines).toContain(
|
|
234
|
-
'
|
|
231
|
+
'Updated profile "team" to the current profile format',
|
|
235
232
|
);
|
|
236
233
|
// In profiles.json...
|
|
237
234
|
const team = (await readManifest(project)).profiles.team;
|
|
@@ -268,9 +265,8 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
268
265
|
enabledPlugins: { "dev@magus": true, "code-search@magus": true },
|
|
269
266
|
permissions: { allow: ["Bash(bun test:*)"] },
|
|
270
267
|
});
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
);
|
|
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");
|
|
274
270
|
expect(await readdir(join(project, ".claude", "profiles"))).toEqual([
|
|
275
271
|
"active.json",
|
|
276
272
|
]);
|
|
@@ -319,7 +315,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
319
315
|
// stands, the key is named, and the older copy is in the backup.
|
|
320
316
|
expect(team?.models?.agents).toEqual({ "dev:architect": "smart" });
|
|
321
317
|
expect(prep.lines).toContain(
|
|
322
|
-
'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)',
|
|
323
319
|
);
|
|
324
320
|
const live = await fs.readJson(settingsPath());
|
|
325
321
|
expect(live.enabledPlugins["code-search@magus"]).toBe(true);
|
|
@@ -429,7 +425,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
429
425
|
// Removals are not applied, and the run says so, once.
|
|
430
426
|
expect(
|
|
431
427
|
prep.lines.filter((l) =>
|
|
432
|
-
l.includes("a removal made
|
|
428
|
+
l.includes("a removal made in the old format is not applied"),
|
|
433
429
|
),
|
|
434
430
|
).toHaveLength(1);
|
|
435
431
|
const live = await fs.readJson(settingsPath());
|
|
@@ -481,7 +477,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
481
477
|
"mcpServers.db",
|
|
482
478
|
]) {
|
|
483
479
|
expect(prep.lines).toContain(
|
|
484
|
-
`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)`,
|
|
485
481
|
);
|
|
486
482
|
}
|
|
487
483
|
// The older copy really is in the backup.
|
|
@@ -580,7 +576,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
580
576
|
const prep = await prepareProfileState(project);
|
|
581
577
|
|
|
582
578
|
expect(prep.problems).toHaveLength(1);
|
|
583
|
-
expect(prep.problems[0]).toContain("did not
|
|
579
|
+
expect(prep.problems[0]).toContain("did not update the old profile format");
|
|
584
580
|
expect(
|
|
585
581
|
(await lstat(join(project, ".claude", "settings.json"))).isSymbolicLink(),
|
|
586
582
|
).toBe(true);
|
|
@@ -626,7 +622,7 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
626
622
|
}
|
|
627
623
|
|
|
628
624
|
expect(await snapshot()).toEqual(before);
|
|
629
|
-
expect(printed()).toContain("
|
|
625
|
+
expect(printed()).toContain("uses an old magus profile format");
|
|
630
626
|
expect(await fs.pathExists(activeMarkerPath(project))).toBe(false);
|
|
631
627
|
});
|
|
632
628
|
|
|
@@ -640,11 +636,9 @@ describe("older layouts: Claude Code's edits are kept, the old directory is back
|
|
|
640
636
|
const counts = await reportProfileState(project, false);
|
|
641
637
|
|
|
642
638
|
const out = printed();
|
|
643
|
-
expect(out).toContain(
|
|
644
|
-
'No active-profile marker, but the live config matches profile "other" exactly',
|
|
645
|
-
);
|
|
639
|
+
expect(out).toContain('This project\'s plugins match profile "other"');
|
|
646
640
|
// The contradicting line is gone, and so is the failure.
|
|
647
|
-
expect(out).not.toContain("
|
|
641
|
+
expect(out).not.toContain("match none of the profiles");
|
|
648
642
|
expect(counts).toEqual({ fixable: 0, unfixable: 0 });
|
|
649
643
|
// Report only: the marker is not restored.
|
|
650
644
|
expect(await readActiveMarker(project)).toBeNull();
|
|
@@ -686,7 +680,7 @@ function git(cwd: string, ...args: string[]) {
|
|
|
686
680
|
);
|
|
687
681
|
}
|
|
688
682
|
|
|
689
|
-
describe("generated files git still tracks", () => {
|
|
683
|
+
describe("generated files git still tracks: written, and warned about", () => {
|
|
690
684
|
/** A repo that committed profiles.json AND settings.json, like magus-src. */
|
|
691
685
|
async function commitTrackedSettings(): Promise<void> {
|
|
692
686
|
expect(git(project, "init", "-q").status).toBe(0);
|
|
@@ -698,61 +692,70 @@ describe("generated files git still tracks", () => {
|
|
|
698
692
|
expect(git(project, "commit", "-q", "-m", "init").status).toBe(0);
|
|
699
693
|
}
|
|
700
694
|
|
|
701
|
-
/**
|
|
702
|
-
|
|
703
|
-
expect(git(project, "
|
|
704
|
-
expect(await fs.pathExists(join(project, ".claude", "profiles"))).toBe(
|
|
705
|
-
false,
|
|
706
|
-
);
|
|
707
|
-
expect(await fs.pathExists(backupRoot(project))).toBe(false);
|
|
708
|
-
expect(await fs.pathExists(join(project, ".gitignore"))).toBe(false);
|
|
709
|
-
}
|
|
695
|
+
/** magus never runs git: nothing is staged, whatever it wrote. */
|
|
696
|
+
const nothingStaged = () =>
|
|
697
|
+
expect(git(project, "diff", "--cached", "--name-only").stdout).toBe("");
|
|
710
698
|
|
|
711
|
-
|
|
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
|
+
};
|
|
707
|
+
|
|
708
|
+
test("B7: activating over a tracked settings.json writes it and names the fix", async () => {
|
|
712
709
|
await commitTrackedSettings();
|
|
713
710
|
|
|
714
|
-
const
|
|
711
|
+
const result = await activateProfile(MANIFEST, "team", project, {
|
|
715
712
|
onDisplace: () => {},
|
|
716
|
-
})
|
|
717
|
-
() => null,
|
|
718
|
-
(e: unknown) => e,
|
|
719
|
-
);
|
|
713
|
+
});
|
|
720
714
|
|
|
721
|
-
expect(
|
|
722
|
-
|
|
723
|
-
expect(
|
|
724
|
-
expect(
|
|
725
|
-
|
|
726
|
-
|
|
715
|
+
expect(result.tracked).toEqual([".claude/settings.json"]);
|
|
716
|
+
expect(activationNotes(result)).toContain(PROBLEM);
|
|
717
|
+
expect(activationNotes(result)).toContain(FIX);
|
|
718
|
+
expect((await fs.readJson(settingsPath())).enabledPlugins).toEqual({
|
|
719
|
+
"dev@magus": true,
|
|
720
|
+
"terminal@magus": false,
|
|
721
|
+
});
|
|
722
|
+
expect((await readActiveMarker(project))?.profile).toBe("team");
|
|
723
|
+
expect(git(project, "status", "--porcelain").stdout).toContain(
|
|
724
|
+
" M .claude/settings.json",
|
|
725
|
+
);
|
|
726
|
+
nothingStaged();
|
|
727
727
|
});
|
|
728
728
|
|
|
729
|
-
test("
|
|
729
|
+
test("B7: the gate, the switch path and a startup choice all write", async () => {
|
|
730
730
|
await commitTrackedSettings();
|
|
731
731
|
|
|
732
|
-
const prep = await prepareProfileState(project);
|
|
733
|
-
expect(prep.refusal).toBeInstanceOf(TrackedLiveFileError);
|
|
734
|
-
expect(prep.problems[0]).toContain("git rm --cached .claude/settings.json");
|
|
735
|
-
expect(await profileGate(project)).toBe(false);
|
|
736
|
-
|
|
737
|
-
const switched = await switchToProfile(project, "other", {
|
|
738
|
-
onDisplace: () => {},
|
|
739
|
-
});
|
|
740
|
-
expect(switched.activation).toBeNull();
|
|
741
|
-
expect(switched.problems[0]).toContain(".claude/settings.json");
|
|
742
|
-
|
|
743
732
|
for (const choice of [
|
|
744
733
|
{ kind: "activate", id: "team" },
|
|
745
734
|
{ kind: "adopt", id: "default-2" },
|
|
746
735
|
] as const) {
|
|
747
|
-
await
|
|
748
|
-
|
|
749
|
-
)
|
|
736
|
+
const result = await applyProfileChoice(choice, MANIFEST, project, {
|
|
737
|
+
onDisplace: () => {},
|
|
738
|
+
});
|
|
739
|
+
expect(result.profileId).toBe(choice.id);
|
|
750
740
|
}
|
|
751
|
-
await
|
|
741
|
+
const prep = await prepareProfileState(project);
|
|
742
|
+
expect(prep.problems).toEqual([]);
|
|
743
|
+
expect(await profileGate(project)).toBe(true);
|
|
744
|
+
|
|
745
|
+
const switched = await switchToProfile(project, "other", {
|
|
746
|
+
onDisplace: () => {},
|
|
747
|
+
});
|
|
748
|
+
expect(switched.problems).toEqual([]);
|
|
749
|
+
expect(switched.activation?.profileId).toBe("other");
|
|
750
|
+
expect(
|
|
751
|
+
(await fs.readJson(settingsPath())).enabledPlugins["terminal@magus"],
|
|
752
|
+
).toBe(true);
|
|
753
|
+
nothingStaged();
|
|
752
754
|
});
|
|
753
755
|
|
|
754
|
-
test("
|
|
756
|
+
test("B7: `magus profile switch` exits 0 and warns once, running no git", async () => {
|
|
755
757
|
await commitTrackedSettings();
|
|
758
|
+
await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
|
|
756
759
|
const cwd = process.cwd();
|
|
757
760
|
process.chdir(project);
|
|
758
761
|
let outcome: Awaited<ReturnType<typeof route>>;
|
|
@@ -761,63 +764,47 @@ describe("generated files git still tracks", () => {
|
|
|
761
764
|
} finally {
|
|
762
765
|
process.chdir(cwd);
|
|
763
766
|
}
|
|
764
|
-
expect(outcome.exitCode).toBe(
|
|
765
|
-
|
|
766
|
-
await
|
|
767
|
+
expect(outcome.exitCode).toBe(0);
|
|
768
|
+
warnedOnce(printed());
|
|
769
|
+
expect((await readActiveMarker(project))?.profile).toBe("other");
|
|
770
|
+
nothingStaged();
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test("B7: `magus update` warns once about a tracked generated file, running no git", async () => {
|
|
774
|
+
await commitTrackedSettings();
|
|
775
|
+
await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
|
|
776
|
+
// A named profile that does not exist: the command stops right after the
|
|
777
|
+
// warning, before anything reaches the network.
|
|
778
|
+
expect(await runUpdateCommand(["no-such-profile"], project)).toBe(1);
|
|
779
|
+
warnedOnce(printed());
|
|
780
|
+
nothingStaged();
|
|
767
781
|
});
|
|
768
782
|
|
|
769
|
-
test("
|
|
783
|
+
test("B7: once settings.json is tracked, a pulled change is still regenerated", async () => {
|
|
770
784
|
expect(git(project, "init", "-q").status).toBe(0);
|
|
771
785
|
await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
|
|
772
786
|
// Somebody commits the generated file after all.
|
|
773
787
|
git(project, "add", "-f", ".claude/settings.json");
|
|
774
788
|
git(project, "add", ".claude/profiles.json");
|
|
775
789
|
expect(git(project, "commit", "-q", "-m", "oops").status).toBe(0);
|
|
776
|
-
const before = await fs.readFile(settingsPath(), "utf8");
|
|
777
790
|
// A teammate's pull changes the profile.
|
|
778
791
|
const pulled = structuredClone(MANIFEST);
|
|
779
792
|
(pulled.profiles.team as { plugins: Record<string, string> }).plugins[
|
|
780
793
|
"mnemex@magus"
|
|
781
794
|
] = "latest";
|
|
782
795
|
await writeManifest(pulled, project);
|
|
783
|
-
const manifestBefore = await fs.readFile(
|
|
784
|
-
join(project, ".claude", "profiles.json"),
|
|
785
|
-
"utf8",
|
|
786
|
-
);
|
|
787
796
|
|
|
788
797
|
const prep = await prepareProfileState(project);
|
|
789
|
-
expect(prep.problems[0]).toContain("git rm --cached .claude/settings.json");
|
|
790
|
-
expect((await reconcileActiveProfile(project)).problems[0]).toContain(
|
|
791
|
-
".claude/settings.json",
|
|
792
|
-
);
|
|
793
|
-
await expect(regenerateLiveFiles(project)).rejects.toBeInstanceOf(
|
|
794
|
-
TrackedLiveFileError,
|
|
795
|
-
);
|
|
796
|
-
expect(await fs.readFile(settingsPath(), "utf8")).toBe(before);
|
|
797
|
-
expect(
|
|
798
|
-
await fs.readFile(join(project, ".claude", "profiles.json"), "utf8"),
|
|
799
|
-
).toBe(manifestBefore);
|
|
800
|
-
});
|
|
801
|
-
|
|
802
|
-
test("B1: after `git rm --cached` and a commit, activation proceeds and keeps the committed profiles.json", async () => {
|
|
803
|
-
await commitTrackedSettings();
|
|
804
|
-
git(project, "rm", "-q", "--cached", ".claude/settings.json");
|
|
805
|
-
expect(git(project, "commit", "-q", "-m", "untrack").status).toBe(0);
|
|
806
|
-
const manifestBytes = await fs.readFile(
|
|
807
|
-
join(project, ".claude", "profiles.json"),
|
|
808
|
-
"utf8",
|
|
809
|
-
);
|
|
810
798
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
expect((await lstat(settingsPath())).isFile()).toBe(true);
|
|
814
|
-
expect((await readActiveMarker(project))?.profile).toBe("team");
|
|
799
|
+
expect(prep.problems).toEqual([]);
|
|
800
|
+
expect(prep.lines.join("\n")).toContain("regenerated");
|
|
815
801
|
expect(
|
|
816
|
-
await fs.
|
|
817
|
-
).toBe(
|
|
802
|
+
(await fs.readJson(settingsPath())).enabledPlugins["mnemex@magus"],
|
|
803
|
+
).toBe(true);
|
|
804
|
+
nothingStaged();
|
|
818
805
|
});
|
|
819
806
|
|
|
820
|
-
test("
|
|
807
|
+
test("B7: adopting a repo with no profiles.json and a tracked settings.json writes and warns", async () => {
|
|
821
808
|
await fs.remove(join(project, ".claude", "profiles.json"));
|
|
822
809
|
expect(git(project, "init", "-q").status).toBe(0);
|
|
823
810
|
await fs.outputJson(settingsPath(), {
|
|
@@ -829,112 +816,113 @@ describe("generated files git still tracks", () => {
|
|
|
829
816
|
const savedHome = process.env.HOME;
|
|
830
817
|
process.env.HOME = home;
|
|
831
818
|
try {
|
|
832
|
-
|
|
833
|
-
ensureManifest(project, { allowAdopt: true }),
|
|
834
|
-
).
|
|
819
|
+
expect(
|
|
820
|
+
await ensureManifest(project, { allowAdopt: true }),
|
|
821
|
+
).not.toBeNull();
|
|
835
822
|
} finally {
|
|
836
823
|
process.env.HOME = savedHome;
|
|
837
824
|
await rm(home, { recursive: true, force: true });
|
|
838
825
|
}
|
|
839
|
-
|
|
840
|
-
|
|
826
|
+
const manifest = await readManifest(project);
|
|
827
|
+
expect(Object.keys(manifest.profiles)).toHaveLength(1);
|
|
828
|
+
expect(await activeProfile(project)).toBe(
|
|
829
|
+
Object.keys(manifest.profiles)[0],
|
|
841
830
|
);
|
|
842
|
-
|
|
831
|
+
warnedOnce(printed());
|
|
832
|
+
nothingStaged();
|
|
843
833
|
});
|
|
844
834
|
|
|
845
|
-
test("
|
|
835
|
+
test("B7: doctor notes a tracked live file with the fix, and does not count it", async () => {
|
|
846
836
|
await commitTrackedSettings();
|
|
837
|
+
await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
|
|
847
838
|
const counts = await reportProfileState(project, false);
|
|
848
|
-
expect(counts.unfixable).
|
|
849
|
-
expect(printed()).toContain(
|
|
850
|
-
|
|
839
|
+
expect(counts.unfixable).toBe(0);
|
|
840
|
+
expect(printed()).toContain(`• ${PROBLEM}`);
|
|
841
|
+
expect(printed()).toContain(FIX);
|
|
851
842
|
});
|
|
852
843
|
|
|
853
|
-
test("
|
|
844
|
+
test("B7: writeSettings and writeMcpConfig write a tracked live file", async () => {
|
|
854
845
|
await commitTrackedSettings();
|
|
855
846
|
await fs.outputJson(join(project, ".mcp.json"), { mcpServers: {} });
|
|
856
847
|
git(project, "add", ".mcp.json");
|
|
857
848
|
expect(git(project, "commit", "-q", "-m", "mcp").status).toBe(0);
|
|
858
|
-
const before = await fs.readFile(settingsPath(), "utf8");
|
|
859
849
|
|
|
860
850
|
// What the TUI Profiles tab's "apply predefined" does.
|
|
861
851
|
const settings = await readSettings(project);
|
|
862
|
-
(settings.enabledPlugins as Record<string, boolean>)["frontend@magus"] =
|
|
863
|
-
true;
|
|
864
|
-
await expect(writeSettings(settings, project)).rejects.toBeInstanceOf(
|
|
865
|
-
TrackedLiveFileError,
|
|
866
|
-
);
|
|
867
|
-
await expect(
|
|
868
|
-
writeMcpConfig({ mcpServers: { x: { command: "x" } } }, project),
|
|
869
|
-
).rejects.toBeInstanceOf(TrackedLiveFileError);
|
|
870
|
-
|
|
871
|
-
expect(await fs.readFile(settingsPath(), "utf8")).toBe(before);
|
|
872
|
-
await untouched();
|
|
873
|
-
});
|
|
874
|
-
|
|
875
|
-
test("review M1: a project-scope Claude CLI install or uninstall is refused before the CLI runs", async () => {
|
|
876
|
-
await commitTrackedSettings();
|
|
877
|
-
const cwd = process.cwd();
|
|
878
|
-
const pathBefore = process.env.PATH;
|
|
879
|
-
// git stays reachable; `claude` does not — were the refusal missing, the
|
|
880
|
-
// CLI could not run here and touch this machine's plugins anyway.
|
|
881
|
-
const gitDir = spawnSync("sh", ["-c", "command -v git"], {
|
|
882
|
-
encoding: "utf8",
|
|
883
|
-
}).stdout.trim();
|
|
884
|
-
process.env.PATH = gitDir.slice(0, gitDir.lastIndexOf("/"));
|
|
885
|
-
process.chdir(project);
|
|
886
|
-
try {
|
|
887
|
-
await expect(
|
|
888
|
-
installPlugin("dev@magus", "project"),
|
|
889
|
-
).rejects.toBeInstanceOf(TrackedLiveFileError);
|
|
890
|
-
await expect(
|
|
891
|
-
uninstallPlugin("dev@magus", "project", project),
|
|
892
|
-
).rejects.toBeInstanceOf(TrackedLiveFileError);
|
|
893
|
-
} finally {
|
|
894
|
-
process.chdir(cwd);
|
|
895
|
-
process.env.PATH = pathBefore;
|
|
896
|
-
}
|
|
897
|
-
await untouched();
|
|
898
|
-
});
|
|
899
|
-
|
|
900
|
-
test("review M1: a project without profiles writes its own settings.json as before", async () => {
|
|
901
|
-
await commitTrackedSettings();
|
|
902
|
-
await writeManifest({ version: 2, profiles: {} }, project);
|
|
903
|
-
const settings = await readSettings(project);
|
|
904
852
|
(settings.enabledPlugins as Record<string, boolean>)["frontend@magus"] =
|
|
905
853
|
true;
|
|
906
854
|
await writeSettings(settings, project);
|
|
855
|
+
await writeMcpConfig({ mcpServers: { x: { command: "x" } } }, project);
|
|
856
|
+
|
|
907
857
|
expect(
|
|
908
858
|
(await fs.readJson(settingsPath())).enabledPlugins["frontend@magus"],
|
|
909
859
|
).toBe(true);
|
|
860
|
+
expect((await fs.readJson(join(project, ".mcp.json"))).mcpServers).toEqual({
|
|
861
|
+
x: { command: "x" },
|
|
862
|
+
});
|
|
863
|
+
nothingStaged();
|
|
910
864
|
});
|
|
911
865
|
|
|
912
|
-
test("
|
|
866
|
+
test("B7: inside a work tree git cannot read, activation writes and says git could not be asked", async () => {
|
|
913
867
|
// A .git pointing at a repository that is not there: git exits 128.
|
|
914
868
|
await fs.outputFile(
|
|
915
869
|
join(project, ".git"),
|
|
916
870
|
`gitdir: ${join(project, "no-such-repository")}\n`,
|
|
917
871
|
);
|
|
918
872
|
|
|
919
|
-
const
|
|
873
|
+
const result = await activateProfile(MANIFEST, "team", project, {
|
|
920
874
|
onDisplace: () => {},
|
|
921
|
-
})
|
|
922
|
-
|
|
923
|
-
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
expect(await fs.pathExists(settingsPath())).toBe(true);
|
|
878
|
+
expect(typeof result.tracked).toBe("string");
|
|
879
|
+
expect(activationNotes(result).join("\n")).toContain(
|
|
880
|
+
"magus could not check which files git tracks in this project",
|
|
924
881
|
);
|
|
882
|
+
expect((await prepareProfileState(project)).problems).toEqual([]);
|
|
883
|
+
});
|
|
925
884
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
885
|
+
test("B7 review L3: converting an older layout where git cannot answer exits 1 with git's message, no stack trace", async () => {
|
|
886
|
+
// 7.4.1 layout, a listed skill linked: the conversion must ask git which
|
|
887
|
+
// skill folders the team committed. `--dry-run` skips the entry gate, so
|
|
888
|
+
// the error reaches the router's own handler.
|
|
889
|
+
const withSkill = structuredClone(MANIFEST);
|
|
890
|
+
(withSkill.profiles.team as { skills: unknown[] }).skills = [
|
|
891
|
+
{ name: "alpha", repo: "a/b", path: "skills/alpha" },
|
|
892
|
+
];
|
|
893
|
+
await writeManifest(withSkill, project);
|
|
894
|
+
const old = join(project, ".claude", "_profiles", "team");
|
|
895
|
+
await fs.outputFile(join(old, "skills", "alpha", "SKILL.md"), "# alpha\n");
|
|
896
|
+
await fs.outputJson(join(old, "settings.json"), {
|
|
897
|
+
enabledPlugins: { "dev@magus": true },
|
|
898
|
+
});
|
|
899
|
+
await fs.symlink("_profiles/team/settings.json", settingsPath());
|
|
900
|
+
await fs.symlink(
|
|
901
|
+
"_profiles/team/skills",
|
|
902
|
+
join(project, ".claude", "skills"),
|
|
903
|
+
);
|
|
904
|
+
await fs.outputFile(
|
|
905
|
+
join(project, ".git"),
|
|
906
|
+
`gitdir: ${join(project, "no-such-repository")}\n`,
|
|
907
|
+
);
|
|
908
|
+
const cwd = process.cwd();
|
|
909
|
+
process.chdir(project);
|
|
910
|
+
let outcome: Awaited<ReturnType<typeof route>>;
|
|
911
|
+
try {
|
|
912
|
+
outcome = await route(
|
|
913
|
+
["profile", "switch", "team", "--dry-run"],
|
|
914
|
+
"0.0.0-test",
|
|
915
|
+
);
|
|
916
|
+
} finally {
|
|
917
|
+
process.chdir(cwd);
|
|
918
|
+
}
|
|
919
|
+
expect(outcome.exitCode).toBe(1);
|
|
920
|
+
expect(printed()).toContain(
|
|
921
|
+
"magus could not check which files git tracks in this project. git said:",
|
|
932
922
|
);
|
|
933
|
-
const prep = await prepareProfileState(project);
|
|
934
|
-
expect(prep.refusal).toBeInstanceOf(GitCheckError);
|
|
935
923
|
});
|
|
936
924
|
|
|
937
|
-
test("
|
|
925
|
+
test("git missing is an error inside a work tree, and no check outside one", async () => {
|
|
938
926
|
expect(git(project, "init", "-q").status).toBe(0);
|
|
939
927
|
const outside = await mkdtemp(join(tmpdir(), "no-git-"));
|
|
940
928
|
const pathBefore = process.env.PATH;
|
|
@@ -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
|
],
|