magus-cli 7.4.0 → 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__/blind/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
- 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__/stale-load-repair.test.ts +5 -4
- 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 +152 -44
- package/src/cli/install.ts +92 -127
- 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/claude-settings.ts +283 -125
- package/src/services/cli-tool-commands.ts +30 -0
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +21 -0
- package/src/services/gitignore-service.ts +39 -22
- 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 +14 -8
- package/src/services/skills-manager.ts +14 -8
- package/src/services/styles-manager.ts +45 -28
- package/src/services/tool-probe.ts +48 -3
- package/src/types/index.ts +15 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/components/modals/ModalContainer.tsx +9 -1
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/input/KeyRouter.tsx +147 -23
- package/src/ui/input/key-dispatch.ts +48 -0
- package/src/ui/keymap.ts +8 -1
- package/src/ui/renderers/cliToolRenderers.tsx +65 -27
- package/src/ui/screens/CliToolsScreen.tsx +74 -13
- package/src/ui/screens/PluginsScreen.tsx +6 -2
- 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
package/src/cli/doctor.ts
CHANGED
|
@@ -5,16 +5,20 @@
|
|
|
5
5
|
* 0. Global settings — `~/.claude/settings.json` parses.
|
|
6
6
|
* 1. Binary dependencies — every requires.bin / cliTools binary on PATH.
|
|
7
7
|
* This is the check that would have caught a dead `tmux-mcp`.
|
|
8
|
-
* 2. Profile
|
|
8
|
+
* 2. Profile state — which profile is active (strictly), what the next magus
|
|
9
|
+
* command would record or regenerate (with `--fix`, it does it now and
|
|
10
|
+
* says what it did), generated files git still tracks, and
|
|
11
|
+
* magus-installed skills that are missing. Without `--fix` this section
|
|
12
|
+
* writes nothing: no absorbing, no converting an older layout.
|
|
9
13
|
* 3. Convention compliance — .gitignore + CLAUDE.md blocks (ported engine).
|
|
10
14
|
* 4. Model routing — config validity, hook registration, and drift.
|
|
11
15
|
* 5. Plugin loads — what Claude Code will load here vs what is installed.
|
|
12
16
|
*
|
|
13
17
|
* A file that exists and does not parse — the global settings, the project's
|
|
14
|
-
* `.claude/profiles.json` or its `.claude/settings.json
|
|
15
|
-
*
|
|
16
|
-
* having. `--fix` repairs none of
|
|
17
|
-
* cannot parse.
|
|
18
|
+
* `.claude/profiles.json` or its `.claude/settings.json`, Claude Code's
|
|
19
|
+
* `installed_plugins.json` — is a finding in the section that meets it, never an
|
|
20
|
+
* abort: the rest of the report is still worth having. `--fix` repairs none of
|
|
21
|
+
* them, because magus never rewrites a file it cannot parse.
|
|
18
22
|
*/
|
|
19
23
|
|
|
20
24
|
import path from "node:path";
|
|
@@ -42,9 +46,31 @@ import {
|
|
|
42
46
|
type RepoRootCache,
|
|
43
47
|
pickLoadedEntry,
|
|
44
48
|
} from "../services/plugin-resolution.js";
|
|
49
|
+
import {
|
|
50
|
+
trackedGeneratedFiles,
|
|
51
|
+
untrackCommand,
|
|
52
|
+
} from "../services/profile-activate.js";
|
|
53
|
+
import {
|
|
54
|
+
LIVE_FILE_KEYS,
|
|
55
|
+
liveKind,
|
|
56
|
+
livePath,
|
|
57
|
+
readActiveMarker,
|
|
58
|
+
} from "../services/profile-live.js";
|
|
59
|
+
import { inspectManagedSkills } from "../services/profile-skills.js";
|
|
60
|
+
import {
|
|
61
|
+
type ActiveProfileCheck,
|
|
62
|
+
describeUnknownProfile,
|
|
63
|
+
describeUnmarked,
|
|
64
|
+
inspectProfileState,
|
|
65
|
+
prepareProfileState,
|
|
66
|
+
switchHint,
|
|
67
|
+
} from "../services/profile-state.js";
|
|
45
68
|
import { resolveAllProfiles } from "../services/resolver.js";
|
|
46
|
-
import {
|
|
47
|
-
|
|
69
|
+
import type {
|
|
70
|
+
ClaudeSettings,
|
|
71
|
+
InstalledPluginsRegistry,
|
|
72
|
+
ProfileManifest,
|
|
73
|
+
} from "../types/index.js";
|
|
48
74
|
import { resolveExecutable } from "../utils/command-utils.js";
|
|
49
75
|
|
|
50
76
|
export async function runDoctorCommand(args: string[]): Promise<number> {
|
|
@@ -94,18 +120,11 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
|
|
|
94
120
|
}
|
|
95
121
|
}
|
|
96
122
|
|
|
97
|
-
// 2. Profile
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
console.log(`Profile "${active}" has broken symlinks:`);
|
|
103
|
-
for (const l of dangling) console.log(` ✗ ${l}`);
|
|
104
|
-
console.log(` Fix: magus install ${active}`);
|
|
105
|
-
problems += dangling.length;
|
|
106
|
-
} else {
|
|
107
|
-
console.log(`✓ Active profile "${active}" symlinks resolve.`);
|
|
108
|
-
}
|
|
123
|
+
// 2. Profile state.
|
|
124
|
+
if (manifest) {
|
|
125
|
+
const profile = await reportProfileState(projectPath, fix);
|
|
126
|
+
problems += profile.fixable;
|
|
127
|
+
unfixable += profile.unfixable;
|
|
109
128
|
}
|
|
110
129
|
|
|
111
130
|
// 3. Convention compliance — scoped to THIS project only (the ported engine
|
|
@@ -232,7 +251,17 @@ export function doctorExitCode(args: {
|
|
|
232
251
|
* loads — the loader's, `pickLoadedEntry`
|
|
233
252
|
*/
|
|
234
253
|
async function checkPluginLoads(projectPath: string): Promise<number> {
|
|
235
|
-
|
|
254
|
+
let registry: InstalledPluginsRegistry;
|
|
255
|
+
try {
|
|
256
|
+
registry = await readInstalledPluginsRegistry();
|
|
257
|
+
} catch (error) {
|
|
258
|
+
// A registry nothing can read is a finding, not an abort: doctor is the
|
|
259
|
+
// command someone runs BECAUSE something is wrong, and it has to reach its
|
|
260
|
+
// remaining sections and its exit code.
|
|
261
|
+
if (!(error instanceof SettingsParseError)) throw error;
|
|
262
|
+
reportUnparseable("Plugin loads", error);
|
|
263
|
+
return 1;
|
|
264
|
+
}
|
|
236
265
|
let settings: ClaudeSettings;
|
|
237
266
|
try {
|
|
238
267
|
settings = await readSettings(projectPath);
|
|
@@ -404,32 +433,111 @@ async function checkModels(
|
|
|
404
433
|
return { fixable: problems, unfixable };
|
|
405
434
|
}
|
|
406
435
|
|
|
407
|
-
/**
|
|
408
|
-
|
|
409
|
-
|
|
436
|
+
/**
|
|
437
|
+
* Section 2: which profile is active, and whether its live config is whole.
|
|
438
|
+
*
|
|
439
|
+
* Runs the same gate as every other command first — restoring a lost marker,
|
|
440
|
+
* converting an older layout, absorbing what Claude Code wrote into the live
|
|
441
|
+
* config and regenerating from a changed profiles.json — and reports what it
|
|
442
|
+
* did. What is left is reported plainly, each with its fix.
|
|
443
|
+
*/
|
|
444
|
+
export async function reportProfileState(
|
|
410
445
|
projectPath: string,
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
{
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
if (stat.isSymbolicLink() && !(await fs.pathExists(target))) {
|
|
428
|
-
broken.push(path.relative(projectPath, link));
|
|
446
|
+
fix: boolean,
|
|
447
|
+
): Promise<{ fixable: number; unfixable: number }> {
|
|
448
|
+
const rel = (p: string) => path.relative(projectPath, p);
|
|
449
|
+
let fixable = 0;
|
|
450
|
+
let unfixable = 0;
|
|
451
|
+
let check: ActiveProfileCheck;
|
|
452
|
+
try {
|
|
453
|
+
if (fix) {
|
|
454
|
+
// `--fix`: the same gate every other command runs, reported.
|
|
455
|
+
const prep = await prepareProfileState(projectPath);
|
|
456
|
+
check = prep.check;
|
|
457
|
+
if (check.state === "no-profiles") return { fixable, unfixable };
|
|
458
|
+
for (const line of prep.lines) console.log(`• ${line}`);
|
|
459
|
+
for (const problem of prep.problems) {
|
|
460
|
+
console.log(`✗ ${problem}`);
|
|
461
|
+
unfixable++;
|
|
429
462
|
}
|
|
430
|
-
}
|
|
431
|
-
//
|
|
463
|
+
} else {
|
|
464
|
+
// Report only. Nothing here writes — not an absorb, not a marker, not
|
|
465
|
+
// a conversion: a diagnostic that changes the project is not one.
|
|
466
|
+
const report = await inspectProfileState(projectPath);
|
|
467
|
+
check = report.check;
|
|
468
|
+
if (check.state === "no-profiles") return { fixable, unfixable };
|
|
469
|
+
for (const error of report.unreadable) {
|
|
470
|
+
console.log(`✗ ${error.message}`);
|
|
471
|
+
console.log(
|
|
472
|
+
" Fix the JSON by hand; magus leaves the file as it is.",
|
|
473
|
+
);
|
|
474
|
+
unfixable++;
|
|
475
|
+
}
|
|
476
|
+
// What the next magus command will do on its own — record an edit,
|
|
477
|
+
// regenerate, convert, restore a lost marker — is information, not
|
|
478
|
+
// a fault: none of it makes doctor exit non-zero.
|
|
479
|
+
for (const line of report.pending) console.log(`• ${line}`);
|
|
480
|
+
}
|
|
481
|
+
} catch (error) {
|
|
482
|
+
console.log("\nProfile:");
|
|
483
|
+
console.log(
|
|
484
|
+
` ✗ ${error instanceof Error ? error.message : String(error)}`,
|
|
485
|
+
);
|
|
486
|
+
return { fixable: 0, unfixable: 1 };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (check.state === "unknown") {
|
|
490
|
+
console.log(`✗ ${describeUnknownProfile(check)}`);
|
|
491
|
+
unfixable++;
|
|
492
|
+
} else if (check.state === "unmarked" && check.match !== null) {
|
|
493
|
+
// The live config matches exactly one profile: the pending line above
|
|
494
|
+
// already says the next command restores the marker. Not a fault.
|
|
495
|
+
} else if (check.state === "unmarked") {
|
|
496
|
+
console.log(
|
|
497
|
+
check.liveFiles
|
|
498
|
+
? `✗ ${describeUnmarked(check)}`
|
|
499
|
+
: `✗ No profile is active yet. Run \`magus install\` or ${switchHint(check.available, check.suggested)}.`,
|
|
500
|
+
);
|
|
501
|
+
unfixable++;
|
|
502
|
+
} else if (check.state === "active" || check.state === "old-layout") {
|
|
503
|
+
const broken: string[] = [];
|
|
504
|
+
for (const key of LIVE_FILE_KEYS) {
|
|
505
|
+
const kind = await liveKind(key, projectPath);
|
|
506
|
+
// An older layout's links are reported as pending conversion above.
|
|
507
|
+
if (
|
|
508
|
+
kind === "other" ||
|
|
509
|
+
(kind === "symlink" && check.state === "active")
|
|
510
|
+
) {
|
|
511
|
+
broken.push(`${rel(livePath(key, projectPath))} is not a regular file`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
const marker = await readActiveMarker(projectPath);
|
|
515
|
+
if (marker) {
|
|
516
|
+
const { missing } = await inspectManagedSkills(
|
|
517
|
+
marker.skills,
|
|
518
|
+
projectPath,
|
|
519
|
+
);
|
|
520
|
+
for (const folder of missing) {
|
|
521
|
+
broken.push(`skill .claude/skills/${folder} is missing`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (broken.length > 0) {
|
|
525
|
+
console.log(`Profile "${check.profile}" is not fully in place:`);
|
|
526
|
+
for (const line of broken) console.log(` ✗ ${line}`);
|
|
527
|
+
console.log(` Fix: magus profile switch ${check.profile}`);
|
|
528
|
+
fixable += broken.length;
|
|
529
|
+
} else if (check.state === "active") {
|
|
530
|
+
console.log(`✓ Active profile "${check.profile}" is in place.`);
|
|
432
531
|
}
|
|
433
532
|
}
|
|
434
|
-
|
|
533
|
+
|
|
534
|
+
// A .gitignore entry does not untrack a file committed before it. Report
|
|
535
|
+
// only: magus never runs git rm.
|
|
536
|
+
for (const tracked of await trackedGeneratedFiles(projectPath)) {
|
|
537
|
+
console.log(
|
|
538
|
+
`✗ ${tracked} is generated from profiles.json but tracked by git. Fix: \`${untrackCommand(tracked)}\`, then commit.`,
|
|
539
|
+
);
|
|
540
|
+
unfixable++;
|
|
541
|
+
}
|
|
542
|
+
return { fixable, unfixable };
|
|
435
543
|
}
|
package/src/cli/install.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `magus install [profile] [--check] [--yes]`
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Put a committed team profile from .claude/profiles.json onto this machine:
|
|
5
|
+
* register marketplaces, activate the profile (render its settings.json,
|
|
6
|
+
* .mcp.json and models.json, install its skills), install its pinned plugins
|
|
7
|
+
* and the binaries plugins declare (requires.bin) plus the profile's cliTools,
|
|
8
|
+
* then collect required env.
|
|
8
9
|
*
|
|
9
|
-
* With no argument it installs the union of every profile's deps
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* With no argument it installs the union of every profile's deps (so `profile
|
|
11
|
+
* switch` needs no reinstall) and keeps the active profile active, asking only
|
|
12
|
+
* when none is. `--check` is a read-only version gate for CI. `--yes` skips
|
|
12
13
|
* the confirmation prompt.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
|
-
import path from "node:path";
|
|
16
16
|
import { installPlugin, isClaudeAvailable } from "../services/claude-cli.js";
|
|
17
17
|
import {
|
|
18
18
|
getLocalSettingsPath,
|
|
@@ -20,45 +20,36 @@ import {
|
|
|
20
20
|
readLocalSettings,
|
|
21
21
|
writeLocalSettings,
|
|
22
22
|
} from "../services/claude-settings.js";
|
|
23
|
-
import { ensureGitignoreEntries } from "../services/conventions-manager.js";
|
|
24
23
|
import {
|
|
25
24
|
computeEnvStatus,
|
|
26
25
|
computeVersionDrift,
|
|
27
26
|
summarizeClosure,
|
|
28
27
|
} from "../services/install-plan.js";
|
|
29
|
-
import { validateManifest } from "../services/manifest.js";
|
|
28
|
+
import { readManifest, validateManifest } from "../services/manifest.js";
|
|
30
29
|
import { registerClosureMarketplaces } from "../services/marketplace-sync.js";
|
|
31
30
|
import { reportDisplaced } from "../services/profile-activate.js";
|
|
32
|
-
import {
|
|
33
|
-
import { detectProfileDrift } from "../services/profile-sync.js";
|
|
34
|
-
import { resolveAllProfiles, resolveProfile } from "../services/resolver.js";
|
|
35
|
-
import { writableMap } from "../services/settings-file.js";
|
|
36
|
-
import { installSkill } from "../services/skills-manager.js";
|
|
31
|
+
import { activeProfile } from "../services/profile-live.js";
|
|
37
32
|
import {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
} from "../services/
|
|
33
|
+
switchToProfile,
|
|
34
|
+
withCliBookkeeping,
|
|
35
|
+
} from "../services/profile-state.js";
|
|
36
|
+
import { resolveAllProfiles, resolveProfile } from "../services/resolver.js";
|
|
37
|
+
import { SettingsParseError, writableMap } from "../services/settings-file.js";
|
|
42
38
|
import {
|
|
43
39
|
TOOLCHAIN_BOOTSTRAP,
|
|
44
40
|
binInstallCommand,
|
|
45
41
|
detectToolchains,
|
|
46
42
|
} from "../services/toolchain.js";
|
|
47
|
-
import type {
|
|
48
|
-
ProfileSkillRef,
|
|
49
|
-
ResolvedBin,
|
|
50
|
-
ResolvedClosure,
|
|
51
|
-
SkillInfo,
|
|
52
|
-
} from "../types/index.js";
|
|
43
|
+
import type { ResolvedBin, ResolvedClosure } from "../types/index.js";
|
|
53
44
|
import { formatCommand, resolveExecutable } from "../utils/command-utils.js";
|
|
54
45
|
import { runCommand, runShellScript } from "../utils/run.js";
|
|
55
|
-
import { ensureManifest } from "./bootstrap.js";
|
|
46
|
+
import { ensureManifest, printActivationNotes } from "./bootstrap.js";
|
|
56
47
|
import { confirm, promptValue } from "./prompt.js";
|
|
48
|
+
import { note as noteLine } from "./update-view.js";
|
|
57
49
|
|
|
58
50
|
interface InstallFlags {
|
|
59
51
|
check: boolean;
|
|
60
52
|
yes: boolean;
|
|
61
|
-
force: boolean;
|
|
62
53
|
profile?: string;
|
|
63
54
|
}
|
|
64
55
|
|
|
@@ -66,12 +57,11 @@ function parseArgs(args: string[]): InstallFlags {
|
|
|
66
57
|
return {
|
|
67
58
|
check: args.includes("--check"),
|
|
68
59
|
yes: args.includes("--yes") || args.includes("-y"),
|
|
69
|
-
force: args.includes("--force"),
|
|
70
60
|
profile: args.find((a) => !a.startsWith("-")),
|
|
71
61
|
};
|
|
72
62
|
}
|
|
73
63
|
|
|
74
|
-
// ── --check: read-only
|
|
64
|
+
// ── --check: read-only version gate ─────────────────────────────────────────
|
|
75
65
|
|
|
76
66
|
async function runCheck(
|
|
77
67
|
closure: ResolvedClosure,
|
|
@@ -149,9 +139,10 @@ async function ensureToolchains(
|
|
|
149
139
|
* Project scope keeps the blast radius to this repo. The plugin *cache* is
|
|
150
140
|
* global either way, so `profile switch` stays offline.
|
|
151
141
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
142
|
+
* The CLI records each install in the live `.claude/settings.json`. That is
|
|
143
|
+
* its bookkeeping, not a change to the profile — with no argument this installs
|
|
144
|
+
* EVERY profile's plugins — so the caller runs this inside
|
|
145
|
+
* `withCliBookkeeping`, which renders the live files over it afterwards.
|
|
155
146
|
*/
|
|
156
147
|
async function installPlugins(closure: ResolvedClosure): Promise<void> {
|
|
157
148
|
for (const pluginId of Object.keys(closure.plugins)) {
|
|
@@ -179,39 +170,6 @@ async function installBins(bins: ResolvedBin[]): Promise<void> {
|
|
|
179
170
|
}
|
|
180
171
|
}
|
|
181
172
|
|
|
182
|
-
/** Map a manifest skill ref to the SkillInfo shape installSkill expects. */
|
|
183
|
-
function toSkillInfo(ref: ProfileSkillRef): SkillInfo {
|
|
184
|
-
return {
|
|
185
|
-
id: `${ref.repo}/${ref.path}`,
|
|
186
|
-
name: ref.name,
|
|
187
|
-
source: {
|
|
188
|
-
label: ref.repo,
|
|
189
|
-
repo: ref.repo,
|
|
190
|
-
skillsPath: path.dirname(ref.path),
|
|
191
|
-
},
|
|
192
|
-
repoPath: ref.path,
|
|
193
|
-
gitBlobSha: "",
|
|
194
|
-
frontmatter: null,
|
|
195
|
-
installed: false,
|
|
196
|
-
installedScope: null,
|
|
197
|
-
hasUpdate: false,
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async function installSkills(
|
|
202
|
-
skills: ProfileSkillRef[],
|
|
203
|
-
projectPath: string,
|
|
204
|
-
): Promise<void> {
|
|
205
|
-
for (const ref of skills) {
|
|
206
|
-
try {
|
|
207
|
-
console.log(`+ skill ${ref.name}`);
|
|
208
|
-
await installSkill(toSkillInfo(ref), "project", projectPath);
|
|
209
|
-
} catch (e) {
|
|
210
|
-
console.warn(`⚠ skill ${ref.name}: ${(e as Error).message}`);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
173
|
async function collectEnv(
|
|
216
174
|
closure: ResolvedClosure,
|
|
217
175
|
projectPath: string,
|
|
@@ -230,17 +188,42 @@ async function collectEnv(
|
|
|
230
188
|
return;
|
|
231
189
|
}
|
|
232
190
|
|
|
233
|
-
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
191
|
+
// EVERY answer first, then one read-modify-write.
|
|
192
|
+
//
|
|
193
|
+
// The read has to happen AFTER the last prompt. Claude Code writes
|
|
194
|
+
// settings.local.json while these prompts are on screen — it is where a
|
|
195
|
+
// "don't ask again" permission rule lands — and this used to read the file
|
|
196
|
+
// before the first question and write that copy back at the end, so every
|
|
197
|
+
// rule the user granted in between was erased. The prompts are the slow part,
|
|
198
|
+
// and a read from before them describes a file that no longer exists.
|
|
199
|
+
const answers: Array<[string, string]> = [];
|
|
239
200
|
for (const name of status.missingRequired) {
|
|
240
201
|
const value = await promptValue(` ${name} = `);
|
|
241
|
-
if (value)
|
|
202
|
+
if (value) answers.push([name, value]);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const localPath = getLocalSettingsPath(projectPath);
|
|
206
|
+
try {
|
|
207
|
+
// Refuses a file that stopped parsing while the prompts were up, and a
|
|
208
|
+
// malformed `env` map, before anything is written. Either way the file is
|
|
209
|
+
// left exactly as it is.
|
|
210
|
+
const local = await readLocalSettings(projectPath);
|
|
211
|
+
const env = writableMap(local, "env", localPath) ?? {};
|
|
212
|
+
local.env = env;
|
|
213
|
+
for (const [name, value] of answers) env[name] = value;
|
|
214
|
+
await writeLocalSettings(local, projectPath);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
// Which variables went unsaved, by NAME — never the values. They are
|
|
217
|
+
// tokens, and a refusal is not a reason to print one to a terminal.
|
|
218
|
+
if (error instanceof SettingsParseError && answers.length > 0) {
|
|
219
|
+
console.error(
|
|
220
|
+
`Not saved: ${answers
|
|
221
|
+
.map(([name]) => name)
|
|
222
|
+
.join(", ")} — set them again once the file is fixed.`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
throw error;
|
|
242
226
|
}
|
|
243
|
-
await writeLocalSettings(local, projectPath);
|
|
244
227
|
console.log("Saved to .claude/settings.local.json (personal, gitignored).");
|
|
245
228
|
}
|
|
246
229
|
|
|
@@ -287,26 +270,6 @@ export async function runInstallCommand(
|
|
|
287
270
|
return runCheck(closure, manifest.strictVersions === true);
|
|
288
271
|
}
|
|
289
272
|
|
|
290
|
-
// Drift guard: refuse to clobber unsynced local edits to the active profile
|
|
291
|
-
// (materialize would overwrite them) unless --force is given.
|
|
292
|
-
if (!flags.force) {
|
|
293
|
-
const active = await activeProfile(projectPath);
|
|
294
|
-
if (active && (flags.profile === undefined || flags.profile === active)) {
|
|
295
|
-
const drift = await detectProfileDrift(active, projectPath);
|
|
296
|
-
if (drift.length > 0) {
|
|
297
|
-
console.error(
|
|
298
|
-
`Active profile "${active}" has unsynced local edits that install would overwrite:`,
|
|
299
|
-
);
|
|
300
|
-
for (const c of drift) console.error(` ${c.kind}: ${c.detail}`);
|
|
301
|
-
console.error(
|
|
302
|
-
"\nRun `magus profile sync` to promote them into .claude/profiles.json,\n" +
|
|
303
|
-
"or re-run with --force to discard them.",
|
|
304
|
-
);
|
|
305
|
-
return 1;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
273
|
// Plan
|
|
311
274
|
console.log(`\nPlan for ${flags.profile ?? "all profiles"}:`);
|
|
312
275
|
for (const line of summarizeClosure(closure)) console.log(` ${line}`);
|
|
@@ -332,45 +295,47 @@ export async function runInstallCommand(
|
|
|
332
295
|
|
|
333
296
|
await ensureToolchains(closure, flags.yes);
|
|
334
297
|
await registerClosureMarketplaces(closure.marketplaces);
|
|
335
|
-
await installPlugins(closure);
|
|
336
|
-
await installBins(closure.bins);
|
|
337
298
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
|
|
299
|
+
const current = await activeProfile(projectPath);
|
|
300
|
+
const activeId =
|
|
301
|
+
flags.profile ??
|
|
302
|
+
current ??
|
|
303
|
+
(await pickActiveProfile(profileIds, flags.yes));
|
|
304
|
+
if (activeId) {
|
|
305
|
+
// The one switch path: what Claude Code wrote into the live config — or
|
|
306
|
+
// through an older layout's links — goes into the profile it was written
|
|
307
|
+
// under (or, if profiles.json no longer defines that one, into this one)
|
|
308
|
+
// before anything is rendered over it. The displacement reporter is not
|
|
309
|
+
// optional: a first `install` over a hand-written settings.json moves it
|
|
310
|
+
// aside, and the move must never be silent.
|
|
311
|
+
const switched = await switchToProfile(projectPath, activeId, {
|
|
312
|
+
onDisplace: reportDisplaced(projectPath),
|
|
313
|
+
});
|
|
314
|
+
for (const line of switched.lines) console.log(noteLine(line));
|
|
315
|
+
if (!switched.activation) {
|
|
316
|
+
for (const problem of switched.problems) console.error(problem);
|
|
317
|
+
return 1;
|
|
318
|
+
}
|
|
319
|
+
console.log(`\n✓ Active profile: ${activeId}`);
|
|
320
|
+
printActivationNotes(switched.activation);
|
|
352
321
|
}
|
|
353
322
|
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
323
|
+
// The CLI's installs write enabledPlugins for every plugin installed — all
|
|
324
|
+
// profiles' when no profile was named. `withCliBookkeeping` flags the marker
|
|
325
|
+
// while they run and reconciles in `finally`: what the user changed in the
|
|
326
|
+
// meantime is kept, only the install's own records are discarded, and a
|
|
327
|
+
// Ctrl-C leaves the flag for the next command to do the same.
|
|
328
|
+
await withCliBookkeeping(projectPath, () => installPlugins(closure), {
|
|
329
|
+
report: (line) => console.log(noteLine(line)),
|
|
330
|
+
});
|
|
331
|
+
await installBins(closure.bins);
|
|
361
332
|
|
|
362
|
-
const activeId =
|
|
363
|
-
flags.profile ?? (await pickActiveProfile(profileIds, flags.yes));
|
|
364
333
|
if (activeId) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
// Skills route through the now-active .claude/skills symlink.
|
|
371
|
-
const activeClosure = await resolveProfile(manifest, activeId);
|
|
372
|
-
await installSkills(activeClosure.skills, projectPath);
|
|
373
|
-
await collectEnv(activeClosure, projectPath, flags.yes);
|
|
334
|
+
await collectEnv(
|
|
335
|
+
await resolveProfile(await readManifest(projectPath), activeId),
|
|
336
|
+
projectPath,
|
|
337
|
+
flags.yes,
|
|
338
|
+
);
|
|
374
339
|
}
|
|
375
340
|
|
|
376
341
|
console.log("\nDone.");
|
package/src/cli/models.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
presetLabel,
|
|
19
19
|
presetNames,
|
|
20
20
|
} from "../data/models-presets.js";
|
|
21
|
+
import { readManifest } from "../services/manifest.js";
|
|
21
22
|
import {
|
|
22
23
|
loadMateCatalog,
|
|
23
24
|
stalenessNote,
|
|
@@ -40,7 +41,23 @@ import {
|
|
|
40
41
|
readModelsConfig,
|
|
41
42
|
readModelsStatus,
|
|
42
43
|
} from "../services/models-manager.js";
|
|
43
|
-
import {
|
|
44
|
+
import { activeProfile } from "../services/profile-live.js";
|
|
45
|
+
import { ensureManifest, requireActiveProfile } from "./bootstrap.js";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A project with profiles must be ON one before routing is written: the
|
|
49
|
+
* routing is a property of the active profile. With none active this asks at a
|
|
50
|
+
* terminal and stops with the instruction anywhere else, rather than writing a
|
|
51
|
+
* plain models.json the next activation would replace.
|
|
52
|
+
*/
|
|
53
|
+
async function requireProfileWhenManaged(
|
|
54
|
+
projectPath: string,
|
|
55
|
+
): Promise<boolean> {
|
|
56
|
+
const manifest = await readManifest(projectPath);
|
|
57
|
+
if (Object.keys(manifest.profiles).length === 0) return true;
|
|
58
|
+
if ((await activeProfile(projectPath)) !== null) return true;
|
|
59
|
+
return (await requireActiveProfile(projectPath, manifest)) !== null;
|
|
60
|
+
}
|
|
44
61
|
|
|
45
62
|
const HEADLINE: Record<ModelsState, string> = {
|
|
46
63
|
off: "off — no .claude/models.json, subagents inherit the session model",
|
|
@@ -206,6 +223,8 @@ async function runUse(projectPath: string, name?: string): Promise<number> {
|
|
|
206
223
|
return 1;
|
|
207
224
|
}
|
|
208
225
|
|
|
226
|
+
if (!(await requireProfileWhenManaged(projectPath))) return 1;
|
|
227
|
+
|
|
209
228
|
let result: Awaited<ReturnType<typeof applyModelPreset>>;
|
|
210
229
|
try {
|
|
211
230
|
result = await applyModelPreset({
|
|
@@ -247,6 +266,7 @@ async function runUse(projectPath: string, name?: string): Promise<number> {
|
|
|
247
266
|
}
|
|
248
267
|
|
|
249
268
|
async function runOff(projectPath: string): Promise<number> {
|
|
269
|
+
if (!(await requireProfileWhenManaged(projectPath))) return 1;
|
|
250
270
|
await clearModels(projectPath);
|
|
251
271
|
console.log(
|
|
252
272
|
"\n✓ Model tiers off. Subagents inherit the session model again.",
|