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
|
@@ -1,76 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Generated files that git tracks: reported, never refused.
|
|
3
3
|
*
|
|
4
|
-
* In a project with profiles, `.claude/settings.json`, `.mcp.json
|
|
5
|
-
* `.claude/models.json`
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* everyone. `.gitignore` cannot fix that — it does not untrack a file already
|
|
10
|
-
* committed. So every writer refuses, having written nothing, and says how to
|
|
11
|
-
* stop tracking it; the user decides.
|
|
4
|
+
* In a project with profiles, `.claude/settings.json`, `.mcp.json`,
|
|
5
|
+
* `.claude/models.json` and the magus-installed skill folders are generated
|
|
6
|
+
* per developer from `.claude/profiles.json`, and are expected to be
|
|
7
|
+
* gitignored. A repository that committed one before adopting profiles keeps
|
|
8
|
+
* tracking it — `.gitignore` does not untrack a committed file.
|
|
12
9
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
10
|
+
* magus writes those files anyway: every command that writes them proceeds
|
|
11
|
+
* exactly as for an untracked file. What changes is what it says. The CLI
|
|
12
|
+
* prints one warning per tracked file with the fix (`git rm --cached <path>`,
|
|
13
|
+
* then commit) and never runs git itself; `magus doctor` reports it as a note;
|
|
14
|
+
* the TUI asks on every open and, when told to, untracks them in place
|
|
15
|
+
* (`gitignore-check.ts`, `profileGitignoreManifest`).
|
|
17
16
|
*/
|
|
18
17
|
|
|
19
18
|
import path from "node:path";
|
|
19
|
+
import type { GitignoreManifest } from "../types/gitignore.js";
|
|
20
20
|
import { GitQueryError, trackedByGit } from "./git-worktree.js";
|
|
21
21
|
import { getManifestPath, readProfilesFile } from "./manifest.js";
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
constructor(readonly paths: readonly string[]) {
|
|
29
|
-
const one = paths.length === 1;
|
|
30
|
-
super(
|
|
31
|
-
[
|
|
32
|
-
`magus will not write ${one ? "a file" : "files"} git tracks in this project — ${
|
|
33
|
-
one ? "it is" : "they are"
|
|
34
|
-
} generated from .claude/profiles.json and ${one ? "differs" : "differ"} per developer:`,
|
|
35
|
-
...paths.map((p) => ` ${p}`),
|
|
36
|
-
`Stop tracking ${one ? "it" : "them"}, commit, then run the command again:`,
|
|
37
|
-
...paths.map((p) => ` ${untrackCommand(p)}`),
|
|
38
|
-
' git commit -m "Stop tracking generated Claude Code config"',
|
|
39
|
-
"Nothing was changed.",
|
|
40
|
-
].join("\n"),
|
|
41
|
-
);
|
|
42
|
-
this.name = "TrackedLiveFileError";
|
|
43
|
-
}
|
|
44
|
-
}
|
|
22
|
+
import {
|
|
23
|
+
LIVE_FILE_KEYS,
|
|
24
|
+
livePath,
|
|
25
|
+
readActiveMarker,
|
|
26
|
+
skillsRoot,
|
|
27
|
+
} from "./profile-live.js";
|
|
45
28
|
|
|
46
29
|
/**
|
|
47
30
|
* The project is a git work tree, but git could not say what it tracks — it
|
|
48
31
|
* timed out, is missing, or refused the repository (`detected dubious
|
|
49
|
-
* ownership`).
|
|
50
|
-
*
|
|
32
|
+
* ownership`). Reported with git's own words wherever the check is made; it
|
|
33
|
+
* never stops a write.
|
|
51
34
|
*/
|
|
52
|
-
export class GitCheckError extends
|
|
35
|
+
export class GitCheckError extends Error {
|
|
53
36
|
constructor(readonly detail: string) {
|
|
54
37
|
super(
|
|
55
|
-
|
|
56
|
-
"magus could not check which files git tracks in this project, so it wrote nothing. git said:",
|
|
57
|
-
...detail
|
|
58
|
-
.trim()
|
|
59
|
-
.split("\n")
|
|
60
|
-
.map((line) => ` ${line}`),
|
|
61
|
-
"Fix that, then run the command again. Nothing was changed.",
|
|
62
|
-
].join("\n"),
|
|
38
|
+
`magus could not check which files git tracks in this project. git said: ${detail.trim()}`,
|
|
63
39
|
);
|
|
64
40
|
this.name = "GitCheckError";
|
|
65
41
|
}
|
|
66
42
|
}
|
|
67
43
|
|
|
68
|
-
/** How to untrack one generated path
|
|
44
|
+
/** How to untrack one generated path. */
|
|
69
45
|
export function untrackCommand(rel: string): string {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
46
|
+
return `git rm --cached ${rel}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The warning the CLI prints for one tracked generated path. */
|
|
50
|
+
export function trackedWarning(rel: string): string {
|
|
51
|
+
return `${rel} is generated from .claude/profiles.json but tracked by git — run \`${untrackCommand(rel)}\`, then commit`;
|
|
74
52
|
}
|
|
75
53
|
|
|
76
54
|
/**
|
|
@@ -92,34 +70,70 @@ export async function trackedPaths(
|
|
|
92
70
|
}
|
|
93
71
|
|
|
94
72
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
73
|
+
* Of `folders` under `.claude/skills/`, the ones git tracks — the team's own
|
|
74
|
+
* copies. A skill folder git tracks is the team's, never magus's: magus does
|
|
75
|
+
* not install over it, manage it, flag it or untrack it. Empty when git cannot
|
|
76
|
+
* say, so a failed git call never turns a team skill into one magus removes —
|
|
77
|
+
* it only leaves a folder magus recorded as its own until git answers.
|
|
98
78
|
*/
|
|
99
|
-
async function
|
|
79
|
+
export async function trackedSkillFolders(
|
|
80
|
+
projectPath: string,
|
|
81
|
+
folders: readonly string[],
|
|
82
|
+
): Promise<Set<string>> {
|
|
83
|
+
if (folders.length === 0) return new Set();
|
|
84
|
+
const root = path.relative(projectPath, skillsRoot(projectPath));
|
|
85
|
+
let tracked: Set<string>;
|
|
100
86
|
try {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
87
|
+
tracked = await trackedPaths(
|
|
88
|
+
projectPath,
|
|
89
|
+
[],
|
|
90
|
+
folders.map((folder) => path.join(root, folder)),
|
|
91
|
+
);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error instanceof GitCheckError) return new Set();
|
|
94
|
+
throw error;
|
|
105
95
|
}
|
|
96
|
+
return new Set(folders.filter((f) => tracked.has(path.join(root, f))));
|
|
106
97
|
}
|
|
107
98
|
|
|
108
99
|
/**
|
|
109
|
-
*
|
|
110
|
-
* `
|
|
111
|
-
*
|
|
112
|
-
*
|
|
100
|
+
* The gitignore rules the profile model implies, for the gitignore manager's
|
|
101
|
+
* `profile` tier — or null in a project without profiles.
|
|
102
|
+
*
|
|
103
|
+
* In a project with profiles — profiles.json defines at least one — the three
|
|
104
|
+
* live files and the magus-installed skill folders are generated per
|
|
105
|
+
* developer, so they must be ignored. The built-in defaults say to TRACK
|
|
106
|
+
* `.claude/settings.json` and `.mcp.json`, which is right for a project that
|
|
107
|
+
* writes them by hand; this tier overrides that for a profiled one, so a
|
|
108
|
+
* committed copy is reported as "tracked but should be ignored". A left-over
|
|
109
|
+
* marker in a repo that dropped profiles.json does not make it profiled.
|
|
110
|
+
*
|
|
111
|
+
* A skill folder git tracks is left out even when the marker records it:
|
|
112
|
+
* markers written by 7.5.0's conversion recorded committed skills as
|
|
113
|
+
* magus-installed, and the team's copy must never be flagged or untracked.
|
|
113
114
|
*/
|
|
114
|
-
export async function
|
|
115
|
+
export async function profileGitignoreManifest(
|
|
115
116
|
projectPath: string,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
117
|
+
): Promise<GitignoreManifest | null> {
|
|
118
|
+
try {
|
|
119
|
+
const file = await readProfilesFile(getManifestPath(projectPath));
|
|
120
|
+
if (Object.keys(file.profiles).length === 0) return null;
|
|
121
|
+
} catch {
|
|
122
|
+
return null; // An unreadable profiles.json is reported by every profile command.
|
|
123
|
+
}
|
|
124
|
+
const marker = await readActiveMarker(projectPath);
|
|
125
|
+
const managed = Object.keys(marker?.skills ?? {});
|
|
126
|
+
const committed = await trackedSkillFolders(projectPath, managed);
|
|
127
|
+
const posix = (abs: string) =>
|
|
128
|
+
path.relative(projectPath, abs).split(path.sep).join("/");
|
|
129
|
+
const skills = posix(skillsRoot(projectPath));
|
|
130
|
+
return {
|
|
131
|
+
ignore: [
|
|
132
|
+
...LIVE_FILE_KEYS.map((key) => posix(livePath(key, projectPath))),
|
|
133
|
+
...managed
|
|
134
|
+
.filter((folder) => !committed.has(folder))
|
|
135
|
+
.map((folder) => `${skills}/${folder}/`),
|
|
136
|
+
],
|
|
137
|
+
track: [],
|
|
138
|
+
};
|
|
125
139
|
}
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
import path from "node:path";
|
|
33
33
|
import type {
|
|
34
|
+
InstalledPluginEntry,
|
|
34
35
|
ProfileSkillRef,
|
|
35
36
|
ResolvedBin,
|
|
36
37
|
SkillInfo,
|
|
@@ -43,9 +44,12 @@ import { installPlugin, repairPlugin, updatePlugin } from "./claude-cli.js";
|
|
|
43
44
|
import {
|
|
44
45
|
type StaleLoadRepairResult,
|
|
45
46
|
loadedVersionHere,
|
|
47
|
+
readInstalledPluginsRegistry,
|
|
46
48
|
repairStaleLoad,
|
|
49
|
+
rowBelongsToRepository,
|
|
47
50
|
} from "./claude-settings.js";
|
|
48
51
|
import {
|
|
52
|
+
compareVersions,
|
|
49
53
|
isRepairablyBehind,
|
|
50
54
|
readInstalledVersionForScope,
|
|
51
55
|
saveInstalledPluginVersionForScope,
|
|
@@ -192,6 +196,21 @@ export interface PluginApplyDeps {
|
|
|
192
196
|
pluginId: string,
|
|
193
197
|
projectPath: string,
|
|
194
198
|
) => Promise<string | null>;
|
|
199
|
+
/**
|
|
200
|
+
* Every registry row for a plugin, read fresh — compared before and after
|
|
201
|
+
* `update` to see WHICH row Claude Code moved (see
|
|
202
|
+
* {@link followMisdirectedUpdate}). Optional so a test double that does not
|
|
203
|
+
* model the registry skips the comparison.
|
|
204
|
+
*/
|
|
205
|
+
registryRows?: (pluginId: string) => Promise<readonly InstalledPluginEntry[]>;
|
|
206
|
+
/**
|
|
207
|
+
* Is a row this project's repository's — the rows the stale-load repair may
|
|
208
|
+
* rewrite. Defaults to that repair's own predicate (`rowBelongsToRepository`).
|
|
209
|
+
*/
|
|
210
|
+
inThisRepository?: (
|
|
211
|
+
row: InstalledPluginEntry,
|
|
212
|
+
projectPath: string,
|
|
213
|
+
) => boolean;
|
|
195
214
|
}
|
|
196
215
|
|
|
197
216
|
export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
@@ -202,8 +221,129 @@ export const REAL_PLUGIN_DEPS: PluginApplyDeps = {
|
|
|
202
221
|
saveInstalled: saveInstalledPluginVersionForScope,
|
|
203
222
|
repairStaleLoad,
|
|
204
223
|
loadedVersion: loadedVersionHere,
|
|
224
|
+
registryRows: async (pluginId) => {
|
|
225
|
+
const rows = (await readInstalledPluginsRegistry()).plugins?.[pluginId];
|
|
226
|
+
return Array.isArray(rows) ? rows : [];
|
|
227
|
+
},
|
|
205
228
|
};
|
|
206
229
|
|
|
230
|
+
/**
|
|
231
|
+
* An update Claude Code applied to another project's row instead of this one.
|
|
232
|
+
*
|
|
233
|
+
* Claude Code 2.1.281 changed `claude plugin update P --scope project`: it
|
|
234
|
+
* updates the FIRST registry row relevant to the repository — the row its
|
|
235
|
+
* loader would pick — not the row of the project it runs in. Measured
|
|
236
|
+
* 2026-09-24 on two APFS clones of one sandbox, same registry, cwd = the
|
|
237
|
+
* project:
|
|
238
|
+
*
|
|
239
|
+
* 2.1.280 updated from 8.0.0 to 8.1.3 for scope project (<sandbox>/magus-src)
|
|
240
|
+
* 2.1.281 updated from 8.0.0 to 8.1.3 for scope project (<sandbox>/magus-src/tools/claudeup)
|
|
241
|
+
*
|
|
242
|
+
* Both exit 0; under 2.1.281 this project's row stays at 8.0.0.
|
|
243
|
+
*/
|
|
244
|
+
export interface MisdirectedUpdate {
|
|
245
|
+
/** Where Claude Code wrote instead: each moved row's project path, or scope. */
|
|
246
|
+
elsewhere: string[];
|
|
247
|
+
/** The version it moved them to. */
|
|
248
|
+
version: string;
|
|
249
|
+
/** Why this project's row did not follow to `version`; null when it did. */
|
|
250
|
+
notFollowed: string | null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** One registry row's identity: scope, and the project it belongs to. */
|
|
254
|
+
function rowKey(
|
|
255
|
+
row: Pick<InstalledPluginEntry, "scope" | "projectPath">,
|
|
256
|
+
): string {
|
|
257
|
+
const where = row.projectPath ? path.resolve(row.projectPath) : "";
|
|
258
|
+
return `${row.scope}\u0000${where}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function readRows(
|
|
262
|
+
deps: PluginApplyDeps,
|
|
263
|
+
pluginId: string,
|
|
264
|
+
): Promise<readonly InstalledPluginEntry[] | null> {
|
|
265
|
+
if (!deps.registryRows) return null;
|
|
266
|
+
try {
|
|
267
|
+
return await deps.registryRows(pluginId);
|
|
268
|
+
} catch {
|
|
269
|
+
return null; // No readable registry: nothing to compare.
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* After `claude plugin update P --scope <scope>`: if this project's row did not
|
|
275
|
+
* move but another row OF THIS REPOSITORY did — Claude Code 2.1.281 updating
|
|
276
|
+
* the repository's first relevant row instead (see {@link MisdirectedUpdate})
|
|
277
|
+
* — move this project's row forward to that version, with the registry
|
|
278
|
+
* rewrite the stale-load repair performs. That repair refuses a version whose
|
|
279
|
+
* cache directory does not exist.
|
|
280
|
+
*
|
|
281
|
+
* Only rows the repair itself would rewrite count (`inThisRepository`): a row
|
|
282
|
+
* of another repository that happens to move while the CLI runs says nothing
|
|
283
|
+
* about this update. And when the update's `target` is known, the row must
|
|
284
|
+
* have reached exactly that version, or it is not followed.
|
|
285
|
+
*
|
|
286
|
+
* The row Claude Code moved is left as it is: it belongs to this repository,
|
|
287
|
+
* and bringing this repository's rows forward is what the repair does anyway.
|
|
288
|
+
* Null when there was nothing to follow — this project's row moved (2.1.280),
|
|
289
|
+
* or no row of this repository moved at all.
|
|
290
|
+
*/
|
|
291
|
+
async function followMisdirectedUpdate(
|
|
292
|
+
deps: PluginApplyDeps,
|
|
293
|
+
pluginId: string,
|
|
294
|
+
scope: PluginScope,
|
|
295
|
+
projectPath: string,
|
|
296
|
+
before: readonly InstalledPluginEntry[],
|
|
297
|
+
target: string | null,
|
|
298
|
+
): Promise<MisdirectedUpdate | null> {
|
|
299
|
+
const now = await deps.readInstalled(pluginId, scope, projectPath);
|
|
300
|
+
const after = await readRows(deps, pluginId);
|
|
301
|
+
if (!now || !after) return null;
|
|
302
|
+
const inThisRepository = deps.inThisRepository ?? rowBelongsToRepository;
|
|
303
|
+
const was = new Map(before.map((row) => [rowKey(row), row.version]));
|
|
304
|
+
const moved = after.filter(
|
|
305
|
+
(row) =>
|
|
306
|
+
row.version &&
|
|
307
|
+
was.get(rowKey(row)) !== row.version &&
|
|
308
|
+
inThisRepository(row, projectPath),
|
|
309
|
+
);
|
|
310
|
+
const mine = rowKey({
|
|
311
|
+
scope,
|
|
312
|
+
projectPath: scope === "user" ? undefined : projectPath,
|
|
313
|
+
});
|
|
314
|
+
if (moved.length === 0 || moved.some((row) => rowKey(row) === mine)) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
const version = moved
|
|
318
|
+
.map((row) => row.version)
|
|
319
|
+
.reduce((a, b) => ((compareVersions(b, a) ?? 0) > 0 ? b : a));
|
|
320
|
+
if (!isRepairablyBehind(version, now)) return null;
|
|
321
|
+
const elsewhere = moved.map((row) => row.projectPath ?? `${row.scope} scope`);
|
|
322
|
+
if (target && version !== target) {
|
|
323
|
+
return {
|
|
324
|
+
elsewhere,
|
|
325
|
+
version,
|
|
326
|
+
notFollowed: `it went to v${version}, not the v${target} this update asked for`,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const repair = await deps.repairStaleLoad(pluginId, version, projectPath);
|
|
330
|
+
return {
|
|
331
|
+
elsewhere,
|
|
332
|
+
version,
|
|
333
|
+
notFollowed: repair.ok
|
|
334
|
+
? null
|
|
335
|
+
: (repair.error ?? "the registry was not rewritten"),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The words for a misdirected update, for an item's detail. */
|
|
340
|
+
function describeMisdirected(m: MisdirectedUpdate): string {
|
|
341
|
+
const where = m.elsewhere.join(", ");
|
|
342
|
+
return m.notFollowed === null
|
|
343
|
+
? `Claude Code updated ${where} instead of this project — moved this project's record to v${m.version} too`
|
|
344
|
+
: `Claude Code updated ${where} instead of this project, and this project's record could not follow: ${m.notFollowed}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
207
347
|
/**
|
|
208
348
|
* After a plugin landed at `target` for `projectPath`: make sure that is the
|
|
209
349
|
* version Claude Code actually loads there, and bring the loader forward when
|
|
@@ -319,10 +459,26 @@ async function advanceInScope(
|
|
|
319
459
|
pluginId: string,
|
|
320
460
|
scope: PluginScope,
|
|
321
461
|
projectPath: string,
|
|
322
|
-
|
|
462
|
+
/** The version this update targets, when known — see followMisdirectedUpdate. */
|
|
463
|
+
target: string | null = null,
|
|
464
|
+
): Promise<MisdirectedUpdate | null> {
|
|
323
465
|
const here = await deps.readInstalled(pluginId, scope, projectPath);
|
|
324
|
-
if (here)
|
|
325
|
-
|
|
466
|
+
if (!here) {
|
|
467
|
+
await deps.install(pluginId, scope);
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
const before = await readRows(deps, pluginId);
|
|
471
|
+
await deps.update(pluginId, scope);
|
|
472
|
+
return before
|
|
473
|
+
? followMisdirectedUpdate(
|
|
474
|
+
deps,
|
|
475
|
+
pluginId,
|
|
476
|
+
scope,
|
|
477
|
+
projectPath,
|
|
478
|
+
before,
|
|
479
|
+
target,
|
|
480
|
+
)
|
|
481
|
+
: null;
|
|
326
482
|
}
|
|
327
483
|
|
|
328
484
|
/**
|
|
@@ -449,6 +605,7 @@ export async function applyPlugins(
|
|
|
449
605
|
report.begin(item.pluginId);
|
|
450
606
|
const startedAt = Date.now();
|
|
451
607
|
const landed: Array<[PluginScope, string | null]> = [];
|
|
608
|
+
const misdirected: MisdirectedUpdate[] = [];
|
|
452
609
|
|
|
453
610
|
try {
|
|
454
611
|
for (const scope of scopes) {
|
|
@@ -467,7 +624,14 @@ export async function applyPlugins(
|
|
|
467
624
|
} else if (item.action === "install") {
|
|
468
625
|
await deps.install(item.pluginId, scope);
|
|
469
626
|
} else {
|
|
470
|
-
await advanceInScope(
|
|
627
|
+
const m = await advanceInScope(
|
|
628
|
+
deps,
|
|
629
|
+
item.pluginId,
|
|
630
|
+
scope,
|
|
631
|
+
projectPath,
|
|
632
|
+
item.target,
|
|
633
|
+
);
|
|
634
|
+
if (m) misdirected.push(m);
|
|
471
635
|
}
|
|
472
636
|
|
|
473
637
|
// Record what LANDED, not what we asked for.
|
|
@@ -520,19 +684,24 @@ export async function applyPlugins(
|
|
|
520
684
|
);
|
|
521
685
|
result.failed.push(item.pluginId);
|
|
522
686
|
} else if (noMove && !forward) {
|
|
687
|
+
const why = misdirected.map(describeMisdirected);
|
|
523
688
|
report.finish(
|
|
524
689
|
true,
|
|
525
690
|
item.pluginId,
|
|
526
|
-
`no change — ${noMove}`,
|
|
691
|
+
`no change — ${noMove}${why.length > 0 ? ` (${why.join("; ")})` : ""}`,
|
|
527
692
|
Date.now() - startedAt,
|
|
528
693
|
);
|
|
529
694
|
result.unchanged.push(item.pluginId);
|
|
530
695
|
} else {
|
|
531
696
|
const moved = `${scopes.join(", ")}${item.target ? ` → ${item.target}` : ""}`;
|
|
697
|
+
const notes = [
|
|
698
|
+
...misdirected.map(describeMisdirected),
|
|
699
|
+
...(forward ? [describeLoadForward(forward)] : []),
|
|
700
|
+
];
|
|
532
701
|
report.finish(
|
|
533
702
|
true,
|
|
534
703
|
item.pluginId,
|
|
535
|
-
|
|
704
|
+
[moved, ...notes].join(" · "),
|
|
536
705
|
Date.now() - startedAt,
|
|
537
706
|
);
|
|
538
707
|
result.ok++;
|
|
@@ -621,8 +790,12 @@ export async function updatePluginInScope(
|
|
|
621
790
|
deps: PluginApplyDeps = REAL_PLUGIN_DEPS,
|
|
622
791
|
): Promise<string | null> {
|
|
623
792
|
// Same guard as the apply loop, for the same reason — see advanceInScope.
|
|
793
|
+
// A misdirected update is followed there; the version read back afterwards
|
|
794
|
+
// is what this returns.
|
|
624
795
|
return applyOneInScope(
|
|
625
|
-
(id, sc) =>
|
|
796
|
+
async (id, sc) => {
|
|
797
|
+
await advanceInScope(deps, id, sc, projectPath);
|
|
798
|
+
},
|
|
626
799
|
pluginId,
|
|
627
800
|
scope,
|
|
628
801
|
projectPath,
|
package/src/ui/App.tsx
CHANGED
|
@@ -7,10 +7,13 @@ import {
|
|
|
7
7
|
recoverMarketplaceSettings,
|
|
8
8
|
repairAllStaleLoads,
|
|
9
9
|
} from "../services/claude-settings.js";
|
|
10
|
-
import { checkGitignore } from "../services/gitignore-check.js";
|
|
11
10
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
type StartupGitignoreNeed,
|
|
12
|
+
startupGitignoreNeed,
|
|
13
|
+
} from "../services/gitignore-check.js";
|
|
14
|
+
import {
|
|
15
|
+
applyStartupFixes,
|
|
16
|
+
describeStartupFix,
|
|
14
17
|
} from "../services/gitignore-service.js";
|
|
15
18
|
import { repairAllMarketplaces } from "../services/local-marketplace.js";
|
|
16
19
|
import { autoAddMissingMarketplaces } from "../services/marketplace-sync.js";
|
|
@@ -261,10 +264,8 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
261
264
|
const [updateInfo, setUpdateInfo] = useState<VersionCheckResult | null>(null);
|
|
262
265
|
const [recoveryReport, setRecoveryReport] = useState<string | null>(null);
|
|
263
266
|
const [profileNotices, setProfileNotices] = useState<string[]>([]);
|
|
264
|
-
const [gitignoreData, setGitignoreData] =
|
|
265
|
-
|
|
266
|
-
safeCount: number;
|
|
267
|
-
} | null>(null);
|
|
267
|
+
const [gitignoreData, setGitignoreData] =
|
|
268
|
+
useState<StartupGitignoreNeed | null>(null);
|
|
268
269
|
const [profileNeed, setProfileNeed] = useState<ProfileStartupNeed | null>(
|
|
269
270
|
null,
|
|
270
271
|
);
|
|
@@ -273,9 +274,22 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
273
274
|
null,
|
|
274
275
|
);
|
|
275
276
|
const staleLoadModal = useStaleLoadModal();
|
|
277
|
+
// The gitignore question, asked fresh: on every open, and again once the
|
|
278
|
+
// profile modal has activated a profile — a first adoption is what turns a
|
|
279
|
+
// committed settings.json into a generated file git should not track.
|
|
280
|
+
// Nothing remembers an earlier answer.
|
|
281
|
+
const checkGitignoreNow = React.useCallback(() => {
|
|
282
|
+
startupGitignoreNeed(process.cwd())
|
|
283
|
+
.then((need) => {
|
|
284
|
+
if (need) setGitignoreData(need);
|
|
285
|
+
})
|
|
286
|
+
.catch(() => {
|
|
287
|
+
// non-fatal: the Git State tab still shows the same rows
|
|
288
|
+
});
|
|
289
|
+
}, []);
|
|
276
290
|
// The profile modal cannot be dismissed, so it needs the app's own exit: its
|
|
277
291
|
// only non-action is to leave.
|
|
278
|
-
const profileModal = useProfileStartupModal(onExit);
|
|
292
|
+
const profileModal = useProfileStartupModal(onExit, checkGitignoreNow);
|
|
279
293
|
|
|
280
294
|
// Check for updates on startup, then re-check periodically (force-bypassing
|
|
281
295
|
// the session cache) so the banner reflects a release that lands while
|
|
@@ -318,10 +332,11 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
318
332
|
gitignoreModal.show({
|
|
319
333
|
violationCount: gitignoreData.violationCount,
|
|
320
334
|
safeCount: gitignoreData.safeCount,
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
335
|
+
generated: gitignoreData.generated,
|
|
336
|
+
// In place, then back to the app: tracked generated files are
|
|
337
|
+
// untracked, missing entries added; other items stay for the tab.
|
|
338
|
+
onFixSafe: async () =>
|
|
339
|
+
describeStartupFix(await applyStartupFixes(state.projectPath)),
|
|
325
340
|
});
|
|
326
341
|
setGitignoreData(null);
|
|
327
342
|
}, [gitignoreData, gitignoreModal, state.modal, state.projectPath]);
|
|
@@ -389,6 +404,13 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
389
404
|
setProfileNeed(null);
|
|
390
405
|
}, [profileNeed, profileModal, state.modal]);
|
|
391
406
|
|
|
407
|
+
// The gitignore question on every open. Its own effect, NOT part of the
|
|
408
|
+
// marketplace block below, which returns early on `--no-refresh`: that flag
|
|
409
|
+
// is about network calls and must not silence this question.
|
|
410
|
+
useEffect(() => {
|
|
411
|
+
checkGitignoreNow();
|
|
412
|
+
}, [checkGitignoreNow]);
|
|
413
|
+
|
|
392
414
|
// Is this project actually running a profile? Detection only — nothing is
|
|
393
415
|
// written until the user picks something in the modal. The Profiles tab could
|
|
394
416
|
// list two profiles while the project ran neither, and nothing anywhere said
|
|
@@ -470,27 +492,6 @@ function AppContent({ onExit }: AppContentProps) {
|
|
|
470
492
|
})
|
|
471
493
|
.catch(() => {}); // non-fatal
|
|
472
494
|
|
|
473
|
-
// Check for gitignore manifest violations and surface a popup if any.
|
|
474
|
-
// Loads twice (once for count, once when user picks "fix safe") because
|
|
475
|
-
// the second load happens after edits — keeps detection cheap on startup.
|
|
476
|
-
(async () => {
|
|
477
|
-
try {
|
|
478
|
-
const result = await checkGitignore(process.cwd());
|
|
479
|
-
if (result.violationCount > 0) {
|
|
480
|
-
const s = await loadGitignoreState(process.cwd());
|
|
481
|
-
const safeCount = s.violations.filter(
|
|
482
|
-
(v) => v.severity === "safe",
|
|
483
|
-
).length;
|
|
484
|
-
setGitignoreData({
|
|
485
|
-
violationCount: result.violationCount,
|
|
486
|
-
safeCount,
|
|
487
|
-
});
|
|
488
|
-
}
|
|
489
|
-
} catch {
|
|
490
|
-
// non-fatal
|
|
491
|
-
}
|
|
492
|
-
})();
|
|
493
|
-
|
|
494
495
|
repairAllMarketplaces()
|
|
495
496
|
.then(async () => {
|
|
496
497
|
dispatch({ type: "HIDE_PROGRESS" });
|