magus-cli 7.5.2 → 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/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 +0 -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/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
|
@@ -41,10 +41,6 @@ import {
|
|
|
41
41
|
type ProfileChoice,
|
|
42
42
|
activateProfile,
|
|
43
43
|
activationNotes,
|
|
44
|
-
activeWritableSkillFolders,
|
|
45
|
-
assertNoTrackedWriteTargets,
|
|
46
|
-
writableSkillFolders,
|
|
47
|
-
writeRefusal,
|
|
48
44
|
} from "./profile-activate.js";
|
|
49
45
|
import { adoptCurrentProject } from "./profile-adopt.js";
|
|
50
46
|
import {
|
|
@@ -73,7 +69,6 @@ import {
|
|
|
73
69
|
skillFolder,
|
|
74
70
|
} from "./profile-skills.js";
|
|
75
71
|
import type { SettingsParseError } from "./settings-file.js";
|
|
76
|
-
import type { LiveFileRefusal } from "./tracked-live-files.js";
|
|
77
72
|
|
|
78
73
|
export type ActiveProfileCheck =
|
|
79
74
|
| { state: "no-profiles" }
|
|
@@ -304,11 +299,6 @@ export interface ProfileStatePrep {
|
|
|
304
299
|
problems: string[];
|
|
305
300
|
/** The files behind `problems` that could not be parsed. */
|
|
306
301
|
unreadable: SettingsParseError[];
|
|
307
|
-
/**
|
|
308
|
-
* Set when a file magus would write is tracked by git, or git could not
|
|
309
|
-
* say: nothing ran, and `problems` holds its message.
|
|
310
|
-
*/
|
|
311
|
-
refusal?: LiveFileRefusal;
|
|
312
302
|
}
|
|
313
303
|
|
|
314
304
|
/**
|
|
@@ -322,27 +312,6 @@ export async function prepareProfileState(
|
|
|
322
312
|
projectPath: string,
|
|
323
313
|
opts: Pick<ActivateOptions, "installSkill"> = {},
|
|
324
314
|
): Promise<ProfileStatePrep> {
|
|
325
|
-
// A project with profiles whose live files — or the active profile's
|
|
326
|
-
// magus-installed skill folders — git tracks: every path below would write
|
|
327
|
-
// one of them (or the marker, or the manifest on the way), so nothing runs,
|
|
328
|
-
// not even the marker a unique match would restore.
|
|
329
|
-
const manifest = await readManifest(projectPath);
|
|
330
|
-
if (Object.keys(manifest.profiles).length > 0) {
|
|
331
|
-
const refusal = await writeRefusal(
|
|
332
|
-
projectPath,
|
|
333
|
-
await activeWritableSkillFolders(projectPath, manifest),
|
|
334
|
-
);
|
|
335
|
-
if (refusal) {
|
|
336
|
-
return {
|
|
337
|
-
check: await checkActiveProfile(projectPath, { restore: false }),
|
|
338
|
-
reconcile: null,
|
|
339
|
-
lines: [],
|
|
340
|
-
problems: [refusal.message],
|
|
341
|
-
unreadable: [],
|
|
342
|
-
refusal,
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
315
|
const check = await checkActiveProfile(projectPath);
|
|
347
316
|
const out: ProfileStatePrep = {
|
|
348
317
|
check,
|
|
@@ -524,22 +493,6 @@ export async function switchToProfile(
|
|
|
524
493
|
);
|
|
525
494
|
return result;
|
|
526
495
|
}
|
|
527
|
-
// Before the absorbs below write profiles.json: nothing at all is written
|
|
528
|
-
// when a live file this switch would write is tracked by git.
|
|
529
|
-
const marker = await readActiveMarker(projectPath);
|
|
530
|
-
const refusal = await writeRefusal(projectPath, [
|
|
531
|
-
...(await writableSkillFolders(
|
|
532
|
-
projectPath,
|
|
533
|
-
target.skills ?? [],
|
|
534
|
-
marker?.skills,
|
|
535
|
-
)),
|
|
536
|
-
...(await activeWritableSkillFolders(projectPath, before)),
|
|
537
|
-
]);
|
|
538
|
-
if (refusal) {
|
|
539
|
-
result.problems.push(refusal.message);
|
|
540
|
-
return result;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
496
|
if (!take(await absorbBeforeConversion(projectPath, name))) return result;
|
|
544
497
|
const installSkill = { installSkill: opts.installSkill };
|
|
545
498
|
if (!take(await reconcileActiveProfile(projectPath, installSkill))) {
|
|
@@ -580,20 +533,6 @@ export async function applyProfileChoice(
|
|
|
580
533
|
projectPath: string,
|
|
581
534
|
opts: ActivateOptions = {},
|
|
582
535
|
): Promise<ActivationResult> {
|
|
583
|
-
// Nothing is written — not the adopted manifest, not an absorbed edit —
|
|
584
|
-
// when a live file this choice would write is tracked by git.
|
|
585
|
-
const skills =
|
|
586
|
-
choice.kind === "activate"
|
|
587
|
-
? (manifest.profiles[choice.id]?.skills ?? [])
|
|
588
|
-
: [];
|
|
589
|
-
await assertNoTrackedWriteTargets(
|
|
590
|
-
projectPath,
|
|
591
|
-
await writableSkillFolders(
|
|
592
|
-
projectPath,
|
|
593
|
-
skills,
|
|
594
|
-
(await readActiveMarker(projectPath))?.skills,
|
|
595
|
-
),
|
|
596
|
-
);
|
|
597
536
|
if (choice.kind === "activate") {
|
|
598
537
|
const absorbed: string[] = [];
|
|
599
538
|
let changed = false;
|
|
@@ -635,7 +574,6 @@ export async function prepareProfileWrite(
|
|
|
635
574
|
projectPath: string,
|
|
636
575
|
): Promise<ProfileStatePrep> {
|
|
637
576
|
const prep = await prepareProfileState(projectPath);
|
|
638
|
-
if (prep.refusal) throw prep.refusal;
|
|
639
577
|
refuseOnProblems(prep);
|
|
640
578
|
return prep;
|
|
641
579
|
}
|
|
@@ -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
|
}
|
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" });
|
|
@@ -12,19 +12,38 @@ import type { ModalState } from "../state/types.js";
|
|
|
12
12
|
export interface BuildModalArgs {
|
|
13
13
|
violationCount: number;
|
|
14
14
|
safeCount: number;
|
|
15
|
+
/**
|
|
16
|
+
* Files magus generates from `.claude/profiles.json` that git tracks. The
|
|
17
|
+
* fix untracks these in place; every other destructive fix stays in the tab.
|
|
18
|
+
*/
|
|
19
|
+
generated?: readonly string[];
|
|
15
20
|
onFixSafe: () => void;
|
|
16
21
|
onOpenTab: () => void;
|
|
17
22
|
onDismiss: () => void;
|
|
18
23
|
}
|
|
19
24
|
|
|
25
|
+
function entries(n: number): string {
|
|
26
|
+
return `${n} missing .gitignore entr${n === 1 ? "y" : "ies"}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fixLabel(safeCount: number, generated: number): string {
|
|
30
|
+
if (generated > 0) {
|
|
31
|
+
const files = `${generated} generated file${generated === 1 ? "" : "s"}`;
|
|
32
|
+
return safeCount > 0
|
|
33
|
+
? `Stop tracking ${files} and add ${entries(safeCount)}`
|
|
34
|
+
: `Stop tracking ${files}`;
|
|
35
|
+
}
|
|
36
|
+
return safeCount > 0
|
|
37
|
+
? `Add ${entries(safeCount)}`
|
|
38
|
+
: "(no missing .gitignore entries)";
|
|
39
|
+
}
|
|
40
|
+
|
|
20
41
|
export function buildGitignoreModal(args: BuildModalArgs): ModalState {
|
|
21
42
|
const { violationCount, safeCount, onFixSafe, onOpenTab, onDismiss } = args;
|
|
43
|
+
const generated = args.generated ?? [];
|
|
22
44
|
const options = [
|
|
23
45
|
{
|
|
24
|
-
label:
|
|
25
|
-
safeCount > 0
|
|
26
|
-
? `Add ${safeCount} missing .gitignore entr${safeCount === 1 ? "y" : "ies"}`
|
|
27
|
-
: "(no missing .gitignore entries)",
|
|
46
|
+
label: fixLabel(safeCount, generated.length),
|
|
28
47
|
value: "fix-safe",
|
|
29
48
|
},
|
|
30
49
|
{
|
|
@@ -36,10 +55,20 @@ export function buildGitignoreModal(args: BuildModalArgs): ModalState {
|
|
|
36
55
|
value: "dismiss",
|
|
37
56
|
},
|
|
38
57
|
];
|
|
58
|
+
const generatedNote =
|
|
59
|
+
generated.length > 0
|
|
60
|
+
? `magus generates ${generated.length === 1 ? "this file" : "these files"} from .claude/profiles.json for each developer, so ${
|
|
61
|
+
generated.length === 1 ? "it is" : "they are"
|
|
62
|
+
} expected to be gitignored — but git tracks ${generated.length === 1 ? "it" : "them"}:\n${generated
|
|
63
|
+
.map((p) => ` ${p}`)
|
|
64
|
+
.join(
|
|
65
|
+
"\n",
|
|
66
|
+
)}\n\nThe first choice stops tracking ${generated.length === 1 ? "it" : "them"} (git rm --cached, staged for you to commit) and adds the .gitignore entries.\n\n`
|
|
67
|
+
: "";
|
|
39
68
|
return {
|
|
40
69
|
type: "select",
|
|
41
70
|
title: "Gitignore violations detected",
|
|
42
|
-
message:
|
|
71
|
+
message: `${generatedNote}Found ${violationCount} managed gitignore item(s) that do not match the current repo state.\n\nMissing .gitignore entries can be added now. Other items that change git tracking are reviewed and fixed from the Gitignore tab.`,
|
|
43
72
|
options,
|
|
44
73
|
onSelect: (value: string) => {
|
|
45
74
|
if (value === "fix-safe") onFixSafe();
|
|
@@ -58,27 +87,44 @@ export function useGitignoreModal() {
|
|
|
58
87
|
(args: {
|
|
59
88
|
violationCount: number;
|
|
60
89
|
safeCount: number;
|
|
61
|
-
|
|
90
|
+
generated?: readonly string[];
|
|
91
|
+
/** Returns what it did, shown before returning to the app. */
|
|
92
|
+
onFixSafe: () => Promise<string | undefined> | string | undefined;
|
|
62
93
|
}) => {
|
|
63
94
|
if (args.violationCount === 0) return;
|
|
64
95
|
const dismiss = () => dispatch({ type: "HIDE_MODAL" });
|
|
65
96
|
const modal = buildGitignoreModal({
|
|
66
97
|
violationCount: args.violationCount,
|
|
67
98
|
safeCount: args.safeCount,
|
|
99
|
+
generated: args.generated,
|
|
68
100
|
onFixSafe: () => {
|
|
69
101
|
dispatch({ type: "HIDE_MODAL" });
|
|
70
|
-
void Promise.resolve(args.onFixSafe())
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
type: "
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
102
|
+
void Promise.resolve(args.onFixSafe())
|
|
103
|
+
.then((report) => {
|
|
104
|
+
if (!report) return;
|
|
105
|
+
dispatch({
|
|
106
|
+
type: "SHOW_MODAL",
|
|
107
|
+
modal: {
|
|
108
|
+
type: "message",
|
|
109
|
+
title: "Gitignore fixed",
|
|
110
|
+
message: report,
|
|
111
|
+
variant: "success",
|
|
112
|
+
onDismiss: dismiss,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
})
|
|
116
|
+
.catch((err) => {
|
|
117
|
+
dispatch({
|
|
118
|
+
type: "SHOW_MODAL",
|
|
119
|
+
modal: {
|
|
120
|
+
type: "message",
|
|
121
|
+
title: "Safe fixes failed",
|
|
122
|
+
message: err instanceof Error ? err.message : String(err),
|
|
123
|
+
variant: "error",
|
|
124
|
+
onDismiss: dismiss,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
80
127
|
});
|
|
81
|
-
});
|
|
82
128
|
},
|
|
83
129
|
onOpenTab: () => {
|
|
84
130
|
dispatch({ type: "HIDE_MODAL" });
|
|
@@ -157,7 +157,11 @@ export function describeFailure(
|
|
|
157
157
|
].join("\n");
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
export function useProfileStartupModal(
|
|
160
|
+
export function useProfileStartupModal(
|
|
161
|
+
onQuit: () => void,
|
|
162
|
+
/** Called once a choice has activated a profile — see App's gitignore re-check. */
|
|
163
|
+
onActivated?: () => void,
|
|
164
|
+
) {
|
|
161
165
|
const { state, dispatch } = useApp();
|
|
162
166
|
const { navigateToScreen } = useNavigation();
|
|
163
167
|
const projectPath = state.projectPath;
|
|
@@ -191,6 +195,7 @@ export function useProfileStartupModal(onQuit: () => void) {
|
|
|
191
195
|
onDisplace: (artifact) => moved.push(artifact),
|
|
192
196
|
})
|
|
193
197
|
.then((result: ActivationResult) => {
|
|
198
|
+
onActivated?.();
|
|
194
199
|
const { profileId, displaced } = result;
|
|
195
200
|
dispatch({
|
|
196
201
|
type: "SHOW_MODAL",
|
|
@@ -270,7 +275,7 @@ export function useProfileStartupModal(onQuit: () => void) {
|
|
|
270
275
|
}
|
|
271
276
|
ask();
|
|
272
277
|
},
|
|
273
|
-
[dispatch, navigateToScreen, projectPath, onQuit],
|
|
278
|
+
[dispatch, navigateToScreen, projectPath, onQuit, onActivated],
|
|
274
279
|
);
|
|
275
280
|
|
|
276
281
|
return { show };
|
|
@@ -44,7 +44,6 @@ import {
|
|
|
44
44
|
fetchPluginContents,
|
|
45
45
|
fetchPluginSkillDetail,
|
|
46
46
|
} from "../../services/skills-manager.js";
|
|
47
|
-
import { LiveFileRefusal } from "../../services/tracked-live-files.js";
|
|
48
47
|
// The one update implementation, shared with `magus update`. See the header
|
|
49
48
|
// of update-engine.ts for what drifted while the TUI had its own loop.
|
|
50
49
|
import {
|
|
@@ -983,11 +982,7 @@ export function PluginsScreen() {
|
|
|
983
982
|
fetchData();
|
|
984
983
|
} catch (error) {
|
|
985
984
|
modal.hideModal();
|
|
986
|
-
await modal.message(
|
|
987
|
-
"Error",
|
|
988
|
-
error instanceof LiveFileRefusal ? error.message : `Failed: ${error}`,
|
|
989
|
-
"error",
|
|
990
|
-
);
|
|
985
|
+
await modal.message("Error", `Failed: ${error}`, "error");
|
|
991
986
|
}
|
|
992
987
|
}
|
|
993
988
|
};
|
|
@@ -1207,11 +1202,7 @@ export function PluginsScreen() {
|
|
|
1207
1202
|
fetchData();
|
|
1208
1203
|
} catch (error) {
|
|
1209
1204
|
modal.hideModal();
|
|
1210
|
-
await modal.message(
|
|
1211
|
-
"Error",
|
|
1212
|
-
error instanceof LiveFileRefusal ? error.message : `Failed: ${error}`,
|
|
1213
|
-
"error",
|
|
1214
|
-
);
|
|
1205
|
+
await modal.message("Error", `Failed: ${error}`, "error");
|
|
1215
1206
|
}
|
|
1216
1207
|
};
|
|
1217
1208
|
|
|
@@ -26,7 +26,6 @@ import {
|
|
|
26
26
|
renameProfile,
|
|
27
27
|
} from "../../services/profiles.js";
|
|
28
28
|
import { writableMap } from "../../services/settings-file.js";
|
|
29
|
-
import { LiveFileRefusal } from "../../services/tracked-live-files.js";
|
|
30
29
|
import {
|
|
31
30
|
ClipboardUnavailableError,
|
|
32
31
|
readClipboard,
|
|
@@ -178,9 +177,7 @@ export function ProfilesScreen() {
|
|
|
178
177
|
modal.hideModal();
|
|
179
178
|
await modal.message(
|
|
180
179
|
"Error",
|
|
181
|
-
|
|
182
|
-
? error.message
|
|
183
|
-
: `Failed to apply profile: ${error}`,
|
|
180
|
+
`Failed to apply profile: ${error}`,
|
|
184
181
|
"error",
|
|
185
182
|
);
|
|
186
183
|
}
|
|
@@ -245,9 +242,7 @@ export function ProfilesScreen() {
|
|
|
245
242
|
modal.hideModal();
|
|
246
243
|
await modal.message(
|
|
247
244
|
"Error",
|
|
248
|
-
|
|
249
|
-
? error.message
|
|
250
|
-
: `Failed to switch profile: ${error}`,
|
|
245
|
+
`Failed to switch profile: ${error}`,
|
|
251
246
|
"error",
|
|
252
247
|
);
|
|
253
248
|
}
|
|
@@ -280,9 +275,7 @@ export function ProfilesScreen() {
|
|
|
280
275
|
modal.hideModal();
|
|
281
276
|
await modal.message(
|
|
282
277
|
"Error",
|
|
283
|
-
|
|
284
|
-
? error.message
|
|
285
|
-
: `Failed to apply profile: ${error}`,
|
|
278
|
+
`Failed to apply profile: ${error}`,
|
|
286
279
|
"error",
|
|
287
280
|
);
|
|
288
281
|
}
|