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.
Files changed (53) hide show
  1. package/README.md +9 -8
  2. package/package.json +4 -4
  3. package/src/__tests__/install-command.test.ts +4 -3
  4. package/src/__tests__/models-cli.test.ts +4 -7
  5. package/src/__tests__/models-manager.test.ts +52 -40
  6. package/src/__tests__/profile-absorb.test.ts +1138 -0
  7. package/src/__tests__/profile-command.test.ts +90 -32
  8. package/src/__tests__/profile-migration-offer.test.ts +18 -15
  9. package/src/__tests__/profile-migration.test.ts +246 -117
  10. package/src/__tests__/profile-render.test.ts +141 -0
  11. package/src/__tests__/profile-skills.test.ts +425 -0
  12. package/src/__tests__/profile-startup.test.ts +44 -14
  13. package/src/__tests__/profile-state.test.ts +795 -0
  14. package/src/__tests__/settings-file.test.ts +5 -7
  15. package/src/__tests__/styles-manager.test.ts +47 -74
  16. package/src/__tests__/styles-screen-state.test.ts +11 -10
  17. package/src/cli/bootstrap.ts +87 -27
  18. package/src/cli/doctor.ts +132 -38
  19. package/src/cli/install.ts +58 -118
  20. package/src/cli/models.ts +21 -1
  21. package/src/cli/profile.ts +48 -77
  22. package/src/cli/router.ts +32 -6
  23. package/src/cli/update.ts +43 -6
  24. package/src/data/gitignore-defaults.ts +3 -3
  25. package/src/services/active-closure.ts +3 -3
  26. package/src/services/conventions-manager.ts +88 -0
  27. package/src/services/git-worktree.ts +21 -0
  28. package/src/services/manifest.ts +4 -5
  29. package/src/services/models-manager.ts +36 -73
  30. package/src/services/profile-absorb.ts +1532 -0
  31. package/src/services/profile-activate.ts +348 -96
  32. package/src/services/profile-live.ts +825 -0
  33. package/src/services/profile-render.ts +115 -0
  34. package/src/services/profile-skills.ts +462 -0
  35. package/src/services/profile-startup.ts +88 -16
  36. package/src/services/profile-state.ts +622 -0
  37. package/src/services/profiles.ts +20 -32
  38. package/src/services/resolver.ts +6 -1
  39. package/src/services/settings-file.ts +5 -5
  40. package/src/services/skills-manager.ts +14 -8
  41. package/src/services/styles-manager.ts +45 -28
  42. package/src/types/index.ts +15 -9
  43. package/src/ui/App.tsx +25 -2
  44. package/src/ui/hooks/useProfileStartupModal.ts +20 -15
  45. package/src/ui/screens/ProfilesScreen.tsx +75 -27
  46. package/src/ui/screens/StylesScreen.tsx +2 -1
  47. package/src/ui/state/types.ts +1 -1
  48. package/src/__tests__/profile-materializer.test.ts +0 -238
  49. package/src/__tests__/profile-sync.test.ts +0 -154
  50. package/src/__tests__/symlink-manager.test.ts +0 -180
  51. package/src/services/profile-materializer.ts +0 -133
  52. package/src/services/profile-sync.ts +0 -158
  53. package/src/services/symlink-manager.ts +0 -343
@@ -1,18 +1,18 @@
1
1
  /**
2
2
  * `magus install [profile] [--check] [--yes]`
3
3
  *
4
- * Materialize a committed team profile from .claude/profiles.json onto this
5
- * machine: register marketplaces, install pinned plugins, install the binaries
6
- * plugins declare (requires.bin) plus the profile's cliTools, install skills,
7
- * collect required env, then activate the profile via symlinks.
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 and
10
- * materializes them all (so `profile switch` needs no reinstall), then
11
- * activates one. `--check` is a read-only drift gate for CI. `--yes` skips
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 { materializeProfile } from "../services/profile-materializer.js";
33
- import { detectProfileDrift } from "../services/profile-sync.js";
31
+ import { activeProfile } from "../services/profile-live.js";
32
+ import {
33
+ switchToProfile,
34
+ withCliBookkeeping,
35
+ } from "../services/profile-state.js";
34
36
  import { resolveAllProfiles, resolveProfile } from "../services/resolver.js";
35
37
  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
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 drift gate ───────────────────────────────────────────
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
- * Whatever the CLI writes into .claude/settings.json here is transient:
153
- * materialization rewrites that file from the manifest, and activation replaces
154
- * it with a symlink. The manifest stays authoritative.
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,
@@ -312,26 +270,6 @@ export async function runInstallCommand(
312
270
  return runCheck(closure, manifest.strictVersions === true);
313
271
  }
314
272
 
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
273
  // Plan
336
274
  console.log(`\nPlan for ${flags.profile ?? "all profiles"}:`);
337
275
  for (const line of summarizeClosure(closure)) console.log(` ${line}`);
@@ -357,45 +295,47 @@ export async function runInstallCommand(
357
295
 
358
296
  await ensureToolchains(closure, flags.yes);
359
297
  await registerClosureMarketplaces(closure.marketplaces);
360
- await installPlugins(closure);
361
- await installBins(closure.bins);
362
298
 
363
- // Materialize every profile (so `profile switch` needs no reinstall), then
364
- // activate the target (or the sole/first profile).
365
- //
366
- // Every profile is materialized even when one was named: a profile arg scopes
367
- // what gets *installed* and which becomes active, not which settings files
368
- // exist. It also keeps the union below consistent across all of them — a
369
- // profile left holding an older union would stop disabling plugins added
370
- // since, silently making `switch` additive again.
371
- const unionPluginIds = Object.keys(
372
- (flags.profile ? await resolveAllProfiles(manifest) : closure).plugins,
373
- );
374
- for (const id of profileIds) {
375
- const profileClosure = await resolveProfile(manifest, id);
376
- await materializeProfile(id, profileClosure, projectPath, unionPluginIds);
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);
377
321
  }
378
322
 
379
- // Keep the generated dir + active-profile symlinks out of git before
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
- );
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);
386
332
 
387
- const activeId =
388
- flags.profile ?? (await pickActiveProfile(profileIds, flags.yes));
389
333
  if (activeId) {
390
- // The reporter is not optional here. The first `install` in a project that
391
- // had a hand-written .claude/settings.json moves it aside, and without
392
- // this the move is invisible — the file is simply not where it was.
393
- await activateProfile(activeId, projectPath, reportDisplaced(projectPath));
394
- console.log(`\n✓ Active profile: ${activeId}`);
395
- // Skills route through the now-active .claude/skills symlink.
396
- const activeClosure = await resolveProfile(manifest, activeId);
397
- await installSkills(activeClosure.skills, projectPath);
398
- await collectEnv(activeClosure, projectPath, flags.yes);
334
+ await collectEnv(
335
+ await resolveProfile(await readManifest(projectPath), activeId),
336
+ projectPath,
337
+ flags.yes,
338
+ );
399
339
  }
400
340
 
401
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 { ensureManifest } from "./bootstrap.js";
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.",
@@ -1,29 +1,31 @@
1
1
  /**
2
- * `magus profile <list|show|switch|sync> [name]`
2
+ * `magus profile <list|show|switch|init> [name]`
3
3
  *
4
- * Inspect and switch between team profiles materialized under
5
- * `.claude/_profiles/`. Switching is cheap and offline — it just repoints the
6
- * shared-config symlinks. `sync` (Phase 5) promotes local drift back into the
7
- * committed manifest.
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 { syncProfile } from "../services/profile-sync.js";
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
- PROFILE_GITIGNORE_ENTRIES,
22
- activateProfile,
23
- activeProfile,
24
- profileDir,
25
- } from "../services/symlink-manager.js";
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 | sync`,
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("Use `magus profile sync` to promote local edits into it.");
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
- const materialized = (await fs.pathExists(profileDir(id, projectPath)))
107
- ? ""
108
- : " (not installed)";
109
- console.log(` ${marker} ${id} — ${entry.name}${materialized}`);
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
- const manifest = await readManifest(projectPath);
146
- if (!manifest.profiles[name]) {
147
- console.error(
148
- `Profile "${name}" not found. Available: ${Object.keys(manifest.profiles).join(", ")}`,
149
- );
150
- return 1;
151
- }
152
- if (!(await fs.pathExists(profileDir(name, projectPath)))) {
153
- console.error(
154
- `Profile "${name}" isn't installed yet. Run: magus install ${name}`,
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 (symlinks are valid), but warn so "switched" isn't misleading.
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
@@ -10,7 +10,7 @@ import type { ThemeMode } from "@opentui/core";
10
10
  import { SettingsParseError } from "../services/settings-file.js";
11
11
  import { checkForUpdates } from "../services/version-check.js";
12
12
  import { type ThemeEnv, parseThemeWord } from "../ui/theme-resolve.js";
13
- import { reportUnparseable } from "./bootstrap.js";
13
+ import { profileGate, reportUnparseable } from "./bootstrap.js";
14
14
  import { runDoctorCommand } from "./doctor.js";
15
15
  import { runInstallCommand } from "./install.js";
16
16
  import { runModelsCommand } from "./models.js";
@@ -102,6 +102,9 @@ const SUBCOMMANDS = new Set([
102
102
  "doctor",
103
103
  ]);
104
104
 
105
+ /** Subcommands that read or write profile state, and so pass the gate first. */
106
+ const PROFILE_GATED = new Set(["install", "update", "profile", "models"]);
107
+
105
108
  export async function route(
106
109
  argv: string[],
107
110
  version: string,
@@ -173,6 +176,25 @@ export async function route(
173
176
  setThemeMode(mode);
174
177
  }
175
178
 
179
+ // Every command that reads or writes profile state passes the same gate
180
+ // first: restore a lost marker, convert an older layout, absorb what Claude
181
+ // Code wrote into the live config, regenerate from a changed profiles.json.
182
+ // Read-only runs (`--check`, `--dry-run`) promise to write nothing, so they
183
+ // skip it. `doctor` runs it itself, to report what it did.
184
+ if (
185
+ command !== undefined &&
186
+ PROFILE_GATED.has(command) &&
187
+ !rest.includes("--check") &&
188
+ !rest.includes("--dry-run")
189
+ ) {
190
+ const ok = await profileGate(process.cwd(), {
191
+ // Both of these fix a marker naming a missing profile by activating
192
+ // one that exists.
193
+ allowUnknown: command === "install" || command === "profile",
194
+ });
195
+ if (!ok) return HANDLED(1);
196
+ }
197
+
176
198
  try {
177
199
  switch (command) {
178
200
  case "update":
@@ -227,9 +249,9 @@ without one is offered adoption on first run. See docs/team-configuration.md.
227
249
  install [profile] Install the profile's plugins, binaries, skills and env at
228
250
  the manifest's PINNED versions, then activate it.
229
251
  No argument installs every profile.
230
- --check Report drift only, write nothing (exits 1 in strict mode)
252
+ --check Report version mismatches only, write nothing
253
+ (exits 1 in strict mode)
231
254
  --yes, -y Skip the confirmation prompt
232
- --force Discard unsynced local edits instead of refusing
233
255
  update [profile] Keep the ACTIVE profile current: install whatever it
234
256
  declares but this machine lacks, and advance anything
235
257
  behind the version its marketplace publishes. Only the
@@ -244,10 +266,14 @@ without one is offered adoption on first run. See docs/team-configuration.md.
244
266
  missing or known to be behind — the CI gate.
245
267
  profile list Show every profile; ● marks the active one
246
268
  profile show <n> Print a profile's fully resolved closure
247
- profile switch <n> Repoint the active profile — offline, no reinstall
269
+ profile switch <n> Make <n> the active profile: render its settings.json,
270
+ .mcp.json and models.json, install its skills
248
271
  profile init Write .claude/profiles.json from this project's setup
249
- profile sync Promote local edits back into .claude/profiles.json
250
- doctor Check binary deps, profile symlinks, and conventions
272
+
273
+ Changes Claude Code makes to .claude/settings.json or .mcp.json (a plugin
274
+ installed, a setting changed) are changes to the active profile: every command
275
+ above records them in .claude/profiles.json before it acts.
276
+ doctor Check binary deps, the active profile, and conventions
251
277
  --fix Apply the repairs it can make itself
252
278
 
253
279
  Per-subagent model routing — give each subagent its own model and effort. The
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 symlinks and edits a TRACKED
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 symlink — installs go
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 \`magus profile switch <${profileIds.join("|")}>\`.`,
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(closure.skills, installedSkills),
439
+ skills: planSkillUpdates(skillRefs, installedSkills),
422
440
  binSpecs: new Map(closure.bins.map((b) => [b.name, b])),
423
441
  };
424
442
 
@@ -575,9 +593,28 @@ export async function runUpdateCommand(
575
593
  console.log("");
576
594
  applyLive.start((tick) => applyFrame(state, tick, Date.now()));
577
595
 
578
- const outcome = await applyPlan(plan, projectPath, { report: reporter });
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
+ beforeReconcile: () =>
611
+ recordRefetchedSkills(projectPath, skills.fetched()),
612
+ report: (line) => reconciledLines.push(line),
613
+ },
614
+ );
579
615
 
580
616
  applyLive.stop();
617
+ for (const line of reconciledLines) console.log(noteLine(line));
581
618
 
582
619
  const { failed } = outcome;
583
620