magus-cli 7.1.0 → 7.2.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.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Finish a migration: put a profile on disk and make the project run it.
3
+ *
4
+ * Adoption used to end at `writeManifest`, which is the whole reason migrating
5
+ * felt like it did nothing. "Active" is not a field anywhere — it is derived
6
+ * from whether `.claude/settings.json` is a symlink into `_profiles/<id>/`
7
+ * (see `activeProfile`). A manifest that nothing links to leaves the project in
8
+ * exactly the state it was in, so the next command still asks which profile to
9
+ * use, having just been told.
10
+ *
11
+ * The three steps have to happen together and in this order, which is why they
12
+ * live in one function rather than at each call site:
13
+ *
14
+ * 1. materialize — write `_profiles/<id>/`, or activation throws
15
+ * 2. gitignore — before the links exist, or a symlink into generated state
16
+ * lands in `git status` (`.claude/` is committed)
17
+ * 3. activate — point the four links at the profile
18
+ *
19
+ * No network and no installs: `resolveProfile` reads the manifest, and binary
20
+ * resolution is skipped outright because nothing here materializes bins. That
21
+ * matters — this runs inside a prompt the user answered, not inside `install`.
22
+ */
23
+
24
+ import path from "node:path";
25
+ import type { ProfileManifest } from "../types/index.js";
26
+ import { ensureGitignoreEntries } from "./conventions-manager.js";
27
+ import { writeManifest } from "./manifest.js";
28
+ import { adoptCurrentProject } from "./profile-adopt.js";
29
+ import { materializeProfile } from "./profile-materializer.js";
30
+ import { resolveAllProfiles, resolveProfile } from "./resolver.js";
31
+ import {
32
+ type DisplacedArtifact,
33
+ PROFILE_GITIGNORE_ENTRIES,
34
+ activateProfile,
35
+ } from "./symlink-manager.js";
36
+
37
+ /**
38
+ * Bins are declared by plugins and read from the marketplace cache, one
39
+ * `plugin.json` per plugin. Materialization writes settings, mcp, models and
40
+ * skills — never bins — so resolving them here would be pure latency, and on a
41
+ * machine with a cold cache it is latency inside a prompt.
42
+ */
43
+ const noBins = async () => [];
44
+
45
+ export interface ActivationResult {
46
+ /** The profile now live. */
47
+ profileId: string;
48
+ /** Hand-written artifacts moved aside to make room for the symlinks. */
49
+ displaced: DisplacedArtifact[];
50
+ }
51
+
52
+ /**
53
+ * Announce one displaced artifact, in project-relative terms.
54
+ *
55
+ * Shared by every command that activates, because a hand-written file moving is
56
+ * the same event whichever command caused it, and the one that is easiest to
57
+ * leave out: `activateProfile` returns the list, and a caller that discards the
58
+ * return value still compiles. `magus install` and `magus profile switch` both
59
+ * did exactly that.
60
+ *
61
+ * Paths are relative because absolute ones wrap over two terminal lines each,
62
+ * and this is the notice a user must not skim.
63
+ */
64
+ export function reportDisplaced(
65
+ projectPath: string,
66
+ ): (artifact: DisplacedArtifact) => void {
67
+ const rel = (p: string) => path.relative(projectPath, p);
68
+ return ({ link, savedTo }) =>
69
+ console.log(` ${rel(link)} → kept at ${rel(savedTo)}`);
70
+ }
71
+
72
+ /**
73
+ * Materialize `profileId` and make it the active profile.
74
+ *
75
+ * The union of plugin ids across EVERY profile is passed to materialization, so
76
+ * the profile writes an explicit `false` for plugins other profiles own. Without
77
+ * it an omitted id falls through to user scope and stays enabled, which would
78
+ * make the freshly adopted profile additive rather than a description of this
79
+ * project — the exact drift adoption was run to end.
80
+ */
81
+ export async function materializeAndActivate(
82
+ manifest: ProfileManifest,
83
+ profileId: string,
84
+ projectPath: string,
85
+ /** Called as each artifact moves; defaults to printing it. */
86
+ onDisplace: (artifact: DisplacedArtifact) => void = reportDisplaced(
87
+ projectPath,
88
+ ),
89
+ ): Promise<ActivationResult> {
90
+ const closure = await resolveProfile(manifest, profileId, {
91
+ binResolver: noBins,
92
+ });
93
+ const union = await resolveAllProfiles(manifest, { binResolver: noBins });
94
+
95
+ await materializeProfile(
96
+ profileId,
97
+ closure,
98
+ projectPath,
99
+ Object.keys(union.plugins),
100
+ );
101
+
102
+ await ensureGitignoreEntries(
103
+ path.join(projectPath, ".gitignore"),
104
+ PROFILE_GITIGNORE_ENTRIES,
105
+ "# magus: generated profile build output + active-profile symlinks",
106
+ );
107
+
108
+ const displaced = await activateProfile(profileId, projectPath, onDisplace);
109
+ return { profileId, displaced };
110
+ }
111
+
112
+ /** The two ways a project can be put onto a profile. */
113
+ export type ProfileChoice =
114
+ | { kind: "adopt"; id: string }
115
+ | { kind: "activate"; id: string };
116
+
117
+ /**
118
+ * Carry out the user's choice, whichever surface asked.
119
+ *
120
+ * One implementation because "migrate" is two writes that must not come apart —
121
+ * write the manifest, then link the project to it — and a second copy of that
122
+ * pairing in the other surface is how one of them ends up writing the manifest
123
+ * and stopping, which is the defect this whole area was fixed for.
124
+ */
125
+ export async function applyProfileChoice(
126
+ choice: ProfileChoice,
127
+ manifest: ProfileManifest,
128
+ projectPath: string,
129
+ onDisplace?: (artifact: DisplacedArtifact) => void,
130
+ ): Promise<ActivationResult> {
131
+ if (choice.kind === "activate") {
132
+ return materializeAndActivate(manifest, choice.id, projectPath, onDisplace);
133
+ }
134
+ const result = await adoptCurrentProject(
135
+ projectPath,
136
+ undefined,
137
+ manifest,
138
+ choice.id,
139
+ );
140
+ await writeManifest(result.manifest, projectPath);
141
+ return materializeAndActivate(
142
+ result.manifest,
143
+ choice.id,
144
+ projectPath,
145
+ onDisplace,
146
+ );
147
+ }
@@ -30,6 +30,21 @@ import { withDerivedMarketplaces } from "./resolver.js";
30
30
  /** The profile id used when adopting a project that has no manifest. */
31
31
  export const ADOPTED_PROFILE_ID = "default";
32
32
 
33
+ /**
34
+ * First free profile id, so adopting never overwrites an existing profile.
35
+ *
36
+ * Lives here rather than at a call site because both surfaces adopt now — the
37
+ * CLI prompt and the TUI's startup offer — and two copies of "which id is
38
+ * free?" is two chances to overwrite somebody's profile.
39
+ */
40
+ export function freeProfileId(manifest: ProfileManifest): string {
41
+ if (!manifest.profiles[ADOPTED_PROFILE_ID]) return ADOPTED_PROFILE_ID;
42
+ for (let n = 2; ; n++) {
43
+ const id = `${ADOPTED_PROFILE_ID}-${n}`;
44
+ if (!manifest.profiles[id]) return id;
45
+ }
46
+ }
47
+
33
48
  export interface AdoptInput {
34
49
  /** Union of enabled plugins across every scope: id -> enabled. */
35
50
  enabledPlugins: Record<string, boolean>;
@@ -147,7 +162,7 @@ export type AdoptSource = "project" | "user";
147
162
  * everything globally would otherwise have nothing to adopt. The caller is told
148
163
  * which source was used and says so, so the choice is never invisible.
149
164
  */
150
- async function collectEnabledPlugins(
165
+ export async function collectEnabledPlugins(
151
166
  projectPath: string,
152
167
  ): Promise<{ plugins: Record<string, boolean>; source: AdoptSource }> {
153
168
  const project = await getEnabledPlugins(projectPath).catch(() => ({}));
@@ -165,6 +180,19 @@ async function collectEnabledPlugins(
165
180
  export async function adoptCurrentProject(
166
181
  projectPath: string,
167
182
  name?: string,
183
+ /**
184
+ * Manifest to add the adopted profile to. Omitted, adoption starts from an
185
+ * empty manifest — correct for a repo that has none.
186
+ *
187
+ * This parameter is what makes adoption reachable twice. Without it the
188
+ * function always returned `emptyManifest()` plus one profile, so calling
189
+ * it on a repo that HAD profiles would have deleted them, and the zero-
190
+ * profile gate in `ensureManifest` was the only thing preventing that.
191
+ * Existing profiles are preserved untouched; only `id` is written.
192
+ */
193
+ into?: ProfileManifest,
194
+ /** Profile id to write. Defaults to the adopted-profile id. */
195
+ id: string = ADOPTED_PROFILE_ID,
168
196
  ): Promise<AdoptResult> {
169
197
  const { plugins: enabledPlugins, source } =
170
198
  await collectEnabledPlugins(projectPath);
@@ -181,8 +209,26 @@ export async function adoptCurrentProject(
181
209
  name,
182
210
  });
183
211
 
184
- const manifest = emptyManifest();
185
- manifest.profiles[ADOPTED_PROFILE_ID] = entry;
212
+ // Copy rather than mutate the caller's manifest: this function does not
213
+ // write, and a caller that declines the plan must be left with the file it
214
+ // had, not a version already carrying the profile it refused.
215
+ const manifest: ProfileManifest = into
216
+ ? { ...into, profiles: { ...into.profiles } }
217
+ : emptyManifest();
218
+ manifest.profiles[id] = entry;
219
+
220
+ // Record which profile the project wants, but ONLY when nothing has said yet.
221
+ //
222
+ // Writing it on a first adoption is the point: the manifest is committed, and
223
+ // a teammate who clones it should get this profile without being asked. It is
224
+ // the answer the repo now has.
225
+ //
226
+ // Overwriting an existing `default` would be different — one developer saving
227
+ // their local state would republish the team's committed choice as whatever
228
+ // they happened to have on. The new profile still becomes active for them,
229
+ // because activation is per-developer; the declared intent is not theirs to
230
+ // change by accident.
231
+ if (manifest.default === undefined) manifest.default = id;
186
232
 
187
233
  const skipped = Object.entries(enabledPlugins)
188
234
  .filter(([id, on]) => on && !id.includes("@"))
@@ -190,7 +236,7 @@ export async function adoptCurrentProject(
190
236
 
191
237
  return {
192
238
  manifest,
193
- profileId: ADOPTED_PROFILE_ID,
239
+ profileId: id,
194
240
  entry,
195
241
  source,
196
242
  counts: {
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Is this project usable, and if not, what has to happen first.
3
+ *
4
+ * ## One source of truth, not two
5
+ *
6
+ * A profiled project's `.claude/settings.json` is a SYMLINK into
7
+ * `_profiles/<id>/`, and that file is generated from the manifest. So the
8
+ * manifest is the truth and `enabledPlugins` is its output.
9
+ *
10
+ * An earlier version of this file compared the two — the manifest against the
11
+ * plugins `enabledPlugins` lists — and offered to create a new profile when
12
+ * they disagreed. That is coherent only in the profileless world, where
13
+ * `settings.json` is a real file somebody wrote. Once a profile is active it
14
+ * compares the manifest against its own generated output, and "they disagree"
15
+ * cannot mean what the offer claimed: the honest answer is to re-materialize
16
+ * the profile, never to invent a third one describing the corrupted output.
17
+ *
18
+ * Worse, it meant magus behaved as though both worlds were live at once. So the
19
+ * comparison is gone. Nothing here reads `enabledPlugins`, and nothing compares
20
+ * a set of declared plugins to a set of installed ones — they are different
21
+ * kinds of thing and a comparison between them cannot be acted on.
22
+ *
23
+ * ## What is left is a three-state question
24
+ *
25
+ * ok a profile is active; the manifest is in effect; ask nothing
26
+ * unmigrated no manifest — the project is not using profiles at all
27
+ * inactive a manifest with profiles, none of them linked into place
28
+ *
29
+ * `unmigrated` and `inactive` are both unusable, not merely untidy: with no
30
+ * active profile there is no profile state to read and nothing to write updates
31
+ * into, so magus cannot do its job. The caller must not offer to continue.
32
+ *
33
+ * Detection only — nothing here writes. That is what makes it callable from the
34
+ * TUI's startup effect, and testable without a renderer.
35
+ */
36
+
37
+ import type { ProfileManifest } from "../types/index.js";
38
+ // Type-only, so it is erased: this module stays free of the UI and of React,
39
+ // which is what lets `magus hook` and the CLI import it. The shape of a
40
+ // selectable option is presentation-neutral data, and both surfaces render the
41
+ // same list from these builders rather than each composing its own.
42
+ import type { SelectOption } from "../ui/state/types.js";
43
+ import { readManifest } from "./manifest.js";
44
+ import type { ProfileChoice } from "./profile-activate.js";
45
+ import { freeProfileId } from "./profile-adopt.js";
46
+ import { activeProfile, displaceableArtifacts } from "./symlink-manager.js";
47
+
48
+ /** Whether the project can be worked on, and if not, why not. */
49
+ export type ProfileStartupState = "ok" | "unmigrated" | "inactive";
50
+
51
+ /** A profile the user could activate. */
52
+ export interface ActivatableProfile {
53
+ id: string;
54
+ name: string;
55
+ /**
56
+ * How many plugins it declares.
57
+ *
58
+ * A property of the profile, read from the manifest — NOT a comparison
59
+ * against what is installed. It is here to tell two rows apart, nothing more.
60
+ */
61
+ pluginCount: number;
62
+ }
63
+
64
+ export interface ProfileStartupNeed {
65
+ state: ProfileStartupState;
66
+ /**
67
+ * Id to create if the user wants what is turned on captured as a profile.
68
+ * Null when a profile is already active. Never an id already in use.
69
+ */
70
+ adopt: string | null;
71
+ /** Profiles that could be activated. Empty unless the state is `inactive`. */
72
+ activate: ActivatableProfile[];
73
+ /**
74
+ * The profile this project declares it wants, if the manifest names one that
75
+ * exists. Used to preselect the row so Enter does the intended thing.
76
+ */
77
+ declaredDefault: string | null;
78
+ /**
79
+ * Activate `declaredDefault` without asking.
80
+ *
81
+ * True only when the repo answered the question AND activating would replace
82
+ * nothing a human put there — a fresh clone. Asking there is asking someone
83
+ * to re-decide something their team already decided, with nothing at stake.
84
+ *
85
+ * False the moment there is anything to lose, however clear the declared
86
+ * default is: replacing someone's working files is never automatic.
87
+ */
88
+ autoActivate: boolean;
89
+ /** The manifest as read, so the caller does not read it a second time. */
90
+ manifest: ProfileManifest;
91
+ }
92
+
93
+ /** True when the project is usable and there is nothing to ask. */
94
+ export function needsNothing(need: ProfileStartupNeed): boolean {
95
+ return need.state === "ok";
96
+ }
97
+
98
+ /** Inspect the project and decide what, if anything, must happen first. */
99
+ export async function detectProfileStartupNeed(
100
+ projectPath: string,
101
+ ): Promise<ProfileStartupNeed> {
102
+ const manifest = await readManifest(projectPath);
103
+ const ids = Object.keys(manifest.profiles);
104
+
105
+ if (ids.length === 0) {
106
+ return {
107
+ state: "unmigrated",
108
+ adopt: freeProfileId(manifest),
109
+ activate: [],
110
+ declaredDefault: null,
111
+ autoActivate: false,
112
+ manifest,
113
+ };
114
+ }
115
+
116
+ // The ONE question that decides whether the manifest is in effect. Not a
117
+ // comparison, not a heuristic: either settings.json is a link into a
118
+ // profile or it is not.
119
+ if ((await activeProfile(projectPath)) !== null) {
120
+ return {
121
+ state: "ok",
122
+ adopt: null,
123
+ activate: [],
124
+ declaredDefault: null,
125
+ autoActivate: false,
126
+ manifest,
127
+ };
128
+ }
129
+
130
+ // Validated, not trusted: a `default` naming a profile that was renamed or
131
+ // removed must fall back to asking rather than throw somewhere downstream.
132
+ // `validateManifest` reports the typo; this keeps magus usable meanwhile.
133
+ const declaredDefault =
134
+ manifest.default !== undefined && ids.includes(manifest.default)
135
+ ? manifest.default
136
+ : null;
137
+
138
+ return {
139
+ state: "inactive",
140
+ declaredDefault,
141
+ // Only when the repo answered AND there is nothing of the user's to
142
+ // replace. Both halves matter: without the first there is no answer to
143
+ // apply, and without the second applying it silently destroys work.
144
+ autoActivate:
145
+ declaredDefault !== null &&
146
+ (await displaceableArtifacts(projectPath)).length === 0,
147
+ // Still offered, because a manifest whose profiles all describe something
148
+ // this project no longer is would otherwise be a dead end — activating a
149
+ // wrong profile is the only way out. Offering it costs a row; NOT offering
150
+ // it rebuilds the trap. Which one to take is the user's call to make, and
151
+ // magus can no longer form an opinion on it without the comparison that
152
+ // used to be here.
153
+ adopt: freeProfileId(manifest),
154
+ activate: ids.map((id) => ({
155
+ id,
156
+ name: manifest.profiles[id]?.name ?? id,
157
+ pluginCount: Object.keys(manifest.profiles[id]?.plugins ?? {}).length,
158
+ })),
159
+ manifest,
160
+ };
161
+ }
162
+
163
+ // ─── What to offer, as data both surfaces render ─────────────────────────────
164
+
165
+ /** Option values are parsed back, so they are data, not prose. */
166
+ export const ADOPT_VALUE = "adopt";
167
+ export const QUIT_VALUE = "quit";
168
+ export const ACTIVATE_PREFIX = "activate:";
169
+
170
+ /**
171
+ * Title and message for the state we are in.
172
+ *
173
+ * Written about what the user has, never about magus's internals. Two rewrites
174
+ * got here, and both failures are worth keeping in mind.
175
+ *
176
+ * First it explained the machinery: "the manifest is not in effect", "there is
177
+ * no profile state to read and nowhere to write updates". Accurate, and an
178
+ * answer to a question nobody standing in front of this modal is asking.
179
+ *
180
+ * Then it traded jargon for abstraction: "the setup you are running belongs to
181
+ * no profile". "Setup" names nothing the reader can point at, and "belongs to"
182
+ * describes a relationship rather than a consequence. So the copy now names the
183
+ * concrete things — the plugins and skills turned on for this project — and
184
+ * says what happens to them: they will be overwritten.
185
+ *
186
+ * That consequence is the whole reason the save option exists. A screen offering
187
+ * "save" and "switch" without saying they are alternatives asks the reader to
188
+ * guess which one keeps their work.
189
+ *
190
+ * The `unmigrated` copy must NOT carry that warning: adopting there captures
191
+ * what is already present, so the reassurance is the point.
192
+ *
193
+ * Neither mentions the backup, deliberately. A sentence promising "nothing is
194
+ * deleted — the old files are moved to a backup" raises a question the reader
195
+ * did not have (which files? what was at risk?) and implies data loss, when what
196
+ * changes is settings. Where the displaced files went belongs in the outcome
197
+ * modal AFTER the switch, where it is something to act on rather than a worry to
198
+ * plant beforehand.
199
+ *
200
+ * No file paths in these strings, deliberately. The TUI modal soft-wraps its
201
+ * message at whatever width the terminal gives it, and OpenTUI treats `.` as a
202
+ * break opportunity — MEASURED on screen, `.claude/settings.json` wrapped as
203
+ * `.` then `claude/settings.json` on the next line. A path split across a line
204
+ * break reads as two different paths, neither of them real.
205
+ */
206
+ export function describeNeed(need: ProfileStartupNeed): {
207
+ title: string;
208
+ message: string;
209
+ } {
210
+ if (need.state === "unmigrated") {
211
+ return {
212
+ title: "magus uses profiles",
213
+ message:
214
+ "A profile is a saved set of plugins, skills and MCP servers for one project. Commit it, and everyone on the team gets the same ones.\n\nThis project has no profile yet. Saving what it uses now makes the first one, and nothing about what you run changes.",
215
+ };
216
+ }
217
+
218
+ return {
219
+ title: "No profile selected",
220
+ message:
221
+ "A profile is a saved set of plugins, skills and MCP servers for one project.\n\nThe plugins and skills turned on here are not saved in any profile. Switch to one below and they will be overwritten.\n\nTo keep them, save them as a new profile first.",
222
+ };
223
+ }
224
+
225
+ /**
226
+ * The options for a need, in the order they should be offered.
227
+ *
228
+ * There is no "Not now". Without an active profile magus has no profile state
229
+ * to read and nowhere to write updates, so "carry on anyway" is not something
230
+ * it can offer — both surfaces used to have that escape, the TUI as a list row
231
+ * and the CLI as "using X for this run only", and both let the user into a
232
+ * magus that could not do its job.
233
+ *
234
+ * "Quit magus" is last instead. A question that cannot be answered with "no"
235
+ * still has to be answerable with "not in this app", and the way out has to be
236
+ * visible rather than a keystroke the user has to guess.
237
+ */
238
+ export function buildProfileOptions(need: ProfileStartupNeed): SelectOption[] {
239
+ const options: SelectOption[] = [];
240
+
241
+ if (need.adopt !== null) {
242
+ options.push({
243
+ // Says what it PRESERVES, not what it creates. "Create <id> from this
244
+ // project" described the mechanism; the reason to pick this row is that
245
+ // it is the only one that keeps what is turned on right now.
246
+ //
247
+ // Kept short on purpose: this is the longest label in the list, and the
248
+ // meta column is measured against the longest one. "…and use it" made it
249
+ // 41 cells and pushed every plugin count onto a line of its own.
250
+ label: `Save what you have as "${need.adopt}"`,
251
+ value: ADOPT_VALUE,
252
+ });
253
+ }
254
+
255
+ for (const profile of need.activate) {
256
+ options.push({
257
+ label: `Activate ${profile.name}`,
258
+ value: `${ACTIVATE_PREFIX}${profile.id}`,
259
+ // The profile's own plugin count, to tell two rows apart. There used to
260
+ // be a "matches now" badge here, marking the profile that declared
261
+ // exactly what `enabledPlugins` had on. It is gone with the comparison
262
+ // that produced it: it asserted that the installed set was a legitimate
263
+ // thing to judge a profile against, which is the two-worlds confusion
264
+ // this change removes.
265
+ meta: `${profile.pluginCount} plugin${profile.pluginCount === 1 ? "" : "s"}`,
266
+ });
267
+ }
268
+
269
+ options.push({ label: "Quit magus", value: QUIT_VALUE });
270
+ return options;
271
+ }
272
+
273
+ /**
274
+ * Which row to start on.
275
+ *
276
+ * The profile the repo declares, when it names one — so a developer who does
277
+ * have local work to weigh still gets their team's answer under the cursor, and
278
+ * Enter does the intended thing. Falls back to the first row, which is the save
279
+ * option: the safe default when nobody has said otherwise, because it is the
280
+ * only one that keeps what is turned on.
281
+ */
282
+ export function defaultOptionIndex(need: ProfileStartupNeed): number {
283
+ if (need.declaredDefault === null) return 0;
284
+ const at = buildProfileOptions(need).findIndex(
285
+ (o) => o.value === `${ACTIVATE_PREFIX}${need.declaredDefault}`,
286
+ );
287
+ return at === -1 ? 0 : at;
288
+ }
289
+
290
+ /**
291
+ * Turn a chosen option value into the action it means.
292
+ *
293
+ * Returns null for "quit" and for anything unrecognised — including `adopt`
294
+ * when there is nothing to adopt, so a stale value cannot reach the adopt path
295
+ * with no id.
296
+ */
297
+ export function parseProfileChoice(
298
+ value: string,
299
+ need: ProfileStartupNeed,
300
+ ): ProfileChoice | null {
301
+ if (value === ADOPT_VALUE && need.adopt !== null) {
302
+ return { kind: "adopt", id: need.adopt };
303
+ }
304
+ if (value.startsWith(ACTIVATE_PREFIX)) {
305
+ const id = value.slice(ACTIVATE_PREFIX.length);
306
+ return need.activate.some((p) => p.id === id)
307
+ ? { kind: "activate", id }
308
+ : null;
309
+ }
310
+ return null;
311
+ }
@@ -1,7 +1,8 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
  import fs from "fs-extra";
4
- import type { Profile, ProfileEntry, ProfilesFile } from "../types/index.js";
4
+ import type { ProfileEntry, ProfileManifest } from "../types/index.js";
5
+ import { activeProfile } from "./symlink-manager.js";
5
6
 
6
7
  const PROFILES_FILE = "profiles.json";
7
8
 
@@ -21,7 +22,7 @@ export function getProjectProfilesPath(projectPath?: string): string {
21
22
  export async function readProfiles(
22
23
  scope: "user" | "project",
23
24
  projectPath?: string,
24
- ): Promise<ProfilesFile> {
25
+ ): Promise<ProfileManifest> {
25
26
  const filePath =
26
27
  scope === "user"
27
28
  ? getUserProfilesPath()
@@ -29,17 +30,23 @@ export async function readProfiles(
29
30
  try {
30
31
  if (await fs.pathExists(filePath)) {
31
32
  const data = await fs.readJson(filePath);
32
- if (data.version && data.profiles) return data as ProfilesFile;
33
+ // A v1 file differs only in what it omits, so it reads as a v2 entry
34
+ // with absent optional fields. Nothing here converts: `version: 2` is
35
+ // what the next write records, and every consumer already treats
36
+ // `createdAt`, `updatedAt` and a string plugin pin as optional.
37
+ if (data.version && data.profiles) {
38
+ return { ...data, version: 2 } as ProfileManifest;
39
+ }
33
40
  }
34
41
  } catch {
35
42
  // fall through to default
36
43
  }
37
- return { version: 1, profiles: {} };
44
+ return { version: 2, profiles: {} };
38
45
  }
39
46
 
40
47
  async function writeProfiles(
41
48
  scope: "user" | "project",
42
- data: ProfilesFile,
49
+ data: ProfileManifest,
43
50
  projectPath?: string,
44
51
  ): Promise<void> {
45
52
  const filePath =
@@ -56,21 +63,33 @@ async function writeProfiles(
56
63
  export async function listProfiles(
57
64
  projectPath?: string,
58
65
  ): Promise<ProfileEntry[]> {
59
- const [user, project] = await Promise.all([
66
+ // The active profile is read alongside the files rather than by the caller:
67
+ // every surface that lists profiles wants to mark the live one, and a flag
68
+ // each of them derives itself is a flag some of them forget.
69
+ const [user, project, active] = await Promise.all([
60
70
  readProfiles("user"),
61
71
  readProfiles("project", projectPath),
72
+ activeProfile(projectPath),
62
73
  ]);
63
74
  const entries: ProfileEntry[] = [];
64
75
  for (const [id, p] of Object.entries(user.profiles)) {
65
76
  entries.push({ id, scope: "user", ...p });
66
77
  }
67
78
  for (const [id, p] of Object.entries(project.profiles)) {
68
- entries.push({ id, scope: "project", ...p });
79
+ entries.push({ id, scope: "project", ...p, active: id === active });
69
80
  }
70
- // Sort: user profiles first, then project, each by updatedAt desc
81
+ // Sort: user profiles first, then project, each by updatedAt desc.
82
+ //
83
+ // `updatedAt` is optional — a hand-written or CLI-authored profile carries
84
+ // no timestamp, and dereferencing it here is what made this function throw
85
+ // on every v2 file. An undated profile sorts last within its scope, then by
86
+ // id so the order is stable rather than dependent on key order.
71
87
  entries.sort((a, b) => {
72
88
  if (a.scope !== b.scope) return a.scope === "user" ? -1 : 1;
73
- return b.updatedAt.localeCompare(a.updatedAt);
89
+ const at = a.updatedAt ?? "";
90
+ const bt = b.updatedAt ?? "";
91
+ if (at !== bt) return bt.localeCompare(at);
92
+ return a.id.localeCompare(b.id);
74
93
  });
75
94
  return entries;
76
95
  }
@@ -98,6 +117,25 @@ export async function saveProfile(
98
117
  return id;
99
118
  }
100
119
 
120
+ /**
121
+ * A profile's plugin map as Claude Code's `enabledPlugins` needs it.
122
+ *
123
+ * A profile pins a plugin to a version ("1.2.0"), to "latest", or to `true`.
124
+ * Claude Code's `enabledPlugins` takes booleans and nothing else, so spreading
125
+ * a profile straight in writes `"latest"` where `true` belongs and the plugin
126
+ * silently stops loading. Only an explicit `false` means disabled; every pin
127
+ * names a plugin the profile wants on.
128
+ */
129
+ export function toEnabledPlugins(
130
+ plugins: Record<string, string | boolean> | undefined,
131
+ ): Record<string, boolean> {
132
+ const out: Record<string, boolean> = {};
133
+ for (const [id, pin] of Object.entries(plugins ?? {})) {
134
+ out[id] = pin !== false;
135
+ }
136
+ return out;
137
+ }
138
+
101
139
  /**
102
140
  * Apply a profile: replaces enabledPlugins in the target settings scope.
103
141
  * targetScope is where the settings will be written, independent of where
@@ -123,12 +161,12 @@ export async function applyProfile(
123
161
 
124
162
  if (targetScope === "user") {
125
163
  const settings = await readGlobalSettings();
126
- settings.enabledPlugins = { ...profile.plugins };
164
+ settings.enabledPlugins = toEnabledPlugins(profile.plugins);
127
165
  await writeGlobalSettings(settings);
128
166
  } else {
129
167
  // project or local both write to project settings.json
130
168
  const settings = await readSettings(projectPath);
131
- settings.enabledPlugins = { ...profile.plugins };
169
+ settings.enabledPlugins = toEnabledPlugins(profile.plugins);
132
170
  await writeSettings(settings, projectPath);
133
171
  }
134
172
  }