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/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,41 @@ 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";
|
|
30
|
+
import {
|
|
31
|
+
clearMarketplaceCache,
|
|
32
|
+
readInstalledVersionForScope,
|
|
33
|
+
} from "../services/plugin-manager.js";
|
|
31
34
|
import { reportDisplaced } from "../services/profile-activate.js";
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
35
|
+
import { activeProfile } from "../services/profile-live.js";
|
|
36
|
+
import {
|
|
37
|
+
switchToProfile,
|
|
38
|
+
withCliBookkeeping,
|
|
39
|
+
} from "../services/profile-state.js";
|
|
34
40
|
import { resolveAllProfiles, resolveProfile } from "../services/resolver.js";
|
|
35
41
|
import { SettingsParseError, writableMap } from "../services/settings-file.js";
|
|
36
|
-
import { installSkill } from "../services/skills-manager.js";
|
|
37
|
-
import {
|
|
38
|
-
PROFILE_GITIGNORE_ENTRIES,
|
|
39
|
-
activateProfile,
|
|
40
|
-
activeProfile,
|
|
41
|
-
} from "../services/symlink-manager.js";
|
|
42
42
|
import {
|
|
43
43
|
TOOLCHAIN_BOOTSTRAP,
|
|
44
44
|
binInstallCommand,
|
|
45
45
|
detectToolchains,
|
|
46
46
|
} from "../services/toolchain.js";
|
|
47
|
-
import
|
|
48
|
-
|
|
49
|
-
ResolvedBin,
|
|
50
|
-
ResolvedClosure,
|
|
51
|
-
SkillInfo,
|
|
52
|
-
} from "../types/index.js";
|
|
47
|
+
import { bringLoadForward } from "../services/update-engine.js";
|
|
48
|
+
import type { ResolvedBin, ResolvedClosure } from "../types/index.js";
|
|
53
49
|
import { formatCommand, resolveExecutable } from "../utils/command-utils.js";
|
|
54
50
|
import { runCommand, runShellScript } from "../utils/run.js";
|
|
55
|
-
import { ensureManifest } from "./bootstrap.js";
|
|
51
|
+
import { ensureManifest, printActivationNotes } from "./bootstrap.js";
|
|
56
52
|
import { confirm, promptValue } from "./prompt.js";
|
|
53
|
+
import { note as noteLine } from "./update-view.js";
|
|
57
54
|
|
|
58
55
|
interface InstallFlags {
|
|
59
56
|
check: boolean;
|
|
60
57
|
yes: boolean;
|
|
61
|
-
force: boolean;
|
|
62
58
|
profile?: string;
|
|
63
59
|
}
|
|
64
60
|
|
|
@@ -66,12 +62,11 @@ function parseArgs(args: string[]): InstallFlags {
|
|
|
66
62
|
return {
|
|
67
63
|
check: args.includes("--check"),
|
|
68
64
|
yes: args.includes("--yes") || args.includes("-y"),
|
|
69
|
-
force: args.includes("--force"),
|
|
70
65
|
profile: args.find((a) => !a.startsWith("-")),
|
|
71
66
|
};
|
|
72
67
|
}
|
|
73
68
|
|
|
74
|
-
// ── --check: read-only
|
|
69
|
+
// ── --check: read-only version gate ─────────────────────────────────────────
|
|
75
70
|
|
|
76
71
|
async function runCheck(
|
|
77
72
|
closure: ResolvedClosure,
|
|
@@ -149,9 +144,10 @@ async function ensureToolchains(
|
|
|
149
144
|
* Project scope keeps the blast radius to this repo. The plugin *cache* is
|
|
150
145
|
* global either way, so `profile switch` stays offline.
|
|
151
146
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
147
|
+
* The CLI records each install in the live `.claude/settings.json`. That is
|
|
148
|
+
* its bookkeeping, not a change to the profile — with no argument this installs
|
|
149
|
+
* EVERY profile's plugins — so the caller runs this inside
|
|
150
|
+
* `withCliBookkeeping`, which renders the live files over it afterwards.
|
|
155
151
|
*/
|
|
156
152
|
async function installPlugins(closure: ResolvedClosure): Promise<void> {
|
|
157
153
|
for (const pluginId of Object.keys(closure.plugins)) {
|
|
@@ -164,6 +160,41 @@ async function installPlugins(closure: ResolvedClosure): Promise<void> {
|
|
|
164
160
|
}
|
|
165
161
|
}
|
|
166
162
|
|
|
163
|
+
/**
|
|
164
|
+
* After the installs: every plugin must LOAD the version installed here, not
|
|
165
|
+
* an older one from a registry row this repository's other checkouts left
|
|
166
|
+
* first in line. Where it does not, the same stale-load repair `magus update`
|
|
167
|
+
* chains is run, and reported either way.
|
|
168
|
+
*/
|
|
169
|
+
async function bringLoadsForward(
|
|
170
|
+
pluginIds: readonly string[],
|
|
171
|
+
projectPath: string,
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
for (const pluginId of pluginIds) {
|
|
174
|
+
const here = await readInstalledVersionForScope(
|
|
175
|
+
pluginId,
|
|
176
|
+
"project",
|
|
177
|
+
projectPath,
|
|
178
|
+
);
|
|
179
|
+
if (!here) continue;
|
|
180
|
+
const forward = await bringLoadForward(pluginId, here, projectPath);
|
|
181
|
+
if (!forward) continue;
|
|
182
|
+
if (forward.ok) {
|
|
183
|
+
console.log(
|
|
184
|
+
`✓ ${pluginId}: Claude Code now loads v${here} here (${forward.rewritten} older record${
|
|
185
|
+
forward.rewritten === 1 ? "" : "s"
|
|
186
|
+
} moved forward)`,
|
|
187
|
+
);
|
|
188
|
+
} else {
|
|
189
|
+
console.warn(
|
|
190
|
+
`⚠ ${pluginId}: installed v${here}, but Claude Code still loads v${
|
|
191
|
+
forward.loaded ?? "an older version"
|
|
192
|
+
} here — ${forward.error ?? "the older records could not be moved forward"}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
167
198
|
async function installBins(bins: ResolvedBin[]): Promise<void> {
|
|
168
199
|
for (const bin of bins) {
|
|
169
200
|
if (await resolveExecutable(bin.name)) {
|
|
@@ -179,39 +210,6 @@ async function installBins(bins: ResolvedBin[]): Promise<void> {
|
|
|
179
210
|
}
|
|
180
211
|
}
|
|
181
212
|
|
|
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
213
|
async function collectEnv(
|
|
216
214
|
closure: ResolvedClosure,
|
|
217
215
|
projectPath: string,
|
|
@@ -303,35 +301,18 @@ export async function runInstallCommand(
|
|
|
303
301
|
return 1;
|
|
304
302
|
}
|
|
305
303
|
|
|
306
|
-
// Closure: a single profile, or the union of all
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
304
|
+
// Closure: a single profile, or the union of all — with every plugin the
|
|
305
|
+
// enabled ones depend on.
|
|
306
|
+
const resolveClosure = () =>
|
|
307
|
+
flags.profile
|
|
308
|
+
? resolveProfile(manifest, flags.profile)
|
|
309
|
+
: resolveAllProfiles(manifest);
|
|
310
|
+
let closure = await resolveClosure();
|
|
310
311
|
|
|
311
312
|
if (flags.check) {
|
|
312
313
|
return runCheck(closure, manifest.strictVersions === true);
|
|
313
314
|
}
|
|
314
315
|
|
|
315
|
-
// Drift guard: refuse to clobber unsynced local edits to the active profile
|
|
316
|
-
// (materialize would overwrite them) unless --force is given.
|
|
317
|
-
if (!flags.force) {
|
|
318
|
-
const active = await activeProfile(projectPath);
|
|
319
|
-
if (active && (flags.profile === undefined || flags.profile === active)) {
|
|
320
|
-
const drift = await detectProfileDrift(active, projectPath);
|
|
321
|
-
if (drift.length > 0) {
|
|
322
|
-
console.error(
|
|
323
|
-
`Active profile "${active}" has unsynced local edits that install would overwrite:`,
|
|
324
|
-
);
|
|
325
|
-
for (const c of drift) console.error(` ${c.kind}: ${c.detail}`);
|
|
326
|
-
console.error(
|
|
327
|
-
"\nRun `magus profile sync` to promote them into .claude/profiles.json,\n" +
|
|
328
|
-
"or re-run with --force to discard them.",
|
|
329
|
-
);
|
|
330
|
-
return 1;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
316
|
// Plan
|
|
336
317
|
console.log(`\nPlan for ${flags.profile ?? "all profiles"}:`);
|
|
337
318
|
for (const line of summarizeClosure(closure)) console.log(` ${line}`);
|
|
@@ -357,45 +338,68 @@ export async function runInstallCommand(
|
|
|
357
338
|
|
|
358
339
|
await ensureToolchains(closure, flags.yes);
|
|
359
340
|
await registerClosureMarketplaces(closure.marketplaces);
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
341
|
+
// A marketplace added just now carries the plugin.json files that name
|
|
342
|
+
// each plugin's dependencies; resolve again so those are installed too.
|
|
343
|
+
clearMarketplaceCache();
|
|
344
|
+
const before = new Set(Object.keys(closure.plugins));
|
|
345
|
+
closure = await resolveClosure();
|
|
346
|
+
const found = Object.keys(closure.plugins).filter((id) => !before.has(id));
|
|
347
|
+
if (found.length > 0) {
|
|
348
|
+
console.log(
|
|
349
|
+
`Also installing what those plugins depend on: ${found.join(", ")}`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
const unreadable = closure.dependencies?.unreadable ?? [];
|
|
353
|
+
if (unreadable.length > 0) {
|
|
354
|
+
console.warn(
|
|
355
|
+
`⚠ Could not read the plugin.json of ${unreadable.join(", ")} — whatever ${
|
|
356
|
+
unreadable.length === 1 ? "it depends" : "they depend"
|
|
357
|
+
} on is not installed by this run`,
|
|
358
|
+
);
|
|
377
359
|
}
|
|
378
360
|
|
|
379
|
-
|
|
380
|
-
// activating (source of truth is .claude/profiles.json).
|
|
381
|
-
await ensureGitignoreEntries(
|
|
382
|
-
path.join(projectPath, ".gitignore"),
|
|
383
|
-
PROFILE_GITIGNORE_ENTRIES,
|
|
384
|
-
"# magus: generated profile build output + active-profile symlinks",
|
|
385
|
-
);
|
|
386
|
-
|
|
361
|
+
const current = await activeProfile(projectPath);
|
|
387
362
|
const activeId =
|
|
388
|
-
flags.profile ??
|
|
363
|
+
flags.profile ??
|
|
364
|
+
current ??
|
|
365
|
+
(await pickActiveProfile(profileIds, flags.yes));
|
|
389
366
|
if (activeId) {
|
|
390
|
-
// The
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
|
|
367
|
+
// The one switch path: what Claude Code wrote into the live config — or
|
|
368
|
+
// through an older layout's links — goes into the profile it was written
|
|
369
|
+
// under (or, if profiles.json no longer defines that one, into this one)
|
|
370
|
+
// before anything is rendered over it. The displacement reporter is not
|
|
371
|
+
// optional: a first `install` over a hand-written settings.json moves it
|
|
372
|
+
// aside, and the move must never be silent.
|
|
373
|
+
const switched = await switchToProfile(projectPath, activeId, {
|
|
374
|
+
onDisplace: reportDisplaced(projectPath),
|
|
375
|
+
});
|
|
376
|
+
for (const line of switched.lines) console.log(noteLine(line));
|
|
377
|
+
if (!switched.activation) {
|
|
378
|
+
for (const problem of switched.problems) console.error(problem);
|
|
379
|
+
return 1;
|
|
380
|
+
}
|
|
394
381
|
console.log(`\n✓ Active profile: ${activeId}`);
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
382
|
+
printActivationNotes(switched.activation);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// The CLI's installs write enabledPlugins for every plugin installed — all
|
|
386
|
+
// profiles' when no profile was named. `withCliBookkeeping` flags the marker
|
|
387
|
+
// while they run and reconciles in `finally`: what the user changed in the
|
|
388
|
+
// meantime is kept, only the install's own records are discarded, and a
|
|
389
|
+
// Ctrl-C leaves the flag for the next command to do the same.
|
|
390
|
+
await withCliBookkeeping(projectPath, () => installPlugins(closure), {
|
|
391
|
+
installable: Object.keys(closure.plugins),
|
|
392
|
+
report: (line) => console.log(noteLine(line)),
|
|
393
|
+
});
|
|
394
|
+
await bringLoadsForward(Object.keys(closure.plugins), projectPath);
|
|
395
|
+
await installBins(closure.bins);
|
|
396
|
+
|
|
397
|
+
if (activeId) {
|
|
398
|
+
await collectEnv(
|
|
399
|
+
await resolveProfile(await readManifest(projectPath), activeId),
|
|
400
|
+
projectPath,
|
|
401
|
+
flags.yes,
|
|
402
|
+
);
|
|
399
403
|
}
|
|
400
404
|
|
|
401
405
|
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.",
|
package/src/cli/profile.ts
CHANGED
|
@@ -1,29 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `magus profile <list|show|switch|
|
|
2
|
+
* `magus profile <list|show|switch|init> [name]`
|
|
3
3
|
*
|
|
4
|
-
* Inspect and switch between team profiles
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Inspect and switch between the team profiles in `.claude/profiles.json`.
|
|
5
|
+
* Switching renders the chosen profile's settings.json, .mcp.json and
|
|
6
|
+
* models.json and installs its skills; anything Claude Code wrote into the
|
|
7
|
+
* live config first goes into the profile being left.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import path from "node:path";
|
|
11
|
-
import fs from "fs-extra";
|
|
12
|
-
import { ensureGitignoreEntries } from "../services/conventions-manager.js";
|
|
13
10
|
import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
|
|
14
11
|
import { summarizeClosure } from "../services/install-plan.js";
|
|
15
12
|
import { getManifestPath, readManifest } from "../services/manifest.js";
|
|
16
13
|
import { reportDisplaced } from "../services/profile-activate.js";
|
|
17
|
-
import {
|
|
14
|
+
import { activeProfile, namedActiveProfile } from "../services/profile-live.js";
|
|
15
|
+
import type { SkillInstaller } from "../services/profile-skills.js";
|
|
16
|
+
import {
|
|
17
|
+
suggestedProfile,
|
|
18
|
+
switchHint,
|
|
19
|
+
switchToProfile,
|
|
20
|
+
} from "../services/profile-state.js";
|
|
18
21
|
import { resolveProfile } from "../services/resolver.js";
|
|
19
22
|
import { SettingsParseError } from "../services/settings-file.js";
|
|
20
23
|
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
} from "
|
|
26
|
-
import { ensureManifest, reportUnparseable } from "./bootstrap.js";
|
|
24
|
+
ensureManifest,
|
|
25
|
+
printActivationNotes,
|
|
26
|
+
reportUnparseable,
|
|
27
|
+
} from "./bootstrap.js";
|
|
28
|
+
import { note as noteLine } from "./update-view.js";
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* Every subcommand reads `.claude/profiles.json`, and one that does not parse
|
|
@@ -58,11 +60,9 @@ async function dispatchProfileCommand(args: string[]): Promise<number> {
|
|
|
58
60
|
args.includes("--yes") || args.includes("-y"),
|
|
59
61
|
projectPath,
|
|
60
62
|
);
|
|
61
|
-
case "sync":
|
|
62
|
-
return syncActiveProfile(rest[0], projectPath);
|
|
63
63
|
default:
|
|
64
64
|
console.error(
|
|
65
|
-
`Unknown profile command "${sub}". Use: list | show <name> | switch <name> | init
|
|
65
|
+
`Unknown profile command "${sub}". Use: list | show <name> | switch <name> | init`,
|
|
66
66
|
);
|
|
67
67
|
return 1;
|
|
68
68
|
}
|
|
@@ -83,7 +83,9 @@ export async function initProfile(
|
|
|
83
83
|
console.error(
|
|
84
84
|
`${getManifestPath(projectPath)} already defines: ${Object.keys(existing.profiles).join(", ")}`,
|
|
85
85
|
);
|
|
86
|
-
console.error(
|
|
86
|
+
console.error(
|
|
87
|
+
"Changes to the live config are recorded in the active profile automatically.",
|
|
88
|
+
);
|
|
87
89
|
return 1;
|
|
88
90
|
}
|
|
89
91
|
// `profile init` exists to create the manifest, so it is the one caller that
|
|
@@ -103,12 +105,18 @@ export async function listProfiles(projectPath: string): Promise<number> {
|
|
|
103
105
|
for (const id of ids) {
|
|
104
106
|
const entry = manifest.profiles[id];
|
|
105
107
|
const marker = id === active ? "●" : " ";
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
console.log(
|
|
108
|
+
console.log(` ${marker} ${id} — ${entry.name}`);
|
|
109
|
+
}
|
|
110
|
+
if (active) {
|
|
111
|
+
console.log(`\nActive: ${active}`);
|
|
112
|
+
} else {
|
|
113
|
+
const named = await namedActiveProfile(projectPath);
|
|
114
|
+
if (named) {
|
|
115
|
+
console.log(
|
|
116
|
+
`\nThe active profile is recorded as "${named.profile}", which is not defined above. Run: ${switchHint(ids, suggestedProfile(manifest))}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
110
119
|
}
|
|
111
|
-
if (active) console.log(`\nActive: ${active}`);
|
|
112
120
|
return 0;
|
|
113
121
|
}
|
|
114
122
|
|
|
@@ -137,42 +145,32 @@ export async function showProfile(
|
|
|
137
145
|
export async function switchProfile(
|
|
138
146
|
name: string | undefined,
|
|
139
147
|
projectPath: string,
|
|
148
|
+
/** Skill installer seam; the real one fetches from GitHub. */
|
|
149
|
+
opts: { installSkill?: SkillInstaller } = {},
|
|
140
150
|
): Promise<number> {
|
|
141
151
|
if (!name) {
|
|
142
152
|
console.error("Usage: magus profile switch <name>");
|
|
143
153
|
return 1;
|
|
144
154
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
);
|
|
155
|
+
|
|
156
|
+
// The one switch path, shared with the TUI's Profiles tab: edits go into
|
|
157
|
+
// the profile being LEFT (or, if it no longer exists, into this one), then
|
|
158
|
+
// this profile is rendered. A no-op absorb when the router's gate ran first.
|
|
159
|
+
const result = await switchToProfile(projectPath, name, {
|
|
160
|
+
onDisplace: reportDisplaced(projectPath),
|
|
161
|
+
installSkill: opts.installSkill,
|
|
162
|
+
});
|
|
163
|
+
for (const line of result.lines) console.log(noteLine(line));
|
|
164
|
+
if (result.problems.length > 0 || !result.activation) {
|
|
165
|
+
for (const problem of result.problems) console.error(problem);
|
|
156
166
|
return 1;
|
|
157
167
|
}
|
|
158
|
-
|
|
159
|
-
// In profile mode the source of truth is .claude/profiles.json; the
|
|
160
|
-
// generated dir AND the per-developer symlinks must stay out of git so
|
|
161
|
-
// switching profiles causes no churn.
|
|
162
|
-
await ensureGitignoreEntries(
|
|
163
|
-
path.join(projectPath, ".gitignore"),
|
|
164
|
-
PROFILE_GITIGNORE_ENTRIES,
|
|
165
|
-
"# magus: generated profile build output + active-profile symlinks",
|
|
166
|
-
);
|
|
167
|
-
|
|
168
|
-
// Switching between two already-active profiles displaces nothing — the paths
|
|
169
|
-
// are links magus owns. The FIRST switch in an unmanaged project displaces
|
|
170
|
-
// real files, and that is the run that must say so.
|
|
171
|
-
await activateProfile(name, projectPath, reportDisplaced(projectPath));
|
|
172
168
|
console.log(`✓ Switched to profile: ${name}`);
|
|
169
|
+
printActivationNotes(result.activation);
|
|
170
|
+
const manifest = await readManifest(projectPath);
|
|
173
171
|
|
|
174
172
|
// Verify the profile's binary deps are actually usable — the switch itself
|
|
175
|
-
// succeeds (
|
|
173
|
+
// succeeds (the config is in place), but warn so "switched" is not misleading.
|
|
176
174
|
try {
|
|
177
175
|
const closure = await resolveProfile(manifest, name);
|
|
178
176
|
const missing = missingBinaries(await checkBinaries(closure.bins));
|
|
@@ -189,30 +187,3 @@ export async function switchProfile(
|
|
|
189
187
|
}
|
|
190
188
|
return 0;
|
|
191
189
|
}
|
|
192
|
-
|
|
193
|
-
export async function syncActiveProfile(
|
|
194
|
-
nameArg: string | undefined,
|
|
195
|
-
projectPath: string,
|
|
196
|
-
): Promise<number> {
|
|
197
|
-
const name = nameArg ?? (await activeProfile(projectPath));
|
|
198
|
-
if (!name) {
|
|
199
|
-
console.error(
|
|
200
|
-
"No active profile to sync. Pass a name: magus profile sync <name>",
|
|
201
|
-
);
|
|
202
|
-
return 1;
|
|
203
|
-
}
|
|
204
|
-
try {
|
|
205
|
-
const { changes, wrote } = await syncProfile(name, projectPath);
|
|
206
|
-
if (!wrote) {
|
|
207
|
-
console.log(`Profile "${name}" is already in sync with the manifest.`);
|
|
208
|
-
return 0;
|
|
209
|
-
}
|
|
210
|
-
console.log(`Synced "${name}" → .claude/profiles.json:`);
|
|
211
|
-
for (const c of changes) console.log(` ${c.kind}: ${c.detail}`);
|
|
212
|
-
console.log("\nReview the diff and commit .claude/profiles.json.");
|
|
213
|
-
return 0;
|
|
214
|
-
} catch (e) {
|
|
215
|
-
console.error((e as Error).message);
|
|
216
|
-
return 1;
|
|
217
|
-
}
|
|
218
|
-
}
|
package/src/cli/router.ts
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import type { ThemeMode } from "@opentui/core";
|
|
10
10
|
import { SettingsParseError } from "../services/settings-file.js";
|
|
11
|
+
import { LiveFileRefusal } from "../services/tracked-live-files.js";
|
|
11
12
|
import { checkForUpdates } from "../services/version-check.js";
|
|
12
13
|
import { type ThemeEnv, parseThemeWord } from "../ui/theme-resolve.js";
|
|
13
|
-
import { reportUnparseable } from "./bootstrap.js";
|
|
14
|
+
import { profileGate, reportUnparseable } from "./bootstrap.js";
|
|
14
15
|
import { runDoctorCommand } from "./doctor.js";
|
|
15
16
|
import { runInstallCommand } from "./install.js";
|
|
16
17
|
import { runModelsCommand } from "./models.js";
|
|
@@ -102,6 +103,9 @@ const SUBCOMMANDS = new Set([
|
|
|
102
103
|
"doctor",
|
|
103
104
|
]);
|
|
104
105
|
|
|
106
|
+
/** Subcommands that read or write profile state, and so pass the gate first. */
|
|
107
|
+
const PROFILE_GATED = new Set(["install", "update", "profile", "models"]);
|
|
108
|
+
|
|
105
109
|
export async function route(
|
|
106
110
|
argv: string[],
|
|
107
111
|
version: string,
|
|
@@ -173,6 +177,25 @@ export async function route(
|
|
|
173
177
|
setThemeMode(mode);
|
|
174
178
|
}
|
|
175
179
|
|
|
180
|
+
// Every command that reads or writes profile state passes the same gate
|
|
181
|
+
// first: restore a lost marker, convert an older layout, absorb what Claude
|
|
182
|
+
// Code wrote into the live config, regenerate from a changed profiles.json.
|
|
183
|
+
// Read-only runs (`--check`, `--dry-run`) promise to write nothing, so they
|
|
184
|
+
// skip it. `doctor` runs it itself, to report what it did.
|
|
185
|
+
if (
|
|
186
|
+
command !== undefined &&
|
|
187
|
+
PROFILE_GATED.has(command) &&
|
|
188
|
+
!rest.includes("--check") &&
|
|
189
|
+
!rest.includes("--dry-run")
|
|
190
|
+
) {
|
|
191
|
+
const ok = await profileGate(process.cwd(), {
|
|
192
|
+
// Both of these fix a marker naming a missing profile by activating
|
|
193
|
+
// one that exists.
|
|
194
|
+
allowUnknown: command === "install" || command === "profile",
|
|
195
|
+
});
|
|
196
|
+
if (!ok) return HANDLED(1);
|
|
197
|
+
}
|
|
198
|
+
|
|
176
199
|
try {
|
|
177
200
|
switch (command) {
|
|
178
201
|
case "update":
|
|
@@ -201,6 +224,12 @@ export async function route(
|
|
|
201
224
|
reportUnparseable(error);
|
|
202
225
|
return HANDLED(1);
|
|
203
226
|
}
|
|
227
|
+
// A live file git tracks (or git could not say): nothing was written,
|
|
228
|
+
// and the message says what to do. A refusal, not a crash.
|
|
229
|
+
if (error instanceof LiveFileRefusal) {
|
|
230
|
+
console.error(error.message);
|
|
231
|
+
return HANDLED(1);
|
|
232
|
+
}
|
|
204
233
|
throw error;
|
|
205
234
|
}
|
|
206
235
|
}
|
|
@@ -227,9 +256,9 @@ without one is offered adoption on first run. See docs/team-configuration.md.
|
|
|
227
256
|
install [profile] Install the profile's plugins, binaries, skills and env at
|
|
228
257
|
the manifest's PINNED versions, then activate it.
|
|
229
258
|
No argument installs every profile.
|
|
230
|
-
--check Report
|
|
259
|
+
--check Report version mismatches only, write nothing
|
|
260
|
+
(exits 1 in strict mode)
|
|
231
261
|
--yes, -y Skip the confirmation prompt
|
|
232
|
-
--force Discard unsynced local edits instead of refusing
|
|
233
262
|
update [profile] Keep the ACTIVE profile current: install whatever it
|
|
234
263
|
declares but this machine lacks, and advance anything
|
|
235
264
|
behind the version its marketplace publishes. Only the
|
|
@@ -244,10 +273,14 @@ without one is offered adoption on first run. See docs/team-configuration.md.
|
|
|
244
273
|
missing or known to be behind — the CI gate.
|
|
245
274
|
profile list Show every profile; ● marks the active one
|
|
246
275
|
profile show <n> Print a profile's fully resolved closure
|
|
247
|
-
profile switch <n>
|
|
276
|
+
profile switch <n> Make <n> the active profile: render its settings.json,
|
|
277
|
+
.mcp.json and models.json, install its skills
|
|
248
278
|
profile init Write .claude/profiles.json from this project's setup
|
|
249
|
-
|
|
250
|
-
|
|
279
|
+
|
|
280
|
+
Changes Claude Code makes to .claude/settings.json or .mcp.json (a plugin
|
|
281
|
+
installed, a setting changed) are changes to the active profile: every command
|
|
282
|
+
above records them in .claude/profiles.json before it acts.
|
|
283
|
+
doctor Check binary deps, the active profile, and conventions
|
|
251
284
|
--fix Apply the repairs it can make itself
|
|
252
285
|
|
|
253
286
|
Per-subagent model routing — give each subagent its own model and effort. The
|