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
|
@@ -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[],
|
|
@@ -61,8 +61,7 @@ import {
|
|
|
61
61
|
type ActivateOptions,
|
|
62
62
|
activateProfile,
|
|
63
63
|
activationNotes,
|
|
64
|
-
|
|
65
|
-
writeRefusal,
|
|
64
|
+
releasedCommittedSkillNote,
|
|
66
65
|
} from "./profile-activate.js";
|
|
67
66
|
import {
|
|
68
67
|
type ActiveMarker,
|
|
@@ -94,6 +93,7 @@ import {
|
|
|
94
93
|
} from "./profile-skills.js";
|
|
95
94
|
import { resolveExtends, resolveProfile } from "./resolver.js";
|
|
96
95
|
import { SettingsParseError } from "./settings-file.js";
|
|
96
|
+
import { trackedSkillFolders } from "./tracked-live-files.js";
|
|
97
97
|
|
|
98
98
|
// ─── Changes ─────────────────────────────────────────────────────────────────
|
|
99
99
|
|
|
@@ -1302,13 +1302,39 @@ export async function reconcileActiveProfile(
|
|
|
1302
1302
|
opts: ReconcileOptions = {},
|
|
1303
1303
|
): Promise<ReconcileResult> {
|
|
1304
1304
|
if (!(await hasCurrentMarker(projectPath))) return emptyResult(null);
|
|
1305
|
-
|
|
1305
|
+
let marker = await readActiveMarker(projectPath);
|
|
1306
1306
|
if (!marker) return emptyResult(null);
|
|
1307
1307
|
const manifest = await readManifest(projectPath);
|
|
1308
1308
|
const current = manifest.profiles[marker.profile];
|
|
1309
1309
|
if (!current) return emptyResult(null);
|
|
1310
1310
|
const name = marker.profile;
|
|
1311
1311
|
|
|
1312
|
+
// A recorded skill folder git tracks is the team's copy: 7.5.0's conversion
|
|
1313
|
+
// recorded committed skills as magus-installed. Handed back — dropped from
|
|
1314
|
+
// the marker, and from magus's ignore lines — before anything below could
|
|
1315
|
+
// treat it as magus's to sync or remove, and said once.
|
|
1316
|
+
const handedBackLines: string[] = [];
|
|
1317
|
+
const handedBack = await trackedSkillFolders(
|
|
1318
|
+
projectPath,
|
|
1319
|
+
Object.keys(marker.skills),
|
|
1320
|
+
);
|
|
1321
|
+
if (handedBack.size > 0) {
|
|
1322
|
+
const skills: Record<string, string> = {};
|
|
1323
|
+
for (const [folder, hash] of Object.entries(marker.skills)) {
|
|
1324
|
+
if (!handedBack.has(folder)) skills[folder] = hash;
|
|
1325
|
+
}
|
|
1326
|
+
await updateSkillIgnoreLines(
|
|
1327
|
+
projectPath,
|
|
1328
|
+
[...handedBack],
|
|
1329
|
+
Object.keys(skills),
|
|
1330
|
+
);
|
|
1331
|
+
marker = { ...marker, skills };
|
|
1332
|
+
await writeActiveMarker(marker, projectPath);
|
|
1333
|
+
handedBackLines.push(
|
|
1334
|
+
...[...handedBack].sort().map(releasedCommittedSkillNote),
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1312
1338
|
const edited: LiveFileKey[] = [];
|
|
1313
1339
|
const missing: LiveFileKey[] = [];
|
|
1314
1340
|
for (const key of LIVE_FILE_KEYS) {
|
|
@@ -1343,23 +1369,11 @@ export async function reconcileActiveProfile(
|
|
|
1343
1369
|
!definitionChanged &&
|
|
1344
1370
|
!marker.cliBookkeeping
|
|
1345
1371
|
) {
|
|
1346
|
-
return emptyResult(name);
|
|
1372
|
+
return { ...emptyResult(name), lines: handedBackLines };
|
|
1347
1373
|
}
|
|
1348
1374
|
|
|
1349
1375
|
const result = emptyResult(name);
|
|
1350
|
-
|
|
1351
|
-
// Every write below lands in a live file, a skill folder the profile
|
|
1352
|
-
// installs (or the manifest, on the way to one). None happens when git
|
|
1353
|
-
// tracks one of them, or cannot say: see `tracked-live-files.ts`.
|
|
1354
|
-
const refusal = await writeRefusal(
|
|
1355
|
-
projectPath,
|
|
1356
|
-
await activeWritableSkillFolders(projectPath, manifest),
|
|
1357
|
-
);
|
|
1358
|
-
if (refusal) {
|
|
1359
|
-
result.problems.push(refusal.message);
|
|
1360
|
-
result.lines.push(refusal.message);
|
|
1361
|
-
return result;
|
|
1362
|
-
}
|
|
1376
|
+
result.lines.push(...handedBackLines);
|
|
1363
1377
|
|
|
1364
1378
|
// ── a. absorb ────────────────────────────────────────────────────────────
|
|
1365
1379
|
const base = marker.base ?? current;
|
|
@@ -75,10 +75,10 @@ import {
|
|
|
75
75
|
updateSkillIgnoreLines,
|
|
76
76
|
} from "./profile-skills.js";
|
|
77
77
|
import {
|
|
78
|
-
|
|
79
|
-
TrackedLiveFileError,
|
|
78
|
+
GitCheckError,
|
|
80
79
|
trackedPaths,
|
|
81
|
-
|
|
80
|
+
trackedSkillFolders,
|
|
81
|
+
trackedWarning,
|
|
82
82
|
} from "./tracked-live-files.js";
|
|
83
83
|
import type { ApplyReporter } from "./update-engine.js";
|
|
84
84
|
|
|
@@ -91,8 +91,12 @@ export interface ActivationResult {
|
|
|
91
91
|
skills: SkillSyncResult;
|
|
92
92
|
/** What converting an older layout did, if there was one. */
|
|
93
93
|
conversion: LegacySkillConversion;
|
|
94
|
-
/**
|
|
95
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Generated paths git still tracks (see {@link trackedGeneratedFiles}) —
|
|
96
|
+
* written all the same, and each warned about. A string instead when git
|
|
97
|
+
* could not say (`GitCheckError`).
|
|
98
|
+
*/
|
|
99
|
+
tracked: string[] | string;
|
|
96
100
|
/** Where `.claude/_profiles/` was kept, project-relative, or null. */
|
|
97
101
|
keptAt: string | null;
|
|
98
102
|
/** Unsaved changes added to a profile before activating, one line each. */
|
|
@@ -101,6 +105,11 @@ export interface ActivationResult {
|
|
|
101
105
|
dependencies?: DependencyReport;
|
|
102
106
|
/** Listed skills whose folder the team committed: that copy is used. */
|
|
103
107
|
committedSkills?: string[];
|
|
108
|
+
/**
|
|
109
|
+
* Skill folders the marker recorded as magus-installed that git tracks —
|
|
110
|
+
* handed back to the team (see `releasedCommittedSkillNote`).
|
|
111
|
+
*/
|
|
112
|
+
releasedCommitted?: string[];
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
export interface ActivateOptions {
|
|
@@ -123,20 +132,19 @@ export function reportDisplaced(
|
|
|
123
132
|
}
|
|
124
133
|
|
|
125
134
|
/**
|
|
126
|
-
* Generated
|
|
127
|
-
* `.claude/skills` SYMLINK (an older layout committed by mistake)
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
135
|
+
* Generated files that git tracks: the three live files, and a committed
|
|
136
|
+
* `.claude/skills` SYMLINK (an older layout committed by mistake). A skill
|
|
137
|
+
* FOLDER git tracks is never one of them — it is the team's copy (see
|
|
138
|
+
* `trackedSkillFolders`). Empty outside a git repository; throws
|
|
139
|
+
* `GitCheckError` when git cannot say. Reported only — nothing refuses to
|
|
140
|
+
* write because of it (see `tracked-live-files.ts`).
|
|
131
141
|
*/
|
|
132
|
-
export async function
|
|
142
|
+
export async function trackedGeneratedFiles(
|
|
133
143
|
projectPath: string,
|
|
134
|
-
skillFolders: readonly string[] = [],
|
|
135
144
|
): Promise<string[]> {
|
|
136
145
|
const rel = (abs: string) => path.relative(projectPath, abs);
|
|
137
146
|
const live = LIVE_FILE_KEYS.map((key) => rel(livePath(key, projectPath)));
|
|
138
147
|
const skills = rel(skillsRoot(projectPath));
|
|
139
|
-
const folders = skillFolders.map((folder) => path.join(skills, folder));
|
|
140
148
|
let skillsIsLink = false;
|
|
141
149
|
try {
|
|
142
150
|
skillsIsLink = (await lstat(skillsRoot(projectPath))).isSymbolicLink();
|
|
@@ -144,112 +152,44 @@ export async function trackedWriteTargets(
|
|
|
144
152
|
// No .claude/skills at all.
|
|
145
153
|
}
|
|
146
154
|
const files = [...live, ...(skillsIsLink ? [skills] : [])];
|
|
147
|
-
const tracked = await trackedPaths(projectPath, files
|
|
148
|
-
return
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* The folders of `skills` the team committed under `.claude/skills/`. Such a
|
|
153
|
-
* folder is the team's copy of the skill: magus neither installs over it nor
|
|
154
|
-
* manages it, so it never has cause to suggest untracking it.
|
|
155
|
-
*/
|
|
156
|
-
export async function committedSkillFolders(
|
|
157
|
-
projectPath: string,
|
|
158
|
-
skills: readonly ProfileSkillRef[],
|
|
159
|
-
): Promise<Set<string>> {
|
|
160
|
-
const root = path.relative(projectPath, skillsRoot(projectPath));
|
|
161
|
-
const folders = skills.map((ref) => skillFolder(ref));
|
|
162
|
-
const tracked = await trackedPaths(
|
|
163
|
-
projectPath,
|
|
164
|
-
[],
|
|
165
|
-
folders.map((folder) => path.join(root, folder)),
|
|
166
|
-
);
|
|
167
|
-
return new Set(folders.filter((f) => tracked.has(path.join(root, f))));
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** {@link trackedWriteTargets} for the live files alone — what doctor reports. */
|
|
171
|
-
export async function trackedGeneratedFiles(
|
|
172
|
-
projectPath: string,
|
|
173
|
-
): Promise<string[]> {
|
|
174
|
-
return trackedWriteTargets(projectPath);
|
|
155
|
+
const tracked = await trackedPaths(projectPath, files);
|
|
156
|
+
return files.filter((c) => tracked.has(c));
|
|
175
157
|
}
|
|
176
158
|
|
|
177
159
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* yet — unless the team committed them (see {@link committedSkillFolders}).
|
|
181
|
-
* An existing folder magus does not manage is a custom skill, which
|
|
182
|
-
* activation refuses as a clash rather than writing.
|
|
160
|
+
* {@link trackedGeneratedFiles} as the warning lines the CLI prints: one per
|
|
161
|
+
* tracked path, or one saying git could not be asked.
|
|
183
162
|
*/
|
|
184
|
-
export async function
|
|
163
|
+
export async function trackedGeneratedWarnings(
|
|
185
164
|
projectPath: string,
|
|
186
|
-
skills: readonly ProfileSkillRef[],
|
|
187
|
-
managed: Readonly<Record<string, string>> = {},
|
|
188
165
|
): Promise<string[]> {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
try {
|
|
195
|
-
await lstat(path.join(skillsRoot(projectPath), folder));
|
|
196
|
-
} catch {
|
|
197
|
-
missing.push(ref);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
const committed = await committedSkillFolders(projectPath, missing);
|
|
201
|
-
for (const ref of missing) {
|
|
202
|
-
if (!committed.has(skillFolder(ref))) out.add(skillFolder(ref));
|
|
166
|
+
try {
|
|
167
|
+
return (await trackedGeneratedFiles(projectPath)).map(trackedWarning);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error instanceof GitCheckError) return [error.message];
|
|
170
|
+
throw error;
|
|
203
171
|
}
|
|
204
|
-
return [...out];
|
|
205
172
|
}
|
|
206
173
|
|
|
207
174
|
/**
|
|
208
|
-
* The
|
|
209
|
-
*
|
|
210
|
-
*
|
|
175
|
+
* The folders of `skills` the team committed under `.claude/skills/`. Such a
|
|
176
|
+
* folder is the team's copy of the skill: magus neither installs over it nor
|
|
177
|
+
* manages it, so it never has cause to suggest untracking it. None when git
|
|
178
|
+
* cannot say — each existing folder is then judged as a custom skill.
|
|
211
179
|
*/
|
|
212
|
-
export async function
|
|
180
|
+
export async function committedSkillFolders(
|
|
213
181
|
projectPath: string,
|
|
214
|
-
|
|
215
|
-
): Promise<string
|
|
216
|
-
|
|
217
|
-
if (!marker) return [];
|
|
218
|
-
return writableSkillFolders(
|
|
182
|
+
skills: readonly ProfileSkillRef[],
|
|
183
|
+
): Promise<Set<string>> {
|
|
184
|
+
return trackedSkillFolders(
|
|
219
185
|
projectPath,
|
|
220
|
-
|
|
221
|
-
marker.skills,
|
|
186
|
+
skills.map((ref) => skillFolder(ref)),
|
|
222
187
|
);
|
|
223
188
|
}
|
|
224
189
|
|
|
225
|
-
/**
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
*/
|
|
229
|
-
export async function writeRefusal(
|
|
230
|
-
projectPath: string,
|
|
231
|
-
skillFolders: readonly string[] = [],
|
|
232
|
-
): Promise<LiveFileRefusal | null> {
|
|
233
|
-
try {
|
|
234
|
-
await assertNoTrackedWriteTargets(projectPath, skillFolders);
|
|
235
|
-
return null;
|
|
236
|
-
} catch (error) {
|
|
237
|
-
if (error instanceof LiveFileRefusal) return error;
|
|
238
|
-
throw error;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Throw {@link TrackedLiveFileError} — before anything is written — when any
|
|
244
|
-
* of {@link trackedWriteTargets} is tracked, or `GitCheckError` when git
|
|
245
|
-
* cannot say.
|
|
246
|
-
*/
|
|
247
|
-
export async function assertNoTrackedWriteTargets(
|
|
248
|
-
projectPath: string,
|
|
249
|
-
skillFolders: readonly string[] = [],
|
|
250
|
-
): Promise<void> {
|
|
251
|
-
const tracked = await trackedWriteTargets(projectPath, skillFolders);
|
|
252
|
-
if (tracked.length > 0) throw new TrackedLiveFileError(tracked);
|
|
190
|
+
/** The line for a recorded skill folder handed back to the team. */
|
|
191
|
+
export function releasedCommittedSkillNote(folder: string): string {
|
|
192
|
+
return `Skill ${folder} is committed by the team — magus no longer manages it`;
|
|
253
193
|
}
|
|
254
194
|
|
|
255
195
|
/**
|
|
@@ -274,16 +214,16 @@ export function activationNotes(result: ActivationResult): string[] {
|
|
|
274
214
|
for (const f of result.skills.failed) {
|
|
275
215
|
lines.push(`Skill "${f.skill}" could not be installed: ${f.reason}`);
|
|
276
216
|
}
|
|
217
|
+
for (const folder of result.releasedCommitted ?? []) {
|
|
218
|
+
lines.push(releasedCommittedSkillNote(folder));
|
|
219
|
+
}
|
|
277
220
|
for (const folder of result.committedSkills ?? []) {
|
|
278
221
|
lines.push(
|
|
279
222
|
`Skill .claude/skills/${folder} is committed to git — the team's copy is used; magus does not install or manage it`,
|
|
280
223
|
);
|
|
281
224
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
`${rel} is generated but tracked by git — run \`${untrackCommand(rel)}\`, then commit`,
|
|
285
|
-
);
|
|
286
|
-
}
|
|
225
|
+
if (typeof result.tracked === "string") lines.push(result.tracked);
|
|
226
|
+
else lines.push(...result.tracked.map(trackedWarning));
|
|
287
227
|
if (result.keptAt) {
|
|
288
228
|
lines.push(
|
|
289
229
|
`The older layout's .claude/_profiles/ was kept at ${result.keptAt}/`,
|
|
@@ -295,9 +235,10 @@ export function activationNotes(result: ActivationResult): string[] {
|
|
|
295
235
|
/**
|
|
296
236
|
* Render `profileId` from `manifest` and make it the active profile.
|
|
297
237
|
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
* nothing, when a live file of a managed project does not
|
|
238
|
+
* A generated file git tracks is written like any other, and named in
|
|
239
|
+
* `tracked` so the caller can warn about it. Throws `SettingsParseError`,
|
|
240
|
+
* having changed nothing, when a live file of a managed project does not
|
|
241
|
+
* parse. Throws
|
|
301
242
|
* {@link SkillClashError} — having
|
|
302
243
|
* changed nothing but moving an older layout's skills into `.claude/skills/` —
|
|
303
244
|
* when a listed skill's folder is already a custom skill.
|
|
@@ -315,21 +256,27 @@ export async function activateProfile(
|
|
|
315
256
|
// Read before converting: the older marker lives in the directory the
|
|
316
257
|
// conversion moves away, and an older layout is recognised by its links.
|
|
317
258
|
const previous = await readActiveMarker(projectPath);
|
|
318
|
-
// Before anything else is touched — the conversion below writes too.
|
|
319
|
-
await assertNoTrackedWriteTargets(
|
|
320
|
-
projectPath,
|
|
321
|
-
await writableSkillFolders(projectPath, skillRefs, previous?.skills),
|
|
322
|
-
);
|
|
323
259
|
const wasOldLayout = (await oldLayoutProfile(projectPath)) !== null;
|
|
324
260
|
// A managed project's live files are the profile's; one that does not
|
|
325
261
|
// parse is somebody mid-edit, and replacing it would lose their work. (At
|
|
326
262
|
// first adoption a hand-written file is moved to a backup, not replaced.)
|
|
327
263
|
if (previous !== null) await assertLiveFilesParse(projectPath);
|
|
328
264
|
const conversion = await convertLegacySkills(manifest, projectPath);
|
|
329
|
-
const
|
|
265
|
+
const recorded: Record<string, string> = {
|
|
330
266
|
...(previous?.skills ?? {}),
|
|
331
267
|
...conversion.managed,
|
|
332
268
|
};
|
|
269
|
+
// A recorded folder git tracks is the team's copy — 7.5.0's conversion
|
|
270
|
+
// recorded committed skills as magus-installed. Dropped from the record
|
|
271
|
+
// here, before a sync could remove it, and said once.
|
|
272
|
+
const committedRecorded = await trackedSkillFolders(
|
|
273
|
+
projectPath,
|
|
274
|
+
Object.keys(recorded),
|
|
275
|
+
);
|
|
276
|
+
const managedBefore: Record<string, string> = {};
|
|
277
|
+
for (const [folder, hash] of Object.entries(recorded)) {
|
|
278
|
+
if (!committedRecorded.has(folder)) managedBefore[folder] = hash;
|
|
279
|
+
}
|
|
333
280
|
|
|
334
281
|
// Listed skills the team committed and magus does not manage: that copy
|
|
335
282
|
// stands, and magus neither installs over it nor reports it as a clash.
|
|
@@ -397,7 +344,7 @@ export async function activateProfile(
|
|
|
397
344
|
);
|
|
398
345
|
await updateSkillIgnoreLines(
|
|
399
346
|
projectPath,
|
|
400
|
-
Object.keys(
|
|
347
|
+
Object.keys(recorded),
|
|
401
348
|
Object.keys(skills.skills),
|
|
402
349
|
);
|
|
403
350
|
await writeActiveMarker(
|
|
@@ -412,7 +359,12 @@ export async function activateProfile(
|
|
|
412
359
|
displaced,
|
|
413
360
|
skills,
|
|
414
361
|
conversion,
|
|
415
|
-
tracked: await trackedGeneratedFiles(projectPath)
|
|
362
|
+
tracked: await trackedGeneratedFiles(projectPath).catch(
|
|
363
|
+
(error: unknown) => {
|
|
364
|
+
if (error instanceof GitCheckError) return error.message;
|
|
365
|
+
throw error;
|
|
366
|
+
},
|
|
367
|
+
),
|
|
416
368
|
keptAt: conversion.keptAt
|
|
417
369
|
? path.relative(projectPath, conversion.keptAt)
|
|
418
370
|
: null,
|
|
@@ -420,6 +372,9 @@ export async function activateProfile(
|
|
|
420
372
|
...(committedSkills.size > 0
|
|
421
373
|
? { committedSkills: [...committedSkills].sort() }
|
|
422
374
|
: {}),
|
|
375
|
+
...(committedRecorded.size > 0
|
|
376
|
+
? { releasedCommitted: [...committedRecorded].sort() }
|
|
377
|
+
: {}),
|
|
423
378
|
};
|
|
424
379
|
}
|
|
425
380
|
|
|
@@ -445,7 +400,6 @@ export async function regenerateLiveFiles(
|
|
|
445
400
|
const marker = await readActiveMarker(projectPath);
|
|
446
401
|
const manifest = await readManifest(projectPath);
|
|
447
402
|
if (!marker || !manifest.profiles[marker.profile]) return false;
|
|
448
|
-
await assertNoTrackedWriteTargets(projectPath);
|
|
449
403
|
await assertLiveFilesParse(projectPath);
|
|
450
404
|
const rendered = await renderProfile(manifest, marker.profile, projectPath);
|
|
451
405
|
const files = await writeRenderedFiles(rendered, projectPath);
|