magus-cli 7.4.1 → 7.5.0
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 +4 -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 +1138 -0
- package/src/__tests__/profile-command.test.ts +90 -32
- 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 +425 -0
- package/src/__tests__/profile-startup.test.ts +44 -14
- package/src/__tests__/profile-state.test.ts +795 -0
- package/src/__tests__/settings-file.test.ts +5 -7
- package/src/__tests__/styles-manager.test.ts +47 -74
- package/src/__tests__/styles-screen-state.test.ts +11 -10
- package/src/cli/bootstrap.ts +87 -27
- package/src/cli/doctor.ts +132 -38
- package/src/cli/install.ts +58 -118
- package/src/cli/models.ts +21 -1
- package/src/cli/profile.ts +48 -77
- package/src/cli/router.ts +32 -6
- package/src/cli/update.ts +43 -6
- package/src/data/gitignore-defaults.ts +3 -3
- package/src/services/active-closure.ts +3 -3
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +21 -0
- package/src/services/manifest.ts +4 -5
- package/src/services/models-manager.ts +36 -73
- package/src/services/profile-absorb.ts +1532 -0
- package/src/services/profile-activate.ts +348 -96
- package/src/services/profile-live.ts +825 -0
- package/src/services/profile-render.ts +115 -0
- package/src/services/profile-skills.ts +462 -0
- package/src/services/profile-startup.ts +88 -16
- package/src/services/profile-state.ts +622 -0
- package/src/services/profiles.ts +20 -32
- package/src/services/resolver.ts +6 -1
- 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/types/index.ts +15 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/screens/ProfilesScreen.tsx +75 -27
- 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
|
@@ -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
|
*/
|
|
@@ -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
|
*
|
|
@@ -65,6 +65,27 @@ export function clearWorktreeCache(): void {
|
|
|
65
65
|
cache.clear();
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Is `relPath` (relative to `projectPath`) tracked by git? For a directory,
|
|
70
|
+
* true when any file under it is tracked.
|
|
71
|
+
*
|
|
72
|
+
* `git ls-files --error-unmatch` exits 0 only for a tracked path. Not a
|
|
73
|
+
* repository, git missing, or git not replying all answer false: a project
|
|
74
|
+
* outside git has nothing tracked to report.
|
|
75
|
+
*/
|
|
76
|
+
export async function isTrackedByGit(
|
|
77
|
+
projectPath: string,
|
|
78
|
+
relPath: string,
|
|
79
|
+
): Promise<boolean> {
|
|
80
|
+
const { code } = await git(projectPath, [
|
|
81
|
+
"ls-files",
|
|
82
|
+
"--error-unmatch",
|
|
83
|
+
"--",
|
|
84
|
+
relPath,
|
|
85
|
+
]);
|
|
86
|
+
return code === 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
68
89
|
/**
|
|
69
90
|
* Directories whose registry rows this project may inherit, nearest first.
|
|
70
91
|
*
|
package/src/services/manifest.ts
CHANGED
|
@@ -311,11 +311,10 @@ export async function updateProfileEntry(
|
|
|
311
311
|
* Merge `patch` into `profiles.<profile>.settings`, optionally removing the
|
|
312
312
|
* keys named in `unset`.
|
|
313
313
|
*
|
|
314
|
-
* Why this exists: `.claude/settings.json` is
|
|
315
|
-
* profile's
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
* is the durable half of every settings write magus makes.
|
|
314
|
+
* Why this exists: `.claude/settings.json` is rendered from the active
|
|
315
|
+
* profile's entry, so a setting magus writes belongs in the entry — the next
|
|
316
|
+
* render reads it from there. The manifest is the durable half of every
|
|
317
|
+
* settings write magus makes.
|
|
319
318
|
*/
|
|
320
319
|
export async function mergeManifestSettings(
|
|
321
320
|
projectPath: string,
|
|
@@ -11,15 +11,13 @@
|
|
|
11
11
|
*
|
|
12
12
|
* The routing is a PROFILE property, committed in `.claude/profiles.json` under
|
|
13
13
|
* `profiles.<name>.models`. `.claude/models.json` is the generated view of it —
|
|
14
|
-
* a
|
|
15
|
-
*
|
|
16
|
-
* file (it walks up from the session's cwd looking for exactly that
|
|
17
|
-
* teammate gets the routing by pulling profiles.json
|
|
18
|
-
* install`.
|
|
14
|
+
* a real file rendered from the active profile when there is one, a plain
|
|
15
|
+
* hand-kept file when the project has no profiles. The hook reads the
|
|
16
|
+
* generated file (it walks up from the session's cwd looking for exactly that
|
|
17
|
+
* name); a teammate gets the routing by pulling profiles.json.
|
|
19
18
|
*
|
|
20
|
-
* Writing only the generated file would be silently temporary:
|
|
21
|
-
*
|
|
22
|
-
* disappear on the next install with nothing to explain why.
|
|
19
|
+
* Writing only the generated file would be silently temporary: every
|
|
20
|
+
* activation renders it again from the manifest.
|
|
23
21
|
*
|
|
24
22
|
* ## Why the full model id is resolved live
|
|
25
23
|
*
|
|
@@ -61,17 +59,17 @@ import {
|
|
|
61
59
|
computeModelsStatus,
|
|
62
60
|
validateModelsConfig,
|
|
63
61
|
} from "./models-core.js";
|
|
64
|
-
import {
|
|
65
|
-
import {
|
|
62
|
+
import { regenerateLiveFiles } from "./profile-activate.js";
|
|
63
|
+
import { activeProfile } from "./profile-live.js";
|
|
64
|
+
import { prepareProfileWrite } from "./profile-state.js";
|
|
66
65
|
import { readSettingsFile, updateSettingsFile } from "./settings-file.js";
|
|
67
|
-
import { activeProfile, syncProfileLink } from "./symlink-manager.js";
|
|
68
66
|
|
|
69
67
|
/** `<projectPath>/.claude/models.json` — what the hook looks for. */
|
|
70
68
|
export function configPath(projectPath: string): string {
|
|
71
69
|
return path.join(projectPath, ".claude", "models.json");
|
|
72
70
|
}
|
|
73
71
|
|
|
74
|
-
/** `<projectPath>/.claude/settings.json` —
|
|
72
|
+
/** `<projectPath>/.claude/settings.json` — rendered from the profile when one is active. */
|
|
75
73
|
function settingsPathFor(projectPath: string): string {
|
|
76
74
|
return path.join(projectPath, ".claude", "settings.json");
|
|
77
75
|
}
|
|
@@ -368,8 +366,9 @@ export interface ApplyModelPresetResult {
|
|
|
368
366
|
*
|
|
369
367
|
* Validation comes first: an invalid config makes every subagent spawn fail, so
|
|
370
368
|
* it must never reach disk. After that the order of the numbered steps below is
|
|
371
|
-
* load-bearing —
|
|
372
|
-
*
|
|
369
|
+
* load-bearing — Claude Code's edits are absorbed before the manifest is
|
|
370
|
+
* written, the manifest is written before the live files because they are
|
|
371
|
+
* rendered FROM it, and the hook
|
|
373
372
|
* is registered last because a hook pointing at a config that failed to write is
|
|
374
373
|
* worse than no hook at all.
|
|
375
374
|
*/
|
|
@@ -405,10 +404,14 @@ export async function applyModelPreset(
|
|
|
405
404
|
if (Object.keys(manifest.profiles).length === 0 && args.ensureManifest) {
|
|
406
405
|
manifest = (await args.ensureManifest(projectPath)) ?? manifest;
|
|
407
406
|
}
|
|
407
|
+
// 2. The gate: anything Claude Code wrote to the live files goes into the
|
|
408
|
+
// profile first, so the regeneration below replaces nothing unrecorded —
|
|
409
|
+
// and a live file that does not parse stops everything, unwritten.
|
|
410
|
+
await prepareProfileWrite(projectPath);
|
|
408
411
|
const profile = await activeProfile(projectPath);
|
|
409
412
|
|
|
410
|
-
//
|
|
411
|
-
// implies. Without the settings half the next
|
|
413
|
+
// 3. Record BOTH halves in the manifest: the routing, and the settings it
|
|
414
|
+
// implies. Without the settings half the next activation renders
|
|
412
415
|
// settings.json from the manifest and silently reverts the main thread's
|
|
413
416
|
// model — the routing would keep working and the orchestrator would not.
|
|
414
417
|
const recordedInManifest = await updateProfileEntry(
|
|
@@ -420,38 +423,23 @@ export async function applyModelPreset(
|
|
|
420
423
|
},
|
|
421
424
|
);
|
|
422
425
|
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
// effect of choosing a model.
|
|
426
|
+
// 4. The live files. With a profile active, settings.json and models.json
|
|
427
|
+
// are rendered from the manifest just written. Without one, models.json is
|
|
428
|
+
// a plain file and settings.json is edited in place.
|
|
427
429
|
if (recordedInManifest && profile) {
|
|
428
|
-
|
|
429
|
-
// materialize the OLD entry — which, having no `models`, would delete the
|
|
430
|
-
// file this call exists to create.
|
|
431
|
-
const written = await readManifest(projectPath);
|
|
432
|
-
await materializeProfile(
|
|
433
|
-
profile,
|
|
434
|
-
await resolveProfile(written, profile, { binResolver: noBins }),
|
|
435
|
-
projectPath,
|
|
436
|
-
await unionPluginIds(projectPath),
|
|
437
|
-
);
|
|
438
|
-
await syncProfileLink(profile, "models", projectPath);
|
|
430
|
+
await regenerateLiveFiles(projectPath);
|
|
439
431
|
} else {
|
|
440
|
-
// No profile: models.json is a plain committed-alongside file.
|
|
441
432
|
await fs.outputFile(
|
|
442
433
|
configPath(projectPath),
|
|
443
434
|
`${JSON.stringify(config, null, 2)}\n`,
|
|
444
435
|
);
|
|
436
|
+
await updateSettingsFile(settingsPath, (settings) => {
|
|
437
|
+
for (const [key, value] of Object.entries(patch.set)) {
|
|
438
|
+
settings[key] = value;
|
|
439
|
+
}
|
|
440
|
+
});
|
|
445
441
|
}
|
|
446
442
|
|
|
447
|
-
// 4. The live settings file (a symlink into the profile when one is active,
|
|
448
|
-
// so this lands in the same place materialization just wrote).
|
|
449
|
-
await updateSettingsFile(settingsPath, (settings) => {
|
|
450
|
-
for (const [key, value] of Object.entries(patch.set)) {
|
|
451
|
-
settings[key] = value;
|
|
452
|
-
}
|
|
453
|
-
});
|
|
454
|
-
|
|
455
443
|
// 5. Without this, nothing routes.
|
|
456
444
|
const hook = await registerAgentModelHook();
|
|
457
445
|
|
|
@@ -480,6 +468,8 @@ export async function clearModels(projectPath: string): Promise<void> {
|
|
|
480
468
|
)
|
|
481
469
|
: [];
|
|
482
470
|
|
|
471
|
+
// The gate first; a live file that does not parse stops everything.
|
|
472
|
+
await prepareProfileWrite(projectPath);
|
|
483
473
|
const profile = await activeProfile(projectPath);
|
|
484
474
|
const removed = await updateProfileEntry(projectPath, profile, (entry) => {
|
|
485
475
|
// biome-ignore lint/performance/noDelete: absence is the state, not undefined
|
|
@@ -493,21 +483,14 @@ export async function clearModels(projectPath: string): Promise<void> {
|
|
|
493
483
|
}
|
|
494
484
|
});
|
|
495
485
|
|
|
486
|
+
// With a profile active, rendering from an entry with no `models` REMOVES
|
|
487
|
+
// .claude/models.json — that is the whole mechanism by which `off` is off.
|
|
496
488
|
if (removed && profile) {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
// .claude/models.json. That is the whole mechanism by which `off` is off.
|
|
500
|
-
const manifest = await readManifest(projectPath);
|
|
501
|
-
await materializeProfile(
|
|
502
|
-
profile,
|
|
503
|
-
await resolveProfile(manifest, profile, { binResolver: noBins }),
|
|
504
|
-
projectPath,
|
|
505
|
-
await unionPluginIds(projectPath),
|
|
506
|
-
);
|
|
507
|
-
await syncProfileLink(profile, "models", projectPath);
|
|
489
|
+
await regenerateLiveFiles(projectPath);
|
|
490
|
+
return;
|
|
508
491
|
}
|
|
509
|
-
await fs.remove(configPath(projectPath));
|
|
510
492
|
|
|
493
|
+
await fs.remove(configPath(projectPath));
|
|
511
494
|
const settingsPath = settingsPathFor(projectPath);
|
|
512
495
|
if (await fs.pathExists(settingsPath)) {
|
|
513
496
|
await updateSettingsFile(settingsPath, (settings) => {
|
|
@@ -543,26 +526,6 @@ function unclaimModelSettings(
|
|
|
543
526
|
}
|
|
544
527
|
}
|
|
545
528
|
|
|
546
|
-
/**
|
|
547
|
-
* Materialization needs no binary requirements — it writes settings, mcp,
|
|
548
|
-
* models and skills, and reads none of `closure.bins`. Skipping the resolver's
|
|
549
|
-
* per-plugin `plugin.json` reads keeps `models use` off the marketplace cache
|
|
550
|
-
* entirely.
|
|
551
|
-
*/
|
|
552
|
-
const noBins = async () => [];
|
|
553
|
-
|
|
554
|
-
/**
|
|
555
|
-
* The manifest-wide union of plugin ids, which `materializeProfile` needs to
|
|
556
|
-
* keep writing the explicit `false` entries that make `profile switch`
|
|
557
|
-
* exclusive. Re-materializing without it would silently make switching additive
|
|
558
|
-
* again — a routing change must not have that side effect.
|
|
559
|
-
*/
|
|
560
|
-
async function unionPluginIds(projectPath: string): Promise<string[]> {
|
|
561
|
-
const manifest = await readManifest(projectPath);
|
|
562
|
-
const union = await resolveAllProfiles(manifest, { binResolver: noBins });
|
|
563
|
-
return Object.keys(union.plugins);
|
|
564
|
-
}
|
|
565
|
-
|
|
566
529
|
// ─── Status ───────────────────────────────────────────────────────────────────
|
|
567
530
|
|
|
568
531
|
/** What `magus models status` and `magus doctor` report. */
|
|
@@ -596,7 +559,7 @@ async function readSettingsFileSafely(
|
|
|
596
559
|
}
|
|
597
560
|
}
|
|
598
561
|
|
|
599
|
-
/** Re-apply the config already on disk. Used by `doctor --fix` to
|
|
562
|
+
/** Re-apply the config already on disk. Used by `doctor --fix` to repair settings the config implies. */
|
|
600
563
|
export async function reapplyModels(
|
|
601
564
|
projectPath: string,
|
|
602
565
|
): Promise<ApplyModelPresetResult | null> {
|