magus-cli 7.5.0 → 7.5.2
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 +5 -4
- package/src/__tests__/profile-absorb.test.ts +72 -0
- package/src/__tests__/profile-dependencies.test.ts +442 -0
- package/src/__tests__/profile-manifest-write.test.ts +231 -0
- package/src/__tests__/profile-skills.test.ts +99 -0
- package/src/__tests__/profile-state.test.ts +328 -13
- package/src/__tests__/stale-load-repair.test.ts +265 -0
- package/src/__tests__/support/preload.ts +14 -0
- package/src/__tests__/update-apply.test.ts +342 -1
- package/src/cli/bootstrap.ts +4 -0
- package/src/cli/doctor.ts +39 -6
- package/src/cli/install.ts +68 -4
- package/src/cli/router.ts +7 -0
- package/src/cli/update.ts +3 -0
- package/src/services/claude-cli.ts +24 -0
- package/src/services/claude-settings.ts +45 -0
- package/src/services/git-worktree.ts +92 -9
- package/src/services/manifest.ts +220 -3
- package/src/services/plugin-dependencies.ts +48 -0
- package/src/services/plugin-requires.ts +137 -14
- package/src/services/profile-absorb.ts +69 -15
- package/src/services/profile-activate.ts +179 -33
- package/src/services/profile-live.ts +66 -7
- package/src/services/profile-render.ts +73 -6
- package/src/services/profile-skills.ts +32 -7
- package/src/services/profile-state.ts +89 -5
- package/src/services/profiles.ts +6 -5
- package/src/services/resolver.ts +96 -3
- package/src/services/tracked-live-files.ts +125 -0
- package/src/services/update-engine.ts +266 -8
- package/src/types/index.ts +19 -0
- package/src/ui/screens/PluginsScreen.tsx +11 -2
- package/src/ui/screens/ProfilesScreen.tsx +10 -3
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
PluginUpdateItem,
|
|
26
26
|
UpdatePlan,
|
|
27
27
|
} from "../services/update-plan.js";
|
|
28
|
-
import type { ResolvedBin } from "../types/index.js";
|
|
28
|
+
import type { InstalledPluginEntry, ResolvedBin } from "../types/index.js";
|
|
29
29
|
import { noMoveReasons } from "../ui/adapters/pluginsAdapter.js";
|
|
30
30
|
|
|
31
31
|
const PROJECT = "/tmp/project";
|
|
@@ -780,3 +780,344 @@ describe("applyPlugins — read-back against beforeVersions", () => {
|
|
|
780
780
|
expect(reasons.lines(["other@magus"])).toBe("other@magus");
|
|
781
781
|
});
|
|
782
782
|
});
|
|
783
|
+
|
|
784
|
+
// ── B4: installed must mean LOADED ──────────────────────────────────────────
|
|
785
|
+
//
|
|
786
|
+
// Claude Code's loader takes the FIRST registry row relevant to the repository,
|
|
787
|
+
// and rows from this repository's other checkouts (deleted worktrees included)
|
|
788
|
+
// often precede this project's own. `claude plugin update` writes only this
|
|
789
|
+
// project's row, so an update could show "updated" and load the old version at
|
|
790
|
+
// once. The same operation now runs the stale-load repair as its last step.
|
|
791
|
+
|
|
792
|
+
describe("B4: an install or update ends with the loader on the new version", () => {
|
|
793
|
+
/** `loaded` is what the loader resolves until the repair has run. */
|
|
794
|
+
function loaderDeps(loaded: string | null, repairOk = true) {
|
|
795
|
+
const stub = stubDeps("2.0.0", (pluginId, target) =>
|
|
796
|
+
repairOk
|
|
797
|
+
? {
|
|
798
|
+
pluginId,
|
|
799
|
+
target,
|
|
800
|
+
rewritten: 3,
|
|
801
|
+
backup: "/tmp/installed_plugins.json.backup.1",
|
|
802
|
+
loaded: target,
|
|
803
|
+
ok: true,
|
|
804
|
+
}
|
|
805
|
+
: {
|
|
806
|
+
pluginId,
|
|
807
|
+
target,
|
|
808
|
+
rewritten: 0,
|
|
809
|
+
backup: null,
|
|
810
|
+
loaded,
|
|
811
|
+
ok: false,
|
|
812
|
+
error: `the loader still resolves v${loaded} here, not v${target}`,
|
|
813
|
+
},
|
|
814
|
+
);
|
|
815
|
+
stub.deps.loadedVersion = mock(async () => loaded);
|
|
816
|
+
return stub;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
test("B4: an update the loader does not pick up moves the older records forward and says so", async () => {
|
|
820
|
+
const { deps, rowsRepaired } = loaderDeps("1.0.0");
|
|
821
|
+
const { reporter, rows } = recordingReporter();
|
|
822
|
+
|
|
823
|
+
const result = await applyPlugins([item({})], PROJECT, deps, reporter);
|
|
824
|
+
|
|
825
|
+
expect(rowsRepaired).toEqual([
|
|
826
|
+
{ id: "dev@magus", target: "2.0.0", projectPath: PROJECT },
|
|
827
|
+
]);
|
|
828
|
+
expect(result.ok).toBe(1);
|
|
829
|
+
expect(result.failed).toEqual([]);
|
|
830
|
+
expect(rows).toEqual([
|
|
831
|
+
{
|
|
832
|
+
ok: true,
|
|
833
|
+
name: "dev@magus",
|
|
834
|
+
detail:
|
|
835
|
+
"project → 2.0.0 · Claude Code now loads v2.0.0 here (3 older records moved forward)",
|
|
836
|
+
},
|
|
837
|
+
]);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
test("B4: a loader already on the landed version runs no repair", async () => {
|
|
841
|
+
const { deps, rowsRepaired } = loaderDeps("2.0.0");
|
|
842
|
+
const { reporter, rows } = recordingReporter();
|
|
843
|
+
|
|
844
|
+
const result = await applyPlugins([item({})], PROJECT, deps, reporter);
|
|
845
|
+
|
|
846
|
+
expect(rowsRepaired).toEqual([]);
|
|
847
|
+
expect(result.ok).toBe(1);
|
|
848
|
+
expect(rows[0]?.detail).toBe("project → 2.0.0");
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
test("B4: a repair that cannot move the loader makes the update a failure, never 'updated'", async () => {
|
|
852
|
+
const { deps } = loaderDeps("1.0.0", false);
|
|
853
|
+
const { reporter, rows } = recordingReporter();
|
|
854
|
+
|
|
855
|
+
const result = await applyPlugins([item({})], PROJECT, deps, reporter);
|
|
856
|
+
|
|
857
|
+
expect(result.ok).toBe(0);
|
|
858
|
+
expect(result.failed).toEqual(["dev@magus"]);
|
|
859
|
+
expect(rows).toEqual([
|
|
860
|
+
{
|
|
861
|
+
ok: false,
|
|
862
|
+
name: "dev@magus",
|
|
863
|
+
detail:
|
|
864
|
+
"installed v2.0.0, but Claude Code still loads v1.0.0 here — the loader still resolves v1.0.0 here, not v2.0.0",
|
|
865
|
+
},
|
|
866
|
+
]);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
test("B4: an install whose version did not move still brings the loader forward, and is not 'no change'", async () => {
|
|
870
|
+
// The project row already held 2.0.0; the older row ahead of it is what
|
|
871
|
+
// loaded. Reporting "no change" would leave the plugin loading 1.0.0.
|
|
872
|
+
const { deps, rowsRepaired } = loaderDeps("1.0.0");
|
|
873
|
+
|
|
874
|
+
const result = await applyPlugins(
|
|
875
|
+
[item({ beforeVersions: { project: "2.0.0" } })],
|
|
876
|
+
PROJECT,
|
|
877
|
+
deps,
|
|
878
|
+
);
|
|
879
|
+
|
|
880
|
+
expect(rowsRepaired).toHaveLength(1);
|
|
881
|
+
expect(result.unchanged).toEqual([]);
|
|
882
|
+
expect(result.ok).toBe(1);
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
test("B4: the TUI's per-scope install and update helpers repair at project scope", async () => {
|
|
886
|
+
const a = loaderDeps("1.0.0");
|
|
887
|
+
expect(
|
|
888
|
+
await updatePluginInScope("dev@magus", "project", PROJECT, a.deps),
|
|
889
|
+
).toBe("2.0.0");
|
|
890
|
+
expect(a.rowsRepaired).toEqual([
|
|
891
|
+
{ id: "dev@magus", target: "2.0.0", projectPath: PROJECT },
|
|
892
|
+
]);
|
|
893
|
+
|
|
894
|
+
const b = loaderDeps("1.0.0");
|
|
895
|
+
await installPluginInScope("dev@magus", "project", PROJECT, b.deps);
|
|
896
|
+
expect(b.rowsRepaired).toHaveLength(1);
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test("B4: a per-scope helper whose repair fails throws, so the TUI reports a failure", async () => {
|
|
900
|
+
const { deps } = loaderDeps("1.0.0", false);
|
|
901
|
+
await expect(
|
|
902
|
+
updatePluginInScope("dev@magus", "project", PROJECT, deps),
|
|
903
|
+
).rejects.toThrow(
|
|
904
|
+
"installed v2.0.0, but Claude Code still loads v1.0.0 here",
|
|
905
|
+
);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test("B4: the per-scope helpers bring the loader forward at user and local scope too", async () => {
|
|
909
|
+
for (const scope of ["user", "local"] as const) {
|
|
910
|
+
const up = loaderDeps("1.0.0");
|
|
911
|
+
await updatePluginInScope("dev@magus", scope, PROJECT, up.deps);
|
|
912
|
+
expect(up.rowsRepaired).toEqual([
|
|
913
|
+
{ id: "dev@magus", target: "2.0.0", projectPath: PROJECT },
|
|
914
|
+
]);
|
|
915
|
+
const fresh = loaderDeps("1.0.0");
|
|
916
|
+
await installPluginInScope("dev@magus", scope, PROJECT, fresh.deps);
|
|
917
|
+
expect(fresh.rowsRepaired).toHaveLength(1);
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
test("B4: a machine-wide winner the repair refuses fails the user-scope update, naming why", async () => {
|
|
922
|
+
const { deps } = loaderDeps("1.0.0", false);
|
|
923
|
+
await expect(
|
|
924
|
+
updatePluginInScope("dev@magus", "user", PROJECT, deps),
|
|
925
|
+
).rejects.toThrow("the loader still resolves v1.0.0 here, not v2.0.0");
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
// ── 7.5.2: Claude Code 2.1.281 updates ANOTHER project's row ─────────────────
|
|
930
|
+
//
|
|
931
|
+
// `claude plugin update P --scope project` under 2.1.281 moves the repository's
|
|
932
|
+
// FIRST relevant registry row — the one its loader would pick — not this
|
|
933
|
+
// project's. Measured 2026-09-24, same registry, cwd = the project:
|
|
934
|
+
// 2.1.280 updated from 8.0.0 to 8.1.3 for scope project (<sandbox>/magus-src)
|
|
935
|
+
// 2.1.281 updated from 8.0.0 to 8.1.3 for scope project (<sandbox>/magus-src/tools/claudeup)
|
|
936
|
+
// This project's row stayed at 8.0.0, and `magus update` said "no change".
|
|
937
|
+
|
|
938
|
+
describe("7.5.2: an update Claude Code applied to another project's row", () => {
|
|
939
|
+
const HERE = "/tmp/project";
|
|
940
|
+
const OTHER = "/tmp/project/tools/claudeup";
|
|
941
|
+
const UNRELATED = "/tmp/another-repository";
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* A registry for dev@magus — another project's row of the same repository
|
|
945
|
+
* first, this project's second — and a fake Claude CLI that updates either
|
|
946
|
+
* the first relevant row (`"2.1.281"`) or this project's (`"2.1.280"`).
|
|
947
|
+
*/
|
|
948
|
+
function registry(
|
|
949
|
+
cli: "2.1.280" | "2.1.281" | "unrelated" | "other-version",
|
|
950
|
+
cached: string[] = ["8.1.3", "8.5.0"],
|
|
951
|
+
) {
|
|
952
|
+
const rows: InstalledPluginEntry[] = [
|
|
953
|
+
{
|
|
954
|
+
scope: "project",
|
|
955
|
+
projectPath: OTHER,
|
|
956
|
+
version: "8.0.0",
|
|
957
|
+
} as InstalledPluginEntry,
|
|
958
|
+
{
|
|
959
|
+
scope: "project",
|
|
960
|
+
projectPath: HERE,
|
|
961
|
+
version: "8.0.0",
|
|
962
|
+
} as InstalledPluginEntry,
|
|
963
|
+
{
|
|
964
|
+
scope: "project",
|
|
965
|
+
projectPath: UNRELATED,
|
|
966
|
+
version: "8.0.0",
|
|
967
|
+
} as InstalledPluginEntry,
|
|
968
|
+
];
|
|
969
|
+
const repaired: string[] = [];
|
|
970
|
+
const saved: string[] = [];
|
|
971
|
+
const row = (projectPath: string) =>
|
|
972
|
+
rows.find((r) => r.projectPath === projectPath) as InstalledPluginEntry;
|
|
973
|
+
const deps: PluginApplyDeps = {
|
|
974
|
+
install: async () => {},
|
|
975
|
+
update: async () => {
|
|
976
|
+
if (cli === "2.1.280") row(HERE).version = "8.1.3";
|
|
977
|
+
if (cli === "2.1.281") row(OTHER).version = "8.1.3";
|
|
978
|
+
// Another repository's row moving while the CLI runs.
|
|
979
|
+
if (cli === "unrelated") row(UNRELATED).version = "8.1.3";
|
|
980
|
+
// This repository's row, but not to the version this update targets.
|
|
981
|
+
if (cli === "other-version") row(OTHER).version = "8.5.0";
|
|
982
|
+
},
|
|
983
|
+
repair: async () => {},
|
|
984
|
+
readInstalled: async (_id, scope, projectPath) =>
|
|
985
|
+
scope === "project" ? (row(projectPath)?.version ?? null) : null,
|
|
986
|
+
saveInstalled: async (_id, version) => {
|
|
987
|
+
saved.push(version);
|
|
988
|
+
},
|
|
989
|
+
registryRows: async () => rows.map((r) => ({ ...r })),
|
|
990
|
+
// Same repository = this project's tree, as git would answer.
|
|
991
|
+
inThisRepository: (r, projectPath) =>
|
|
992
|
+
(r.projectPath ?? "").startsWith(projectPath),
|
|
993
|
+
// What repairStaleLoad does: refuse an uncached target, else move every
|
|
994
|
+
// row of this repository that is behind it.
|
|
995
|
+
repairStaleLoad: async (pluginId, target) => {
|
|
996
|
+
repaired.push(target);
|
|
997
|
+
if (!cached.includes(target)) {
|
|
998
|
+
return {
|
|
999
|
+
pluginId,
|
|
1000
|
+
target,
|
|
1001
|
+
rewritten: 0,
|
|
1002
|
+
backup: null,
|
|
1003
|
+
loaded: null,
|
|
1004
|
+
ok: false,
|
|
1005
|
+
error: `no cache directory for v${target}`,
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
let rewritten = 0;
|
|
1009
|
+
for (const r of rows) {
|
|
1010
|
+
if (r.projectPath?.startsWith(HERE) && r.version !== target) {
|
|
1011
|
+
r.version = target;
|
|
1012
|
+
rewritten++;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
return {
|
|
1016
|
+
pluginId,
|
|
1017
|
+
target,
|
|
1018
|
+
rewritten,
|
|
1019
|
+
backup: null,
|
|
1020
|
+
loaded: target,
|
|
1021
|
+
ok: true,
|
|
1022
|
+
};
|
|
1023
|
+
},
|
|
1024
|
+
// The loader: the first relevant row.
|
|
1025
|
+
loadedVersion: async () => rows[0]?.version ?? null,
|
|
1026
|
+
};
|
|
1027
|
+
return { rows, deps, repaired, saved, row };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const update = () =>
|
|
1031
|
+
item({
|
|
1032
|
+
installed: "8.0.0",
|
|
1033
|
+
available: "8.1.3",
|
|
1034
|
+
target: "8.1.3",
|
|
1035
|
+
beforeVersions: { project: "8.0.0" },
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
test("7.5.2: under 2.1.281 this project's row follows to the fetched version, reported as updated", async () => {
|
|
1039
|
+
const { deps, row, repaired, saved } = registry("2.1.281");
|
|
1040
|
+
const { reporter, rows } = recordingReporter();
|
|
1041
|
+
|
|
1042
|
+
const result = await applyPlugins([update()], HERE, deps, reporter);
|
|
1043
|
+
|
|
1044
|
+
expect(row(HERE).version).toBe("8.1.3");
|
|
1045
|
+
expect(row(OTHER).version).toBe("8.1.3"); // the CLI's change stands
|
|
1046
|
+
expect(repaired).toEqual(["8.1.3"]);
|
|
1047
|
+
expect(saved).toEqual(["8.1.3"]);
|
|
1048
|
+
expect(result.ok).toBe(1);
|
|
1049
|
+
expect(result.unchanged).toEqual([]);
|
|
1050
|
+
expect(rows).toEqual([
|
|
1051
|
+
{
|
|
1052
|
+
ok: true,
|
|
1053
|
+
name: "dev@magus",
|
|
1054
|
+
detail: `project → 8.1.3 · Claude Code updated ${OTHER} instead of this project — moved this project's record to v8.1.3 too`,
|
|
1055
|
+
},
|
|
1056
|
+
]);
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
test("7.5.2: under 2.1.280 the CLI updates this project's row and nothing extra happens", async () => {
|
|
1060
|
+
const { deps, row, repaired } = registry("2.1.280");
|
|
1061
|
+
const { reporter, rows } = recordingReporter();
|
|
1062
|
+
|
|
1063
|
+
const result = await applyPlugins([update()], HERE, deps, reporter);
|
|
1064
|
+
|
|
1065
|
+
expect(row(HERE).version).toBe("8.1.3");
|
|
1066
|
+
expect(result.ok).toBe(1);
|
|
1067
|
+
// Exactly 7.5.1's outcome: the older row ahead of this one is brought
|
|
1068
|
+
// forward by the loader check (B4), and no misdirection is reported.
|
|
1069
|
+
expect(repaired).toEqual(["8.1.3"]);
|
|
1070
|
+
expect(rows[0]?.detail).toBe(
|
|
1071
|
+
"project → 8.1.3 · Claude Code now loads v8.1.3 here (1 older record moved forward)",
|
|
1072
|
+
);
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
test("7.5.2: a version the CLI did not cache is not followed — still an honest no change", async () => {
|
|
1076
|
+
const { deps, row } = registry("2.1.281", []);
|
|
1077
|
+
const { reporter, rows } = recordingReporter();
|
|
1078
|
+
|
|
1079
|
+
const result = await applyPlugins([update()], HERE, deps, reporter);
|
|
1080
|
+
|
|
1081
|
+
expect(row(HERE).version).toBe("8.0.0");
|
|
1082
|
+
expect(result.unchanged).toEqual(["dev@magus"]);
|
|
1083
|
+
expect(rows[0]?.detail).toBe(
|
|
1084
|
+
`no change — the installer exited 0 but dev@magus is still 8.0.0 (Claude Code updated ${OTHER} instead of this project, and this project's record could not follow: no cache directory for v8.1.3)`,
|
|
1085
|
+
);
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
test("7.5.2 review: a row of ANOTHER repository moving to the target is not followed", async () => {
|
|
1089
|
+
const { deps, row, repaired } = registry("unrelated");
|
|
1090
|
+
const { reporter, rows } = recordingReporter();
|
|
1091
|
+
|
|
1092
|
+
const result = await applyPlugins([update()], HERE, deps, reporter);
|
|
1093
|
+
|
|
1094
|
+
expect(row(HERE).version).toBe("8.0.0");
|
|
1095
|
+
expect(repaired).toEqual([]);
|
|
1096
|
+
expect(result.unchanged).toEqual(["dev@magus"]);
|
|
1097
|
+
expect(rows[0]?.detail).toBe(
|
|
1098
|
+
"no change — the installer exited 0 but dev@magus is still 8.0.0",
|
|
1099
|
+
);
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
test("7.5.2 review: this repository's row moving to a version other than the target is not followed", async () => {
|
|
1103
|
+
const { deps, row, repaired } = registry("other-version");
|
|
1104
|
+
const { reporter, rows } = recordingReporter();
|
|
1105
|
+
|
|
1106
|
+
const result = await applyPlugins([update()], HERE, deps, reporter);
|
|
1107
|
+
|
|
1108
|
+
expect(row(HERE).version).toBe("8.0.0");
|
|
1109
|
+
expect(repaired).toEqual([]);
|
|
1110
|
+
expect(result.unchanged).toEqual(["dev@magus"]);
|
|
1111
|
+
expect(rows[0]?.detail).toBe(
|
|
1112
|
+
`no change — the installer exited 0 but dev@magus is still 8.0.0 (Claude Code updated ${OTHER} instead of this project, and this project's record could not follow: it went to v8.5.0, not the v8.1.3 this update asked for)`,
|
|
1113
|
+
);
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
test("7.5.2: the TUI's per-scope update key follows it too", async () => {
|
|
1117
|
+
const { deps, row } = registry("2.1.281");
|
|
1118
|
+
expect(await updatePluginInScope("dev@magus", "project", HERE, deps)).toBe(
|
|
1119
|
+
"8.1.3",
|
|
1120
|
+
);
|
|
1121
|
+
expect(row(HERE).version).toBe("8.1.3");
|
|
1122
|
+
});
|
|
1123
|
+
});
|
package/src/cli/bootstrap.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type ActivationResult,
|
|
19
19
|
activateProfile,
|
|
20
20
|
activationNotes,
|
|
21
|
+
assertNoTrackedWriteTargets,
|
|
21
22
|
reportDisplaced,
|
|
22
23
|
} from "../services/profile-activate.js";
|
|
23
24
|
import {
|
|
@@ -83,6 +84,9 @@ async function writeAndActivate(
|
|
|
83
84
|
// their hand-written files went.
|
|
84
85
|
const rel = (p: string) => path.relative(projectPath, p);
|
|
85
86
|
|
|
87
|
+
// Before the manifest is written: adopting activates, and activating a
|
|
88
|
+
// project whose settings.json git tracks would take that file over.
|
|
89
|
+
await assertNoTrackedWriteTargets(projectPath);
|
|
86
90
|
await writeManifest(result.manifest, projectPath);
|
|
87
91
|
console.log(`✓ Wrote ${rel(getManifestPath(projectPath))}`);
|
|
88
92
|
|
package/src/cli/doctor.ts
CHANGED
|
@@ -46,16 +46,17 @@ import {
|
|
|
46
46
|
type RepoRootCache,
|
|
47
47
|
pickLoadedEntry,
|
|
48
48
|
} from "../services/plugin-resolution.js";
|
|
49
|
-
import {
|
|
50
|
-
trackedGeneratedFiles,
|
|
51
|
-
untrackCommand,
|
|
52
|
-
} from "../services/profile-activate.js";
|
|
49
|
+
import { trackedGeneratedFiles } from "../services/profile-activate.js";
|
|
53
50
|
import {
|
|
54
51
|
LIVE_FILE_KEYS,
|
|
55
52
|
liveKind,
|
|
56
53
|
livePath,
|
|
57
54
|
readActiveMarker,
|
|
58
55
|
} from "../services/profile-live.js";
|
|
56
|
+
import {
|
|
57
|
+
describeDependencies,
|
|
58
|
+
renderProfile,
|
|
59
|
+
} from "../services/profile-render.js";
|
|
59
60
|
import { inspectManagedSkills } from "../services/profile-skills.js";
|
|
60
61
|
import {
|
|
61
62
|
type ActiveProfileCheck,
|
|
@@ -66,6 +67,10 @@ import {
|
|
|
66
67
|
switchHint,
|
|
67
68
|
} from "../services/profile-state.js";
|
|
68
69
|
import { resolveAllProfiles } from "../services/resolver.js";
|
|
70
|
+
import {
|
|
71
|
+
GitCheckError,
|
|
72
|
+
untrackCommand,
|
|
73
|
+
} from "../services/tracked-live-files.js";
|
|
69
74
|
import type {
|
|
70
75
|
ClaudeSettings,
|
|
71
76
|
InstalledPluginsRegistry,
|
|
@@ -529,13 +534,41 @@ export async function reportProfileState(
|
|
|
529
534
|
} else if (check.state === "active") {
|
|
530
535
|
console.log(`✓ Active profile "${check.profile}" is in place.`);
|
|
531
536
|
}
|
|
537
|
+
// Plugin dependencies: a dependency the profile turns off stops a plugin
|
|
538
|
+
// loading; a plugin.json that cannot be read leaves its dependencies
|
|
539
|
+
// unknown, and they were not enabled.
|
|
540
|
+
const manifest = await readManifest(projectPath);
|
|
541
|
+
const { dependencies } = manifest.profiles[check.profile]
|
|
542
|
+
? await renderProfile(manifest, check.profile, projectPath)
|
|
543
|
+
: { dependencies: undefined };
|
|
544
|
+
for (const line of describeDependencies(
|
|
545
|
+
dependencies && { ...dependencies, unreadable: [] },
|
|
546
|
+
check.profile,
|
|
547
|
+
)) {
|
|
548
|
+
console.log(`✗ ${line}`);
|
|
549
|
+
unfixable++;
|
|
550
|
+
}
|
|
551
|
+
for (const line of describeDependencies(
|
|
552
|
+
dependencies && { ...dependencies, blocked: [] },
|
|
553
|
+
check.profile,
|
|
554
|
+
)) {
|
|
555
|
+
console.log(`⚠ ${line}`);
|
|
556
|
+
}
|
|
532
557
|
}
|
|
533
558
|
|
|
534
559
|
// A .gitignore entry does not untrack a file committed before it. Report
|
|
535
560
|
// only: magus never runs git rm.
|
|
536
|
-
|
|
561
|
+
let tracked: string[] = [];
|
|
562
|
+
try {
|
|
563
|
+
tracked = await trackedGeneratedFiles(projectPath);
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (!(error instanceof GitCheckError)) throw error;
|
|
566
|
+
console.log(`✗ ${error.message}`);
|
|
567
|
+
unfixable++;
|
|
568
|
+
}
|
|
569
|
+
for (const rel of tracked) {
|
|
537
570
|
console.log(
|
|
538
|
-
`✗ ${
|
|
571
|
+
`✗ ${rel} is generated from profiles.json but tracked by git. Fix: \`${untrackCommand(rel)}\`, then commit.`,
|
|
539
572
|
);
|
|
540
573
|
unfixable++;
|
|
541
574
|
}
|
package/src/cli/install.ts
CHANGED
|
@@ -27,6 +27,10 @@ import {
|
|
|
27
27
|
} from "../services/install-plan.js";
|
|
28
28
|
import { readManifest, validateManifest } from "../services/manifest.js";
|
|
29
29
|
import { registerClosureMarketplaces } from "../services/marketplace-sync.js";
|
|
30
|
+
import {
|
|
31
|
+
clearMarketplaceCache,
|
|
32
|
+
readInstalledVersionForScope,
|
|
33
|
+
} from "../services/plugin-manager.js";
|
|
30
34
|
import { reportDisplaced } from "../services/profile-activate.js";
|
|
31
35
|
import { activeProfile } from "../services/profile-live.js";
|
|
32
36
|
import {
|
|
@@ -40,6 +44,7 @@ import {
|
|
|
40
44
|
binInstallCommand,
|
|
41
45
|
detectToolchains,
|
|
42
46
|
} from "../services/toolchain.js";
|
|
47
|
+
import { bringLoadForward } from "../services/update-engine.js";
|
|
43
48
|
import type { ResolvedBin, ResolvedClosure } from "../types/index.js";
|
|
44
49
|
import { formatCommand, resolveExecutable } from "../utils/command-utils.js";
|
|
45
50
|
import { runCommand, runShellScript } from "../utils/run.js";
|
|
@@ -155,6 +160,41 @@ async function installPlugins(closure: ResolvedClosure): Promise<void> {
|
|
|
155
160
|
}
|
|
156
161
|
}
|
|
157
162
|
|
|
163
|
+
/**
|
|
164
|
+
* After the installs: every plugin must LOAD the version installed here, not
|
|
165
|
+
* an older one from a registry row this repository's other checkouts left
|
|
166
|
+
* first in line. Where it does not, the same stale-load repair `magus update`
|
|
167
|
+
* chains is run, and reported either way.
|
|
168
|
+
*/
|
|
169
|
+
async function bringLoadsForward(
|
|
170
|
+
pluginIds: readonly string[],
|
|
171
|
+
projectPath: string,
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
for (const pluginId of pluginIds) {
|
|
174
|
+
const here = await readInstalledVersionForScope(
|
|
175
|
+
pluginId,
|
|
176
|
+
"project",
|
|
177
|
+
projectPath,
|
|
178
|
+
);
|
|
179
|
+
if (!here) continue;
|
|
180
|
+
const forward = await bringLoadForward(pluginId, here, projectPath);
|
|
181
|
+
if (!forward) continue;
|
|
182
|
+
if (forward.ok) {
|
|
183
|
+
console.log(
|
|
184
|
+
`✓ ${pluginId}: Claude Code now loads v${here} here (${forward.rewritten} older record${
|
|
185
|
+
forward.rewritten === 1 ? "" : "s"
|
|
186
|
+
} moved forward)`,
|
|
187
|
+
);
|
|
188
|
+
} else {
|
|
189
|
+
console.warn(
|
|
190
|
+
`⚠ ${pluginId}: installed v${here}, but Claude Code still loads v${
|
|
191
|
+
forward.loaded ?? "an older version"
|
|
192
|
+
} here — ${forward.error ?? "the older records could not be moved forward"}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
158
198
|
async function installBins(bins: ResolvedBin[]): Promise<void> {
|
|
159
199
|
for (const bin of bins) {
|
|
160
200
|
if (await resolveExecutable(bin.name)) {
|
|
@@ -261,10 +301,13 @@ export async function runInstallCommand(
|
|
|
261
301
|
return 1;
|
|
262
302
|
}
|
|
263
303
|
|
|
264
|
-
// Closure: a single profile, or the union of all
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
304
|
+
// Closure: a single profile, or the union of all — with every plugin the
|
|
305
|
+
// enabled ones depend on.
|
|
306
|
+
const resolveClosure = () =>
|
|
307
|
+
flags.profile
|
|
308
|
+
? resolveProfile(manifest, flags.profile)
|
|
309
|
+
: resolveAllProfiles(manifest);
|
|
310
|
+
let closure = await resolveClosure();
|
|
268
311
|
|
|
269
312
|
if (flags.check) {
|
|
270
313
|
return runCheck(closure, manifest.strictVersions === true);
|
|
@@ -295,6 +338,25 @@ export async function runInstallCommand(
|
|
|
295
338
|
|
|
296
339
|
await ensureToolchains(closure, flags.yes);
|
|
297
340
|
await registerClosureMarketplaces(closure.marketplaces);
|
|
341
|
+
// A marketplace added just now carries the plugin.json files that name
|
|
342
|
+
// each plugin's dependencies; resolve again so those are installed too.
|
|
343
|
+
clearMarketplaceCache();
|
|
344
|
+
const before = new Set(Object.keys(closure.plugins));
|
|
345
|
+
closure = await resolveClosure();
|
|
346
|
+
const found = Object.keys(closure.plugins).filter((id) => !before.has(id));
|
|
347
|
+
if (found.length > 0) {
|
|
348
|
+
console.log(
|
|
349
|
+
`Also installing what those plugins depend on: ${found.join(", ")}`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
const unreadable = closure.dependencies?.unreadable ?? [];
|
|
353
|
+
if (unreadable.length > 0) {
|
|
354
|
+
console.warn(
|
|
355
|
+
`⚠ Could not read the plugin.json of ${unreadable.join(", ")} — whatever ${
|
|
356
|
+
unreadable.length === 1 ? "it depends" : "they depend"
|
|
357
|
+
} on is not installed by this run`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
298
360
|
|
|
299
361
|
const current = await activeProfile(projectPath);
|
|
300
362
|
const activeId =
|
|
@@ -326,8 +388,10 @@ export async function runInstallCommand(
|
|
|
326
388
|
// meantime is kept, only the install's own records are discarded, and a
|
|
327
389
|
// Ctrl-C leaves the flag for the next command to do the same.
|
|
328
390
|
await withCliBookkeeping(projectPath, () => installPlugins(closure), {
|
|
391
|
+
installable: Object.keys(closure.plugins),
|
|
329
392
|
report: (line) => console.log(noteLine(line)),
|
|
330
393
|
});
|
|
394
|
+
await bringLoadsForward(Object.keys(closure.plugins), projectPath);
|
|
331
395
|
await installBins(closure.bins);
|
|
332
396
|
|
|
333
397
|
if (activeId) {
|
package/src/cli/router.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { ThemeMode } from "@opentui/core";
|
|
10
10
|
import { SettingsParseError } from "../services/settings-file.js";
|
|
11
|
+
import { LiveFileRefusal } from "../services/tracked-live-files.js";
|
|
11
12
|
import { checkForUpdates } from "../services/version-check.js";
|
|
12
13
|
import { type ThemeEnv, parseThemeWord } from "../ui/theme-resolve.js";
|
|
13
14
|
import { profileGate, reportUnparseable } from "./bootstrap.js";
|
|
@@ -223,6 +224,12 @@ export async function route(
|
|
|
223
224
|
reportUnparseable(error);
|
|
224
225
|
return HANDLED(1);
|
|
225
226
|
}
|
|
227
|
+
// A live file git tracks (or git could not say): nothing was written,
|
|
228
|
+
// and the message says what to do. A refusal, not a crash.
|
|
229
|
+
if (error instanceof LiveFileRefusal) {
|
|
230
|
+
console.error(error.message);
|
|
231
|
+
return HANDLED(1);
|
|
232
|
+
}
|
|
226
233
|
throw error;
|
|
227
234
|
}
|
|
228
235
|
}
|
package/src/cli/update.ts
CHANGED
|
@@ -607,6 +607,9 @@ export async function runUpdateCommand(
|
|
|
607
607
|
projectPath,
|
|
608
608
|
() => applyPlan(plan, projectPath, { report: skills.report }),
|
|
609
609
|
{
|
|
610
|
+
// Only the active profile's plugins are this run's to install, so
|
|
611
|
+
// only their records are the CLI's.
|
|
612
|
+
installable: Object.keys(closure.plugins),
|
|
610
613
|
beforeReconcile: () =>
|
|
611
614
|
recordRefetchedSkills(projectPath, skills.fetched()),
|
|
612
615
|
report: (line) => reconciledLines.push(line),
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { execFile } from "node:child_process";
|
|
13
|
+
import path from "node:path";
|
|
13
14
|
import { promisify } from "node:util";
|
|
14
15
|
import { defaultMarketplaces } from "../data/marketplaces.js";
|
|
15
16
|
import { which } from "../utils/command-utils.js";
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
removeLocalInstalledPluginVersion,
|
|
20
21
|
} from "./claude-settings.js";
|
|
21
22
|
import { removeInstalledPluginVersion } from "./plugin-manager.js";
|
|
23
|
+
import { refuseTrackedLiveFiles } from "./tracked-live-files.js";
|
|
22
24
|
|
|
23
25
|
const execFileAsync = promisify(execFile);
|
|
24
26
|
|
|
@@ -138,6 +140,23 @@ async function execPluginCommand(
|
|
|
138
140
|
}
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Every `claude plugin … --scope project` command writes the project's
|
|
145
|
+
* `.claude/settings.json` (its enabledPlugins entry). In a project with
|
|
146
|
+
* profiles that file is generated; where git tracks it, refuse — before the
|
|
147
|
+
* CLI runs — rather than let the CLI write it (see `tracked-live-files.ts`).
|
|
148
|
+
* The CLI works on the current directory.
|
|
149
|
+
*/
|
|
150
|
+
async function refuseProjectSettingsWrite(
|
|
151
|
+
scope: PluginScope,
|
|
152
|
+
projectPath: string = process.cwd(),
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
if (scope !== "project") return;
|
|
155
|
+
await refuseTrackedLiveFiles(projectPath, [
|
|
156
|
+
path.join(".claude", "settings.json"),
|
|
157
|
+
]);
|
|
158
|
+
}
|
|
159
|
+
|
|
141
160
|
/**
|
|
142
161
|
* Install a plugin using claude CLI
|
|
143
162
|
* Handles enabling + version tracking + cache copy in one shot.
|
|
@@ -154,6 +173,7 @@ export async function installPlugin(
|
|
|
154
173
|
pluginId: string,
|
|
155
174
|
scope: PluginScope = "user",
|
|
156
175
|
): Promise<void> {
|
|
176
|
+
await refuseProjectSettingsWrite(scope);
|
|
157
177
|
// 60s, matching update and repair. Before the install/update split this path
|
|
158
178
|
// ran through `updatePlugin`'s 60s budget; leaving it on the 30s default
|
|
159
179
|
// would have quietly halved the time a fresh install — the SLOWEST of the
|
|
@@ -172,6 +192,7 @@ export async function uninstallPlugin(
|
|
|
172
192
|
scope: PluginScope = "user",
|
|
173
193
|
projectPath?: string,
|
|
174
194
|
): Promise<void> {
|
|
195
|
+
await refuseProjectSettingsWrite(scope, projectPath);
|
|
175
196
|
try {
|
|
176
197
|
await execClaude(["plugin", "uninstall", pluginId, "--scope", scope]);
|
|
177
198
|
} catch {
|
|
@@ -224,6 +245,7 @@ export async function enablePlugin(
|
|
|
224
245
|
pluginId: string,
|
|
225
246
|
scope: PluginScope = "user",
|
|
226
247
|
): Promise<void> {
|
|
248
|
+
await refuseProjectSettingsWrite(scope);
|
|
227
249
|
await execClaude(["plugin", "enable", pluginId, "--scope", scope]);
|
|
228
250
|
}
|
|
229
251
|
|
|
@@ -234,6 +256,7 @@ export async function disablePlugin(
|
|
|
234
256
|
pluginId: string,
|
|
235
257
|
scope: PluginScope = "user",
|
|
236
258
|
): Promise<void> {
|
|
259
|
+
await refuseProjectSettingsWrite(scope);
|
|
237
260
|
await execClaude(["plugin", "disable", pluginId, "--scope", scope]);
|
|
238
261
|
}
|
|
239
262
|
|
|
@@ -276,6 +299,7 @@ export async function updatePlugin(
|
|
|
276
299
|
pluginId: string,
|
|
277
300
|
scope: PluginScope = "user",
|
|
278
301
|
): Promise<void> {
|
|
302
|
+
await refuseProjectSettingsWrite(scope);
|
|
279
303
|
await execPluginCommand("update", pluginId, scope, 60000);
|
|
280
304
|
}
|
|
281
305
|
|