magus-cli 7.4.1 → 7.5.1
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/README.md +9 -8
- package/package.json +5 -4
- package/src/__tests__/install-command.test.ts +4 -3
- package/src/__tests__/models-cli.test.ts +4 -7
- package/src/__tests__/models-manager.test.ts +52 -40
- package/src/__tests__/profile-absorb.test.ts +1210 -0
- package/src/__tests__/profile-command.test.ts +90 -32
- package/src/__tests__/profile-dependencies.test.ts +442 -0
- package/src/__tests__/profile-manifest-write.test.ts +231 -0
- package/src/__tests__/profile-migration-offer.test.ts +18 -15
- package/src/__tests__/profile-migration.test.ts +246 -117
- package/src/__tests__/profile-render.test.ts +141 -0
- package/src/__tests__/profile-skills.test.ts +524 -0
- package/src/__tests__/profile-startup.test.ts +44 -14
- package/src/__tests__/profile-state.test.ts +1110 -0
- package/src/__tests__/settings-file.test.ts +5 -7
- package/src/__tests__/stale-load-repair.test.ts +105 -0
- package/src/__tests__/styles-manager.test.ts +47 -74
- package/src/__tests__/styles-screen-state.test.ts +11 -10
- package/src/__tests__/support/preload.ts +14 -0
- package/src/__tests__/update-apply.test.ts +145 -0
- package/src/cli/bootstrap.ts +91 -27
- package/src/cli/doctor.ts +165 -38
- package/src/cli/install.ts +126 -122
- package/src/cli/models.ts +21 -1
- package/src/cli/profile.ts +48 -77
- package/src/cli/router.ts +39 -6
- package/src/cli/update.ts +46 -6
- package/src/data/gitignore-defaults.ts +3 -3
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-cli.ts +24 -0
- package/src/services/claude-settings.ts +27 -0
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +113 -9
- package/src/services/manifest.ts +224 -8
- package/src/services/models-manager.ts +36 -73
- package/src/services/plugin-dependencies.ts +48 -0
- package/src/services/plugin-requires.ts +137 -14
- package/src/services/profile-absorb.ts +1586 -0
- package/src/services/profile-activate.ts +492 -94
- package/src/services/profile-live.ts +884 -0
- package/src/services/profile-render.ts +182 -0
- package/src/services/profile-skills.ts +487 -0
- package/src/services/profile-startup.ts +88 -16
- package/src/services/profile-state.ts +706 -0
- package/src/services/profiles.ts +26 -37
- package/src/services/resolver.ts +100 -2
- package/src/services/settings-file.ts +5 -5
- package/src/services/skills-manager.ts +14 -8
- package/src/services/styles-manager.ts +45 -28
- package/src/services/tracked-live-files.ts +125 -0
- package/src/services/update-engine.ts +87 -2
- package/src/types/index.ts +34 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/screens/PluginsScreen.tsx +11 -2
- package/src/ui/screens/ProfilesScreen.tsx +84 -29
- package/src/ui/screens/StylesScreen.tsx +2 -1
- package/src/ui/state/types.ts +1 -1
- package/src/__tests__/profile-materializer.test.ts +0 -238
- package/src/__tests__/profile-sync.test.ts +0 -154
- package/src/__tests__/symlink-manager.test.ts +0 -180
- package/src/services/profile-materializer.ts +0 -133
- package/src/services/profile-sync.ts +0 -158
- package/src/services/symlink-manager.ts +0 -343
package/src/cli/update.ts
CHANGED
|
@@ -48,9 +48,20 @@ import {
|
|
|
48
48
|
clearMarketplaceCache,
|
|
49
49
|
getAvailablePlugins,
|
|
50
50
|
} from "../services/plugin-manager.js";
|
|
51
|
+
import {
|
|
52
|
+
recordRefetchedSkills,
|
|
53
|
+
trackFetchedSkills,
|
|
54
|
+
updatableSkills,
|
|
55
|
+
} from "../services/profile-activate.js";
|
|
56
|
+
import { activeProfile } from "../services/profile-live.js";
|
|
57
|
+
import { skillFolder } from "../services/profile-skills.js";
|
|
58
|
+
import {
|
|
59
|
+
suggestedProfile,
|
|
60
|
+
switchHint,
|
|
61
|
+
withCliBookkeeping,
|
|
62
|
+
} from "../services/profile-state.js";
|
|
51
63
|
import { resolveProfile } from "../services/resolver.js";
|
|
52
64
|
import { getInstalledSkillNames } from "../services/skills-manager.js";
|
|
53
|
-
import { activeProfile } from "../services/symlink-manager.js";
|
|
54
65
|
import { REAL_TOOL_PROBE_IO, probeTools } from "../services/tool-probe.js";
|
|
55
66
|
// The apply half lives in services/ so the TUI runs the SAME code — see the
|
|
56
67
|
// header of update-engine.ts for what drifted while it did not.
|
|
@@ -197,7 +208,7 @@ export async function runUpdateCommand(
|
|
|
197
208
|
//
|
|
198
209
|
// BOTH `--check` and `--dry-run` are read-only here. An earlier version let
|
|
199
210
|
// `--dry-run` activate, reasoning that a preview needs a real profile to
|
|
200
|
-
// report against — but activating writes
|
|
211
|
+
// report against — but activating writes config files and edits a TRACKED
|
|
201
212
|
// `.gitignore`, and `--dry-run` promises to write nothing and exit 0. A
|
|
202
213
|
// preview that mutates the repo is not a preview.
|
|
203
214
|
//
|
|
@@ -207,7 +218,7 @@ export async function runUpdateCommand(
|
|
|
207
218
|
// preview result rather than an error.
|
|
208
219
|
//
|
|
209
220
|
// NAMING A PROFILE SKIPS THE GATE. `magus update lean` says which profile to
|
|
210
|
-
// update, and updating it needs the manifest, not the
|
|
221
|
+
// update, and updating it needs the manifest, not the marker — installs go
|
|
211
222
|
// to project scope either way. Gating it would refuse a question the user
|
|
212
223
|
// already answered on the command line, and would break `magus update lean`
|
|
213
224
|
// and `--check lean` in CI, both of which worked before this gate existed.
|
|
@@ -254,7 +265,7 @@ export async function runUpdateCommand(
|
|
|
254
265
|
if (!resolvedId) {
|
|
255
266
|
console.error(
|
|
256
267
|
failLine(
|
|
257
|
-
`No active profile. Run
|
|
268
|
+
`No active profile. Run ${switchHint(profileIds, suggestedProfile(current))}.`,
|
|
258
269
|
),
|
|
259
270
|
);
|
|
260
271
|
return 1;
|
|
@@ -414,11 +425,18 @@ export async function runUpdateCommand(
|
|
|
414
425
|
|
|
415
426
|
// ---- plan ----------------------------------------------------------------
|
|
416
427
|
|
|
428
|
+
// Skills go into the live `.claude/skills/`, so only the ACTIVE profile's,
|
|
429
|
+
// and never over a custom skill folder.
|
|
430
|
+
const skillRefs = await updatableSkills(
|
|
431
|
+
projectPath,
|
|
432
|
+
targetId,
|
|
433
|
+
closure.skills,
|
|
434
|
+
);
|
|
417
435
|
const plan: UpdatePlan = {
|
|
418
436
|
profileId: targetId,
|
|
419
437
|
plugins: planPluginUpdates(closure.plugins, catalog),
|
|
420
438
|
bins: planBinUpdates(closure.bins, toolFacts),
|
|
421
|
-
skills: planSkillUpdates(
|
|
439
|
+
skills: planSkillUpdates(skillRefs, installedSkills),
|
|
422
440
|
binSpecs: new Map(closure.bins.map((b) => [b.name, b])),
|
|
423
441
|
};
|
|
424
442
|
|
|
@@ -575,9 +593,31 @@ export async function runUpdateCommand(
|
|
|
575
593
|
console.log("");
|
|
576
594
|
applyLive.start((tick) => applyFrame(state, tick, Date.now()));
|
|
577
595
|
|
|
578
|
-
|
|
596
|
+
// `claude plugin install --scope project` records each install in the live
|
|
597
|
+
// settings.json — for a profile that may not be the active one. The marker
|
|
598
|
+
// is flagged while that runs; in `finally` the re-fetched skill folders are
|
|
599
|
+
// recorded with their new contents, then the live config is reconciled:
|
|
600
|
+
// the user's own edits in the meantime are kept, the install's records are
|
|
601
|
+
// discarded, and an interrupted run leaves the flag for the next command.
|
|
602
|
+
const reconciledLines: string[] = [];
|
|
603
|
+
// Only the skills this run actually fetched get new hashes: one it did
|
|
604
|
+
// not fetch and someone edited meanwhile must still read as edited.
|
|
605
|
+
const skills = trackFetchedSkills(skillRefs, reporter);
|
|
606
|
+
const outcome = await withCliBookkeeping(
|
|
607
|
+
projectPath,
|
|
608
|
+
() => applyPlan(plan, projectPath, { report: skills.report }),
|
|
609
|
+
{
|
|
610
|
+
// Only the active profile's plugins are this run's to install, so
|
|
611
|
+
// only their records are the CLI's.
|
|
612
|
+
installable: Object.keys(closure.plugins),
|
|
613
|
+
beforeReconcile: () =>
|
|
614
|
+
recordRefetchedSkills(projectPath, skills.fetched()),
|
|
615
|
+
report: (line) => reconciledLines.push(line),
|
|
616
|
+
},
|
|
617
|
+
);
|
|
579
618
|
|
|
580
619
|
applyLive.stop();
|
|
620
|
+
for (const line of reconciledLines) console.log(noteLine(line));
|
|
581
621
|
|
|
582
622
|
const { failed } = outcome;
|
|
583
623
|
|
|
@@ -12,9 +12,9 @@ export const BUILTIN_DEFAULTS: GitignoreManifest = {
|
|
|
12
12
|
".claude/scheduled_tasks.lock",
|
|
13
13
|
".claude/cache/",
|
|
14
14
|
".claude/.statusline-worktree-*",
|
|
15
|
-
//
|
|
16
|
-
".claude/
|
|
17
|
-
// Generated from profiles.json (
|
|
15
|
+
// The per-developer active-profile marker.
|
|
16
|
+
".claude/profiles/",
|
|
17
|
+
// Generated from profiles.json (rendered from the active profile when one
|
|
18
18
|
// is active). The routing itself IS committed — in profiles.json, under
|
|
19
19
|
// the profile that declares it.
|
|
20
20
|
".claude/models.json",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* pick the profile in different ways — the CLI inline, the TUI not at all (it
|
|
7
7
|
* judged everything against "latest") — so an exact pin could never agree.
|
|
8
8
|
* Both now choose through {@link selectProfileId} over the same manifest and
|
|
9
|
-
* the same active-profile
|
|
9
|
+
* the same active-profile marker.
|
|
10
10
|
*
|
|
11
11
|
* Read-only. Unlike the CLI's `ensureManifest`, nothing here bootstraps a
|
|
12
12
|
* manifest: a screen opening must never author a file a team commits.
|
|
@@ -20,14 +20,14 @@ import type {
|
|
|
20
20
|
ResolvedClosure,
|
|
21
21
|
} from "../types/index.js";
|
|
22
22
|
import { readManifest, validateManifest } from "./manifest.js";
|
|
23
|
+
import { activeProfile } from "./profile-live.js";
|
|
23
24
|
import { resolveProfile } from "./resolver.js";
|
|
24
|
-
import { activeProfile } from "./symlink-manager.js";
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* The profile to use: the explicit one, else the active one, else the only one.
|
|
28
28
|
*
|
|
29
29
|
* Each candidate counts only if the manifest defines it. A stale active-profile
|
|
30
|
-
*
|
|
30
|
+
* marker naming a profile that was since removed would otherwise be chosen and
|
|
31
31
|
* then fail to resolve, even when the manifest has exactly one profile and the
|
|
32
32
|
* answer is unambiguous.
|
|
33
33
|
*/
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { execFile } from "node:child_process";
|
|
13
|
+
import path from "node:path";
|
|
13
14
|
import { promisify } from "node:util";
|
|
14
15
|
import { defaultMarketplaces } from "../data/marketplaces.js";
|
|
15
16
|
import { which } from "../utils/command-utils.js";
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
removeLocalInstalledPluginVersion,
|
|
20
21
|
} from "./claude-settings.js";
|
|
21
22
|
import { removeInstalledPluginVersion } from "./plugin-manager.js";
|
|
23
|
+
import { refuseTrackedLiveFiles } from "./tracked-live-files.js";
|
|
22
24
|
|
|
23
25
|
const execFileAsync = promisify(execFile);
|
|
24
26
|
|
|
@@ -138,6 +140,23 @@ async function execPluginCommand(
|
|
|
138
140
|
}
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Every `claude plugin … --scope project` command writes the project's
|
|
145
|
+
* `.claude/settings.json` (its enabledPlugins entry). In a project with
|
|
146
|
+
* profiles that file is generated; where git tracks it, refuse — before the
|
|
147
|
+
* CLI runs — rather than let the CLI write it (see `tracked-live-files.ts`).
|
|
148
|
+
* The CLI works on the current directory.
|
|
149
|
+
*/
|
|
150
|
+
async function refuseProjectSettingsWrite(
|
|
151
|
+
scope: PluginScope,
|
|
152
|
+
projectPath: string = process.cwd(),
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
if (scope !== "project") return;
|
|
155
|
+
await refuseTrackedLiveFiles(projectPath, [
|
|
156
|
+
path.join(".claude", "settings.json"),
|
|
157
|
+
]);
|
|
158
|
+
}
|
|
159
|
+
|
|
141
160
|
/**
|
|
142
161
|
* Install a plugin using claude CLI
|
|
143
162
|
* Handles enabling + version tracking + cache copy in one shot.
|
|
@@ -154,6 +173,7 @@ export async function installPlugin(
|
|
|
154
173
|
pluginId: string,
|
|
155
174
|
scope: PluginScope = "user",
|
|
156
175
|
): Promise<void> {
|
|
176
|
+
await refuseProjectSettingsWrite(scope);
|
|
157
177
|
// 60s, matching update and repair. Before the install/update split this path
|
|
158
178
|
// ran through `updatePlugin`'s 60s budget; leaving it on the 30s default
|
|
159
179
|
// would have quietly halved the time a fresh install — the SLOWEST of the
|
|
@@ -172,6 +192,7 @@ export async function uninstallPlugin(
|
|
|
172
192
|
scope: PluginScope = "user",
|
|
173
193
|
projectPath?: string,
|
|
174
194
|
): Promise<void> {
|
|
195
|
+
await refuseProjectSettingsWrite(scope, projectPath);
|
|
175
196
|
try {
|
|
176
197
|
await execClaude(["plugin", "uninstall", pluginId, "--scope", scope]);
|
|
177
198
|
} catch {
|
|
@@ -224,6 +245,7 @@ export async function enablePlugin(
|
|
|
224
245
|
pluginId: string,
|
|
225
246
|
scope: PluginScope = "user",
|
|
226
247
|
): Promise<void> {
|
|
248
|
+
await refuseProjectSettingsWrite(scope);
|
|
227
249
|
await execClaude(["plugin", "enable", pluginId, "--scope", scope]);
|
|
228
250
|
}
|
|
229
251
|
|
|
@@ -234,6 +256,7 @@ export async function disablePlugin(
|
|
|
234
256
|
pluginId: string,
|
|
235
257
|
scope: PluginScope = "user",
|
|
236
258
|
): Promise<void> {
|
|
259
|
+
await refuseProjectSettingsWrite(scope);
|
|
237
260
|
await execClaude(["plugin", "disable", pluginId, "--scope", scope]);
|
|
238
261
|
}
|
|
239
262
|
|
|
@@ -276,6 +299,7 @@ export async function updatePlugin(
|
|
|
276
299
|
pluginId: string,
|
|
277
300
|
scope: PluginScope = "user",
|
|
278
301
|
): Promise<void> {
|
|
302
|
+
await refuseProjectSettingsWrite(scope);
|
|
279
303
|
await execPluginCommand("update", pluginId, scope, 60000);
|
|
280
304
|
}
|
|
281
305
|
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
writableList,
|
|
38
38
|
writableMap,
|
|
39
39
|
} from "./settings-file.js";
|
|
40
|
+
import { refuseTrackedLiveFiles } from "./tracked-live-files.js";
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* What every reader below throws for a file that exists but does not parse.
|
|
@@ -148,6 +149,11 @@ export async function writeSettings(
|
|
|
148
149
|
settings: ClaudeSettings,
|
|
149
150
|
projectPath?: string,
|
|
150
151
|
): 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
|
+
]);
|
|
151
157
|
await refuseUnwritableFile(getSettingsPath(projectPath));
|
|
152
158
|
const claudeDir = await ensureClaudeDir(projectPath);
|
|
153
159
|
const settingsPath = path.join(claudeDir, SETTINGS_FILE);
|
|
@@ -191,6 +197,7 @@ export async function writeMcpConfig(
|
|
|
191
197
|
projectPath?: string,
|
|
192
198
|
): Promise<void> {
|
|
193
199
|
const mcpPath = getMcpConfigPath(projectPath);
|
|
200
|
+
await refuseTrackedLiveFiles(projectPath ?? process.cwd(), [mcpPath]);
|
|
194
201
|
await refuseUnwritableFile(mcpPath);
|
|
195
202
|
await fs.writeJson(mcpPath, config, { spaces: 2 });
|
|
196
203
|
}
|
|
@@ -2143,6 +2150,26 @@ export async function repairAllStaleLoads(
|
|
|
2143
2150
|
return out;
|
|
2144
2151
|
}
|
|
2145
2152
|
|
|
2153
|
+
/**
|
|
2154
|
+
* The version Claude Code's loader resolves for `pluginId` in `projectPath`:
|
|
2155
|
+
* the first relevant registry row whose installPath exists (`pickLoadedEntry`).
|
|
2156
|
+
* Null when no row resolves, or the registry cannot be read.
|
|
2157
|
+
*/
|
|
2158
|
+
export async function loadedVersionHere(
|
|
2159
|
+
pluginId: string,
|
|
2160
|
+
projectPath: string,
|
|
2161
|
+
root: string = pluginsDir(),
|
|
2162
|
+
): Promise<string | null> {
|
|
2163
|
+
try {
|
|
2164
|
+
const registry = await readInstalledPluginsRegistry(root);
|
|
2165
|
+
const rows = registry.plugins?.[pluginId];
|
|
2166
|
+
if (!Array.isArray(rows) || rows.length === 0) return null;
|
|
2167
|
+
return pickLoadedEntry(rows, projectPath)?.version ?? null;
|
|
2168
|
+
} catch {
|
|
2169
|
+
return null;
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2146
2173
|
/**
|
|
2147
2174
|
* What one {@link repairStaleLoad} attempt did, and whether it worked.
|
|
2148
2175
|
*
|
|
@@ -258,6 +258,94 @@ export async function removeGitignoreEntries(
|
|
|
258
258
|
});
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
+
/** A `.gitignore` comment that heads a block magus wrote. */
|
|
262
|
+
const MAGUS_BLOCK_HEADER_RE = /^#\s*magus:/;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Edit magus's own block in a `.gitignore`, and nothing else in the file.
|
|
266
|
+
*
|
|
267
|
+
* A block is a `# magus: …` comment line and the entry lines under it, up to
|
|
268
|
+
* the next blank line or comment. Every magus release wrote its profile lines
|
|
269
|
+
* that way, so that block is the only part of the file magus may take lines
|
|
270
|
+
* OUT of: the same pattern elsewhere is a person's, and they put it there on
|
|
271
|
+
* purpose.
|
|
272
|
+
*
|
|
273
|
+
* - `add`: entries not already present anywhere in the file are appended to
|
|
274
|
+
* the block headed exactly by `header`, which is created at the end of the
|
|
275
|
+
* file when there is none.
|
|
276
|
+
* - `remove`: entries are removed from every `# magus:` block. Lines outside
|
|
277
|
+
* the blocks are never touched.
|
|
278
|
+
*
|
|
279
|
+
* Under the file lock, written atomically, a no-op when nothing changes.
|
|
280
|
+
*/
|
|
281
|
+
export async function updateManagedGitignoreBlock(
|
|
282
|
+
gitignorePath: string,
|
|
283
|
+
header: string,
|
|
284
|
+
edits: { add?: readonly string[]; remove?: readonly string[] },
|
|
285
|
+
): Promise<{ added: string[]; removed: string[]; modified: boolean }> {
|
|
286
|
+
const add = (edits.add ?? []).map((e) => e.trim()).filter(Boolean);
|
|
287
|
+
const remove = new Set(
|
|
288
|
+
(edits.remove ?? []).map((e) => e.trim()).filter((e) => !add.includes(e)),
|
|
289
|
+
);
|
|
290
|
+
if (add.length === 0 && remove.size === 0) {
|
|
291
|
+
return { added: [], removed: [], modified: false };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return withFileLock(gitignorePath, async () => {
|
|
295
|
+
const content = await readFileOrEmpty(gitignorePath);
|
|
296
|
+
const lines = content.length > 0 ? content.split("\n") : [];
|
|
297
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
298
|
+
|
|
299
|
+
// Pass 1: drop `remove` entries inside magus blocks only.
|
|
300
|
+
const removed: string[] = [];
|
|
301
|
+
const kept: string[] = [];
|
|
302
|
+
let inBlock = false;
|
|
303
|
+
for (const line of lines) {
|
|
304
|
+
const trimmed = line.trim();
|
|
305
|
+
if (MAGUS_BLOCK_HEADER_RE.test(trimmed)) {
|
|
306
|
+
inBlock = true;
|
|
307
|
+
kept.push(line);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (trimmed === "" || trimmed.startsWith("#")) inBlock = false;
|
|
311
|
+
if (inBlock && remove.has(trimmed)) {
|
|
312
|
+
removed.push(trimmed);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
kept.push(line);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Pass 2: add what the file lacks, at the end of the named block.
|
|
319
|
+
const present = new Set(
|
|
320
|
+
kept.map((l) => l.trim()).filter((l) => l && !l.startsWith("#")),
|
|
321
|
+
);
|
|
322
|
+
const toAdd = add.filter((e) => !present.has(e));
|
|
323
|
+
let out = kept;
|
|
324
|
+
if (toAdd.length > 0) {
|
|
325
|
+
const at = kept.findIndex((l) => l.trim() === header.trim());
|
|
326
|
+
if (at === -1) {
|
|
327
|
+
out = [...kept];
|
|
328
|
+
if (out.length > 0 && out[out.length - 1]?.trim() !== "") out.push("");
|
|
329
|
+
out.push(header, ...toAdd);
|
|
330
|
+
} else {
|
|
331
|
+
let end = at + 1;
|
|
332
|
+
while (end < kept.length) {
|
|
333
|
+
const t = kept[end]?.trim() ?? "";
|
|
334
|
+
if (t === "" || t.startsWith("#")) break;
|
|
335
|
+
end++;
|
|
336
|
+
}
|
|
337
|
+
out = [...kept.slice(0, end), ...toAdd, ...kept.slice(end)];
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (toAdd.length === 0 && removed.length === 0) {
|
|
342
|
+
return { added: [], removed: [], modified: false };
|
|
343
|
+
}
|
|
344
|
+
await atomicWrite(gitignorePath, `${out.join("\n")}\n`);
|
|
345
|
+
return { added: toAdd, removed, modified: true };
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
261
349
|
/**
|
|
262
350
|
* Check which expected entries are missing from a .gitignore file.
|
|
263
351
|
*
|
|
@@ -24,36 +24,67 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { spawn } from "node:child_process";
|
|
27
|
+
import { lstat } from "node:fs/promises";
|
|
27
28
|
import path from "node:path";
|
|
28
29
|
|
|
29
30
|
function git(
|
|
30
31
|
cwd: string,
|
|
31
32
|
args: string[],
|
|
32
|
-
): Promise<{ code: number; out: string }> {
|
|
33
|
+
): Promise<{ code: number; out: string; err: string }> {
|
|
33
34
|
return new Promise((resolve) => {
|
|
34
35
|
const child = spawn("git", args, {
|
|
35
36
|
cwd,
|
|
36
|
-
stdio: ["ignore", "pipe", "
|
|
37
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
37
38
|
});
|
|
38
39
|
let out = "";
|
|
40
|
+
let err = "";
|
|
39
41
|
child.stdout.on("data", (d) => {
|
|
40
42
|
out += String(d);
|
|
41
43
|
});
|
|
44
|
+
child.stderr.on("data", (d) => {
|
|
45
|
+
err += String(d);
|
|
46
|
+
});
|
|
47
|
+
const settle = (code: number, text: string, why: string) => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
resolve({ code, out: text, err: why });
|
|
50
|
+
};
|
|
42
51
|
// Never let a wedged git block the UI.
|
|
43
52
|
const timer = setTimeout(() => {
|
|
44
53
|
child.kill();
|
|
45
|
-
|
|
54
|
+
settle(-1, "", `git ${args[0]} did not answer within 5 seconds`);
|
|
46
55
|
}, 5000);
|
|
47
56
|
timer.unref?.();
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
child.on("error", () => settle(-1, ""));
|
|
53
|
-
child.on("close", (code) => settle(code ?? -1, out.trim()));
|
|
57
|
+
child.on("error", (error) =>
|
|
58
|
+
settle(-1, "", `could not run git: ${error.message}`),
|
|
59
|
+
);
|
|
60
|
+
child.on("close", (code) => settle(code ?? -1, out.trim(), err.trim()));
|
|
54
61
|
});
|
|
55
62
|
}
|
|
56
63
|
|
|
64
|
+
/**
|
|
65
|
+
* git was asked about a work tree and gave no answer: it timed out, is not
|
|
66
|
+
* installed, or refused the repository. `detail` is git's own message.
|
|
67
|
+
*/
|
|
68
|
+
export class GitQueryError extends Error {
|
|
69
|
+
constructor(readonly detail: string) {
|
|
70
|
+
super(detail);
|
|
71
|
+
this.name = "GitQueryError";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Is `dir`, or a directory above it, a git work tree (holds a `.git`)? */
|
|
76
|
+
async function insideWorkTree(dir: string): Promise<boolean> {
|
|
77
|
+
for (let at = path.resolve(dir); ; at = path.dirname(at)) {
|
|
78
|
+
try {
|
|
79
|
+
await lstat(path.join(at, ".git"));
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
// Not here; try the parent.
|
|
83
|
+
}
|
|
84
|
+
if (path.dirname(at) === at) return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
57
88
|
/**
|
|
58
89
|
* Session cache. Keyed by resolved project path — the repository layout cannot
|
|
59
90
|
* change under a running magus in any way worth tracking, and the resolution
|
|
@@ -65,6 +96,79 @@ export function clearWorktreeCache(): void {
|
|
|
65
96
|
cache.clear();
|
|
66
97
|
}
|
|
67
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The paths (relative to `projectPath`) that git tracks. A path in `files` is
|
|
101
|
+
* tracked when git has exactly that entry — a symlink is an entry too; a path
|
|
102
|
+
* in `dirs` when git tracks any file under it. Empty outside a work tree.
|
|
103
|
+
*
|
|
104
|
+
* Throws {@link GitQueryError} when there IS a `.git` at or above
|
|
105
|
+
* `projectPath` and git still fails — a timeout, git missing, or git refusing
|
|
106
|
+
* the repository (`detected dubious ownership`, exit 128). "Nothing is
|
|
107
|
+
* tracked" is not an answer git gave there, and a caller that believed it
|
|
108
|
+
* would write a tracked file.
|
|
109
|
+
*
|
|
110
|
+
* The distinction matters for `.claude/skills`: as a directory of committed
|
|
111
|
+
* custom skills it has tracked files under it, which says nothing about
|
|
112
|
+
* whether the path ITSELF (an older layout's symlink) was committed.
|
|
113
|
+
*/
|
|
114
|
+
export async function trackedByGit(
|
|
115
|
+
projectPath: string,
|
|
116
|
+
files: readonly string[],
|
|
117
|
+
dirs: readonly string[] = [],
|
|
118
|
+
): Promise<Set<string>> {
|
|
119
|
+
const out = new Set<string>();
|
|
120
|
+
const relPaths = [...files, ...dirs];
|
|
121
|
+
if (relPaths.length === 0) return out;
|
|
122
|
+
// One call for every path: this runs on every magus command that touches
|
|
123
|
+
// profile state, and a spawn per path adds up. `ls-files` prints each
|
|
124
|
+
// tracked FILE matching the pathspecs, so a directory shows as its files.
|
|
125
|
+
const {
|
|
126
|
+
code,
|
|
127
|
+
out: listed,
|
|
128
|
+
err,
|
|
129
|
+
} = await git(projectPath, ["ls-files", "-z", "--", ...relPaths]);
|
|
130
|
+
if (code !== 0) {
|
|
131
|
+
if (!(await insideWorkTree(projectPath))) return out; // Nothing to track.
|
|
132
|
+
throw new GitQueryError(err || `git ls-files exited with code ${code}`);
|
|
133
|
+
}
|
|
134
|
+
const entries = new Set(listed.split("\0").filter(Boolean));
|
|
135
|
+
const norm = (rel: string) => rel.split(path.sep).join("/");
|
|
136
|
+
for (const rel of files) {
|
|
137
|
+
if (entries.has(norm(rel))) out.add(rel);
|
|
138
|
+
}
|
|
139
|
+
for (const rel of dirs) {
|
|
140
|
+
const prefix = `${norm(rel)}/`;
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
if (entry.startsWith(prefix)) {
|
|
143
|
+
out.add(rel);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Is `relPath` (relative to `projectPath`) tracked by git? For a directory,
|
|
153
|
+
* true when any file under it is tracked.
|
|
154
|
+
*
|
|
155
|
+
* `git ls-files --error-unmatch` exits 0 only for a tracked path. Not a
|
|
156
|
+
* repository, git missing, or git not replying all answer false: a project
|
|
157
|
+
* outside git has nothing tracked to report.
|
|
158
|
+
*/
|
|
159
|
+
export async function isTrackedByGit(
|
|
160
|
+
projectPath: string,
|
|
161
|
+
relPath: string,
|
|
162
|
+
): Promise<boolean> {
|
|
163
|
+
const { code } = await git(projectPath, [
|
|
164
|
+
"ls-files",
|
|
165
|
+
"--error-unmatch",
|
|
166
|
+
"--",
|
|
167
|
+
relPath,
|
|
168
|
+
]);
|
|
169
|
+
return code === 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
68
172
|
/**
|
|
69
173
|
* Directories whose registry rows this project may inherit, nearest first.
|
|
70
174
|
*
|