drexler 0.2.14 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,11 +64,17 @@ Paste the key, hit return. Drexler saves it to `~/.config/drexler/config.json` (
64
64
 
65
65
  ## Update
66
66
 
67
+ ```bash
68
+ bun update -g drexler --latest
69
+ ```
70
+
71
+ If your Bun install does not have the package in its global lockfile, reinstall it instead:
72
+
67
73
  ```bash
68
74
  bun install -g drexler@latest
69
75
  ```
70
76
 
71
- Global installs replace the existing `drexler` package in Bun's global install location; they do not keep stacking duplicate app copies.
77
+ Global updates replace the existing `drexler` package in Bun's global install location; they do not keep stacking duplicate app copies.
72
78
 
73
79
  ## Uninstall
74
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drexler",
3
- "version": "0.2.14",
3
+ "version": "0.2.15",
4
4
  "description": "CLI chat with Drexler, a corporate-executive AI persona built on OpenRouter Gemma 4 31B.",
5
5
  "license": "MIT",
6
6
  "author": "showOS",
package/src/commands.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, writeFileSync } from "node:fs";
3
3
  import { resolve as pathResolve } from "node:path";
4
- import { resolveModel } from "./config.ts";
4
+ import {
5
+ getConfigPath,
6
+ getDrexlerVersion,
7
+ getResolvedConfigPath,
8
+ isValidApiKey,
9
+ resolveModel,
10
+ } from "./config.ts";
5
11
  import type { Conversation } from "./conversation.ts";
6
12
  import { error, resetMarkedTheme } from "./renderer.ts";
7
13
  import { THEME_NAMES, type Config, type ThemeName } from "./types.ts";
@@ -17,6 +23,14 @@ export type CommandAction =
17
23
  | { type: "exit"; message?: string }
18
24
  | { type: "regenerate"; instruction?: string; removedAssistant: boolean };
19
25
 
26
+ export type CommandGroup =
27
+ | "directives"
28
+ | "themes"
29
+ | "models"
30
+ | "startup"
31
+ | "retry"
32
+ | "export";
33
+
20
34
  interface CommandContext {
21
35
  conversation: Conversation;
22
36
  config: Config;
@@ -33,6 +47,14 @@ const HELP_TEXT = `New memo to staff! Drexler permit following directives:
33
47
  /clear - shred all documents (reset history)
34
48
  /exit - meeting adjourned
35
49
  /synergy - SYNERGY!
50
+ /feed - feed Drexler a deal memo
51
+ /play - corporate synergy game with Drexler
52
+ /work - Drexler grinds the deal pipeline
53
+ /praise - affirm Drexler's contributions
54
+ /rest - Drexler takes a strategic nap
55
+ /vibe - let Drexler choose his own adventure
56
+ /name [name] - view or assign Drexler's pet name
57
+ /profile - print Drexler's personnel file
36
58
  /model - show or switch model (e.g. /model 26b)
37
59
  /theme - show or switch theme (${THEME_NAMES.join(", ")})
38
60
  /startup - persist startup mode (fast, no-intro, normal)
@@ -46,7 +68,9 @@ const HELP_TEXT = `New memo to staff! Drexler permit following directives:
46
68
  /export <fmt> [path] - export as md, txt, json, or html
47
69
  /save [path] - archive conversation to markdown file
48
70
  /save-last [path] - save Drexler's last response
49
- /copy-last - copy Drexler's last response to clipboard`;
71
+ /copy-last - copy Drexler's last response to clipboard
72
+ /setup - show config + API key source
73
+ /update - show upgrade instructions`;
50
74
 
51
75
  const WHITESPACE_RE = /\s+/;
52
76
 
@@ -54,27 +78,38 @@ export interface SlashCommand {
54
78
  readonly name: string;
55
79
  readonly description: string;
56
80
  readonly hint?: string;
81
+ readonly group?: CommandGroup;
57
82
  }
58
83
 
59
84
  export const COMMAND_PALETTE: ReadonlyArray<SlashCommand> = [
60
- { name: "/help", description: "Show directives" },
61
- { name: "/clear", description: "Reset conversation" },
62
- { name: "/exit", description: "Adjourn meeting" },
63
- { name: "/synergy", description: "SYNERGY!" },
64
- { name: "/model", description: "Show or switch model" },
65
- { name: "/theme", description: "Show or switch theme" },
66
- { name: "/startup", description: "Persist startup mode" },
67
- { name: "/history", description: "Message + token count" },
68
- { name: "/regenerate", description: "Re-roll last response" },
69
- { name: "/redo", description: "Alias for regenerate" },
70
- { name: "/retry", description: "Retry terse or brutal" },
71
- { name: "/expand", description: "Print last response" },
72
- { name: "/quote", description: "Quote last response" },
73
- { name: "/search", description: "Search transcript" },
74
- { name: "/export", description: "Export md, txt, json, or html" },
75
- { name: "/save", description: "Archive conversation as markdown" },
76
- { name: "/save-last", description: "Save last Drexler response" },
77
- { name: "/copy-last", description: "Copy last response" },
85
+ { name: "/help", description: "Show directives", group: "directives" },
86
+ { name: "/clear", description: "Reset conversation", group: "directives" },
87
+ { name: "/exit", description: "Adjourn meeting", group: "directives" },
88
+ { name: "/synergy", description: "SYNERGY!", group: "directives" },
89
+ { name: "/feed", description: "Feed Drexler a deal memo", group: "directives" },
90
+ { name: "/play", description: "Play with Drexler", group: "directives" },
91
+ { name: "/work", description: "Drexler grinds deals", group: "directives" },
92
+ { name: "/praise", description: "Affirm Drexler", group: "directives" },
93
+ { name: "/rest", description: "Drexler takes a strategic nap", group: "directives" },
94
+ { name: "/vibe", description: "Drexler chooses his own adventure", group: "directives" },
95
+ { name: "/name", description: "Issue or view Drexler's pet name", group: "directives" },
96
+ { name: "/profile", description: "Print Drexler's personnel file", group: "directives" },
97
+ { name: "/model", description: "Show or switch model", group: "models" },
98
+ { name: "/theme", description: "Show or switch theme", group: "themes" },
99
+ { name: "/startup", description: "Persist startup mode", group: "startup" },
100
+ { name: "/history", description: "Message + token count", group: "directives" },
101
+ { name: "/regenerate", description: "Re-roll last response", group: "directives" },
102
+ { name: "/redo", description: "Alias for regenerate", group: "directives" },
103
+ { name: "/retry", description: "Retry terse or brutal", group: "retry" },
104
+ { name: "/expand", description: "Print last response", group: "directives" },
105
+ { name: "/quote", description: "Quote last response", group: "directives" },
106
+ { name: "/search", description: "Search transcript", group: "directives" },
107
+ { name: "/export", description: "Export md, txt, json, or html", group: "export" },
108
+ { name: "/save", description: "Archive conversation as markdown", group: "directives" },
109
+ { name: "/save-last", description: "Save last Drexler response", group: "directives" },
110
+ { name: "/copy-last", description: "Copy last response", group: "directives" },
111
+ { name: "/setup", description: "Show config + key source", group: "directives" },
112
+ { name: "/update", description: "Show upgrade instructions", group: "directives" },
78
113
  ];
79
114
 
80
115
  const THEME_PALETTE_COPY: Record<
@@ -265,6 +300,21 @@ export function filterPaletteByPrefix(
265
300
  );
266
301
  }
267
302
 
303
+ const ARGUMENT_BASE_NAMES: ReadonlySet<string> = new Set(
304
+ ARGUMENT_PALETTE.map((g) => g.command),
305
+ );
306
+
307
+ /**
308
+ * True if `name` is a bare command (no space) that has child argument
309
+ * suggestions. Palette Enter on such a name should NOT execute — it should
310
+ * open the chooser.
311
+ */
312
+ export function isArgumentParentCommand(name: string): boolean {
313
+ if (!name.startsWith("/")) return false;
314
+ if (name.includes(" ")) return false;
315
+ return ARGUMENT_BASE_NAMES.has(name.toLowerCase());
316
+ }
317
+
268
318
  export function isSlash(input: string): boolean {
269
319
  return input.startsWith("/");
270
320
  }
@@ -319,6 +369,19 @@ export function dispatch(input: string, ctx: CommandContext): CommandAction {
319
369
  );
320
370
  return { type: "continue" };
321
371
 
372
+ case "feed":
373
+ case "play":
374
+ case "work":
375
+ case "praise":
376
+ case "rest":
377
+ case "vibe":
378
+ case "name":
379
+ case "profile":
380
+ ctx.print(
381
+ "Drexler pet directives require the interactive deal desk. Launch Drexler in a TTY.",
382
+ );
383
+ return { type: "continue" };
384
+
322
385
  case "model":
323
386
  handleModel(args, ctx);
324
387
  return { type: "continue" };
@@ -407,6 +470,14 @@ export function dispatch(input: string, ctx: CommandContext): CommandAction {
407
470
  return { type: "continue" };
408
471
  }
409
472
 
473
+ case "setup":
474
+ handleSetup(ctx);
475
+ return { type: "continue" };
476
+
477
+ case "update":
478
+ handleUpdate(ctx);
479
+ return { type: "continue" };
480
+
410
481
  default:
411
482
  ctx.print(
412
483
  "Drexler not recognize that corporate directive. Try /help.",
@@ -869,3 +940,39 @@ function handleTheme(args: string[], ctx: CommandContext): CommandAction {
869
940
  ? { type: "continue", persistConfig: { theme: requested } }
870
941
  : { type: "continue" };
871
942
  }
943
+
944
+ function handleSetup(ctx: CommandContext): void {
945
+ const envValid = isValidApiKey(process.env.OPENROUTER_API_KEY);
946
+ const target = getResolvedConfigPath() ?? getConfigPath();
947
+ const keySourceLabel = envValid
948
+ ? "(env: OPENROUTER_API_KEY)"
949
+ : isValidApiKey(ctx.config.apiKey)
950
+ ? `(config file: ${target})`
951
+ : "(missing — first-run prompt will request one)";
952
+
953
+ const lines = [
954
+ "Drexler setup ledger:",
955
+ ` version : ${getDrexlerVersion()}`,
956
+ ` config file : ${target}`,
957
+ ` API key : ${keySourceLabel}`,
958
+ ` model : ${ctx.config.model}`,
959
+ ` theme : ${currentThemeName(ctx.config)}`,
960
+ ` startup mode : ${currentStartupMode(ctx.config)}`,
961
+ ` persona file : ${ctx.config.personaPath}`,
962
+ ];
963
+ ctx.print(lines.join("\n"));
964
+ }
965
+
966
+ function handleUpdate(ctx: CommandContext): void {
967
+ const lines = [
968
+ `Drexler upgrade dossier (drexler v${getDrexlerVersion()}):`,
969
+ "",
970
+ " bun update: bun update -g drexler --latest",
971
+ " bun reinstall: bun install -g drexler@latest",
972
+ " npm: npm install -g drexler@latest",
973
+ " pnpm: pnpm add -g drexler@latest",
974
+ "",
975
+ "Drexler will not run installs. Type the command into your shell.",
976
+ ];
977
+ ctx.print(lines.join("\n"));
978
+ }
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import {
3
3
  chmod,
4
4
  lstat,
@@ -16,6 +16,53 @@ import { MODEL_FALLBACK, MODEL_PRIMARY, THEME_NAMES } from "./types.ts";
16
16
 
17
17
  const DEFAULT_MAX_HISTORY = 50;
18
18
 
19
+ export function getDrexlerVersion(): string {
20
+ try {
21
+ const pkgPath = join(import.meta.dir, "..", "package.json");
22
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
23
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
24
+ } catch {
25
+ return "0.0.0";
26
+ }
27
+ }
28
+
29
+ export function getConfigPath(): string {
30
+ return configPath();
31
+ }
32
+
33
+ export function getLegacyConfigPath(): string {
34
+ return legacyConfigPath();
35
+ }
36
+
37
+ export function getResolvedConfigPath(): string | null {
38
+ const cp = configPath();
39
+ const lp = legacyConfigPath();
40
+ if (existsSync(cp)) return cp;
41
+ if (existsSync(lp)) return lp;
42
+ return null;
43
+ }
44
+
45
+ export type ApiKeySource = "env" | "config-file" | "missing";
46
+
47
+ export class LaunchConfigError extends Error {
48
+ readonly reason:
49
+ | "model-alias"
50
+ | "persona-path"
51
+ | "config-unreadable"
52
+ | "api-key-empty";
53
+ readonly detail: Record<string, unknown>;
54
+ constructor(
55
+ reason: LaunchConfigError["reason"],
56
+ message: string,
57
+ detail: Record<string, unknown> = {},
58
+ ) {
59
+ super(message);
60
+ this.name = "LaunchConfigError";
61
+ this.reason = reason;
62
+ this.detail = detail;
63
+ }
64
+ }
65
+
19
66
  function getHome(): string {
20
67
  return process.env.HOME ?? process.env.USERPROFILE ?? homedir();
21
68
  }
@@ -75,6 +122,35 @@ export function defaultPersonaPath(): string {
75
122
  return resolve(import.meta.dir, "..", "prompts", "drexler.md");
76
123
  }
77
124
 
125
+ async function validatePersonaFile(
126
+ inputPath: unknown,
127
+ label: string,
128
+ ): Promise<string> {
129
+ if (typeof inputPath !== "string" || inputPath.trim().length === 0) {
130
+ throw new LaunchConfigError(
131
+ "persona-path",
132
+ `Invalid ${label}: expected a regular .md file path.`,
133
+ { path: inputPath },
134
+ );
135
+ }
136
+ const resolved = resolve(inputPath);
137
+ // lstat (not stat) so symlinks pointing to non-.md targets cannot bypass
138
+ // the extension check via `ln -s /etc/passwd evil.md`.
139
+ const st = await lstat(resolved).catch(() => null);
140
+ if (
141
+ !st?.isFile() ||
142
+ st.isSymbolicLink() ||
143
+ !resolved.toLowerCase().endsWith(".md")
144
+ ) {
145
+ throw new LaunchConfigError(
146
+ "persona-path",
147
+ `Invalid ${label}: ${inputPath} (must be a regular .md file; symlinks rejected).`,
148
+ { path: inputPath },
149
+ );
150
+ }
151
+ return resolved;
152
+ }
153
+
78
154
  export async function loadConfigFile(): Promise<Partial<Config>> {
79
155
  const cp = configPath();
80
156
  const lp = legacyConfigPath();
@@ -192,43 +268,38 @@ export async function ensureApiKey(opts?: {
192
268
  return apiKey;
193
269
  }
194
270
 
195
- export async function resolveConfig(argv: string[]): Promise<Config> {
271
+ export interface LaunchStructural {
272
+ model: string;
273
+ personaPath: string;
274
+ theme?: ThemeName;
275
+ noIntro?: boolean;
276
+ fast?: boolean;
277
+ maxHistory: number;
278
+ fileCfg: Partial<Config>;
279
+ }
280
+
281
+ export async function validateLaunchConfig(
282
+ argv: string[],
283
+ ): Promise<LaunchStructural> {
196
284
  const flags = parseFlags(argv);
197
285
  const fileCfg = await loadConfigFile();
198
- const envKey = process.env.OPENROUTER_API_KEY;
199
286
  const envModel = process.env.DREXLER_MODEL;
200
287
 
201
- const apiKey = isValidApiKey(envKey)
202
- ? envKey.trim()
203
- : isValidApiKey(fileCfg.apiKey)
204
- ? fileCfg.apiKey.trim()
205
- : "";
206
-
207
- if (!apiKey) {
208
- throw new Error(
209
- "API key missing. Run drexler interactively to set one, or export OPENROUTER_API_KEY.",
210
- );
211
- }
212
-
213
288
  const modelInput = flags.model ?? envModel ?? fileCfg.model ?? "31b";
214
- const model = resolveModel(modelInput);
215
-
216
- let personaPath: string;
217
- if (flags.persona) {
218
- const resolved = resolve(flags.persona);
219
- // lstat (not stat) so symlinks pointing to non-.md targets cannot bypass
220
- // the extension check via `ln -s /etc/passwd evil.md`.
221
- const st = await lstat(resolved).catch(() => null);
222
- if (!st?.isFile() || !resolved.toLowerCase().endsWith(".md")) {
223
- throw new Error(
224
- `Invalid --persona: ${flags.persona} (must be a regular .md file; symlinks rejected).`,
225
- );
226
- }
227
- personaPath = resolved;
228
- } else {
229
- personaPath = fileCfg.personaPath ?? defaultPersonaPath();
289
+ let model: string;
290
+ try {
291
+ model = resolveModel(modelInput);
292
+ } catch (e) {
293
+ const msg = e instanceof Error ? e.message : String(e);
294
+ throw new LaunchConfigError("model-alias", msg, { input: modelInput });
230
295
  }
231
296
 
297
+ const personaPath = flags.persona
298
+ ? await validatePersonaFile(flags.persona, "--persona")
299
+ : fileCfg.personaPath !== undefined
300
+ ? await validatePersonaFile(fileCfg.personaPath, "config personaPath")
301
+ : await validatePersonaFile(defaultPersonaPath(), "default persona");
302
+
232
303
  const maxHistory =
233
304
  typeof fileCfg.maxHistory === "number" &&
234
305
  Number.isInteger(fileCfg.maxHistory) &&
@@ -253,5 +324,43 @@ export async function resolveConfig(argv: string[]): Promise<Config> {
253
324
  parseOptionalBoolean(process.env.DREXLER_FAST) ??
254
325
  parseOptionalBoolean(fileCfg.fast);
255
326
 
256
- return { apiKey, model, maxHistory, personaPath, theme, noIntro, fast };
327
+ return { model, personaPath, theme, noIntro, fast, maxHistory, fileCfg };
328
+ }
329
+
330
+ export async function describeApiKeySource(): Promise<{
331
+ source: ApiKeySource;
332
+ configPath: string | null;
333
+ }> {
334
+ const envKey = process.env.OPENROUTER_API_KEY;
335
+ if (isValidApiKey(envKey)) return { source: "env", configPath: null };
336
+ const fileCfg = await loadConfigFile();
337
+ if (isValidApiKey(fileCfg.apiKey)) {
338
+ return { source: "config-file", configPath: getResolvedConfigPath() };
339
+ }
340
+ return { source: "missing", configPath: null };
341
+ }
342
+
343
+ export async function resolveConfig(argv: string[]): Promise<Config> {
344
+ const structural = await validateLaunchConfig(argv);
345
+ const envKey = process.env.OPENROUTER_API_KEY;
346
+ const apiKey = isValidApiKey(envKey)
347
+ ? envKey.trim()
348
+ : isValidApiKey(structural.fileCfg.apiKey)
349
+ ? structural.fileCfg.apiKey.trim()
350
+ : "";
351
+ if (!apiKey) {
352
+ throw new LaunchConfigError(
353
+ "api-key-empty",
354
+ "API key missing. Run drexler interactively to set one, or export OPENROUTER_API_KEY.",
355
+ );
356
+ }
357
+ return {
358
+ apiKey,
359
+ model: structural.model,
360
+ maxHistory: structural.maxHistory,
361
+ personaPath: structural.personaPath,
362
+ theme: structural.theme,
363
+ noIntro: structural.noIntro,
364
+ fast: structural.fast,
365
+ };
257
366
  }
@@ -63,10 +63,6 @@ export class Conversation {
63
63
  return this.messages.length - 1;
64
64
  }
65
65
 
66
- get totalLength(): number {
67
- return this.messages.length;
68
- }
69
-
70
66
  get userTurns(): number {
71
67
  return this.userTurnCount;
72
68
  }
package/src/index.ts CHANGED
@@ -3,7 +3,12 @@ import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import React from "react";
5
5
  import { render } from "ink";
6
- import { ensureApiKey, resolveConfig } from "./config.ts";
6
+ import {
7
+ ensureApiKey,
8
+ LaunchConfigError,
9
+ resolveConfig,
10
+ validateLaunchConfig,
11
+ } from "./config.ts";
7
12
  import { Conversation } from "./conversation.ts";
8
13
  import { moodLine, pickMood } from "./mood.ts";
9
14
  import { loadPersona, pickGreeting } from "./persona.ts";
@@ -52,6 +57,12 @@ Slash commands inside REPL:
52
57
  /clear reset conversation
53
58
  /exit exit
54
59
  /synergy SYNERGY!
60
+ /feed feed Drexler a deal memo
61
+ /play corporate synergy game (flexing included)
62
+ /work Drexler grinds the pipeline
63
+ /rest strategic nap (restores energy)
64
+ /praise affirm Drexler's contributions
65
+ /vibe let Drexler choose his own adventure
55
66
  /model [id] show or switch model
56
67
  /theme [name] show or switch theme; append save to persist
57
68
  /startup [mode] persist startup mode: fast, no-intro, normal
@@ -66,6 +77,8 @@ Slash commands inside REPL:
66
77
  /save [path] archive conversation as markdown
67
78
  /save-last [path] save latest response
68
79
  /copy-last copy latest response to clipboard
80
+ /setup show config + API key source
81
+ /update show upgrade instructions
69
82
 
70
83
  Ctrl+C exits gracefully.`;
71
84
 
@@ -85,18 +98,39 @@ async function main(): Promise<void> {
85
98
  const isInteractive =
86
99
  process.stdout.isTTY === true && process.stdin.isTTY === true;
87
100
 
88
- // Acquire API key. Prompts interactively if missing runs BEFORE banner.
101
+ // 1. Validate non-secret config FIRST so a bogus --model or --persona
102
+ // fails fast before we ask the user for an API key.
103
+ try {
104
+ await validateLaunchConfig(argv);
105
+ } catch (e) {
106
+ if (e instanceof LaunchConfigError) {
107
+ console.error(error(formatLaunchError(e)));
108
+ } else {
109
+ console.error(
110
+ error(`Drexler config tantrum: ${e instanceof Error ? e.message : e}`),
111
+ );
112
+ }
113
+ process.exit(1);
114
+ }
115
+
116
+ // 2. Acquire API key (may prompt). Runs after validation so bad CLI args
117
+ // no longer trigger the first-run setup flow.
89
118
  await ensureApiKey({
90
119
  prompt: isInteractive ? promptForApiKeyWithInk : undefined,
91
120
  });
92
121
 
122
+ // 3. Resolve full Config (API key now present).
93
123
  let config;
94
124
  try {
95
125
  config = await resolveConfig(argv);
96
126
  } catch (e) {
97
- console.error(
98
- error(`Drexler config tantrum: ${e instanceof Error ? e.message : e}`),
99
- );
127
+ if (e instanceof LaunchConfigError) {
128
+ console.error(error(formatLaunchError(e)));
129
+ } else {
130
+ console.error(
131
+ error(`Drexler config tantrum: ${e instanceof Error ? e.message : e}`),
132
+ );
133
+ }
100
134
  process.exit(1);
101
135
  }
102
136
 
@@ -154,6 +188,7 @@ async function main(): Promise<void> {
154
188
  }
155
189
 
156
190
  // Non-TTY fallback: linear output, readline-based REPL.
191
+ installFatalHandlers();
157
192
  console.log("");
158
193
  if (!skipIntro) {
159
194
  console.log(banner());
@@ -172,6 +207,34 @@ async function main(): Promise<void> {
172
207
  });
173
208
  }
174
209
 
210
+ function formatLaunchError(e: LaunchConfigError): string {
211
+ switch (e.reason) {
212
+ case "model-alias":
213
+ return `Bad model alias: ${e.message}`;
214
+ case "persona-path":
215
+ return `Bad persona file: ${e.message}`;
216
+ case "config-unreadable":
217
+ return `Config file unreadable: ${e.message}`;
218
+ case "api-key-empty":
219
+ return `API key required: ${e.message}`;
220
+ }
221
+ }
222
+
223
+ // Fatal handlers are installed only in the non-TTY path; the interactive
224
+ // path lets Ink's signal-exit hooks run cleanup so the alt-screen restores.
225
+ function installFatalHandlers(): void {
226
+ process.on("unhandledRejection", (reason) => {
227
+ const msg =
228
+ reason instanceof Error ? (reason.stack ?? reason.message) : String(reason);
229
+ console.error(error("Unhandled rejection:"), msg);
230
+ process.exitCode = 1;
231
+ });
232
+ process.on("uncaughtException", (err) => {
233
+ console.error(error("Uncaught exception:"), err.stack ?? err.message);
234
+ process.exitCode = 1;
235
+ });
236
+ }
237
+
175
238
  main().catch((e) => {
176
239
  console.error(error("Fatal:"), e);
177
240
  process.exit(1);