magus-cli 7.5.1 → 7.5.3
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__/gitignore-check.test.ts +11 -29
- package/src/__tests__/gitignore-profile.test.ts +248 -0
- package/src/__tests__/models-cli.test.ts +11 -0
- package/src/__tests__/profile-skills.test.ts +16 -21
- package/src/__tests__/profile-state.test.ts +132 -149
- package/src/__tests__/stale-load-repair.test.ts +160 -0
- package/src/__tests__/update-apply.test.ts +197 -1
- package/src/cli/bootstrap.ts +0 -4
- package/src/cli/doctor.ts +6 -20
- package/src/cli/router.ts +5 -4
- package/src/cli/update.ts +7 -0
- package/src/services/claude-cli.ts +0 -24
- package/src/services/claude-settings.ts +18 -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/profile-absorb.ts +31 -17
- package/src/services/profile-activate.ts +74 -120
- package/src/services/profile-state.ts +0 -62
- package/src/services/tracked-live-files.ts +88 -74
- package/src/services/update-engine.ts +180 -7
- package/src/ui/App.tsx +34 -33
- package/src/ui/hooks/useGitignoreModal.ts +63 -17
- package/src/ui/hooks/useProfileStartupModal.ts +7 -2
- package/src/ui/screens/PluginsScreen.tsx +2 -11
- package/src/ui/screens/ProfilesScreen.tsx +3 -10
|
@@ -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";
|
|
@@ -925,3 +925,199 @@ describe("B4: an install or update ends with the loader on the new version", ()
|
|
|
925
925
|
).rejects.toThrow("the loader still resolves v1.0.0 here, not v2.0.0");
|
|
926
926
|
});
|
|
927
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,7 +18,6 @@ import {
|
|
|
18
18
|
type ActivationResult,
|
|
19
19
|
activateProfile,
|
|
20
20
|
activationNotes,
|
|
21
|
-
assertNoTrackedWriteTargets,
|
|
22
21
|
reportDisplaced,
|
|
23
22
|
} from "../services/profile-activate.js";
|
|
24
23
|
import {
|
|
@@ -84,9 +83,6 @@ async function writeAndActivate(
|
|
|
84
83
|
// their hand-written files went.
|
|
85
84
|
const rel = (p: string) => path.relative(projectPath, p);
|
|
86
85
|
|
|
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);
|
|
90
86
|
await writeManifest(result.manifest, projectPath);
|
|
91
87
|
console.log(`✓ Wrote ${rel(getManifestPath(projectPath))}`);
|
|
92
88
|
|
package/src/cli/doctor.ts
CHANGED
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
type RepoRootCache,
|
|
47
47
|
pickLoadedEntry,
|
|
48
48
|
} from "../services/plugin-resolution.js";
|
|
49
|
-
import {
|
|
49
|
+
import { trackedGeneratedWarnings } from "../services/profile-activate.js";
|
|
50
50
|
import {
|
|
51
51
|
LIVE_FILE_KEYS,
|
|
52
52
|
liveKind,
|
|
@@ -67,10 +67,6 @@ import {
|
|
|
67
67
|
switchHint,
|
|
68
68
|
} from "../services/profile-state.js";
|
|
69
69
|
import { resolveAllProfiles } from "../services/resolver.js";
|
|
70
|
-
import {
|
|
71
|
-
GitCheckError,
|
|
72
|
-
untrackCommand,
|
|
73
|
-
} from "../services/tracked-live-files.js";
|
|
74
70
|
import type {
|
|
75
71
|
ClaudeSettings,
|
|
76
72
|
InstalledPluginsRegistry,
|
|
@@ -556,21 +552,11 @@ export async function reportProfileState(
|
|
|
556
552
|
}
|
|
557
553
|
}
|
|
558
554
|
|
|
559
|
-
// A .gitignore entry does not untrack a file committed before it.
|
|
560
|
-
//
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
} catch (error) {
|
|
565
|
-
if (!(error instanceof GitCheckError)) throw error;
|
|
566
|
-
console.log(`✗ ${error.message}`);
|
|
567
|
-
unfixable++;
|
|
568
|
-
}
|
|
569
|
-
for (const rel of tracked) {
|
|
570
|
-
console.log(
|
|
571
|
-
`✗ ${rel} is generated from profiles.json but tracked by git. Fix: \`${untrackCommand(rel)}\`, then commit.`,
|
|
572
|
-
);
|
|
573
|
-
unfixable++;
|
|
555
|
+
// A .gitignore entry does not untrack a file committed before it. A note
|
|
556
|
+
// with the fix, not a failure: magus writes the file all the same, and
|
|
557
|
+
// never runs git rm itself.
|
|
558
|
+
for (const line of await trackedGeneratedWarnings(projectPath)) {
|
|
559
|
+
console.log(`• ${line}`);
|
|
574
560
|
}
|
|
575
561
|
return { fixable, unfixable };
|
|
576
562
|
}
|
package/src/cli/router.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { ThemeMode } from "@opentui/core";
|
|
10
10
|
import { SettingsParseError } from "../services/settings-file.js";
|
|
11
|
-
import {
|
|
11
|
+
import { GitCheckError } from "../services/tracked-live-files.js";
|
|
12
12
|
import { checkForUpdates } from "../services/version-check.js";
|
|
13
13
|
import { type ThemeEnv, parseThemeWord } from "../ui/theme-resolve.js";
|
|
14
14
|
import { profileGate, reportUnparseable } from "./bootstrap.js";
|
|
@@ -224,9 +224,10 @@ export async function route(
|
|
|
224
224
|
reportUnparseable(error);
|
|
225
225
|
return HANDLED(1);
|
|
226
226
|
}
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
227
|
+
// git could not say what it tracks, and converting an older layout needs
|
|
228
|
+
// to know which skill folders the team committed. git's own message, not
|
|
229
|
+
// a stack trace.
|
|
230
|
+
if (error instanceof GitCheckError) {
|
|
230
231
|
console.error(error.message);
|
|
231
232
|
return HANDLED(1);
|
|
232
233
|
}
|
package/src/cli/update.ts
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
import {
|
|
52
52
|
recordRefetchedSkills,
|
|
53
53
|
trackFetchedSkills,
|
|
54
|
+
trackedGeneratedWarnings,
|
|
54
55
|
updatableSkills,
|
|
55
56
|
} from "../services/profile-activate.js";
|
|
56
57
|
import { activeProfile } from "../services/profile-live.js";
|
|
@@ -202,6 +203,12 @@ export async function runUpdateCommand(
|
|
|
202
203
|
return 1;
|
|
203
204
|
}
|
|
204
205
|
|
|
206
|
+
// A generated file git tracks is written like any other; say so once, with
|
|
207
|
+
// the fix. magus never runs git itself.
|
|
208
|
+
for (const line of await trackedGeneratedWarnings(projectPath)) {
|
|
209
|
+
console.log(warnLine(line));
|
|
210
|
+
}
|
|
211
|
+
|
|
205
212
|
// No active profile, no update: that is where plugin state is read from and
|
|
206
213
|
// where changes are written. Asked before the profile is resolved, so what
|
|
207
214
|
// the user activates here is what this run uses.
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { execFile } from "node:child_process";
|
|
13
|
-
import path from "node:path";
|
|
14
13
|
import { promisify } from "node:util";
|
|
15
14
|
import { defaultMarketplaces } from "../data/marketplaces.js";
|
|
16
15
|
import { which } from "../utils/command-utils.js";
|
|
@@ -20,7 +19,6 @@ import {
|
|
|
20
19
|
removeLocalInstalledPluginVersion,
|
|
21
20
|
} from "./claude-settings.js";
|
|
22
21
|
import { removeInstalledPluginVersion } from "./plugin-manager.js";
|
|
23
|
-
import { refuseTrackedLiveFiles } from "./tracked-live-files.js";
|
|
24
22
|
|
|
25
23
|
const execFileAsync = promisify(execFile);
|
|
26
24
|
|
|
@@ -140,23 +138,6 @@ async function execPluginCommand(
|
|
|
140
138
|
}
|
|
141
139
|
}
|
|
142
140
|
|
|
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
|
-
|
|
160
141
|
/**
|
|
161
142
|
* Install a plugin using claude CLI
|
|
162
143
|
* Handles enabling + version tracking + cache copy in one shot.
|
|
@@ -173,7 +154,6 @@ export async function installPlugin(
|
|
|
173
154
|
pluginId: string,
|
|
174
155
|
scope: PluginScope = "user",
|
|
175
156
|
): Promise<void> {
|
|
176
|
-
await refuseProjectSettingsWrite(scope);
|
|
177
157
|
// 60s, matching update and repair. Before the install/update split this path
|
|
178
158
|
// ran through `updatePlugin`'s 60s budget; leaving it on the 30s default
|
|
179
159
|
// would have quietly halved the time a fresh install — the SLOWEST of the
|
|
@@ -192,7 +172,6 @@ export async function uninstallPlugin(
|
|
|
192
172
|
scope: PluginScope = "user",
|
|
193
173
|
projectPath?: string,
|
|
194
174
|
): Promise<void> {
|
|
195
|
-
await refuseProjectSettingsWrite(scope, projectPath);
|
|
196
175
|
try {
|
|
197
176
|
await execClaude(["plugin", "uninstall", pluginId, "--scope", scope]);
|
|
198
177
|
} catch {
|
|
@@ -245,7 +224,6 @@ export async function enablePlugin(
|
|
|
245
224
|
pluginId: string,
|
|
246
225
|
scope: PluginScope = "user",
|
|
247
226
|
): Promise<void> {
|
|
248
|
-
await refuseProjectSettingsWrite(scope);
|
|
249
227
|
await execClaude(["plugin", "enable", pluginId, "--scope", scope]);
|
|
250
228
|
}
|
|
251
229
|
|
|
@@ -256,7 +234,6 @@ export async function disablePlugin(
|
|
|
256
234
|
pluginId: string,
|
|
257
235
|
scope: PluginScope = "user",
|
|
258
236
|
): Promise<void> {
|
|
259
|
-
await refuseProjectSettingsWrite(scope);
|
|
260
237
|
await execClaude(["plugin", "disable", pluginId, "--scope", scope]);
|
|
261
238
|
}
|
|
262
239
|
|
|
@@ -299,7 +276,6 @@ export async function updatePlugin(
|
|
|
299
276
|
pluginId: string,
|
|
300
277
|
scope: PluginScope = "user",
|
|
301
278
|
): Promise<void> {
|
|
302
|
-
await refuseProjectSettingsWrite(scope);
|
|
303
279
|
await execPluginCommand("update", pluginId, scope, 60000);
|
|
304
280
|
}
|
|
305
281
|
|
|
@@ -37,7 +37,6 @@ import {
|
|
|
37
37
|
writableList,
|
|
38
38
|
writableMap,
|
|
39
39
|
} from "./settings-file.js";
|
|
40
|
-
import { refuseTrackedLiveFiles } from "./tracked-live-files.js";
|
|
41
40
|
|
|
42
41
|
/**
|
|
43
42
|
* What every reader below throws for a file that exists but does not parse.
|
|
@@ -149,11 +148,6 @@ export async function writeSettings(
|
|
|
149
148
|
settings: ClaudeSettings,
|
|
150
149
|
projectPath?: string,
|
|
151
150
|
): Promise<void> {
|
|
152
|
-
// In a project with profiles this file is generated: never written where
|
|
153
|
-
// git tracks it (see `tracked-live-files.ts`).
|
|
154
|
-
await refuseTrackedLiveFiles(projectPath ?? process.cwd(), [
|
|
155
|
-
getSettingsPath(projectPath),
|
|
156
|
-
]);
|
|
157
151
|
await refuseUnwritableFile(getSettingsPath(projectPath));
|
|
158
152
|
const claudeDir = await ensureClaudeDir(projectPath);
|
|
159
153
|
const settingsPath = path.join(claudeDir, SETTINGS_FILE);
|
|
@@ -197,7 +191,6 @@ export async function writeMcpConfig(
|
|
|
197
191
|
projectPath?: string,
|
|
198
192
|
): Promise<void> {
|
|
199
193
|
const mcpPath = getMcpConfigPath(projectPath);
|
|
200
|
-
await refuseTrackedLiveFiles(projectPath ?? process.cwd(), [mcpPath]);
|
|
201
194
|
await refuseUnwritableFile(mcpPath);
|
|
202
195
|
await fs.writeJson(mcpPath, config, { spaces: 2 });
|
|
203
196
|
}
|
|
@@ -1991,6 +1984,24 @@ function belongsToThisRepository(
|
|
|
1991
1984
|
return repoRootForPath(entry.projectPath, cache) === cwdRoot;
|
|
1992
1985
|
}
|
|
1993
1986
|
|
|
1987
|
+
/**
|
|
1988
|
+
* Is `entry` a row of the repository `projectPath` is in — one
|
|
1989
|
+
* {@link repairStaleLoad} may rewrite? The same predicate, for callers that
|
|
1990
|
+
* judge one row at a time.
|
|
1991
|
+
*/
|
|
1992
|
+
export function rowBelongsToRepository(
|
|
1993
|
+
entry: InstalledPluginEntry,
|
|
1994
|
+
projectPath: string,
|
|
1995
|
+
): boolean {
|
|
1996
|
+
const cache: RepoRootCache = new Map();
|
|
1997
|
+
return belongsToThisRepository(
|
|
1998
|
+
entry,
|
|
1999
|
+
projectPath,
|
|
2000
|
+
repoRootForPath(projectPath, cache),
|
|
2001
|
+
cache,
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
2004
|
+
|
|
1994
2005
|
/** A plugin whose loader-resolved version is behind a row this repair can rewrite. */
|
|
1995
2006
|
export interface StaleLoad {
|
|
1996
2007
|
pluginId: string;
|
|
@@ -1,63 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The TUI's startup gitignore question.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Asked on every open (and again after a first adoption). Resolves the
|
|
5
|
+
* manifest — including, in a project with profiles, the files magus generates
|
|
6
|
+
* from `.claude/profiles.json` — runs detection, and reports. Nothing here
|
|
7
|
+
* applies a fix: the startup question applies the in-place fix only when the
|
|
8
|
+
* user picks it (`applyStartupFixes`), and every other destructive fix is
|
|
9
|
+
* confirmed per item in the Git State tab.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import type { Violation } from "../types/gitignore.js";
|
|
12
|
-
import {
|
|
13
|
-
import { formatViolationSummary } from "./gitignore-fixer.js";
|
|
14
|
-
import { resolveGitignore } from "./gitignore-resolver.js";
|
|
13
|
+
import { loadGitignoreState } from "./gitignore-service.js";
|
|
15
14
|
|
|
16
|
-
|
|
15
|
+
/** What the TUI's startup gitignore question needs to show. */
|
|
16
|
+
export interface StartupGitignoreNeed {
|
|
17
17
|
violationCount: number;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
const resolved = await resolveGitignore(cwd, {
|
|
27
|
-
logger: (m) => warnings.push(m),
|
|
28
|
-
});
|
|
29
|
-
const violations = await detectViolations(cwd, resolved);
|
|
30
|
-
return {
|
|
31
|
-
violationCount: violations.length,
|
|
32
|
-
warnings,
|
|
33
|
-
};
|
|
34
|
-
} catch (err) {
|
|
35
|
-
warnings.push(
|
|
36
|
-
`[gitignore] check failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
37
|
-
);
|
|
38
|
-
return { violationCount: 0, warnings };
|
|
39
|
-
}
|
|
18
|
+
/** Missing `.gitignore` entries — the additive fix. */
|
|
19
|
+
safeCount: number;
|
|
20
|
+
/**
|
|
21
|
+
* Files magus generates from `.claude/profiles.json` that git tracks — the
|
|
22
|
+
* ones the startup fix untracks in place.
|
|
23
|
+
*/
|
|
24
|
+
generated: string[];
|
|
40
25
|
}
|
|
41
26
|
|
|
42
27
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
28
|
+
* The startup gitignore question, or null when there is nothing to ask.
|
|
29
|
+
*
|
|
30
|
+
* Asked fresh on every call — the TUI calls it on every open, and again after
|
|
31
|
+
* a first adoption — and nothing remembers an earlier answer: a tracked
|
|
32
|
+
* generated file is asked about until it is fixed.
|
|
45
33
|
*/
|
|
46
|
-
export function
|
|
47
|
-
|
|
48
|
-
):
|
|
49
|
-
|
|
50
|
-
|
|
34
|
+
export async function startupGitignoreNeed(
|
|
35
|
+
cwd: string,
|
|
36
|
+
): Promise<StartupGitignoreNeed | null> {
|
|
37
|
+
const { violations } = await loadGitignoreState(cwd);
|
|
38
|
+
if (violations.length === 0) return null;
|
|
39
|
+
return {
|
|
40
|
+
violationCount: violations.length,
|
|
41
|
+
safeCount: violations.filter((v) => v.severity === "safe").length,
|
|
42
|
+
generated: generatedTracked(violations).map((v) => v.path),
|
|
43
|
+
};
|
|
51
44
|
}
|
|
52
45
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
cwd: string,
|
|
59
|
-
violations: Violation[],
|
|
60
|
-
): Promise<string> {
|
|
61
|
-
void cwd;
|
|
62
|
-
return formatViolationSummary(violations);
|
|
46
|
+
/** The violations the startup fix handles in place: tracked generated files. */
|
|
47
|
+
export function generatedTracked(violations: Violation[]): Violation[] {
|
|
48
|
+
return violations.filter(
|
|
49
|
+
(v) => v.kind === "tracked-but-should-ignore" && v.source === "profile",
|
|
50
|
+
);
|
|
63
51
|
}
|
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* 1. Built-in defaults (shipped with magus)
|
|
6
6
|
* 2. Global manifest at ~/.claude/gitignore.json (team-level, manually shared)
|
|
7
7
|
* 3. Project manifest at <cwd>/.claude/gitignore.json
|
|
8
|
+
* 4. Profile: in a project with profiles, the files magus generates from
|
|
9
|
+
* `.claude/profiles.json` (passed in as `opts.profile`). Highest, because
|
|
10
|
+
* those files are rewritten per developer — tracking one cannot be right
|
|
11
|
+
* whatever another manifest says.
|
|
8
12
|
*
|
|
9
13
|
* `extends` references are resolved against BUILTIN_TEMPLATES. Within a single
|
|
10
14
|
* tier, the same pattern cannot appear in both `ignore` and `track` — that
|
|
@@ -33,9 +37,11 @@ export interface ResolveOptions {
|
|
|
33
37
|
homeDir?: string;
|
|
34
38
|
/** Optional logger for non-fatal warnings (malformed JSON, missing template). */
|
|
35
39
|
logger?: (msg: string) => void;
|
|
40
|
+
/** The profile tier — see `profileGitignoreManifest`. */
|
|
41
|
+
profile?: GitignoreManifest | null;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
|
-
const TIER_ORDER: Tier[] = ["builtin", "global", "project"];
|
|
44
|
+
const TIER_ORDER: Tier[] = ["builtin", "global", "project", "profile"];
|
|
39
45
|
|
|
40
46
|
export async function resolveGitignore(
|
|
41
47
|
projectCwd: string,
|
|
@@ -59,6 +65,9 @@ export async function resolveGitignore(
|
|
|
59
65
|
const projectManifest = await readManifest(projectPath, log);
|
|
60
66
|
if (projectManifest) tierManifests.set("project", projectManifest);
|
|
61
67
|
|
|
68
|
+
// 4. The files the profile model generates
|
|
69
|
+
if (opts.profile) tierManifests.set("profile", opts.profile);
|
|
70
|
+
|
|
62
71
|
// Apply `extends` for each tier (templates count as part of their tier)
|
|
63
72
|
for (const tier of TIER_ORDER) {
|
|
64
73
|
const m = tierManifests.get(tier);
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "./gitignore-fixer.js";
|
|
29
29
|
import { resolveGitignore } from "./gitignore-resolver.js";
|
|
30
30
|
import { readSettingsFile, writableList } from "./settings-file.js";
|
|
31
|
+
import { profileGitignoreManifest } from "./tracked-live-files.js";
|
|
31
32
|
|
|
32
33
|
export interface GitignoreState {
|
|
33
34
|
resolved: ResolvedManifest;
|
|
@@ -40,11 +41,77 @@ export async function loadGitignoreState(cwd: string): Promise<GitignoreState> {
|
|
|
40
41
|
const warnings: string[] = [];
|
|
41
42
|
const resolved = await resolveGitignore(cwd, {
|
|
42
43
|
logger: (m) => warnings.push(m),
|
|
44
|
+
profile: await profileGitignoreManifest(cwd),
|
|
43
45
|
});
|
|
44
46
|
const violations = await detectViolations(cwd, resolved);
|
|
45
47
|
return { resolved, violations, warnings };
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
export interface StartupFixResult {
|
|
51
|
+
/** Generated files removed from the index — staged, not committed. */
|
|
52
|
+
untracked: string[];
|
|
53
|
+
/** `.gitignore` entries added. */
|
|
54
|
+
appended: string[];
|
|
55
|
+
/** Files that could not be untracked, with git's reason. */
|
|
56
|
+
failed: Array<{ path: string; reason: string }>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The startup question's fix, applied in place: every file magus generates
|
|
61
|
+
* from `.claude/profiles.json` that git tracks is removed from the index
|
|
62
|
+
* (`git rm --cached`, the existing tracked-but-should-ignore fix, which also
|
|
63
|
+
* makes sure `.gitignore` ignores it), and the additive `.gitignore` fixes
|
|
64
|
+
* are applied too. Every other violation is left for the Git State tab.
|
|
65
|
+
*/
|
|
66
|
+
export async function applyStartupFixes(
|
|
67
|
+
cwd: string,
|
|
68
|
+
): Promise<StartupFixResult> {
|
|
69
|
+
const { resolved, violations } = await loadGitignoreState(cwd);
|
|
70
|
+
const generatedRules = resolved.rules
|
|
71
|
+
.filter((r) => r.source === "profile" && r.action === "ignore")
|
|
72
|
+
.map((r) => r.pattern);
|
|
73
|
+
const result: StartupFixResult = { untracked: [], appended: [], failed: [] };
|
|
74
|
+
for (const v of violations) {
|
|
75
|
+
if (v.kind !== "tracked-but-should-ignore" || v.source !== "profile") {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const rule =
|
|
79
|
+
generatedRules.find(
|
|
80
|
+
(p) => p === v.path || (p.endsWith("/") && v.path.startsWith(p)),
|
|
81
|
+
) ?? v.path;
|
|
82
|
+
try {
|
|
83
|
+
await coreApplyDestructiveFix(cwd, v, rule);
|
|
84
|
+
result.untracked.push(v.path);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
result.failed.push({
|
|
87
|
+
path: v.path,
|
|
88
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
result.appended = (await coreApplySafeFixes(cwd, violations)).appended;
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What {@link applyStartupFixes} did, for the message the TUI shows. */
|
|
97
|
+
export function describeStartupFix(result: StartupFixResult): string {
|
|
98
|
+
const lines: string[] = [];
|
|
99
|
+
if (result.untracked.length > 0) {
|
|
100
|
+
lines.push(
|
|
101
|
+
"Stopped tracking (the removal is staged — commit it):",
|
|
102
|
+
...result.untracked.map((p) => ` ${p}`),
|
|
103
|
+
' git commit -m "Stop tracking generated Claude Code config"',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (result.appended.length > 0) {
|
|
107
|
+
lines.push("Added to .gitignore:", ...result.appended.map((p) => ` ${p}`));
|
|
108
|
+
}
|
|
109
|
+
for (const f of result.failed) {
|
|
110
|
+
lines.push(`Could not untrack ${f.path}: ${f.reason}`);
|
|
111
|
+
}
|
|
112
|
+
return lines.length > 0 ? lines.join("\n") : "Nothing needed changing.";
|
|
113
|
+
}
|
|
114
|
+
|
|
48
115
|
export async function applyAllSafeFixes(
|
|
49
116
|
cwd: string,
|
|
50
117
|
violations: Violation[],
|