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,65 +1,104 @@
1
1
  /**
2
- * Finish a migration: put a profile on disk and make the project run it.
2
+ * Put a project on a profile: render it, write it, install its skills.
3
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.
4
+ * "Active" is not a field in the manifest — it is the per-developer marker
5
+ * `.claude/profiles/active.json` that activation writes (see `activeProfile`).
6
+ * A manifest nothing has activated leaves the project exactly as it was.
10
7
  *
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:
8
+ * Activation, in order:
13
9
  *
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
10
+ * 1. refuse, changing nothing, when a live file magus manages exists and
11
+ * does not parse — magus never writes over a file it cannot read
12
+ * 2. convert an older layout's skills (`.claude/_profiles/`), restoring any
13
+ * committed custom skills it displaced
14
+ * 3. refuse — before anything else changes — when the profile lists a skill
15
+ * whose folder is already a custom skill
16
+ * 4. gitignore the generated files, before they exist, in magus's own block
17
+ * 5. write settings.json, .mcp.json and models.json from the manifest; only
18
+ * at first adoption (no marker) is a hand-written file moved aside first
19
+ * 6. install/remove magus-installed skill folders; custom ones are untouched
20
+ * 7. record the marker, then move what is left of `.claude/_profiles/` into
21
+ * `.claude/.magus-backups/<stamp>-layout/` — kept, never deleted
18
22
  *
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`.
23
+ * Callers absorb the live files into the PREVIOUS profile before switching
24
+ * away (`profile-state.ts`) — including, for an older layout, what Claude
25
+ * Code wrote through its links — so what step 5 replaces is already in the
26
+ * manifest. No network except skill installs.
22
27
  */
23
28
 
29
+ import { lstat } from "node:fs/promises";
24
30
  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 type { ProfileManifest, ProfileSkillRef } from "../types/index.js";
32
+ import { updateManagedGitignoreBlock } from "./conventions-manager.js";
33
+ import { isTrackedByGit } from "./git-worktree.js";
34
+ import { readManifest } from "./manifest.js";
31
35
  import {
36
+ type ActiveMarker,
32
37
  type DisplacedArtifact,
38
+ LIVE_FILE_KEYS,
39
+ OBSOLETE_GITIGNORE_ENTRIES,
40
+ PROFILE_GITIGNORE_COMMENT,
33
41
  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 () => [];
42
+ assertLiveFilesParse,
43
+ backupRoot,
44
+ backupStamp,
45
+ displace,
46
+ folderHash,
47
+ generatedPaths,
48
+ hasCurrentMarker,
49
+ liveKind,
50
+ livePath,
51
+ markerFor,
52
+ oldLayoutProfile,
53
+ readActiveMarker,
54
+ skillsRoot,
55
+ writeActiveMarker,
56
+ writeLiveFile,
57
+ writeRenderedFiles,
58
+ } from "./profile-live.js";
59
+ import { renderProfile } from "./profile-render.js";
60
+ import {
61
+ type LegacySkillConversion,
62
+ SkillClashError,
63
+ type SkillInstaller,
64
+ type SkillSyncResult,
65
+ archiveOlderLayout,
66
+ convertLegacySkills,
67
+ findSkillClashes,
68
+ installProfileSkill,
69
+ skillFolder,
70
+ syncSkills,
71
+ updateSkillIgnoreLines,
72
+ } from "./profile-skills.js";
73
+ import type { ApplyReporter } from "./update-engine.js";
44
74
 
45
75
  export interface ActivationResult {
46
76
  /** The profile now live. */
47
77
  profileId: string;
48
- /** Hand-written artifacts moved aside to make room for the symlinks. */
78
+ /** Hand-written files moved aside at first adoption. */
49
79
  displaced: DisplacedArtifact[];
80
+ /** What happened to the profile's skill folders. */
81
+ skills: SkillSyncResult;
82
+ /** What converting an older layout did, if there was one. */
83
+ conversion: LegacySkillConversion;
84
+ /** Generated paths git still tracks (see {@link trackedGeneratedFiles}). */
85
+ tracked: string[];
86
+ /** Where `.claude/_profiles/` was kept, project-relative, or null. */
87
+ keptAt: string | null;
88
+ /** Unsaved changes added to a profile before activating, one line each. */
89
+ absorbed?: string[];
90
+ }
91
+
92
+ export interface ActivateOptions {
93
+ /** Called as each hand-written file moves; defaults to printing it. */
94
+ onDisplace?: (artifact: DisplacedArtifact) => void;
95
+ /** Skill installer; the real one fetches from GitHub. */
96
+ installSkill?: SkillInstaller;
50
97
  }
51
98
 
52
99
  /**
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.
100
+ * Announce one displaced artifact, in project-relative terms, the instant it
101
+ * moves — so a throw partway through still says where the first one went.
63
102
  */
64
103
  export function reportDisplaced(
65
104
  projectPath: string,
@@ -70,78 +109,291 @@ export function reportDisplaced(
70
109
  }
71
110
 
72
111
  /**
73
- * Materialize `profileId` and make it the active profile.
112
+ * Generated paths that git tracks. A .gitignore entry does not untrack a file
113
+ * that was committed before it, so each one is reported with the fix. magus
114
+ * never runs `git rm` itself.
115
+ */
116
+ export async function trackedGeneratedFiles(
117
+ projectPath: string,
118
+ ): Promise<string[]> {
119
+ const out: string[] = [];
120
+ for (const abs of generatedPaths(projectPath)) {
121
+ const rel = path.relative(projectPath, abs);
122
+ // `.claude/skills` holds committed custom skills by design; only a
123
+ // tracked symlink there is generated state.
124
+ if (rel === path.join(".claude", "skills")) {
125
+ if (await isTrackedByGit(projectPath, rel)) {
126
+ try {
127
+ if ((await lstat(abs)).isSymbolicLink()) out.push(rel);
128
+ } catch {
129
+ // Deleted in the working tree but still tracked as a link.
130
+ out.push(rel);
131
+ }
132
+ }
133
+ continue;
134
+ }
135
+ if (await isTrackedByGit(projectPath, rel)) out.push(rel);
136
+ }
137
+ return out;
138
+ }
139
+
140
+ /** How to untrack one generated path. */
141
+ export function untrackCommand(rel: string): string {
142
+ return `git rm --cached ${rel}`;
143
+ }
144
+
145
+ /**
146
+ * Lines describing what activation did beyond the switch itself, phrased for
147
+ * both the terminal and the TUI's outcome modal.
148
+ */
149
+ export function activationNotes(result: ActivationResult): string[] {
150
+ const lines: string[] = [...(result.absorbed ?? [])];
151
+ for (const folder of result.conversion.restored) {
152
+ lines.push(
153
+ `Restored custom skill .claude/skills/${folder} (the older profile layout had moved it)`,
154
+ );
155
+ }
156
+ for (const c of result.conversion.conflicts) {
157
+ lines.push(
158
+ `Skill "${c.folder}" exists in two versions; kept ${c.kept}, the other is at ${c.other}`,
159
+ );
160
+ }
161
+ for (const f of result.skills.failed) {
162
+ lines.push(`Skill "${f.skill}" could not be installed: ${f.reason}`);
163
+ }
164
+ for (const rel of result.tracked) {
165
+ lines.push(
166
+ `${rel} is generated but tracked by git — run \`${untrackCommand(rel)}\`, then commit`,
167
+ );
168
+ }
169
+ if (result.keptAt) {
170
+ lines.push(
171
+ `The older layout's .claude/_profiles/ was kept at ${result.keptAt}/`,
172
+ );
173
+ }
174
+ return lines;
175
+ }
176
+
177
+ /**
178
+ * Render `profileId` from `manifest` and make it the active profile.
74
179
  *
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.
180
+ * Throws `SettingsParseError`, having changed nothing, when a live file of a
181
+ * managed project does not parse. Throws {@link SkillClashError} — having
182
+ * changed nothing but moving an older layout's skills into `.claude/skills/` —
183
+ * when a listed skill's folder is already a custom skill.
80
184
  */
81
- export async function materializeAndActivate(
185
+ export async function activateProfile(
82
186
  manifest: ProfileManifest,
83
187
  profileId: string,
84
188
  projectPath: string,
85
- /** Called as each artifact moves; defaults to printing it. */
86
- onDisplace: (artifact: DisplacedArtifact) => void = reportDisplaced(
87
- projectPath,
88
- ),
189
+ opts: ActivateOptions = {},
89
190
  ): Promise<ActivationResult> {
90
- const closure = await resolveProfile(manifest, profileId, {
91
- binResolver: noBins,
92
- });
93
- const union = await resolveAllProfiles(manifest, { binResolver: noBins });
191
+ const onDisplace = opts.onDisplace ?? reportDisplaced(projectPath);
192
+ const rendered = await renderProfile(manifest, profileId);
193
+ const skillRefs = rendered.entry.skills ?? [];
94
194
 
95
- await materializeProfile(
96
- profileId,
97
- closure,
98
- projectPath,
99
- Object.keys(union.plugins),
100
- );
195
+ // Read before converting: the older marker lives in the directory the
196
+ // conversion moves away, and an older layout is recognised by its links.
197
+ const previous = await readActiveMarker(projectPath);
198
+ const wasOldLayout = (await oldLayoutProfile(projectPath)) !== null;
199
+ // A managed project's live files are the profile's; one that does not
200
+ // parse is somebody mid-edit, and replacing it would lose their work. (At
201
+ // first adoption a hand-written file is moved to a backup, not replaced.)
202
+ if (previous !== null) await assertLiveFilesParse(projectPath);
203
+ const conversion = await convertLegacySkills(manifest, projectPath);
204
+ const managedBefore: Record<string, string> = {
205
+ ...(previous?.skills ?? {}),
206
+ ...conversion.managed,
207
+ };
208
+
209
+ const clashes = await findSkillClashes(skillRefs, managedBefore, projectPath);
210
+ if (clashes.length > 0) throw new SkillClashError(profileId, clashes);
101
211
 
102
- await ensureGitignoreEntries(
212
+ await updateManagedGitignoreBlock(
103
213
  path.join(projectPath, ".gitignore"),
104
- PROFILE_GITIGNORE_ENTRIES,
105
- "# magus: generated profile build output + active-profile symlinks",
214
+ PROFILE_GITIGNORE_COMMENT,
215
+ {
216
+ add: PROFILE_GITIGNORE_ENTRIES,
217
+ // Only while converting an older layout, and only from magus's own
218
+ // block: the blanket `.claude/skills` line it wrote hid committed
219
+ // custom skills. The same line a person wrote elsewhere is theirs.
220
+ remove: conversion.converted ? OBSOLETE_GITIGNORE_ENTRIES : [],
221
+ },
106
222
  );
107
223
 
108
- const displaced = await activateProfile(profileId, projectPath, onDisplace);
109
- return { profileId, displaced };
110
- }
224
+ const backupDir = path.join(backupRoot(projectPath), backupStamp());
225
+ const displaced: DisplacedArtifact[] = [];
226
+ const files: ActiveMarker["files"] = {};
227
+ for (const key of LIVE_FILE_KEYS) {
228
+ // First adoption only: nothing marks this project as magus's and it is
229
+ // not an older magus layout, so a real file here is somebody's.
230
+ if (
231
+ previous === null &&
232
+ !wasOldLayout &&
233
+ (await liveKind(key, projectPath)) !== "missing"
234
+ ) {
235
+ const moved = await displace(livePath(key, projectPath), backupDir);
236
+ if (moved) {
237
+ displaced.push(moved);
238
+ onDisplace(moved);
239
+ }
240
+ }
241
+ const hash = await writeLiveFile(key, rendered[key], projectPath);
242
+ if (hash) files[key] = hash;
243
+ // Recorded after EACH file, so a throw on the next cannot leave a file
244
+ // magus just wrote looking like an edit to absorb.
245
+ await writeActiveMarker(
246
+ markerFor(profileId, rendered, files, managedBefore),
247
+ projectPath,
248
+ );
249
+ }
111
250
 
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 };
251
+ const skills = await syncSkills(
252
+ skillRefs,
253
+ managedBefore,
254
+ projectPath,
255
+ opts.installSkill ?? installProfileSkill,
256
+ );
257
+ await updateSkillIgnoreLines(
258
+ projectPath,
259
+ Object.keys(managedBefore),
260
+ Object.keys(skills.skills),
261
+ );
262
+ await writeActiveMarker(
263
+ markerFor(profileId, rendered, files, skills.skills),
264
+ projectPath,
265
+ );
266
+ // Last, so a failure above leaves the older layout readable.
267
+ await archiveOlderLayout(projectPath, conversion.keptAt);
268
+
269
+ return {
270
+ profileId,
271
+ displaced,
272
+ skills,
273
+ conversion,
274
+ tracked: await trackedGeneratedFiles(projectPath),
275
+ keptAt: conversion.keptAt
276
+ ? path.relative(projectPath, conversion.keptAt)
277
+ : null,
278
+ };
279
+ }
116
280
 
117
281
  /**
118
- * Carry out the user's choice, whichever surface asked.
282
+ * Re-render the ACTIVE profile's settings.json, .mcp.json and models.json from
283
+ * the manifest and record them, leaving skills alone.
119
284
  *
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.
285
+ * For a command that has just changed the manifest (`models use`, a style) or
286
+ * let the Claude CLI write its own bookkeeping into settings.json (`magus
287
+ * install`, `magus update`). Anything Claude Code wrote was absorbed before the
288
+ * command acted, so replacing the files loses nothing.
289
+ *
290
+ * Returns false, writing nothing, when there is no current-format marker
291
+ * naming a defined profile. An older layout is NOT converted here: conversion
292
+ * must first record what Claude Code wrote through the old links, which is the
293
+ * entry-point gate's job (`prepareProfileState`). Throws `SettingsParseError`,
294
+ * writing nothing, when a live file does not parse.
124
295
  */
125
- export async function applyProfileChoice(
126
- choice: ProfileChoice,
127
- manifest: ProfileManifest,
296
+ export async function regenerateLiveFiles(
128
297
  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(
298
+ ): Promise<boolean> {
299
+ if (!(await hasCurrentMarker(projectPath))) return false;
300
+ const marker = await readActiveMarker(projectPath);
301
+ const manifest = await readManifest(projectPath);
302
+ if (!marker || !manifest.profiles[marker.profile]) return false;
303
+ await assertLiveFilesParse(projectPath);
304
+ const rendered = await renderProfile(manifest, marker.profile);
305
+ const files = await writeRenderedFiles(rendered, projectPath);
306
+ await writeActiveMarker(
307
+ markerFor(marker.profile, rendered, files, marker.skills),
135
308
  projectPath,
136
- undefined,
137
- manifest,
138
- choice.id,
139
309
  );
140
- await writeManifest(result.manifest, projectPath);
141
- return materializeAndActivate(
142
- result.manifest,
143
- choice.id,
310
+ return true;
311
+ }
312
+
313
+ /**
314
+ * The profile skills `magus update` may fetch into `.claude/skills/`: only for
315
+ * the ACTIVE profile (another profile's skills would land as folders nothing
316
+ * records, which read as custom skills), and only into a folder that is
317
+ * magus-installed or absent — never over a custom skill.
318
+ */
319
+ export async function updatableSkills(
320
+ projectPath: string,
321
+ profileId: string,
322
+ refs: readonly ProfileSkillRef[],
323
+ ): Promise<ProfileSkillRef[]> {
324
+ const marker = await readActiveMarker(projectPath);
325
+ if (!marker || marker.profile !== profileId) return [];
326
+ const out: ProfileSkillRef[] = [];
327
+ for (const ref of refs) {
328
+ const folder = skillFolder(ref);
329
+ if (folder in marker.skills) {
330
+ out.push(ref);
331
+ continue;
332
+ }
333
+ try {
334
+ await lstat(path.join(skillsRoot(projectPath), folder));
335
+ } catch {
336
+ out.push(ref);
337
+ }
338
+ }
339
+ return out;
340
+ }
341
+
342
+ /**
343
+ * Which skill folders an apply actually fetched, read off its reporter: a
344
+ * skill counts once its item finishes successfully. Pass the returned
345
+ * `report` to the apply and `fetched()` to {@link recordRefetchedSkills} — a
346
+ * skill that was not fetched keeps its old hash, so an edit someone made to it
347
+ * during the run is still seen as an edit, not recorded as magus's own.
348
+ */
349
+ export function trackFetchedSkills(
350
+ refs: readonly ProfileSkillRef[],
351
+ report: ApplyReporter,
352
+ ): { report: ApplyReporter; fetched: () => string[] } {
353
+ const folders = new Map(refs.map((ref) => [ref.name, skillFolder(ref)]));
354
+ const fetched = new Set<string>();
355
+ return {
356
+ report: {
357
+ begin: (name) => report.begin(name),
358
+ finish: (ok, name, detail, ms) => {
359
+ const folder = folders.get(name);
360
+ if (ok && folder) fetched.add(folder);
361
+ report.finish(ok, name, detail, ms);
362
+ },
363
+ detach: (work) => report.detach(work),
364
+ },
365
+ fetched: () => [...fetched],
366
+ };
367
+ }
368
+
369
+ /**
370
+ * After `magus update` re-fetched skills in place: record those folders as
371
+ * magus-installed with their new contents, so they do not read as edited.
372
+ * Only the folders it fetched (see {@link trackFetchedSkills}).
373
+ * Keeps every other part of the marker — including a `cliBookkeeping` flag,
374
+ * which the reconcile that follows (`withCliBookkeeping`) clears.
375
+ */
376
+ export async function recordRefetchedSkills(
377
+ projectPath: string,
378
+ skillFolders: readonly string[],
379
+ ): Promise<void> {
380
+ if (!(await hasCurrentMarker(projectPath))) return;
381
+ const marker = await readActiveMarker(projectPath);
382
+ if (!marker) return;
383
+ const skills = { ...marker.skills };
384
+ for (const folder of skillFolders) {
385
+ const hash = await folderHash(path.join(skillsRoot(projectPath), folder));
386
+ if (hash) skills[folder] = hash;
387
+ }
388
+ await writeActiveMarker({ ...marker, skills }, projectPath);
389
+ await updateSkillIgnoreLines(
144
390
  projectPath,
145
- onDisplace,
391
+ Object.keys(marker.skills),
392
+ Object.keys(skills),
146
393
  );
147
394
  }
395
+
396
+ /** The two ways a project can be put onto a profile. */
397
+ export type ProfileChoice =
398
+ | { kind: "adopt"; id: string }
399
+ | { kind: "activate"; id: string };