drexler 0.2.13 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.14
4
+
5
+ - Added a startup Mood panel with a stable boot gauge, percentage-only loading row, and rotating mood-specific posture/detail copy.
6
+ - Anchored the wide startup dashboard so wrapped greeting copy no longer pushes the Mood and Deal Desk boxes down or adds stray rows.
7
+ - Reworked the embedded Deal Desk into satirical mood-shaped product chrome instead of model/context telemetry.
8
+ - Improved Drexler transcript rendering with complete bordered cards, a diamond response marker, cleaned markdown/code fence display, and Dracula-inspired code syntax colors.
9
+ - Updated documentation to match current startup chrome, command palette behavior, keyboard controls, source setup, and layout invariants.
10
+
3
11
  ## 0.2.13
4
12
 
5
13
  - Hardened startup panel layout across narrow, standard, and wide terminals.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![license](https://img.shields.io/npm/l/drexler.svg)](./LICENSE)
5
5
  [![bun](https://img.shields.io/badge/runtime-bun%20%E2%89%A5%201.1-black)](https://bun.sh)
6
6
 
7
- CLI chat with **Drexler**, a corporate-executive AI persona who speaks in broken third-person and treats every conversation like a hostile takeover. Built with Bun + TypeScript. Talks to OpenRouter's Gemma 4 31B model (paid).
7
+ CLI chat with **Drexler**, a corporate-executive AI persona who speaks in broken third-person and treats every conversation like a hostile takeover. Built with Bun + TypeScript, Ink, and OpenRouter-compatible Gemma models.
8
8
 
9
9
  > "Drexler usually charge consulting fee for this. Today, pro bono. You welcome."
10
10
 
@@ -13,7 +13,7 @@ CLI chat with **Drexler**, a corporate-executive AI persona who speaks in broken
13
13
  ## Quickstart
14
14
 
15
15
  ```bash
16
- bun add -g drexler
16
+ bun install -g drexler@latest
17
17
  drexler
18
18
  ```
19
19
 
@@ -39,7 +39,7 @@ Verify: `bun --version` → should print `1.1.0` or higher.
39
39
  ### 2. Install Drexler globally
40
40
 
41
41
  ```bash
42
- bun add -g drexler
42
+ bun install -g drexler@latest
43
43
  ```
44
44
 
45
45
  This installs the `drexler` command into `~/.bun/bin/drexler`. Make sure `~/.bun/bin` is on your `$PATH` (Bun's installer does this automatically; if not, add `export PATH="$HOME/.bun/bin:$PATH"` to your shell rc).
@@ -65,9 +65,17 @@ Paste the key, hit return. Drexler saves it to `~/.config/drexler/config.json` (
65
65
  ## Update
66
66
 
67
67
  ```bash
68
- bun update -g drexler
68
+ bun update -g drexler --latest
69
69
  ```
70
70
 
71
+ If your Bun install does not have the package in its global lockfile, reinstall it instead:
72
+
73
+ ```bash
74
+ bun install -g drexler@latest
75
+ ```
76
+
77
+ Global updates replace the existing `drexler` package in Bun's global install location; they do not keep stacking duplicate app copies.
78
+
71
79
  ## Uninstall
72
80
 
73
81
  ```bash
@@ -79,6 +87,33 @@ rm -rf ~/.config/drexler # optional: wipe stored key + settings
79
87
 
80
88
  ## Usage
81
89
 
90
+ ### Interactive UI
91
+
92
+ Drexler runs as an Ink terminal UI when both stdin and stdout are TTYs. The normal launch shows one integrated startup panel with the mascot, tips, a **Mood** readout, and the **Drexler Deal Desk**. Short terminals automatically suppress oversized startup chrome so the chat stays usable.
93
+
94
+ The startup panel is designed to stay stable while it boots: the mascot loading bar and Mood gauge animate without changing width, greeting copy is held in a fixed slot, and the Mood and Deal Desk boxes stay aligned when the greeting wraps. After boot, Mood resolves into a rotating Drexler-flavored posture with a short satirical subtext line.
95
+
96
+ The Deal Desk is intentionally not a frontier-model telemetry panel. It shows mood-shaped corporate nonsense like boardroom status, memo count, mandate, risk, fees, and counsel posture. Values rotate by mood and session so repeated moods still feel alive.
97
+
98
+ Conversation turns render as bordered cards aligned to the chat input width. User and Drexler responses use separate accents, wrapped text, and fixed-width borders so long responses stay inside the terminal instead of clipping at the right edge. Drexler responses use a diamond body marker. Markdown/code fence labels are cleaned up for display, and code blocks render with Dracula-inspired terminal syntax colors.
99
+
100
+ Typing `/` opens the directive palette. Use `Tab`, `Enter`, or `↑`/`↓` to select. Commands with fixed arguments open smoother option choosers:
101
+
102
+ - `/theme` previews all themes with descriptions.
103
+ - `/startup` offers `fast`, `no-intro`, and `normal`.
104
+ - `/retry` offers `terse` and `brutal`.
105
+ - `/export` offers `md`, `txt`, `json`, and `html`.
106
+ - `/model` offers `31b` and `26b`.
107
+
108
+ `/synergy` runs a rotating animated corporate event in the live UI, then returns control to the chat when the animation completes.
109
+
110
+ Keyboard notes:
111
+
112
+ - `Tab`, `Enter`, and `↑`/`↓` operate the directive palette.
113
+ - `PageUp`/`PageDown` scroll transcript history when it exceeds the visible viewport.
114
+ - `Esc` cancels an in-flight model response without quitting.
115
+ - `Ctrl+C` exits gracefully with an in-character farewell.
116
+
82
117
  ### Flags
83
118
 
84
119
  | flag | what |
@@ -93,12 +128,12 @@ rm -rf ~/.config/drexler # optional: wipe stored key + settings
93
128
 
94
129
  ### Slash commands (inside the REPL)
95
130
 
96
- | cmd | what it do |
131
+ | cmd | what it does |
97
132
  | -------------- | ------------------------------------------------ |
98
133
  | `/help` | list directives |
99
134
  | `/clear` | shred conversation history (system prompt pinned) |
100
135
  | `/exit` | meeting adjourned |
101
- | `/synergy` | SYNERGY! |
136
+ | `/synergy` | run a rotating animated morale event |
102
137
  | `/model` | show current model, or `/model 26b` to switch |
103
138
  | `/theme` | show/switch theme; append `save` to persist, e.g. `/theme midnight save` |
104
139
  | `/startup fast\|no-intro\|normal` | persist startup behavior for future launches |
@@ -114,8 +149,6 @@ rm -rf ~/.config/drexler # optional: wipe stored key + settings
114
149
  | `/save-last [path]` | save Drexler's last response only |
115
150
  | `/copy-last` | copy Drexler's latest response to the clipboard |
116
151
 
117
- `Ctrl+C` exits gracefully with an in-character farewell.
118
-
119
152
  ---
120
153
 
121
154
  ## Configuration
@@ -126,6 +159,8 @@ Drexler reads config in this priority (later wins):
126
159
  2. Environment variables
127
160
  3. CLI flags
128
161
 
162
+ If the current config file does not exist, Drexler also checks the legacy `~/.drexlerrc` path.
163
+
129
164
  ### Environment variables
130
165
 
131
166
  | var | purpose |
@@ -159,13 +194,26 @@ Default `maxHistory`: 50 messages.
159
194
  Available launch/config themes: `apollo`, `amber`, `mono`, `terminal`, `dealroom`, `midnight`, `paper`, and `plasma`.
160
195
  `NO_COLOR` always forces `mono`.
161
196
 
197
+ Theme notes:
198
+
199
+ | theme | character |
200
+ | ---------- | ------------------------------------------ |
201
+ | `apollo` | signature Drexler green, the default |
202
+ | `amber` | warm amber deal glow |
203
+ | `mono` | plain high-contrast ANSI colors |
204
+ | `terminal` | classic green/cyan terminal |
205
+ | `dealroom` | restrained teal boardroom palette |
206
+ | `midnight` | cool blue late-session desk |
207
+ | `paper` | clean document-style contrast |
208
+ | `plasma` | high-energy magenta trading-floor accent |
209
+
162
210
  ---
163
211
 
164
212
  ## Models
165
213
 
166
214
  | alias | id | notes |
167
215
  | ------ | ----------------------------------- | ------------------------------ |
168
- | `31b` | `google/gemma-4-31b-it` | primary (paid) |
216
+ | `31b` | `google/gemma-4-31b-it` | primary default |
169
217
  | `26b` | `google/gemma-4-26b-a4b-it` | fallback, auto-retry on 429 |
170
218
 
171
219
  Pass `--model vendor/name:tag` for any other OpenRouter-compatible model.
@@ -178,7 +226,7 @@ Pass `--model vendor/name:tag` for any other OpenRouter-compatible model.
178
226
  git clone https://github.com/showOS/Drexler.git
179
227
  cd Drexler
180
228
  bun install
181
- cp .env.example .env # then edit, paste key into OPENROUTER_API_KEY=
229
+ export OPENROUTER_API_KEY=sk-or-v1-your-key
182
230
  bun run start
183
231
  ```
184
232
 
@@ -192,11 +240,12 @@ bun run typecheck
192
240
  ### Releasing a new version
193
241
 
194
242
  ```bash
195
- npm version <patch|minor> # bumps package.json, commits, tags
196
- git push --follow-tags # CI publishes to npm automatically
243
+ bun run prepublishOnly
244
+ npm version <patch|minor> # bumps package.json, commits, and tags
245
+ git push origin main --follow-tags
197
246
  ```
198
247
 
199
- The `.github/workflows/publish.yml` workflow runs typecheck + tests + `npm publish --provenance` on every `v*` tag push.
248
+ The `.github/workflows/publish.yml` workflow runs install, tag/package version verification, typecheck, tests, and `npm publish --provenance` on every `v*` tag push.
200
249
 
201
250
  ---
202
251
 
@@ -210,6 +259,8 @@ The `.github/workflows/publish.yml` workflow runs typecheck + tests + `npm publi
210
259
  | Garbled box-drawing characters | Use a UTF-8 terminal with a Nerd Font (e.g. iTerm2, Alacritty, WezTerm) |
211
260
  | Want to switch themes mid-session | Use `/theme midnight`, `/theme dealroom`, `/theme amber`, or any listed theme inside the REPL |
212
261
  | Want a faster launch | Use `drexler --fast` or set `"fast": true` in config |
262
+ | Startup panel looks cramped | Enlarge the terminal, or use `/startup no-intro` or `/startup fast` |
263
+ | Slash command options are not visible | Type `/`, `/theme`, `/startup`, `/retry`, `/export`, or `/model`; exact fixed-argument commands open their chooser automatically |
213
264
 
214
265
  ---
215
266
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drexler",
3
- "version": "0.2.13",
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
  }