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.
@@ -8,17 +8,74 @@
8
8
  * stands, then the original command proceeds against it normally.
9
9
  */
10
10
 
11
+ import path from "node:path";
11
12
  import {
12
13
  getManifestPath,
13
14
  readManifest,
14
15
  writeManifest,
15
16
  } from "../services/manifest.js";
17
+ import {
18
+ applyProfileChoice,
19
+ materializeAndActivate,
20
+ reportDisplaced,
21
+ } from "../services/profile-activate.js";
16
22
  import {
17
23
  type AdoptResult,
18
24
  adoptCurrentProject,
19
25
  } from "../services/profile-adopt.js";
26
+ import {
27
+ buildProfileOptions,
28
+ defaultOptionIndex,
29
+ describeNeed,
30
+ detectProfileStartupNeed,
31
+ needsNothing,
32
+ parseProfileChoice,
33
+ } from "../services/profile-startup.js";
20
34
  import type { ProfileManifest } from "../types/index.js";
35
+ import { dim } from "./ansi.js";
21
36
  import { confirm } from "./prompt.js";
37
+ import { selectDialog } from "./tui-prompt.js";
38
+ import { fail as failLine, note as noteLine } from "./update-view.js";
39
+
40
+ /**
41
+ * Write the adopted manifest, then put the project ON the profile it describes.
42
+ *
43
+ * Both halves or neither. Writing alone is what made migrating feel like a
44
+ * no-op: `activeProfile()` is derived from the symlink, so a manifest nothing
45
+ * links to changes no behaviour, and the run that just migrated still has to
46
+ * ask which profile to use.
47
+ *
48
+ * Activation is reported in full, including anything it moved aside, because it
49
+ * replaces files the user wrote by hand. A migration is allowed to change the
50
+ * working tree; it is not allowed to do it quietly.
51
+ */
52
+ async function writeAndActivate(
53
+ result: AdoptResult,
54
+ projectPath: string,
55
+ ): Promise<void> {
56
+ // Relative to the project throughout. These are all project paths, and an
57
+ // absolute one wraps over two terminal lines — several wrapped lines in a row
58
+ // is where a reader stops reading, and this notice is the one that says where
59
+ // their hand-written files went.
60
+ const rel = (p: string) => path.relative(projectPath, p);
61
+
62
+ await writeManifest(result.manifest, projectPath);
63
+ console.log(`✓ Wrote ${rel(getManifestPath(projectPath))}`);
64
+
65
+ // Each move is printed as it happens, not from the returned list afterwards.
66
+ // Activation touches up to four artifacts in sequence, so a throw on the
67
+ // third would discard the record of the first two — and the first of those
68
+ // is the user's `.claude/settings.json`, now absent from where they left it.
69
+ // Printing first means a failed activation still says where everything went.
70
+ await materializeAndActivate(
71
+ result.manifest,
72
+ result.profileId,
73
+ projectPath,
74
+ reportDisplaced(projectPath),
75
+ );
76
+ console.log(`✓ Active profile: ${result.profileId}`);
77
+ console.log();
78
+ }
22
79
 
23
80
  /** Print what adoption would capture, so the user can judge it before writing. */
24
81
  function printAdoptionPlan(result: AdoptResult): void {
@@ -64,6 +121,16 @@ function printAdoptionPlan(result: AdoptResult): void {
64
121
  console.log(
65
122
  "\nCommit the file to share this setup with the team; edit it to pin versions.",
66
123
  );
124
+ // Said before the question, not after it: adopting also SWITCHES the project
125
+ // onto the profile, which replaces two files that are in the working tree
126
+ // right now. The prompt below is the only place to decline.
127
+ console.log(
128
+ "The project then runs this profile: .claude/settings.json, .mcp.json and",
129
+ );
130
+ console.log(
131
+ ".claude/skills become links into it, and your current copies are kept where",
132
+ );
133
+ console.log("the run reports.");
67
134
  }
68
135
 
69
136
  export interface EnsureManifestOptions {
@@ -123,12 +190,111 @@ export async function ensureManifest(
123
190
  "\nRefusing to create .claude/profiles.json without a human: run `magus profile init` to create it deliberately.",
124
191
  );
125
192
  return null;
126
- } else if (!(await confirm("\nCreate .claude/profiles.json?"))) {
193
+ } else if (!(await confirm("\nCreate it and switch to it?"))) {
127
194
  console.log("Aborted — nothing written.");
128
195
  return null;
129
196
  }
130
197
 
131
- await writeManifest(result.manifest, projectPath);
132
- console.log(`✓ Wrote ${getManifestPath(projectPath)}\n`);
198
+ await writeAndActivate(result, projectPath);
133
199
  return result.manifest;
134
200
  }
201
+
202
+ /**
203
+ * Require an active profile, or stop the command.
204
+ *
205
+ * magus manages a project THROUGH its active profile: that is where plugin
206
+ * state is read from and where updates are written. With none there is nothing
207
+ * to read and nowhere to write, so there is no useful work to do — and the
208
+ * previous version of this offered exactly that anyway. It asked only when the
209
+ * manifest had drifted, let the user decline, and `update` then carried on with
210
+ * a profile "for this run only", resolving against a manifest the project was
211
+ * not running. Two worlds live at once, which is the confusion being removed.
212
+ *
213
+ * So this asks whenever no profile is active, and the only answers are migrate,
214
+ * activate, or stop. Returns the manifest to carry on with, or null when the
215
+ * command must exit non-zero — by which point it has printed why.
216
+ *
217
+ * Nothing is compared. An earlier version weighed the manifest against
218
+ * `enabledPlugins`; see the header of `services/profile-startup.ts` for why
219
+ * that reads a profiled project's own generated output and cannot be acted on.
220
+ */
221
+ export async function requireActiveProfile(
222
+ projectPath: string,
223
+ manifest: ProfileManifest,
224
+ opts: EnsureManifestOptions = {},
225
+ ): Promise<ProfileManifest | null> {
226
+ const need = await detectProfileStartupNeed(projectPath);
227
+ if (needsNothing(need)) return manifest;
228
+
229
+ // The repo declares which profile it wants and there is nothing of the
230
+ // user's to replace — a fresh clone. Activate it and carry on, even under
231
+ // `--check` and with no TTY: this is the committed answer being applied, not
232
+ // a decision being made, and it is exactly the case CI hits.
233
+ if (need.autoActivate && need.declaredDefault !== null && !opts.check) {
234
+ const { profileId } = await applyProfileChoice(
235
+ { kind: "activate", id: need.declaredDefault },
236
+ need.manifest,
237
+ projectPath,
238
+ reportDisplaced(projectPath),
239
+ );
240
+ console.log(
241
+ `✓ Active profile: ${profileId} ${dim("(declared by this project)")}`,
242
+ );
243
+ console.log("");
244
+ return readManifest(projectPath);
245
+ }
246
+
247
+ const { title } = describeNeed(need);
248
+
249
+ // `--check` is a read-only CI gate, and "this project is not on a profile"
250
+ // is a legitimate thing for it to fail on rather than quietly repair.
251
+ if (opts.check) {
252
+ console.error(failLine(title));
253
+ console.error(noteLine("Run `magus update` at a terminal to resolve it."));
254
+ return null;
255
+ }
256
+
257
+ // CI has nobody to ask. Exiting beats hanging on an answer that cannot come.
258
+ if (!process.stdin.isTTY) {
259
+ console.error(failLine(title));
260
+ console.error(
261
+ noteLine(
262
+ need.state === "unmigrated"
263
+ ? "Run `magus profile init` to create one deliberately."
264
+ : `Run \`magus profile switch <${need.activate
265
+ .map((p) => p.id)
266
+ .join("|")}>\` first.`,
267
+ ),
268
+ );
269
+ return null;
270
+ }
271
+
272
+ console.log("");
273
+ const chosen = await selectDialog({
274
+ title,
275
+ message: describeNeed(need).message,
276
+ options: buildProfileOptions(need),
277
+ defaultIndex: defaultOptionIndex(need),
278
+ });
279
+
280
+ // Esc, Ctrl+C and the "Quit magus" row all land here, and there is no third
281
+ // outcome: the command stops rather than proceed without a profile.
282
+ const choice = chosen === null ? null : parseProfileChoice(chosen, need);
283
+ if (!choice) {
284
+ console.error(failLine("No active profile — stopping."));
285
+ return null;
286
+ }
287
+
288
+ const { profileId } = await applyProfileChoice(
289
+ choice,
290
+ need.manifest,
291
+ projectPath,
292
+ reportDisplaced(projectPath),
293
+ );
294
+ console.log(`✓ Active profile: ${profileId}`);
295
+ console.log("");
296
+
297
+ // Re-read rather than return `need.manifest`: adopting wrote a new profile
298
+ // into the file, and the caller resolves against whatever comes back.
299
+ return readManifest(projectPath);
300
+ }
@@ -27,6 +27,7 @@ import {
27
27
  } from "../services/install-plan.js";
28
28
  import { validateManifest } from "../services/manifest.js";
29
29
  import { registerClosureMarketplaces } from "../services/marketplace-sync.js";
30
+ import { reportDisplaced } from "../services/profile-activate.js";
30
31
  import { materializeProfile } from "../services/profile-materializer.js";
31
32
  import { detectProfileDrift } from "../services/profile-sync.js";
32
33
  import { resolveAllProfiles, resolveProfile } from "../services/resolver.js";
@@ -355,7 +356,10 @@ export async function runInstallCommand(
355
356
  const activeId =
356
357
  flags.profile ?? (await pickActiveProfile(profileIds, flags.yes));
357
358
  if (activeId) {
358
- await activateProfile(activeId, projectPath);
359
+ // The reporter is not optional here. The first `install` in a project that
360
+ // had a hand-written .claude/settings.json moves it aside, and without
361
+ // this the move is invisible — the file is simply not where it was.
362
+ await activateProfile(activeId, projectPath, reportDisplaced(projectPath));
359
363
  console.log(`\n✓ Active profile: ${activeId}`);
360
364
  // Skills route through the now-active .claude/skills symlink.
361
365
  const activeClosure = await resolveProfile(manifest, activeId);
@@ -13,6 +13,7 @@ import { ensureGitignoreEntries } from "../services/conventions-manager.js";
13
13
  import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
14
14
  import { summarizeClosure } from "../services/install-plan.js";
15
15
  import { getManifestPath, readManifest } from "../services/manifest.js";
16
+ import { reportDisplaced } from "../services/profile-activate.js";
16
17
  import { syncProfile } from "../services/profile-sync.js";
17
18
  import { resolveProfile } from "../services/resolver.js";
18
19
  import {
@@ -147,7 +148,10 @@ export async function switchProfile(
147
148
  "# magus: generated profile build output + active-profile symlinks",
148
149
  );
149
150
 
150
- await activateProfile(name, projectPath);
151
+ // Switching between two already-active profiles displaces nothing — the paths
152
+ // are links magus owns. The FIRST switch in an unmanaged project displaces
153
+ // real files, and that is the run that must say so.
154
+ await activateProfile(name, projectPath, reportDisplaced(projectPath));
151
155
  console.log(`✓ Switched to profile: ${name}`);
152
156
 
153
157
  // Verify the profile's binary deps are actually usable — the switch itself
package/src/cli/prompt.ts CHANGED
@@ -8,25 +8,57 @@
8
8
 
9
9
  import { createInterface } from "node:readline/promises";
10
10
 
11
- /** Y/n prompt. Empty input means yes. */
12
- export async function confirm(question: string): Promise<boolean> {
13
- const rl = createInterface({ input: process.stdin, output: process.stdout });
14
- try {
15
- const answer = (await rl.question(`${question} [Y/n] `))
16
- .trim()
17
- .toLowerCase();
18
- return answer === "" || answer === "y" || answer === "yes";
19
- } finally {
20
- rl.close();
11
+ /**
12
+ * Ctrl+C at a prompt.
13
+ *
14
+ * `readline/promises` rejects `question()` with an `AbortError` carrying
15
+ * `code: "ABORT_ERR"`. That is a Node internal, and letting it escape printed
16
+ * nine frames of `internal:repl/node-errors` under "Error starting magus:" —
17
+ * a stack trace for the one input every terminal user knows how to give.
18
+ *
19
+ * It is converted here rather than at each call site so no future prompt can
20
+ * forget, and it stays a throw rather than a sentinel return so a caller that
21
+ * ignores it aborts instead of proceeding with a missing answer.
22
+ */
23
+ export class PromptAborted extends Error {
24
+ constructor() {
25
+ super("Aborted with Ctrl+C");
26
+ this.name = "PromptAborted";
21
27
  }
22
28
  }
23
29
 
24
- /** Free-text prompt, trimmed. */
25
- export async function promptValue(question: string): Promise<string> {
30
+ /** True for a Ctrl+C at a prompt, ours or readline's own. */
31
+ export function isPromptAborted(error: unknown): boolean {
32
+ if (error instanceof PromptAborted) return true;
33
+ // Belt and braces: an abort raised somewhere that does not route through
34
+ // `ask` still reads as a cancellation rather than a crash.
35
+ return (
36
+ typeof error === "object" &&
37
+ error !== null &&
38
+ (error as { code?: unknown }).code === "ABORT_ERR"
39
+ );
40
+ }
41
+
42
+ /** One readline question, with Ctrl+C normalised. */
43
+ async function ask(question: string): Promise<string> {
26
44
  const rl = createInterface({ input: process.stdin, output: process.stdout });
27
45
  try {
28
46
  return (await rl.question(question)).trim();
47
+ } catch (error) {
48
+ if (isPromptAborted(error)) throw new PromptAborted();
49
+ throw error;
29
50
  } finally {
30
51
  rl.close();
31
52
  }
32
53
  }
54
+
55
+ /** Y/n prompt. Empty input means yes. */
56
+ export async function confirm(question: string): Promise<boolean> {
57
+ const answer = (await ask(`${question} [Y/n] `)).toLowerCase();
58
+ return answer === "" || answer === "y" || answer === "yes";
59
+ }
60
+
61
+ /** Free-text prompt, trimmed. */
62
+ export async function promptValue(question: string): Promise<string> {
63
+ return ask(question);
64
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * One OpenTUI dialog, for a command that is otherwise not a TUI.
3
+ *
4
+ * `magus update` prints a report and exits; it is not the interactive app. But
5
+ * a question it must ask deserves the same dialog the TUI asks with, so this
6
+ * mounts the REAL `SelectModal` — the component tab 5 and tab 0 use — for the
7
+ * length of one question, then tears the renderer down and lets the command
8
+ * carry on printing. Nothing here re-draws a list, a border or a highlight: the
9
+ * component and the theme own all of that, and a second implementation is how
10
+ * the two surfaces drift apart.
11
+ *
12
+ * Every import of `@opentui/*` and `ui/` is DYNAMIC. `main.tsx` splits the TUI
13
+ * out precisely so `magus hook …`, which Claude Code runs on every matching
14
+ * tool call, never pays to load the renderer — a static import here would undo
15
+ * that for every command that imports this module's file, whether or not it
16
+ * ever asks a question.
17
+ *
18
+ * `screenMode: "main-screen"` keeps the dialog in the scrollback beside the
19
+ * report. The alternate screen would take the terminal over and then hand back
20
+ * a screen with no trace the question was ever asked.
21
+ */
22
+
23
+ import type { SelectOption } from "../ui/state/types.js";
24
+
25
+ export interface SelectDialogInput {
26
+ title: string;
27
+ message: string;
28
+ options: SelectOption[];
29
+ /** Row to start on. Defaults to the first. */
30
+ defaultIndex?: number;
31
+ }
32
+
33
+ /**
34
+ * Ask the user to pick one option. Resolves to the chosen value, or null when
35
+ * they cancel with Esc or Ctrl+C — the caller decides whether that is fatal.
36
+ *
37
+ * Falls back to `null` when stdin or stdout is not a TTY. A renderer needs both:
38
+ * `magus update > file` has a TTY stdin and a pipe for stdout, and mounting
39
+ * there writes escape sequences into the file.
40
+ */
41
+ export async function selectDialog(
42
+ input: SelectDialogInput,
43
+ ): Promise<string | null> {
44
+ if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
45
+ return null;
46
+ }
47
+
48
+ const [{ createCliRenderer }, { createRoot }, React, modals, dimensions] =
49
+ await Promise.all([
50
+ import("@opentui/core"),
51
+ import("@opentui/react"),
52
+ import("react"),
53
+ import("../ui/components/modals/index.js"),
54
+ import("../ui/state/DimensionsContext.js"),
55
+ ]);
56
+
57
+ const renderer = await createCliRenderer({
58
+ screenMode: "main-screen",
59
+ // BOTH, so the renderer registers no handler of its own and teardown
60
+ // below is the only path. With either left at its default, a Ctrl+C
61
+ // destroys the renderer underneath this promise and it never settles.
62
+ exitSignals: [],
63
+ exitOnCtrlC: false,
64
+ });
65
+
66
+ const root = createRoot(renderer);
67
+
68
+ return await new Promise<string | null>((resolve) => {
69
+ // Clamped, not trusted: a caller computing an index from a list it built
70
+ // separately could hand over one that is out of range, and a cursor
71
+ // pointing past the end makes Enter resolve to null.
72
+ let index = Math.min(
73
+ Math.max(0, input.defaultIndex ?? 0),
74
+ Math.max(0, input.options.length - 1),
75
+ );
76
+ let done = false;
77
+
78
+ const finish = (value: string | null): void => {
79
+ if (done) return;
80
+ done = true;
81
+ renderer.keyInput.off("keypress", onKey);
82
+ root.unmount();
83
+ renderer.destroy();
84
+ resolve(value);
85
+ };
86
+
87
+ function onKey(key: {
88
+ name?: string;
89
+ ctrl?: boolean;
90
+ sequence?: string;
91
+ }): void {
92
+ if ((key.ctrl && key.name === "c") || key.name === "escape") {
93
+ finish(null);
94
+ return;
95
+ }
96
+ if (key.name === "return" || key.name === "enter") {
97
+ finish(input.options[index]?.value ?? null);
98
+ return;
99
+ }
100
+ if (key.name === "up" || key.name === "k") {
101
+ index = (index - 1 + input.options.length) % input.options.length;
102
+ draw();
103
+ return;
104
+ }
105
+ if (key.name === "down" || key.name === "j") {
106
+ index = (index + 1) % input.options.length;
107
+ draw();
108
+ return;
109
+ }
110
+ // The numbers stay live: the list is short, and the plain-text prompt
111
+ // this replaced numbered it, so the habit already exists.
112
+ const digit = Number(key.sequence);
113
+ if (
114
+ Number.isInteger(digit) &&
115
+ digit >= 1 &&
116
+ digit <= input.options.length
117
+ ) {
118
+ index = digit - 1;
119
+ finish(input.options[index]?.value ?? null);
120
+ }
121
+ }
122
+
123
+ const draw = (): void => {
124
+ root.render(
125
+ React.createElement(
126
+ dimensions.DimensionsProvider,
127
+ null,
128
+ React.createElement(modals.SelectModal, {
129
+ title: input.title,
130
+ message: input.message,
131
+ options: input.options,
132
+ defaultIndex: index,
133
+ onSelect: finish,
134
+ onCancel: () => finish(null),
135
+ }),
136
+ ),
137
+ );
138
+ };
139
+
140
+ renderer.keyInput.on("keypress", onKey);
141
+ draw();
142
+ });
143
+ }
@@ -37,6 +37,7 @@ import { formatCommand } from "../utils/command-utils.js";
37
37
  import {
38
38
  badge,
39
39
  bold,
40
+ dialog,
40
41
  dim,
41
42
  elapsed,
42
43
  fg,
package/src/cli/update.ts CHANGED
@@ -70,8 +70,9 @@ import {
70
70
  } from "../services/update-plan.js";
71
71
  import { brand } from "../ui/theme.js";
72
72
  import { bold, dim, fg, width } from "./ansi.js";
73
- import { ensureManifest } from "./bootstrap.js";
73
+ import { ensureManifest, requireActiveProfile } from "./bootstrap.js";
74
74
  import { LiveRegion, withPaused } from "./live.js";
75
+ import { selectDialog } from "./tui-prompt.js";
75
76
  import {
76
77
  type ApplyState,
77
78
  type Step,
@@ -190,8 +191,42 @@ export async function runUpdateCommand(
190
191
  return 1;
191
192
  }
192
193
 
193
- const profileIds = Object.keys(manifest.profiles);
194
- if (flags.profile && !manifest.profiles[flags.profile]) {
194
+ // No active profile, no update: that is where plugin state is read from and
195
+ // where changes are written. Asked before the profile is resolved, so what
196
+ // the user activates here is what this run uses.
197
+ //
198
+ // BOTH `--check` and `--dry-run` are read-only here. An earlier version let
199
+ // `--dry-run` activate, reasoning that a preview needs a real profile to
200
+ // report against — but activating writes symlinks and edits a TRACKED
201
+ // `.gitignore`, and `--dry-run` promises to write nothing and exit 0. A
202
+ // preview that mutates the repo is not a preview.
203
+ //
204
+ // So a read-only run refuses instead, and the two flags differ only in what
205
+ // that refusal MEANS: `--check` is a gate, so it fails; `--dry-run` is a
206
+ // preview, and "nothing to preview until a profile is active" is a legitimate
207
+ // preview result rather than an error.
208
+ //
209
+ // 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
211
+ // to project scope either way. Gating it would refuse a question the user
212
+ // already answered on the command line, and would break `magus update lean`
213
+ // and `--check lean` in CI, both of which worked before this gate existed.
214
+ // ANY positional argument skips it, not just a valid one. A wrong name has to
215
+ // reach the "Profile not found. Available: …" check below, which says what
216
+ // the user got wrong; gating it first answers a different question — "no
217
+ // profile is selected" — which is true, unhelpful, and not the mistake.
218
+ const current =
219
+ flags.profile !== undefined
220
+ ? manifest
221
+ : await requireActiveProfile(projectPath, manifest, {
222
+ check: flags.check || flags.dryRun,
223
+ });
224
+ // A refused dry run exits 0: it wrote nothing and previewed nothing, which is
225
+ // what it promised. A refused `--check` exits 1, because that is a gate.
226
+ if (!current) return flags.dryRun ? 0 : 1;
227
+
228
+ const profileIds = Object.keys(current.profiles);
229
+ if (flags.profile && !current.profiles[flags.profile]) {
195
230
  console.error(
196
231
  failLine(
197
232
  `Profile "${flags.profile}" not found. Available: ${profileIds.join(", ")}`,
@@ -204,19 +239,27 @@ export async function runUpdateCommand(
204
239
  // use. A single-profile manifest needs no activation to be unambiguous.
205
240
  // Chosen through the same function the TUI uses, so both judge a plugin
206
241
  // against the same pin.
207
- const targetId = selectProfileId(
242
+ const resolvedId = selectProfileId(
208
243
  profileIds,
209
244
  flags.profile,
210
245
  await activeProfile(projectPath),
211
246
  );
212
- if (!targetId) {
247
+
248
+ // `requireActiveProfile` above has already guaranteed one is active, so rule
249
+ // 2 of `selectProfileId` resolves and this is only unreachable-guard. There
250
+ // is deliberately no "pick one for this run" fallback any more: it used to
251
+ // ask here and then update a profile the project was not running, which is
252
+ // the two-worlds behaviour the gate exists to end. An explicit argument is
253
+ // still honoured — naming a profile is naming intent.
254
+ if (!resolvedId) {
213
255
  console.error(
214
256
  failLine(
215
- `No active profile. Pass one: magus update <${profileIds.join("|")}>`,
257
+ `No active profile. Run \`magus profile switch <${profileIds.join("|")}>\`.`,
216
258
  ),
217
259
  );
218
260
  return 1;
219
261
  }
262
+ const targetId = resolvedId;
220
263
 
221
264
  for (const line of header(targetId)) console.log(line);
222
265
 
@@ -243,7 +286,7 @@ export async function runUpdateCommand(
243
286
 
244
287
  const closure = await runStep(steps.profile, async (say) => {
245
288
  say(`resolving "${targetId}"`);
246
- const resolved = await resolveProfile(manifest, targetId);
289
+ const resolved = await resolveProfile(current, targetId);
247
290
  steps.profile.detail = [
248
291
  `${Object.keys(resolved.plugins).length} plugins`,
249
292
  `${resolved.bins.length} tools`,
package/src/main.tsx CHANGED
@@ -104,7 +104,16 @@ async function main(): Promise<void> {
104
104
  });
105
105
  }
106
106
 
107
- main().catch((error) => {
107
+ main().catch(async (error) => {
108
+ // Ctrl+C at a prompt is an answer — "stop" — not a failure. 130 is the
109
+ // shell's own convention for SIGINT (128 + 2), so `magus update && next`
110
+ // stops rather than running `next`. The newline lands the shell prompt on
111
+ // its own line instead of against the half-finished question.
112
+ const { isPromptAborted } = await import("./cli/prompt.js");
113
+ if (isPromptAborted(error)) {
114
+ process.stderr.write("\n");
115
+ process.exit(130);
116
+ }
108
117
  console.error("Error starting magus:", error);
109
118
  process.exit(1);
110
119
  });
@@ -52,6 +52,12 @@ export function migrateToV2(raw: unknown): ProfileManifest {
52
52
  // Only carry strictVersions when enabled, so a committed manifest that
53
53
  // doesn't opt in stays minimal (no spurious `"strictVersions": false`).
54
54
  if (obj.strictVersions === true) manifest.strictVersions = true;
55
+ // Carried through even when it names no existing profile: dropping it here
56
+ // would silently "fix" a typo that `validateManifest` is meant to report,
57
+ // and the commands that read it all check the id resolves first.
58
+ if (typeof obj.default === "string" && obj.default !== "") {
59
+ manifest.default = obj.default;
60
+ }
55
61
  return manifest;
56
62
  }
57
63
 
@@ -110,6 +116,21 @@ export function validateManifest(
110
116
  errors.push({ path: "profiles", message: "no profiles defined" });
111
117
  }
112
118
 
119
+ // A `default` naming nothing is worse than none: a fresh clone would fall
120
+ // through to asking, with no sign that the repo tried to answer and got the
121
+ // id wrong. Reported here so it fails the same gate every other manifest
122
+ // mistake does, rather than being discovered as silence.
123
+ if (manifest.default !== undefined) {
124
+ if (typeof manifest.default !== "string") {
125
+ errors.push({ path: "default", message: "must be a profile id" });
126
+ } else if (!ids.includes(manifest.default)) {
127
+ errors.push({
128
+ path: "default",
129
+ message: `no profile "${manifest.default}" — declared: ${ids.join(", ")}`,
130
+ });
131
+ }
132
+ }
133
+
113
134
  for (const [id, entry] of Object.entries(manifest.profiles ?? {})) {
114
135
  const base = `profiles.${id}`;
115
136
  if (!entry || typeof entry !== "object") {