@wrongstack/cli 0.1.2 → 0.1.3

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/dist/index.js CHANGED
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
- import { color, DefaultPathResolver, resolveWstackPaths, DefaultSecretVault, migratePlaintextSecrets, DefaultConfigLoader, DefaultLogger, DefaultModelsRegistry, Container, TOKENS, DefaultSecretScrubber, DefaultRetryPolicy, DefaultErrorHandler, DefaultTokenCounter, DefaultSessionStore, DefaultMemoryStore, DefaultSkillLoader, DefaultSystemPromptBuilder, DefaultPermissionPolicy, HybridCompactor, ProviderRegistry, ToolRegistry, createContextManagerTool, EventBus, RecoveryLock, DefaultAttachmentStore, QueueStore, Context, createDefaultPipelines, AutoCompactionMiddleware, Agent, SlashCommandRegistry, loadPlugins, InputBuilder, DefaultPluginAPI, rewriteConfigEncrypted, atomicWrite } from '@wrongstack/core';
3
- import * as fs2 from 'fs/promises';
2
+ import { color, DefaultLogger, DefaultModelsRegistry, Container, DefaultConfigStore, TOKENS, DefaultSecretScrubber, DefaultRetryPolicy, DefaultErrorHandler, DefaultTokenCounter, DefaultSessionStore, DefaultMemoryStore, DefaultSkillLoader, DefaultSystemPromptBuilder, DefaultPermissionPolicy, HybridCompactor, ProviderRegistry, ToolRegistry, createContextManagerTool, EventBus, InMemoryMetricsSink, wireMetricsToEvents, DefaultHealthRegistry, startMetricsServer, RecoveryLock, DefaultAttachmentStore, QueueStore, Context, createDefaultPipelines, AutoCompactionMiddleware, Agent, SlashCommandRegistry, loadPlugins, DefaultPathResolver, resolveWstackPaths, DefaultSecretVault, migratePlaintextSecrets, DefaultConfigLoader, InputBuilder, DefaultPluginAPI, DefaultSessionReader, atomicWrite, makeAgentSubagentRunner, DefaultMultiAgentCoordinator, decryptConfigSecrets, encryptConfigSecrets } from '@wrongstack/core';
3
+ import * as fs6 from 'fs/promises';
4
+ import { writeFileSync } from 'fs';
4
5
  import { createRequire } from 'module';
5
- import * as os2 from 'os';
6
- import * as path2 from 'path';
6
+ import * as path5 from 'path';
7
7
  import { MCPRegistry } from '@wrongstack/mcp';
8
8
  import { buildProviderFactoriesFromRegistry, makeProviderFromConfig, capabilitiesFor } from '@wrongstack/providers';
9
- import { builtinTools, rememberTool, forgetTool } from '@wrongstack/tools';
9
+ import { rememberTool, forgetTool } from '@wrongstack/tools';
10
+ import { builtinTools } from '@wrongstack/tools/builtin';
10
11
  import * as readline from 'readline';
12
+ import * as os3 from 'os';
13
+ import { randomUUID } from 'crypto';
11
14
 
12
15
  var __defProp = Object.defineProperty;
13
16
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -37,11 +40,11 @@ var ReadlineInputReader = class {
37
40
  history = [];
38
41
  pending = false;
39
42
  constructor(opts = {}) {
40
- this.historyFile = opts.historyFile ?? path2.join(os2.homedir(), ".wrongstack", "history");
43
+ this.historyFile = opts.historyFile ?? path5.join(os3.homedir(), ".wrongstack", "history");
41
44
  }
42
45
  async loadHistory() {
43
46
  try {
44
- const raw = await fs2.readFile(this.historyFile, "utf8");
47
+ const raw = await fs6.readFile(this.historyFile, "utf8");
45
48
  this.history = raw.split("\n").filter(Boolean).slice(-1e3);
46
49
  } catch {
47
50
  this.history = [];
@@ -49,8 +52,8 @@ var ReadlineInputReader = class {
49
52
  }
50
53
  async saveHistory() {
51
54
  try {
52
- await fs2.mkdir(path2.dirname(this.historyFile), { recursive: true });
53
- await fs2.writeFile(this.historyFile, this.history.slice(-1e3).join("\n"));
55
+ await fs6.mkdir(path5.dirname(this.historyFile), { recursive: true });
56
+ await fs6.writeFile(this.historyFile, this.history.slice(-1e3).join("\n"));
54
57
  } catch {
55
58
  }
56
59
  }
@@ -68,7 +71,7 @@ var ReadlineInputReader = class {
68
71
  async readLine(prompt) {
69
72
  if (this.history.length === 0) await this.loadHistory();
70
73
  while (this.pending) {
71
- await new Promise((resolve2) => setTimeout(resolve2, 50));
74
+ await new Promise((resolve3) => setTimeout(resolve3, 50));
72
75
  }
73
76
  this.pending = true;
74
77
  try {
@@ -78,13 +81,13 @@ var ReadlineInputReader = class {
78
81
  this.rl = void 0;
79
82
  }
80
83
  const fresh = this.ensure();
81
- return new Promise((resolve2, reject) => {
84
+ return new Promise((resolve3, reject) => {
82
85
  fresh.question(prompt ?? "> ", (line) => {
83
86
  if (line.trim()) {
84
87
  this.history.push(line);
85
88
  void this.saveHistory();
86
89
  }
87
- resolve2(line);
90
+ resolve3(line);
88
91
  });
89
92
  fresh.once("close", () => reject(new Error("EOF")));
90
93
  });
@@ -94,7 +97,7 @@ var ReadlineInputReader = class {
94
97
  }
95
98
  async readKey(prompt, options) {
96
99
  process.stdout.write(prompt);
97
- return new Promise((resolve2) => {
100
+ return new Promise((resolve3) => {
98
101
  const stdin = process.stdin;
99
102
  const wasRaw = stdin.isRaw;
100
103
  const wasPaused = stdin.isPaused();
@@ -109,7 +112,7 @@ var ReadlineInputReader = class {
109
112
  cleanup();
110
113
  process.stdout.write(`${opt.key}
111
114
  `);
112
- resolve2(opt.value);
115
+ resolve3(opt.value);
113
116
  }
114
117
  };
115
118
  const cleanup = () => {
@@ -121,9 +124,14 @@ var ReadlineInputReader = class {
121
124
  });
122
125
  }
123
126
  /**
124
- * Read a single line of input without echoing it to the terminal. Used
125
- * for API keys / passwords. Non-TTY input is read normally — there's
126
- * nothing to hide when piped.
127
+ * Read a line of input while masking each character with a bullet so the
128
+ * user gets visual confirmation that bytes are arriving (especially on
129
+ * paste, which previously felt like nothing happened). Pasted chunks
130
+ * are echoed as a run of bullets, Backspace/DEL erases one bullet, and
131
+ * Ctrl+U / Ctrl+W are honored. Non-TTY input is read normally — there's
132
+ * nothing to hide when piped, and echoing bullets to a file is noise.
133
+ *
134
+ * Returns the raw entered string (no trim — caller decides).
127
135
  */
128
136
  async readSecret(prompt) {
129
137
  const stdin = process.stdin;
@@ -131,18 +139,25 @@ var ReadlineInputReader = class {
131
139
  this.rl?.close();
132
140
  this.rl = void 0;
133
141
  process.stdout.write(prompt);
134
- return new Promise((resolve2) => {
142
+ return new Promise((resolve3) => {
135
143
  let buf = "";
136
144
  const wasRaw = stdin.isRaw;
137
145
  stdin.setRawMode(true);
138
146
  stdin.resume();
139
147
  stdin.setEncoding("utf8");
148
+ const eraseChar = () => {
149
+ process.stdout.write("\b \b");
150
+ };
151
+ const eraseAll = () => {
152
+ for (let i = 0; i < buf.length; i++) eraseChar();
153
+ };
140
154
  const onData = (chunk) => {
141
155
  for (const ch of chunk) {
142
156
  if (ch === "\r" || ch === "\n") {
143
157
  cleanup();
144
- process.stdout.write("\n");
145
- resolve2(buf);
158
+ process.stdout.write(` ${dim(`[${buf.length} chars]`)}
159
+ `);
160
+ resolve3(buf);
146
161
  return;
147
162
  }
148
163
  if (ch === "") {
@@ -150,11 +165,28 @@ var ReadlineInputReader = class {
150
165
  process.stdout.write("\n");
151
166
  process.exit(130);
152
167
  }
168
+ if (ch === "") {
169
+ eraseAll();
170
+ buf = "";
171
+ continue;
172
+ }
173
+ if (ch === "") {
174
+ const m = buf.match(/(\S+\s*)$/);
175
+ const drop = m ? m[0].length : buf.length;
176
+ for (let i = 0; i < drop; i++) eraseChar();
177
+ buf = buf.slice(0, buf.length - drop);
178
+ continue;
179
+ }
153
180
  if (ch === "\x7F" || ch === "\b") {
154
- buf = buf.slice(0, -1);
181
+ if (buf.length > 0) {
182
+ buf = buf.slice(0, -1);
183
+ eraseChar();
184
+ }
155
185
  continue;
156
186
  }
187
+ if (ch < " ") continue;
157
188
  buf += ch;
189
+ process.stdout.write("\u2022");
158
190
  }
159
191
  };
160
192
  const cleanup = () => {
@@ -171,6 +203,10 @@ var ReadlineInputReader = class {
171
203
  this.rl = void 0;
172
204
  }
173
205
  };
206
+ function dim(s) {
207
+ if (!process.stdout.isTTY) return s;
208
+ return `\x1B[2m${s}\x1B[22m`;
209
+ }
174
210
  function renderDiff(diff) {
175
211
  if (!diff) return "";
176
212
  const lines = diff.split("\n");
@@ -221,6 +257,13 @@ ${theme.primary("\u258D")} ${theme.bold(tool.name)}
221
257
  return answer;
222
258
  };
223
259
  }
260
+ function makeConfirmAwaiter(reader) {
261
+ const delegate = makePromptDelegate(reader);
262
+ return async (tool, input, _toolUseId, suggestedPattern) => {
263
+ const result = await delegate(tool, input, suggestedPattern);
264
+ return result;
265
+ };
266
+ }
224
267
  function stringifyInput(input) {
225
268
  if (!input || typeof input !== "object") return "";
226
269
  const obj = input;
@@ -236,8 +279,10 @@ function hasDiff(input) {
236
279
  }
237
280
  function hasApiKey(provider, config) {
238
281
  if (provider.envVars.some((v) => !!process.env[v])) return true;
239
- const stored = config?.providers?.[provider.id]?.apiKey;
240
- if (typeof stored === "string" && stored.length > 0) return true;
282
+ const entry = config?.providers?.[provider.id];
283
+ if (!entry) return false;
284
+ if (typeof entry.apiKey === "string" && entry.apiKey.length > 0) return true;
285
+ if (Array.isArray(entry.apiKeys) && entry.apiKeys.some((k) => k?.apiKey)) return true;
241
286
  return false;
242
287
  }
243
288
  async function runPicker(deps) {
@@ -254,15 +299,53 @@ ${color.bold(theme2.primary("WrongStack") + color.dim(" \u2014 Provider & Model
254
299
  return void 0;
255
300
  }
256
301
  const supported = providers.filter((p) => p.family !== "unsupported");
257
- if (supported.length === 0) {
302
+ const catalogById = new Map(supported.map((p) => [p.id, p]));
303
+ const overlay = config?.providers ?? {};
304
+ const seen = /* @__PURE__ */ new Set();
305
+ const merged = [];
306
+ for (const p of supported) {
307
+ const cfg = overlay[p.id];
308
+ seen.add(p.id);
309
+ if (cfg) {
310
+ merged.push({
311
+ ...p,
312
+ family: cfg.family ?? p.family,
313
+ apiBase: cfg.baseUrl ?? p.apiBase,
314
+ envVars: cfg.envVars && cfg.envVars.length > 0 ? cfg.envVars : p.envVars,
315
+ // When the user has saved an explicit model list, it wins — they
316
+ // know which models their endpoint actually serves (e.g. LM
317
+ // Studio, vLLM, or a proxy with custom model ids). Otherwise the
318
+ // catalog list keeps providing suggestions.
319
+ models: cfg.models && cfg.models.length > 0 ? cfg.models.map((m) => ({ id: m, name: m })) : p.models
320
+ });
321
+ } else {
322
+ merged.push(p);
323
+ }
324
+ }
325
+ for (const [id, cfg] of Object.entries(overlay)) {
326
+ if (seen.has(id)) continue;
327
+ if (!cfg?.family || cfg.family === "unsupported") continue;
328
+ const catalogType = cfg.type && cfg.type !== id ? cfg.type : void 0;
329
+ const inherited = catalogType ? catalogById.get(catalogType) : void 0;
330
+ merged.push({
331
+ id,
332
+ name: inherited ? `${inherited.name} ${color.dim("(alias)")}` : id,
333
+ family: cfg.family,
334
+ apiBase: cfg.baseUrl ?? inherited?.apiBase,
335
+ envVars: cfg.envVars ?? inherited?.envVars ?? [],
336
+ models: cfg.models && cfg.models.length > 0 ? cfg.models.map((m) => ({ id: m, name: m })) : inherited?.models ?? [],
337
+ npm: inherited?.npm
338
+ });
339
+ }
340
+ if (merged.length === 0) {
258
341
  renderer.writeError("No supported providers found in catalog.");
259
342
  return void 0;
260
343
  }
261
- const keyed = supported.filter((p) => hasApiKey(p, config));
344
+ const keyed = merged.filter((p) => hasApiKey(p, config));
262
345
  let displayList = keyed;
263
346
  let showingFallback = false;
264
347
  if (keyed.length === 0) {
265
- displayList = supported;
348
+ displayList = merged;
266
349
  showingFallback = true;
267
350
  }
268
351
  const families = /* @__PURE__ */ new Map();
@@ -283,7 +366,8 @@ ${color.bold(theme2.primary("WrongStack") + color.dim(" \u2014 Provider & Model
283
366
  `);
284
367
  for (const p of list) {
285
368
  const envFound = p.envVars.some((v) => !!process.env[v]);
286
- const configKey = typeof config?.providers?.[p.id]?.apiKey === "string" && config.providers[p.id].apiKey.length > 0;
369
+ const entry = config?.providers?.[p.id];
370
+ const configKey = typeof entry?.apiKey === "string" && entry.apiKey.length > 0 || Array.isArray(entry?.apiKeys) && entry.apiKeys.some((k) => k?.apiKey);
287
371
  const marker = envFound ? color.green("\u25CF") : configKey ? color.cyan("\u25C9") : color.dim("\u25CB");
288
372
  const isDefault = p.id === defaultProvider;
289
373
  if (isDefault) defaultIdx = idx;
@@ -321,7 +405,7 @@ ${color.amber("?")} Select provider (1-${ordered.length})${defaultHint}: `)).tri
321
405
  renderer.write(color.dim("Cancelled.\n"));
322
406
  return void 0;
323
407
  }
324
- const providerIdx = parseInt(providerAnswer, 10);
408
+ const providerIdx = Number.parseInt(providerAnswer, 10);
325
409
  if (Number.isNaN(providerIdx) || providerIdx < 1 || providerIdx > ordered.length) {
326
410
  const byId = ordered.find((o) => o.provider.id.toLowerCase() === providerAnswer.toLowerCase());
327
411
  if (!byId) {
@@ -399,7 +483,7 @@ ${color.amber("?")} Select model (1-${models.length})${defaultHint}: `)).trim();
399
483
  return resolveModelSelection(answer, models, provider, registry, renderer);
400
484
  }
401
485
  async function resolveModelSelection(answer, models, provider, _registry, renderer, _reader) {
402
- const idx = parseInt(answer, 10);
486
+ const idx = Number.parseInt(answer, 10);
403
487
  let modelId;
404
488
  if (!Number.isNaN(idx) && idx >= 1 && idx <= models.length) {
405
489
  modelId = models[idx - 1].id;
@@ -432,17 +516,17 @@ async function resolveModelSelection(answer, models, provider, _registry, render
432
516
  var theme2 = { primary: color.amber };
433
517
  async function saveToGlobalConfig(configPath, provider, model) {
434
518
  try {
435
- const { atomicWrite: atomicWrite2 } = await import('@wrongstack/core');
436
- const fs6 = await import('fs/promises');
519
+ const { atomicWrite: atomicWrite3 } = await import('@wrongstack/core');
520
+ const fs8 = await import('fs/promises');
437
521
  let existing = {};
438
522
  try {
439
- const raw = await fs6.readFile(configPath, "utf8");
523
+ const raw = await fs8.readFile(configPath, "utf8");
440
524
  existing = JSON.parse(raw);
441
525
  } catch {
442
526
  }
443
527
  existing.provider = provider;
444
528
  existing.model = model;
445
- await atomicWrite2(configPath, JSON.stringify(existing, null, 2));
529
+ await atomicWrite3(configPath, JSON.stringify(existing, null, 2));
446
530
  return true;
447
531
  } catch {
448
532
  return false;
@@ -455,58 +539,103 @@ function buildBuiltinSlashCommands(opts) {
455
539
  clearCommand(opts),
456
540
  compactCommand(opts),
457
541
  contextCommand(opts),
458
- usageCommand(opts),
459
542
  toolsCommand(opts),
460
543
  skillCommand(opts),
461
- useCommand(opts),
462
- modelCommand(opts),
463
544
  diagCommand(opts),
464
545
  statsCommand(opts),
546
+ spawnCommand(opts),
547
+ agentsCommand(opts),
548
+ metricsCommand(opts),
549
+ healthCommand(opts),
550
+ memoryCommand(opts),
465
551
  saveCommand(opts),
466
552
  loadCommand(opts),
467
553
  exitCommand(opts)
468
554
  ];
469
555
  }
556
+ function memoryCommand(opts) {
557
+ return {
558
+ name: "memory",
559
+ description: "Inspect or edit persistent memory: /memory [show|remember <text>|forget <query>|clear]",
560
+ async run(args) {
561
+ const store = opts.memoryStore;
562
+ if (!store) return { message: "No memory store configured." };
563
+ const [verb, ...rest] = args.trim().split(/\s+/);
564
+ const restJoined = rest.join(" ").trim();
565
+ switch (verb) {
566
+ case "":
567
+ case "show":
568
+ case "list": {
569
+ const text = await store.readAll();
570
+ return {
571
+ message: text.trim().length === 0 ? "Memory is empty. Add an entry with `/memory remember <text>`." : text
572
+ };
573
+ }
574
+ case "remember":
575
+ case "add": {
576
+ if (!restJoined) return { message: "Usage: /memory remember <text>" };
577
+ await store.remember(restJoined);
578
+ return { message: `Remembered: ${restJoined}` };
579
+ }
580
+ case "forget":
581
+ case "rm": {
582
+ if (!restJoined) return { message: "Usage: /memory forget <query>" };
583
+ const n = await store.forget(restJoined);
584
+ return {
585
+ message: n === 0 ? `No entries matched "${restJoined}".` : `Forgot ${n} entries.`
586
+ };
587
+ }
588
+ case "clear": {
589
+ await store.clear();
590
+ return { message: "Cleared all memory scopes." };
591
+ }
592
+ default:
593
+ return {
594
+ message: `Unknown subcommand "${verb}". Try: show | remember <text> | forget <query> | clear`
595
+ };
596
+ }
597
+ }
598
+ };
599
+ }
470
600
  function initCommand(opts) {
471
601
  return {
472
602
  name: "init",
473
603
  description: "Scaffold .wrongstack/AGENTS.md in the current project.",
474
604
  async run(args, ctx) {
475
605
  const force = args.trim() === "--force";
476
- const dir = path2.join(ctx.projectRoot, ".wrongstack");
477
- const file = path2.join(dir, "AGENTS.md");
606
+ const dir = path5.join(ctx.projectRoot, ".wrongstack");
607
+ const file = path5.join(dir, "AGENTS.md");
478
608
  try {
479
- await fs2.access(file);
609
+ await fs6.access(file);
480
610
  if (!force) {
481
- const msg = `AGENTS.md already exists at ${file}. Use "/init --force" to overwrite.`;
482
- opts.renderer.writeWarning(msg);
483
- return { message: msg };
611
+ const msg2 = `AGENTS.md already exists at ${file}. Use "/init --force" to overwrite.`;
612
+ opts.renderer.writeWarning(msg2);
613
+ return { message: msg2 };
484
614
  }
485
615
  } catch {
486
616
  }
487
617
  const detected = await detectProjectFacts(ctx.projectRoot);
488
618
  const body = renderAgentsTemplate(detected);
489
- await fs2.mkdir(dir, { recursive: true });
490
- await fs2.writeFile(file, body, "utf8");
619
+ await fs6.mkdir(dir, { recursive: true });
620
+ await fs6.writeFile(file, body, "utf8");
491
621
  if (detected.hints.length > 0) {
492
- const msg = `Wrote ${file}
622
+ const msg2 = `Wrote ${file}
493
623
  Pre-filled: ${detected.hints.join(", ")}. Edit the file to add anything else worth remembering.`;
494
624
  opts.renderer.writeInfo(`Wrote ${file}`);
495
625
  opts.renderer.writeInfo(`Pre-filled: ${detected.hints.join(", ")}. Edit the file to add anything else worth remembering.`);
496
- return { message: msg };
497
- } else {
498
- const msg = `Wrote ${file}
499
- No project type auto-detected. Edit the file to add build/test commands and conventions.`;
500
- opts.renderer.writeInfo(`Wrote ${file}`);
501
- return { message: msg };
626
+ return { message: msg2 };
502
627
  }
628
+ const msg = `Wrote ${file}
629
+ No project type auto-detected. Edit the file to add build/test commands and conventions.`;
630
+ opts.renderer.writeInfo(`Wrote ${file}`);
631
+ return { message: msg };
503
632
  }
504
633
  };
505
634
  }
506
635
  async function detectProjectFacts(root) {
507
636
  const facts = { hints: [] };
508
637
  try {
509
- const pkg = JSON.parse(await fs2.readFile(path2.join(root, "package.json"), "utf8"));
638
+ const pkg = JSON.parse(await fs6.readFile(path5.join(root, "package.json"), "utf8"));
510
639
  const scripts = pkg.scripts ?? {};
511
640
  const pm = (pkg.packageManager ?? "npm").split("@")[0] ?? "npm";
512
641
  if (scripts["build"]) facts.build = `${pm} run build`;
@@ -517,28 +646,28 @@ async function detectProjectFacts(root) {
517
646
  } catch {
518
647
  }
519
648
  try {
520
- await fs2.access(path2.join(root, "pyproject.toml"));
649
+ await fs6.access(path5.join(root, "pyproject.toml"));
521
650
  facts.test ??= "pytest";
522
651
  facts.lint ??= "ruff check .";
523
652
  facts.hints.push("pyproject.toml");
524
653
  } catch {
525
654
  }
526
655
  try {
527
- await fs2.access(path2.join(root, "go.mod"));
656
+ await fs6.access(path5.join(root, "go.mod"));
528
657
  facts.build ??= "go build ./...";
529
658
  facts.test ??= "go test ./...";
530
659
  facts.hints.push("go.mod");
531
660
  } catch {
532
661
  }
533
662
  try {
534
- await fs2.access(path2.join(root, "Cargo.toml"));
663
+ await fs6.access(path5.join(root, "Cargo.toml"));
535
664
  facts.build ??= "cargo build";
536
665
  facts.test ??= "cargo test";
537
666
  facts.hints.push("Cargo.toml");
538
667
  } catch {
539
668
  }
540
669
  try {
541
- await fs2.access(path2.join(root, "Makefile"));
670
+ await fs6.access(path5.join(root, "Makefile"));
542
671
  facts.build ??= "make";
543
672
  facts.test ??= "make test";
544
673
  facts.hints.push("Makefile");
@@ -586,12 +715,8 @@ function diagCommand(opts) {
586
715
  name: "diag",
587
716
  description: "Show runtime diagnostics (provider, tokens, tools, MCP).",
588
717
  async run() {
589
- if (opts.onDiag) {
590
- opts.onDiag();
591
- return { message: "diag" };
592
- } else {
593
- return { message: "Diag not available in this context." };
594
- }
718
+ if (!opts.onDiag) return { message: "Diag not available in this context." };
719
+ return { message: opts.onDiag() };
595
720
  }
596
721
  };
597
722
  }
@@ -600,28 +725,73 @@ function statsCommand(opts) {
600
725
  name: "stats",
601
726
  description: "Show session report: tokens, requests, tools, files, cost.",
602
727
  async run() {
603
- if (opts.onStats) {
604
- opts.onStats();
605
- return { message: "stats" };
606
- } else {
607
- return { message: "Stats not available in this context." };
608
- }
728
+ if (!opts.onStats) return { message: "Stats not available in this context." };
729
+ const text = opts.onStats();
730
+ return { message: text ?? "No session activity recorded yet." };
609
731
  }
610
732
  };
611
733
  }
612
734
  function helpCommand(opts) {
613
735
  return {
614
736
  name: "help",
615
- description: "Show available slash commands.",
616
- async run() {
737
+ description: "Show available slash commands. Pass a name for detailed help.",
738
+ help: [
739
+ "Usage:",
740
+ " /help List every command with its one-line description.",
741
+ " /help <name> Show detailed help for one command (falls back to the description).",
742
+ "",
743
+ "Examples:",
744
+ " /help",
745
+ " /help context",
746
+ " /help model"
747
+ ].join("\n"),
748
+ async run(args) {
749
+ const query = args.trim();
750
+ if (query) {
751
+ const needle = query.startsWith("/") ? query.slice(1) : query;
752
+ let match;
753
+ for (const entry of opts.registry.listWithOwner()) {
754
+ const aliases = entry.cmd.aliases ?? [];
755
+ const candidates = [
756
+ entry.cmd.name,
757
+ entry.fullName,
758
+ ...aliases,
759
+ ...aliases.map(
760
+ (a) => entry.owner === "core" ? a : `${entry.owner}:${a}`
761
+ )
762
+ ];
763
+ if (candidates.includes(needle)) {
764
+ match = entry;
765
+ break;
766
+ }
767
+ }
768
+ if (!match) {
769
+ return { message: `Unknown command: /${needle}. Run /help to list commands.` };
770
+ }
771
+ const prefix = match.owner === "core" ? "" : `${match.owner}:`;
772
+ const header = `/${prefix}${match.cmd.name}`;
773
+ const aliasLine = match.cmd.aliases && match.cmd.aliases.length > 0 ? `Aliases: ${match.cmd.aliases.map((a) => `/${prefix}${a}`).join(", ")}
774
+ ` : "";
775
+ const body = match.cmd.help ?? match.cmd.description;
776
+ return {
777
+ message: [
778
+ header,
779
+ "\u2500".repeat(header.length),
780
+ aliasLine + (match.cmd.help ? "" : `${match.cmd.description}
781
+ `),
782
+ body
783
+ ].filter(Boolean).join("\n")
784
+ };
785
+ }
617
786
  const lines = ["Available slash commands:"];
618
- for (const { cmd, owner, fullName } of opts.registry.listWithOwner()) {
787
+ for (const { cmd, owner } of opts.registry.listWithOwner()) {
619
788
  const isBuiltin = owner === "core";
620
789
  const prefix = isBuiltin ? "" : `${owner}:`;
621
790
  const aliases = cmd.aliases ? cmd.aliases.map((a) => `/${prefix}${a}`).join(", ") : "";
622
791
  const aliasStr = aliases ? ` (${aliases})` : "";
623
792
  lines.push(` /${prefix}${cmd.name}${aliasStr} \u2014 ${cmd.description}`);
624
793
  }
794
+ lines.push("", "Run `/help <name>` for detailed help on a specific command.");
625
795
  return { message: lines.join("\n") };
626
796
  }
627
797
  };
@@ -630,6 +800,14 @@ function clearCommand(opts) {
630
800
  return {
631
801
  name: "clear",
632
802
  description: "Reset the session and start a new one.",
803
+ help: [
804
+ "Usage:",
805
+ " /clear",
806
+ "",
807
+ "Wipes everything in the current REPL state: messages, todos, read-file tracking,",
808
+ "file mtimes, meta. Memory store entries (all scopes) are cleared too. The terminal",
809
+ "is wiped. Use this when you want a fresh conversation without restarting `wstack`."
810
+ ].join("\n"),
633
811
  async run(_args, ctx) {
634
812
  if (ctx) {
635
813
  ctx.messages = [];
@@ -652,6 +830,14 @@ function contextCommand(opts) {
652
830
  name: "context",
653
831
  aliases: ["ctx"],
654
832
  description: "Show context window summary.",
833
+ help: [
834
+ "Usage:",
835
+ " /context Show counts: messages, est. tokens, tool calls, todos, read files.",
836
+ " /context detail As above, plus model, cwd, projectRoot, and the file list.",
837
+ "",
838
+ "Token estimate is a `chars \xF7 4` heuristic, not a real tokenizer call \u2014",
839
+ "good enough to spot growth between turns."
840
+ ].join("\n"),
655
841
  async run(args, ctx) {
656
842
  const messages = ctx.messages;
657
843
  const detailed = args.trim() === "detail";
@@ -732,6 +918,14 @@ function compactCommand(opts) {
732
918
  return {
733
919
  name: "compact",
734
920
  description: "Compact the context window.",
921
+ help: [
922
+ "Usage:",
923
+ " /compact Run the configured compactor with default settings.",
924
+ " /compact aggressive Compact more aggressively \u2014 keeps fewer recent turns verbatim.",
925
+ "",
926
+ "The compactor summarizes older turns to reclaim tokens. The default keeps the most",
927
+ "recent K message pairs untouched; aggressive halves that window."
928
+ ].join("\n"),
735
929
  async run(args, ctx) {
736
930
  if (!opts.compactor) {
737
931
  const msg2 = "No compactor configured.";
@@ -746,26 +940,6 @@ function compactCommand(opts) {
746
940
  }
747
941
  };
748
942
  }
749
- function usageCommand(opts) {
750
- return {
751
- name: "usage",
752
- aliases: ["cost"],
753
- description: "Show token usage and estimated cost.",
754
- async run() {
755
- const total = opts.tokenCounter.total();
756
- const cost = opts.tokenCounter.estimateCost();
757
- const msg = `${color.bold("Usage")}
758
- input: ${total.input}
759
- output: ${total.output}
760
- cache read: ${total.cacheRead ?? 0}
761
- cache write: ${total.cacheWrite ?? 0}
762
- cost: $${cost.total.toFixed(4)} (input $${cost.input.toFixed(4)} / output $${cost.output.toFixed(4)})
763
- `;
764
- opts.renderer.write(msg);
765
- return { message: msg };
766
- }
767
- };
768
- }
769
943
  function toolsCommand(opts) {
770
944
  return {
771
945
  name: "tools",
@@ -803,47 +977,14 @@ function skillCommand(opts) {
803
977
  ${lines.join("\n")}
804
978
  `;
805
979
  return { message: msg };
806
- } else {
807
- const skill = await opts.skillLoader.find(args.trim());
808
- if (!skill) {
809
- const msg = `Skill "${args.trim()}" not found.`;
810
- return { message: msg };
811
- }
812
- const body = await opts.skillLoader.readBody(skill.name);
813
- return { message: body };
814
- }
815
- }
816
- };
817
- }
818
- function useCommand(opts) {
819
- return {
820
- name: "use",
821
- description: "Switch provider mid-session: /use <provider>",
822
- async run(args) {
823
- const name = args.trim();
824
- if (!name) {
825
- const msg2 = "Usage: /use <provider-name>";
826
- return { message: msg2 };
827
980
  }
828
- opts.onSwitchProvider?.(name);
829
- const msg = `Switched provider to "${name}".`;
830
- return { message: msg };
831
- }
832
- };
833
- }
834
- function modelCommand(opts) {
835
- return {
836
- name: "model",
837
- description: "Switch model mid-session: /model <model>",
838
- async run(args) {
839
- const name = args.trim();
840
- if (!name) {
841
- const msg2 = "Usage: /model <model-name>";
842
- return { message: msg2 };
981
+ const skill = await opts.skillLoader.find(args.trim());
982
+ if (!skill) {
983
+ const msg = `Skill "${args.trim()}" not found.`;
984
+ return { message: msg };
843
985
  }
844
- opts.onSwitchModel?.(name);
845
- const msg = `Switched model to "${name}".`;
846
- return { message: msg };
986
+ const body = await opts.skillLoader.readBody(skill.name);
987
+ return { message: body };
847
988
  }
848
989
  };
849
990
  }
@@ -901,6 +1042,102 @@ function exitCommand(opts) {
901
1042
  }
902
1043
  };
903
1044
  }
1045
+ function metricsCommand(opts) {
1046
+ return {
1047
+ name: "metrics",
1048
+ description: "Show metrics snapshot (requires --metrics flag).",
1049
+ async run() {
1050
+ if (!opts.metricsSink) {
1051
+ return { message: "Metrics not enabled. Restart with --metrics to collect." };
1052
+ }
1053
+ const snap = opts.metricsSink.snapshot();
1054
+ if (snap.series.length === 0) {
1055
+ return { message: "No metrics recorded yet." };
1056
+ }
1057
+ const lines = [];
1058
+ const byName = /* @__PURE__ */ new Map();
1059
+ for (const s of snap.series) {
1060
+ const bucket = byName.get(s.name) ?? [];
1061
+ bucket.push(s);
1062
+ byName.set(s.name, bucket);
1063
+ }
1064
+ for (const [name, series] of [...byName.entries()].sort()) {
1065
+ lines.push(color.dim(`# ${name}`));
1066
+ for (const s of series) {
1067
+ const labels = Object.entries(s.labels).map(([k, v]) => `${k}=${v}`).join(" ");
1068
+ const labelStr = labels ? color.dim(` {${labels}}`) : "";
1069
+ if (s.type === "histogram") {
1070
+ lines.push(
1071
+ ` count=${s.values.count} sum=${s.values.sum} min=${s.values.min} max=${s.values.max} p50=${s.values.p50} p95=${s.values.p95} p99=${s.values.p99}${labelStr}`
1072
+ );
1073
+ } else {
1074
+ lines.push(` ${s.values.value}${labelStr}`);
1075
+ }
1076
+ }
1077
+ }
1078
+ const msg = lines.join("\n");
1079
+ return { message: msg };
1080
+ }
1081
+ };
1082
+ }
1083
+ function healthCommand(opts) {
1084
+ return {
1085
+ name: "health",
1086
+ description: "Run health checks (requires --metrics flag).",
1087
+ async run() {
1088
+ if (!opts.healthRegistry) {
1089
+ return { message: "Health checks not enabled. Restart with --metrics." };
1090
+ }
1091
+ const result = await opts.healthRegistry.run();
1092
+ const lines = [
1093
+ `${statusIcon(result.status)} overall: ${result.status}`,
1094
+ ...result.checks.map((c) => {
1095
+ const detail = c.detail ? color.dim(` \u2014 ${c.detail}`) : "";
1096
+ return ` ${statusIcon(c.status)} ${c.name}: ${c.status}${detail}`;
1097
+ })
1098
+ ];
1099
+ return { message: lines.join("\n") };
1100
+ }
1101
+ };
1102
+ }
1103
+ function statusIcon(status) {
1104
+ if (status === "healthy") return color.green("\u25CF");
1105
+ if (status === "degraded") return color.yellow("\u25CF");
1106
+ return color.red("\u25CF");
1107
+ }
1108
+ function spawnCommand(opts) {
1109
+ return {
1110
+ name: "spawn",
1111
+ description: "Spawn an isolated subagent to handle a task. Usage: /spawn <task description>",
1112
+ async run(args) {
1113
+ const description = args.trim();
1114
+ if (!description) return { message: "Usage: /spawn <task description>" };
1115
+ if (!opts.onSpawn) {
1116
+ return { message: "Multi-agent is not enabled in this session." };
1117
+ }
1118
+ try {
1119
+ const summary = await opts.onSpawn(description);
1120
+ return { message: summary };
1121
+ } catch (err) {
1122
+ return {
1123
+ message: `Spawn failed: ${err instanceof Error ? err.message : String(err)}`
1124
+ };
1125
+ }
1126
+ }
1127
+ };
1128
+ }
1129
+ function agentsCommand(opts) {
1130
+ return {
1131
+ name: "agents",
1132
+ description: "Show status of spawned subagents (pending + completed tasks).",
1133
+ async run() {
1134
+ if (!opts.onAgents) {
1135
+ return { message: "Multi-agent is not enabled in this session." };
1136
+ }
1137
+ return { message: opts.onAgents() };
1138
+ }
1139
+ };
1140
+ }
904
1141
 
905
1142
  // src/pre-launch.ts
906
1143
  var MANIFESTS = [
@@ -917,13 +1154,13 @@ var MANIFESTS = [
917
1154
  ];
918
1155
  async function detectProjectKind(projectRoot) {
919
1156
  try {
920
- await fs2.access(path2.join(projectRoot, ".wrongstack", "AGENTS.md"));
1157
+ await fs6.access(path5.join(projectRoot, ".wrongstack", "AGENTS.md"));
921
1158
  return "initialized";
922
1159
  } catch {
923
1160
  }
924
1161
  for (const m of MANIFESTS) {
925
1162
  try {
926
- await fs2.access(path2.join(projectRoot, m));
1163
+ await fs6.access(path5.join(projectRoot, m));
927
1164
  return "project";
928
1165
  } catch {
929
1166
  }
@@ -931,12 +1168,12 @@ async function detectProjectKind(projectRoot) {
931
1168
  return "empty";
932
1169
  }
933
1170
  async function scaffoldAgentsMd(projectRoot) {
934
- const dir = path2.join(projectRoot, ".wrongstack");
935
- const file = path2.join(dir, "AGENTS.md");
1171
+ const dir = path5.join(projectRoot, ".wrongstack");
1172
+ const file = path5.join(dir, "AGENTS.md");
936
1173
  const facts = await detectProjectFacts(projectRoot);
937
1174
  const body = renderAgentsTemplate(facts);
938
- await fs2.mkdir(dir, { recursive: true });
939
- await fs2.writeFile(file, body, "utf8");
1175
+ await fs6.mkdir(dir, { recursive: true });
1176
+ await fs6.writeFile(file, body, "utf8");
940
1177
  return file;
941
1178
  }
942
1179
  async function runProjectCheck(opts) {
@@ -945,7 +1182,7 @@ async function runProjectCheck(opts) {
945
1182
  if (kind === "initialized") {
946
1183
  renderer.write(
947
1184
  `
948
- ${color.green("\u2713")} Project initialized ${color.dim(`(${path2.join(projectRoot, ".wrongstack", "AGENTS.md")})`)}
1185
+ ${color.green("\u2713")} Project initialized ${color.dim(`(${path5.join(projectRoot, ".wrongstack", "AGENTS.md")})`)}
949
1186
  `
950
1187
  );
951
1188
  return true;
@@ -1214,14 +1451,14 @@ function summarize(value, name) {
1214
1451
  if (typeof v === "object" && v !== null) {
1215
1452
  const o = v;
1216
1453
  if (name === "edit") {
1217
- const path6 = typeof o["path"] === "string" ? o["path"] : "";
1454
+ const path7 = typeof o["path"] === "string" ? o["path"] : "";
1218
1455
  const reps = typeof o["replacements"] === "number" ? o["replacements"] : 0;
1219
- return `${path6} ${reps} replacement${reps === 1 ? "" : "s"}`.trim();
1456
+ return `${path7} ${reps} replacement${reps === 1 ? "" : "s"}`.trim();
1220
1457
  }
1221
1458
  if (name === "write") {
1222
- const path6 = typeof o["path"] === "string" ? o["path"] : "";
1459
+ const path7 = typeof o["path"] === "string" ? o["path"] : "";
1223
1460
  const bytes = typeof o["bytes"] === "number" ? o["bytes"] : void 0;
1224
- return bytes !== void 0 ? `${path6} ${bytes}B` : path6;
1461
+ return bytes !== void 0 ? `${path7} ${bytes}B` : path7;
1225
1462
  }
1226
1463
  if (typeof o["count"] === "number") {
1227
1464
  return `${o["count"]} match${o["count"] === 1 ? "" : "es"}`;
@@ -1249,6 +1486,16 @@ try {
1249
1486
  } catch {
1250
1487
  }
1251
1488
 
1489
+ // src/utils.ts
1490
+ function fmtTok(n) {
1491
+ if (n < 1e3) return String(n);
1492
+ if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`;
1493
+ return `${(n / 1e6).toFixed(1)}M`;
1494
+ }
1495
+ function patchConfig(base, patch) {
1496
+ return Object.freeze({ ...base, ...patch });
1497
+ }
1498
+
1252
1499
  // src/repl.ts
1253
1500
  async function runRepl(opts) {
1254
1501
  if (opts.banner !== false) printBanner(opts.renderer);
@@ -1310,9 +1557,13 @@ async function runRepl(opts) {
1310
1557
  if (result.status === "aborted") {
1311
1558
  opts.renderer.writeWarning("Aborted.");
1312
1559
  } else if (result.status === "failed") {
1313
- opts.renderer.writeError(
1314
- `Failed: ${result.error instanceof Error ? result.error.message : String(result.error)}`
1315
- );
1560
+ const err = result.error;
1561
+ if (err) {
1562
+ const tag = err.recoverable ? " (recoverable)" : "";
1563
+ opts.renderer.writeError(`Failed [${err.severity}]${tag}: ${err.describe()}`);
1564
+ } else {
1565
+ opts.renderer.writeError("Failed.");
1566
+ }
1316
1567
  } else if (result.status === "max_iterations") {
1317
1568
  opts.renderer.writeWarning(`Hit max iterations (${result.iterations}).`);
1318
1569
  }
@@ -1359,11 +1610,6 @@ async function readPossiblyMultiline(opts) {
1359
1610
  }
1360
1611
  return buf;
1361
1612
  }
1362
- function fmtTok(n) {
1363
- if (n < 1e3) return String(n);
1364
- if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`;
1365
- return `${(n / 1e6).toFixed(1)}M`;
1366
- }
1367
1613
  var FILLED = "\u2588";
1368
1614
  var EMPTY = "\u2591";
1369
1615
  function renderContextChip(used, max) {
@@ -1422,11 +1668,11 @@ var SessionStats = class {
1422
1668
  if (e.name === "bash") this.bashCommands++;
1423
1669
  else if (e.name === "fetch") this.fetches++;
1424
1670
  if (!e.ok) return;
1425
- const path6 = typeof input?.path === "string" ? input.path : void 0;
1426
- if (e.name === "read" && path6) this.readPaths.add(path6);
1427
- else if (e.name === "edit" && path6) this.editedPaths.add(path6);
1428
- else if (e.name === "write" && path6) {
1429
- this.writtenPaths.add(path6);
1671
+ const path7 = typeof input?.path === "string" ? input.path : void 0;
1672
+ if (e.name === "read" && path7) this.readPaths.add(path7);
1673
+ else if (e.name === "edit" && path7) this.editedPaths.add(path7);
1674
+ else if (e.name === "write" && path7) {
1675
+ this.writtenPaths.add(path7);
1430
1676
  const content = typeof input?.content === "string" ? input.content : "";
1431
1677
  this.bytesWritten += Buffer.byteLength(content, "utf8");
1432
1678
  }
@@ -1435,8 +1681,15 @@ var SessionStats = class {
1435
1681
  hasActivity() {
1436
1682
  return this.apiRequests > 0 || this.iterations > 0 || this.toolStats.size > 0 || this.tokenCounter.total().input > 0;
1437
1683
  }
1438
- render(renderer) {
1439
- if (!this.hasActivity()) return;
1684
+ /**
1685
+ * Build the report string. Returns null when there's no recorded
1686
+ * activity yet — caller decides whether to emit a placeholder or stay
1687
+ * silent. Splitting `format()` out of `render()` lets the TUI's slash
1688
+ * dispatcher take the string and turn it into a history entry, while
1689
+ * REPL keeps the old direct-write path.
1690
+ */
1691
+ format() {
1692
+ if (!this.hasActivity()) return null;
1440
1693
  const u = this.tokenCounter.total();
1441
1694
  const cost = this.tokenCounter.estimateCost();
1442
1695
  const elapsedSec = ((Date.now() - this.startedAt) / 1e3).toFixed(1);
@@ -1451,12 +1704,12 @@ var SessionStats = class {
1451
1704
  lines.push(` Errors: ${color.yellow(String(this.errors))}`);
1452
1705
  }
1453
1706
  lines.push("");
1454
- lines.push(` Tokens: in ${fmtTok2(u.input)} out ${fmtTok2(u.output)}${u.cacheRead ? ` cacheR ${fmtTok2(u.cacheRead)}` : ""}${u.cacheWrite ? ` cacheW ${fmtTok2(u.cacheWrite)}` : ""}`);
1707
+ lines.push(` Tokens: in ${fmtTok(u.input)} out ${fmtTok(u.output)}${u.cacheRead ? ` cacheR ${fmtTok(u.cacheRead)}` : ""}${u.cacheWrite ? ` cacheW ${fmtTok(u.cacheWrite)}` : ""}`);
1455
1708
  const cache = this.tokenCounter.cacheStats();
1456
1709
  if (cache.readTokens > 0 || cache.writeTokens > 0) {
1457
1710
  const pct = (cache.hitRatio * 100).toFixed(1);
1458
1711
  lines.push(
1459
- ` Prompt cache: ${pct}% hit ${color.dim(`(${fmtTok2(cache.readTokens)} read / ${fmtTok2(cache.writeTokens)} write)`)}`
1712
+ ` Prompt cache: ${pct}% hit ${color.dim(`(${fmtTok(cache.readTokens)} read / ${fmtTok(cache.writeTokens)} write)`)}`
1460
1713
  );
1461
1714
  }
1462
1715
  if (cost.total > 0) {
@@ -1497,15 +1750,15 @@ var SessionStats = class {
1497
1750
  if (this.fetches > 0) lines.push(` Web fetches: ${this.fetches}`);
1498
1751
  }
1499
1752
  lines.push("");
1500
- renderer.write(`${lines.join("\n")}
1753
+ return lines.join("\n");
1754
+ }
1755
+ render(renderer) {
1756
+ const text = this.format();
1757
+ if (text === null) return;
1758
+ renderer.write(`${text}
1501
1759
  `);
1502
1760
  }
1503
1761
  };
1504
- function fmtTok2(n) {
1505
- if (n < 1e3) return String(n);
1506
- if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`;
1507
- return `${(n / 1e6).toFixed(1)}M`;
1508
- }
1509
1762
  function samplePaths(set) {
1510
1763
  const arr = [...set];
1511
1764
  if (arr.length <= 2) return arr.join(", ");
@@ -1576,7 +1829,7 @@ function renderContextChip2(ctx) {
1576
1829
  const pct = Math.round(ratio * 100);
1577
1830
  const chipColor = ratio >= 0.85 ? color.red : ratio >= 0.65 ? color.yellow : color.cyan;
1578
1831
  const bar = renderProgress2(ratio, 8);
1579
- return color.dim("ctx ") + chipColor(bar) + chipColor(` ${pct}%`) + color.dim(` (${fmtTok3(ctx.used)}/${fmtTok3(ctx.max)})`);
1832
+ return color.dim("ctx ") + chipColor(bar) + chipColor(` ${pct}%`) + color.dim(` (${fmtTok(ctx.used)}/${fmtTok(ctx.max)})`);
1580
1833
  }
1581
1834
  function renderProgress2(ratio, width) {
1582
1835
  const clamped = Math.max(0, Math.min(1, ratio));
@@ -1584,92 +1837,888 @@ function renderProgress2(ratio, width) {
1584
1837
  const capped = Math.min(width, filled);
1585
1838
  return FILLED2.repeat(capped) + EMPTY2.repeat(width - capped);
1586
1839
  }
1587
- function fmtTok3(n) {
1588
- if (n < 1e3) return String(n);
1589
- if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`;
1590
- return `${(n / 1e6).toFixed(1)}M`;
1840
+ async function bootConfig(flags) {
1841
+ const cwd = typeof flags["cwd"] === "string" ? path5.resolve(flags["cwd"]) : process.cwd();
1842
+ const pathResolver = new DefaultPathResolver(cwd);
1843
+ const projectRoot = pathResolver.projectRoot;
1844
+ const userHome = os3.homedir();
1845
+ const wpaths = resolveWstackPaths({ projectRoot, userHome });
1846
+ await ensureProjectMeta(wpaths, projectRoot);
1847
+ const vault = new DefaultSecretVault({ keyFile: wpaths.secretsKey });
1848
+ for (const file of [wpaths.globalConfig, wpaths.projectLocalConfig]) {
1849
+ try {
1850
+ const { migrated } = await migratePlaintextSecrets(file, vault);
1851
+ if (migrated > 0) {
1852
+ process.stderr.write(`[wstack] Encrypted ${migrated} plaintext secret(s) in ${file}
1853
+ `);
1854
+ }
1855
+ } catch {
1856
+ }
1857
+ }
1858
+ const configLoader = new DefaultConfigLoader({ paths: wpaths, vault });
1859
+ const config = await configLoader.load({ cliFlags: flagsToConfigPatch(flags) });
1860
+ return {
1861
+ paths: { cwd, projectRoot, userHome, wpaths, pathResolver },
1862
+ config,
1863
+ vault
1864
+ };
1591
1865
  }
1592
- var subcommands = {
1593
- init: initCmd,
1594
- auth: authCmd,
1595
- // `resume <id>` is special-cased in src/index.ts: it's lifted into
1596
- // `--resume <id>` so the normal REPL bootstrap runs with a pre-loaded
1597
- // session. There is no standalone subcommand handler.
1598
- sessions: sessionsCmd,
1599
- config: configCmd,
1600
- tools: toolsCmd,
1601
- skills: skillsCmd,
1602
- providers: providersCmd,
1603
- models: modelsCmd,
1604
- mcp: mcpCmd,
1605
- plugin: pluginCmd,
1606
- diag: diagCmd,
1607
- usage: usageCmd,
1608
- version: versionCmd,
1609
- help: helpCmd,
1610
- projects: projectsCmd
1611
- };
1612
- async function authCmd(args, deps) {
1613
- const flags = parseAuthFlags(args);
1614
- let providerId = flags.positional[0];
1615
- if (!providerId) {
1616
- providerId = (await deps.reader.readLine("Provider id: ")).trim();
1866
+ function flagsToConfigPatch(flags) {
1867
+ const patch = {};
1868
+ if (typeof flags["provider"] === "string") patch.provider = flags["provider"];
1869
+ if (typeof flags["model"] === "string") patch.model = flags["model"];
1870
+ if (typeof flags["cwd"] === "string") patch.cwd = flags["cwd"];
1871
+ if (typeof flags["log-level"] === "string") {
1872
+ patch.log = { level: flags["log-level"] };
1873
+ } else if (flags["verbose"]) {
1874
+ patch.log = { level: "debug" };
1875
+ } else if (flags["trace"]) {
1876
+ patch.log = { level: "trace" };
1617
1877
  }
1618
- if (!providerId) {
1619
- deps.renderer.writeError("Provider id is required.");
1620
- return 1;
1878
+ if (flags["yolo"]) patch.yolo = true;
1879
+ if (flags["no-features"]) {
1880
+ patch.features = {
1881
+ mcp: false,
1882
+ plugins: false,
1883
+ memory: false,
1884
+ modelsRegistry: false,
1885
+ skills: false
1886
+ };
1621
1887
  }
1622
- let family = flags.family;
1623
- let baseUrl = flags.baseUrl;
1624
- let envVars = flags.envVars;
1888
+ return patch;
1889
+ }
1890
+ async function ensureProjectMeta(paths, projectRoot) {
1625
1891
  try {
1626
- const known = await deps.modelsRegistry.getProvider(providerId);
1627
- if (known) {
1628
- if (!family) family = known.family;
1629
- if (!baseUrl) baseUrl = known.apiBase;
1630
- if (!envVars) envVars = known.envVars;
1631
- }
1892
+ await fs6.mkdir(paths.projectDir, { recursive: true });
1893
+ const meta = {
1894
+ hash: paths.projectHash,
1895
+ root: projectRoot,
1896
+ lastSeen: (/* @__PURE__ */ new Date()).toISOString()
1897
+ };
1898
+ await fs6.writeFile(paths.projectMeta, JSON.stringify(meta, null, 2));
1632
1899
  } catch {
1633
1900
  }
1634
- if (!family) {
1635
- deps.renderer.writeError(
1636
- `Provider "${providerId}" not in catalog. Pass --family <anthropic|openai|openai-compatible|google> to register it manually.`
1901
+ }
1902
+ var MultiAgentHost = class {
1903
+ constructor(deps) {
1904
+ this.deps = deps;
1905
+ }
1906
+ deps;
1907
+ coordinator;
1908
+ pending = /* @__PURE__ */ new Map();
1909
+ results = [];
1910
+ async ensureCoordinator() {
1911
+ if (this.coordinator) return this.coordinator;
1912
+ const config = this.deps.configStore.get();
1913
+ const factory = async (subCfg) => {
1914
+ const events = new EventBus();
1915
+ const provider = await this.buildSubagentProvider(config);
1916
+ const baseSystem = await this.deps.systemPromptBuilder.build({
1917
+ cwd: this.deps.cwd,
1918
+ projectRoot: this.deps.projectRoot,
1919
+ tools: this.filterTools(subCfg.tools),
1920
+ model: subCfg.model ?? config.model,
1921
+ provider: config.provider
1922
+ });
1923
+ const parentSession = this.deps.session;
1924
+ const subSession = {
1925
+ id: parentSession.id,
1926
+ append: (ev) => parentSession.append({ ...ev })
1927
+ };
1928
+ const ctx = new Context({
1929
+ systemPrompt: baseSystem,
1930
+ provider,
1931
+ session: subSession,
1932
+ signal: new AbortController().signal,
1933
+ tokenCounter: this.deps.tokenCounter,
1934
+ cwd: this.deps.cwd,
1935
+ projectRoot: this.deps.projectRoot,
1936
+ model: subCfg.model ?? config.model,
1937
+ tools: this.filterTools(subCfg.tools)
1938
+ });
1939
+ const agent = new Agent({
1940
+ container: this.deps.container,
1941
+ tools: this.subagentToolRegistry(subCfg.tools),
1942
+ providers: this.deps.providerRegistry,
1943
+ events,
1944
+ pipelines: createDefaultPipelines(),
1945
+ context: ctx
1946
+ });
1947
+ return { agent, events };
1948
+ };
1949
+ const runner = makeAgentSubagentRunner({ factory });
1950
+ this.coordinator = new DefaultMultiAgentCoordinator(
1951
+ {
1952
+ coordinatorId: randomUUID(),
1953
+ doneCondition: { type: "all_tasks_done" },
1954
+ maxConcurrent: 2,
1955
+ defaultBudget: { maxToolCalls: 20, maxIterations: 20, timeoutMs: 12e4 }
1956
+ },
1957
+ { runner }
1637
1958
  );
1638
- return 1;
1959
+ this.coordinator.on(
1960
+ "task.completed",
1961
+ ({ task, result }) => {
1962
+ this.results.push(result);
1963
+ this.pending.delete(task.id);
1964
+ }
1965
+ );
1966
+ return this.coordinator;
1639
1967
  }
1640
- const apiKey = (await deps.reader.readSecret(
1641
- `API key for ${providerId} (hidden, stored encrypted in ${deps.paths.globalConfig}): `
1642
- )).trim();
1643
- if (!apiKey) {
1644
- deps.renderer.writeError("No key entered. Nothing saved.");
1645
- return 1;
1968
+ async buildSubagentProvider(config) {
1969
+ const newCfg = config.providers?.[config.provider] ?? {
1970
+ type: config.provider,
1971
+ apiKey: config.apiKey,
1972
+ baseUrl: config.baseUrl
1973
+ };
1974
+ return makeProviderFromConfig(config.provider, {
1975
+ ...newCfg,
1976
+ type: config.provider
1977
+ });
1646
1978
  }
1647
- const patch = {
1648
- providers: {
1649
- [providerId]: {
1650
- type: providerId,
1651
- apiKey,
1652
- family,
1653
- ...baseUrl ? { baseUrl } : {},
1654
- ...envVars && envVars.length > 0 ? { envVars } : {}
1655
- }
1979
+ /** Returns a tool slice for the subagent — full set unless restricted. */
1980
+ filterTools(allow) {
1981
+ const all = this.deps.toolRegistry.list();
1982
+ if (!allow || allow.length === 0) return all;
1983
+ const allowSet = new Set(allow);
1984
+ return all.filter((t) => allowSet.has(t.name));
1985
+ }
1986
+ subagentToolRegistry(allow) {
1987
+ if (!allow || allow.length === 0) return this.deps.toolRegistry;
1988
+ const cloneCtor = this.deps.toolRegistry.constructor;
1989
+ const sub = new cloneCtor();
1990
+ for (const t of this.filterTools(allow)) sub.register(t);
1991
+ return sub;
1992
+ }
1993
+ /** Spawn a fresh subagent and assign a single task. Returns task id. */
1994
+ async spawn(description) {
1995
+ const coord = await this.ensureCoordinator();
1996
+ const spawned = await coord.spawn({
1997
+ name: "adhoc",
1998
+ role: "general",
1999
+ maxToolCalls: 20,
2000
+ maxIterations: 20
2001
+ });
2002
+ const taskId = randomUUID();
2003
+ this.pending.set(taskId, { description, subagentId: spawned.subagentId });
2004
+ await coord.assign({
2005
+ id: taskId,
2006
+ description,
2007
+ subagentId: spawned.subagentId,
2008
+ maxToolCalls: 20
2009
+ });
2010
+ return { subagentId: spawned.subagentId, taskId };
2011
+ }
2012
+ status() {
2013
+ const pending = Array.from(this.pending.entries()).map(([taskId, v]) => ({
2014
+ taskId,
2015
+ description: v.description,
2016
+ subagentId: v.subagentId
2017
+ }));
2018
+ const summary = !this.coordinator ? "No subagents have been spawned." : `${pending.length} pending, ${this.results.length} completed.`;
2019
+ return { pending, completed: this.results, summary };
2020
+ }
2021
+ async stopAll() {
2022
+ if (this.coordinator) {
2023
+ await this.coordinator.stopAll();
1656
2024
  }
1657
- };
1658
- try {
1659
- await rewriteConfigEncrypted(deps.paths.globalConfig, deps.vault, patch);
1660
- deps.renderer.writeInfo(`Stored encrypted key for ${providerId}.`);
1661
- deps.renderer.writeInfo(`Use: wstack --provider ${providerId} "<task>"`);
1662
- return 0;
1663
- } catch (err) {
1664
- deps.renderer.writeError(`auth: ${err instanceof Error ? err.message : String(err)}`);
1665
- return 1;
2025
+ }
2026
+ };
2027
+ async function runAuthMenu(deps) {
2028
+ for (; ; ) {
2029
+ const providers = await loadProviders(deps);
2030
+ renderTopMenu(deps.renderer, providers);
2031
+ const ids = Object.keys(providers).sort();
2032
+ const choice = (await deps.reader.readLine(`
2033
+ ${color.amber("?")} Pick: `)).trim().toLowerCase();
2034
+ if (!choice || choice === "q" || choice === "quit" || choice === "exit") {
2035
+ deps.renderer.write(color.dim("Done.\n"));
2036
+ return 0;
2037
+ }
2038
+ if (choice === "a" || choice === "add") {
2039
+ await addForNewProvider(deps);
2040
+ continue;
2041
+ }
2042
+ if (choice === "c" || choice === "custom") {
2043
+ await addCustomProvider(deps);
2044
+ continue;
2045
+ }
2046
+ const idx = Number.parseInt(choice, 10);
2047
+ if (!Number.isNaN(idx) && idx >= 1 && idx <= ids.length) {
2048
+ const pid = ids[idx - 1];
2049
+ await manageProvider(pid, deps);
2050
+ continue;
2051
+ }
2052
+ const byId = ids.find((id) => id.toLowerCase() === choice);
2053
+ if (byId) {
2054
+ await manageProvider(byId, deps);
2055
+ continue;
2056
+ }
2057
+ deps.renderer.writeError(`Unknown selection: "${choice}"`);
1666
2058
  }
1667
2059
  }
1668
- function parseAuthFlags(args) {
1669
- const out = { positional: [] };
1670
- for (let i = 0; i < args.length; i++) {
2060
+ function renderTopMenu(renderer, providers) {
2061
+ renderer.write(`
2062
+ ${color.bold("WrongStack")} ${color.dim("\u2014 API keys")}
2063
+
2064
+ `);
2065
+ const ids = Object.keys(providers).sort();
2066
+ if (ids.length === 0) {
2067
+ renderer.write(color.dim(" No providers configured yet.\n"));
2068
+ } else {
2069
+ renderer.write(` ${color.dim("Saved providers:")}
2070
+ `);
2071
+ let idx = 1;
2072
+ for (const id of ids) {
2073
+ const cfg = providers[id];
2074
+ const keys = normalizeKeys(cfg);
2075
+ const active = activeLabel(cfg, keys);
2076
+ const summary = keys.length === 0 ? color.dim("(no keys)") : keys.length === 1 ? maskedKey(keys[0].apiKey) : `${color.dim(`${keys.length} keys`)} ${color.dim("active:")} ${color.bold(active ?? "?")} ${maskedKey(keys.find((k) => k.label === active)?.apiKey ?? keys[0].apiKey)}`;
2077
+ const fam = cfg.family ? color.dim(`[${cfg.family}]`) : "";
2078
+ const aliasHint = cfg.type && cfg.type !== id ? color.dim(`\u2192 ${cfg.type}`) : "";
2079
+ renderer.write(
2080
+ ` ${color.dim(`${idx}.`.padStart(4))} ${id.padEnd(22)} ${fam} ${aliasHint} ${summary}
2081
+ `
2082
+ );
2083
+ idx++;
2084
+ }
2085
+ }
2086
+ renderer.write(`
2087
+ ${color.dim("Actions:")}
2088
+ `);
2089
+ renderer.write(` ${color.bold("a")} Add key for a new provider (from catalog)
2090
+ `);
2091
+ renderer.write(` ${color.bold("c")} Add custom provider (type + family + baseUrl)
2092
+ `);
2093
+ renderer.write(` ${color.bold("q")} Quit
2094
+ `);
2095
+ if (ids.length > 0) {
2096
+ renderer.write(color.dim(`
2097
+ Pick a number to manage that provider's keys.
2098
+ `));
2099
+ }
2100
+ }
2101
+ async function manageProvider(providerId, deps) {
2102
+ for (; ; ) {
2103
+ const providers = await loadProviders(deps);
2104
+ const cfg = providers[providerId];
2105
+ if (!cfg) {
2106
+ deps.renderer.writeError(`Provider "${providerId}" no longer in config.`);
2107
+ return;
2108
+ }
2109
+ const keys = normalizeKeys(cfg);
2110
+ const active = activeLabel(cfg, keys);
2111
+ deps.renderer.write(`
2112
+ ${color.bold(providerId)} ${cfg.family ? color.dim(`[${cfg.family}]`) : color.amber("[no family]")}
2113
+ `);
2114
+ deps.renderer.write(
2115
+ color.dim(` type: ${cfg.type ?? providerId}
2116
+ `) + color.dim(` family: ${cfg.family ?? "(unset \u2192 resolved from models.dev when type matches)"}
2117
+ `) + color.dim(` baseUrl: ${cfg.baseUrl ?? "(unset \u2192 catalog default)"}
2118
+ `)
2119
+ );
2120
+ if (cfg.envVars && cfg.envVars.length > 0) {
2121
+ deps.renderer.write(color.dim(` envVars: ${cfg.envVars.join(", ")}
2122
+ `));
2123
+ }
2124
+ if (cfg.models && cfg.models.length > 0) {
2125
+ deps.renderer.write(color.dim(` models: ${cfg.models.join(", ")}
2126
+ `));
2127
+ }
2128
+ if (keys.length === 0) {
2129
+ deps.renderer.write(color.dim(" (no keys saved)\n"));
2130
+ } else {
2131
+ for (let i = 0; i < keys.length; i++) {
2132
+ const k = keys[i];
2133
+ const marker = k.label === active ? color.green("\u25CF") : color.dim("\u25CB");
2134
+ deps.renderer.write(
2135
+ ` ${color.dim(`${i + 1}.`.padStart(4))} ${marker} ${k.label.padEnd(20)} ${maskedKey(k.apiKey)} ${color.dim(k.createdAt)}
2136
+ `
2137
+ );
2138
+ }
2139
+ }
2140
+ deps.renderer.write(`
2141
+ ${color.dim("Actions:")}
2142
+ `);
2143
+ deps.renderer.write(` ${color.bold("a")} Add another key
2144
+ `);
2145
+ if (keys.length > 0) {
2146
+ deps.renderer.write(` ${color.bold("u")} <n> Update key <n>
2147
+ `);
2148
+ deps.renderer.write(` ${color.bold("d")} <n> Delete key <n>
2149
+ `);
2150
+ deps.renderer.write(` ${color.bold("s")} <n> Set key <n> as active
2151
+ `);
2152
+ }
2153
+ deps.renderer.write(` ${color.bold("f")} Edit family
2154
+ `);
2155
+ deps.renderer.write(` ${color.bold("B")} Edit baseUrl
2156
+ `);
2157
+ deps.renderer.write(` ${color.bold("m")} Edit visible model list
2158
+ `);
2159
+ deps.renderer.write(` ${color.bold("x")} Remove this provider entirely
2160
+ `);
2161
+ deps.renderer.write(` ${color.bold("b")} Back
2162
+ `);
2163
+ const raw = (await deps.reader.readLine(`
2164
+ ${color.amber("?")} ${providerId} > `)).trim();
2165
+ if (!raw || raw === "b" || raw === "back") return;
2166
+ const [verb, argRaw] = raw.split(/\s+/, 2);
2167
+ const arg = argRaw ? Number.parseInt(argRaw, 10) : Number.NaN;
2168
+ if (verb === "a" || verb === "add") {
2169
+ await addKeyForProvider(providerId, deps, cfg);
2170
+ continue;
2171
+ }
2172
+ if (verb === "x" || verb === "remove") {
2173
+ const confirm = (await deps.reader.readLine(
2174
+ ` ${color.amber("?")} Remove provider "${providerId}" and ${keys.length} key(s)? ${color.dim("[y/N]")} `
2175
+ )).trim().toLowerCase();
2176
+ if (confirm === "y" || confirm === "yes") {
2177
+ await mutateProviders(deps, (all) => {
2178
+ delete all[providerId];
2179
+ });
2180
+ deps.renderer.write(` ${color.green("\u2713")} Removed ${providerId}.
2181
+ `);
2182
+ return;
2183
+ }
2184
+ continue;
2185
+ }
2186
+ if (verb === "u" || verb === "update") {
2187
+ if (!Number.isFinite(arg) || arg < 1 || arg > keys.length) {
2188
+ deps.renderer.writeError(`Usage: u <1-${keys.length}>`);
2189
+ continue;
2190
+ }
2191
+ const target = keys[arg - 1];
2192
+ const newKey = await readKeyInput(deps, `Updated key for ${target.label}`);
2193
+ if (!newKey) continue;
2194
+ await mutateProviders(deps, (all) => {
2195
+ const p = all[providerId];
2196
+ if (!p) return;
2197
+ const list = normalizeKeys(p).map(
2198
+ (k) => k.label === target.label ? { ...k, apiKey: newKey, createdAt: nowIso() } : k
2199
+ );
2200
+ writeKeysBack(p, list);
2201
+ });
2202
+ deps.renderer.write(` ${color.green("\u2713")} Updated ${providerId}/${target.label}.
2203
+ `);
2204
+ continue;
2205
+ }
2206
+ if (verb === "d" || verb === "delete" || verb === "rm") {
2207
+ if (!Number.isFinite(arg) || arg < 1 || arg > keys.length) {
2208
+ deps.renderer.writeError(`Usage: d <1-${keys.length}>`);
2209
+ continue;
2210
+ }
2211
+ const target = keys[arg - 1];
2212
+ const confirm = (await deps.reader.readLine(
2213
+ ` ${color.amber("?")} Delete key "${target.label}" (${maskedKey(target.apiKey)})? ${color.dim("[y/N]")} `
2214
+ )).trim().toLowerCase();
2215
+ if (confirm !== "y" && confirm !== "yes") continue;
2216
+ await mutateProviders(deps, (all) => {
2217
+ const p = all[providerId];
2218
+ if (!p) return;
2219
+ const list = normalizeKeys(p).filter((k) => k.label !== target.label);
2220
+ writeKeysBack(p, list);
2221
+ if (p.activeKey === target.label) {
2222
+ p.activeKey = list[0]?.label;
2223
+ }
2224
+ });
2225
+ deps.renderer.write(` ${color.green("\u2713")} Deleted ${providerId}/${target.label}.
2226
+ `);
2227
+ continue;
2228
+ }
2229
+ if (verb === "f" || verb === "family") {
2230
+ const current = cfg.family ?? "";
2231
+ const ans = (await deps.reader.readLine(
2232
+ ` ${color.amber("?")} Family ${color.dim(`(anthropic | openai | openai-compatible | google, empty = unset, current: ${current || "unset"})`)}: `
2233
+ )).trim();
2234
+ if (ans !== "" && !["anthropic", "openai", "openai-compatible", "google"].includes(ans)) {
2235
+ deps.renderer.writeError(`Invalid family: "${ans}"`);
2236
+ continue;
2237
+ }
2238
+ await mutateProviders(deps, (all) => {
2239
+ const p = all[providerId];
2240
+ if (!p) return;
2241
+ if (ans === "") delete p.family;
2242
+ else p.family = ans;
2243
+ });
2244
+ deps.renderer.write(` ${color.green("\u2713")} family \u2192 ${ans || "(unset)"}
2245
+ `);
2246
+ continue;
2247
+ }
2248
+ if (verb === "B" || verb === "baseurl" || verb === "base-url") {
2249
+ const current = cfg.baseUrl ?? "";
2250
+ const ans = (await deps.reader.readLine(
2251
+ ` ${color.amber("?")} Base URL ${color.dim(`(empty = unset, current: ${current || "unset"})`)}: `
2252
+ )).trim();
2253
+ await mutateProviders(deps, (all) => {
2254
+ const p = all[providerId];
2255
+ if (!p) return;
2256
+ if (ans === "") delete p.baseUrl;
2257
+ else p.baseUrl = ans;
2258
+ });
2259
+ deps.renderer.write(` ${color.green("\u2713")} baseUrl \u2192 ${ans || "(unset)"}
2260
+ `);
2261
+ continue;
2262
+ }
2263
+ if (verb === "m" || verb === "models") {
2264
+ const current = (cfg.models ?? []).join(", ");
2265
+ const ans = (await deps.reader.readLine(
2266
+ ` ${color.amber("?")} Model ids ${color.dim(`(comma-separated, empty = catalog default, current: ${current || "none"})`)}: `
2267
+ )).trim();
2268
+ const list = ans ? ans.split(",").map((s) => s.trim()).filter(Boolean) : [];
2269
+ await mutateProviders(deps, (all) => {
2270
+ const p = all[providerId];
2271
+ if (!p) return;
2272
+ if (list.length === 0) delete p.models;
2273
+ else p.models = list;
2274
+ });
2275
+ deps.renderer.write(` ${color.green("\u2713")} models \u2192 ${list.length === 0 ? "(catalog default)" : list.join(", ")}
2276
+ `);
2277
+ continue;
2278
+ }
2279
+ if (verb === "s" || verb === "set" || verb === "active") {
2280
+ if (!Number.isFinite(arg) || arg < 1 || arg > keys.length) {
2281
+ deps.renderer.writeError(`Usage: s <1-${keys.length}>`);
2282
+ continue;
2283
+ }
2284
+ const target = keys[arg - 1];
2285
+ await mutateProviders(deps, (all) => {
2286
+ const p = all[providerId];
2287
+ if (!p) return;
2288
+ const list = normalizeKeys(p);
2289
+ writeKeysBack(p, list);
2290
+ p.activeKey = target.label;
2291
+ });
2292
+ deps.renderer.write(` ${color.green("\u2713")} Active key for ${providerId} \u2192 ${color.bold(target.label)}.
2293
+ `);
2294
+ continue;
2295
+ }
2296
+ deps.renderer.writeError(`Unknown action: "${raw}"`);
2297
+ }
2298
+ }
2299
+ async function addForNewProvider(deps) {
2300
+ let catalog = [];
2301
+ try {
2302
+ catalog = (await deps.modelsRegistry.listProviders()).filter((p) => p.family !== "unsupported");
2303
+ } catch {
2304
+ deps.renderer.writeWarning("Catalog unavailable \u2014 falling back to manual entry.");
2305
+ }
2306
+ if (catalog.length === 0) {
2307
+ const pid = (await deps.reader.readLine(` ${color.amber("?")} Provider id: `)).trim();
2308
+ if (!pid) return;
2309
+ const fam = (await deps.reader.readLine(
2310
+ ` ${color.amber("?")} Family (anthropic/openai/openai-compatible/google): `
2311
+ )).trim();
2312
+ const baseUrl2 = (await deps.reader.readLine(
2313
+ ` ${color.amber("?")} Base URL ${color.dim("(optional)")}: `
2314
+ )).trim();
2315
+ await addKeyForProvider(pid, deps, {
2316
+ type: pid,
2317
+ family: fam || void 0,
2318
+ ...baseUrl2 ? { baseUrl: baseUrl2 } : {}
2319
+ });
2320
+ return;
2321
+ }
2322
+ const saved = new Set(Object.keys(await loadProviders(deps)));
2323
+ deps.renderer.write(
2324
+ color.dim(` Catalog has ${catalog.length} providers. Filter by name to narrow, or "s" for unsaved-only.
2325
+ `)
2326
+ );
2327
+ const filterRaw = (await deps.reader.readLine(
2328
+ ` ${color.amber("?")} Filter ${color.dim('(substring of id/name, "s" for unsaved-only, empty = all)')}: `
2329
+ )).trim();
2330
+ const filterLc = filterRaw.toLowerCase();
2331
+ const showUnsavedOnly = filterLc === "s" || filterLc === "unsaved";
2332
+ const matches = (p) => {
2333
+ if (showUnsavedOnly) return !saved.has(p.id);
2334
+ if (!filterLc) return true;
2335
+ return p.id.toLowerCase().includes(filterLc) || p.name.toLowerCase().includes(filterLc);
2336
+ };
2337
+ const byFamily = /* @__PURE__ */ new Map();
2338
+ let filteredCount = 0;
2339
+ for (const p of catalog) {
2340
+ if (!matches(p)) continue;
2341
+ filteredCount++;
2342
+ const list = byFamily.get(p.family) ?? [];
2343
+ list.push(p);
2344
+ byFamily.set(p.family, list);
2345
+ }
2346
+ if (filteredCount === 0) {
2347
+ deps.renderer.writeError(
2348
+ `No providers match "${filterRaw}". Try a shorter substring or check \`wstack providers\` for valid ids.`
2349
+ );
2350
+ return;
2351
+ }
2352
+ if (filterRaw && !showUnsavedOnly) {
2353
+ deps.renderer.write(
2354
+ color.dim(` ${filteredCount} match${filteredCount === 1 ? "" : "es"} for "${filterRaw}".
2355
+ `)
2356
+ );
2357
+ }
2358
+ const ordered = [];
2359
+ const familyOrder = ["anthropic", "openai", "google", "openai-compatible"];
2360
+ let idx = 1;
2361
+ deps.renderer.write("\n");
2362
+ for (const fam of familyOrder) {
2363
+ const list = byFamily.get(fam);
2364
+ if (!list || list.length === 0) continue;
2365
+ deps.renderer.write(` ${color.bold(fam)}
2366
+ `);
2367
+ for (const p of list) {
2368
+ const savedMark = saved.has(p.id) ? color.cyan("\u25C9") : color.dim("\u25CB");
2369
+ const env = p.envVars[0] ? color.dim(`[${p.envVars[0]}]`) : "";
2370
+ deps.renderer.write(
2371
+ ` ${color.dim(`${idx}.`.padStart(4))} ${savedMark} ${p.id.padEnd(22)} ${color.dim(p.name)} ${env}
2372
+ `
2373
+ );
2374
+ ordered.push(p);
2375
+ idx++;
2376
+ }
2377
+ }
2378
+ deps.renderer.write(`
2379
+ ${color.dim("\u25C9 already saved \u25CB no key yet")}
2380
+ `);
2381
+ const answer = (await deps.reader.readLine(
2382
+ `
2383
+ ${color.amber("?")} Pick (1-${ordered.length}) or type provider id: `
2384
+ )).trim();
2385
+ if (!answer) return;
2386
+ let chosen;
2387
+ const num = Number.parseInt(answer, 10);
2388
+ if (!Number.isNaN(num) && num >= 1 && num <= ordered.length) {
2389
+ chosen = ordered[num - 1];
2390
+ } else {
2391
+ chosen = ordered.find((p) => p.id.toLowerCase() === answer.toLowerCase()) ?? catalog.find((p) => p.id.toLowerCase() === answer.toLowerCase());
2392
+ }
2393
+ if (!chosen) {
2394
+ deps.renderer.writeError(`No such provider: "${answer}"`);
2395
+ return;
2396
+ }
2397
+ deps.renderer.write(
2398
+ color.dim(`
2399
+ Defaults from models.dev \u2014 press Enter to keep, or type a new value.
2400
+ `)
2401
+ );
2402
+ const famRaw = (await deps.reader.readLine(
2403
+ ` ${color.amber("?")} Family ${color.dim(`[${chosen.family}]`)}: `
2404
+ )).trim();
2405
+ let family = chosen.family;
2406
+ if (famRaw) {
2407
+ if (!["anthropic", "openai", "openai-compatible", "google"].includes(famRaw)) {
2408
+ deps.renderer.writeError(`Invalid family: "${famRaw}" (must be anthropic | openai | openai-compatible | google).`);
2409
+ return;
2410
+ }
2411
+ family = famRaw;
2412
+ }
2413
+ const baseRaw = (await deps.reader.readLine(
2414
+ ` ${color.amber("?")} Base URL ${color.dim(`[${chosen.apiBase ?? "unset"}]`)}: `
2415
+ )).trim();
2416
+ const baseUrl = baseRaw || chosen.apiBase;
2417
+ const providersNow = await loadProviders(deps);
2418
+ let suggestedAlias = chosen.id;
2419
+ if (family !== chosen.family) {
2420
+ let candidate = `${chosen.id}-${family}`;
2421
+ let n = 2;
2422
+ while (providersNow[candidate]) {
2423
+ candidate = `${chosen.id}-${family}-${n}`;
2424
+ n++;
2425
+ }
2426
+ suggestedAlias = candidate;
2427
+ }
2428
+ const aliasRaw = (await deps.reader.readLine(
2429
+ ` ${color.amber("?")} Save under alias ${color.dim(`[${suggestedAlias}]`)} ${color.dim("(used as `--provider <alias>`)")}: `
2430
+ )).trim();
2431
+ const alias = aliasRaw || suggestedAlias;
2432
+ const existing = providersNow[alias];
2433
+ if (existing) {
2434
+ const sameFamily = (existing.family ?? chosen.family) === family;
2435
+ const sameBase = (existing.baseUrl ?? chosen.apiBase) === baseUrl;
2436
+ if (!sameFamily || !sameBase) {
2437
+ deps.renderer.writeError(
2438
+ `Alias "${alias}" already exists with different family/baseUrl.
2439
+ Existing: family=${existing.family ?? "(unset)"}, baseUrl=${existing.baseUrl ?? "(unset)"}
2440
+ New: family=${family}, baseUrl=${baseUrl ?? "(unset)"}
2441
+ Pick a different alias to keep them separate.`
2442
+ );
2443
+ return;
2444
+ }
2445
+ }
2446
+ await addKeyForProvider(alias, deps, {
2447
+ type: chosen.id,
2448
+ family,
2449
+ baseUrl,
2450
+ envVars: chosen.envVars
2451
+ });
2452
+ }
2453
+ async function addCustomProvider(deps) {
2454
+ deps.renderer.write(`
2455
+ ${color.bold("Custom provider")} ${color.dim("\u2014 for local models or proxies not in the models.dev catalog.")}
2456
+ `);
2457
+ const type = (await deps.reader.readLine(
2458
+ ` ${color.amber("?")} Provider id ${color.dim('(e.g. "local-llama", "my-proxy")')}: `
2459
+ )).trim();
2460
+ if (!type) return;
2461
+ const existing = (await loadProviders(deps))[type];
2462
+ if (existing) {
2463
+ deps.renderer.writeWarning(`"${type}" already exists. Pick it from the main menu to edit.`);
2464
+ return;
2465
+ }
2466
+ const familyRaw = (await deps.reader.readLine(
2467
+ ` ${color.amber("?")} Wire family ${color.dim("(anthropic | openai | openai-compatible | google)")}: `
2468
+ )).trim();
2469
+ if (!["anthropic", "openai", "openai-compatible", "google"].includes(familyRaw)) {
2470
+ deps.renderer.writeError(`Invalid family: "${familyRaw}"`);
2471
+ return;
2472
+ }
2473
+ const family = familyRaw;
2474
+ const baseUrl = (await deps.reader.readLine(
2475
+ ` ${color.amber("?")} Base URL ${color.dim("(e.g. http://localhost:11434/v1, leave empty if not needed)")}: `
2476
+ )).trim();
2477
+ const modelsRaw = (await deps.reader.readLine(
2478
+ ` ${color.amber("?")} Model ids ${color.dim("(comma-separated, optional)")}: `
2479
+ )).trim();
2480
+ const models = modelsRaw ? modelsRaw.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
2481
+ const envVarsRaw = (await deps.reader.readLine(
2482
+ ` ${color.amber("?")} Env var names ${color.dim("(comma-separated, optional fallback for the key)")}: `
2483
+ )).trim();
2484
+ const envVars = envVarsRaw ? envVarsRaw.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
2485
+ await addKeyForProvider(type, deps, {
2486
+ type,
2487
+ family,
2488
+ ...baseUrl ? { baseUrl } : {},
2489
+ ...models ? { models } : {},
2490
+ ...envVars ? { envVars } : {}
2491
+ });
2492
+ }
2493
+ async function addKeyForProvider(providerId, deps, template) {
2494
+ const providers = await loadProviders(deps);
2495
+ const existing = providers[providerId];
2496
+ const existingKeys = existing ? normalizeKeys(existing) : [];
2497
+ const usedLabels = new Set(existingKeys.map((k) => k.label));
2498
+ let defaultLabel = "default";
2499
+ if (usedLabels.has(defaultLabel)) {
2500
+ let n = 2;
2501
+ while (usedLabels.has(`key${n}`)) n++;
2502
+ defaultLabel = `key${n}`;
2503
+ }
2504
+ const labelRaw = (await deps.reader.readLine(
2505
+ ` ${color.amber("?")} Label for this key ${color.dim(`[${defaultLabel}]`)}: `
2506
+ )).trim();
2507
+ const label = labelRaw || defaultLabel;
2508
+ if (usedLabels.has(label)) {
2509
+ deps.renderer.writeError(`Label "${label}" already used for ${providerId}. Use update (u) instead.`);
2510
+ return;
2511
+ }
2512
+ const apiKey = await readKeyInput(deps, `API key for ${providerId}/${label}`);
2513
+ if (!apiKey) {
2514
+ deps.renderer.writeError("No key entered. Nothing saved.");
2515
+ return;
2516
+ }
2517
+ await mutateProviders(deps, (all) => {
2518
+ const existingProv = all[providerId] ?? { type: providerId, ...template };
2519
+ if (!existingProv.type) existingProv.type = providerId;
2520
+ if (!existingProv.family && template.family) existingProv.family = template.family;
2521
+ if (!existingProv.baseUrl && template.baseUrl) existingProv.baseUrl = template.baseUrl;
2522
+ if (!existingProv.envVars && template.envVars) existingProv.envVars = template.envVars;
2523
+ const list = normalizeKeys(existingProv);
2524
+ list.push({ label, apiKey, createdAt: nowIso() });
2525
+ writeKeysBack(existingProv, list);
2526
+ if (!existingProv.activeKey) existingProv.activeKey = label;
2527
+ all[providerId] = existingProv;
2528
+ });
2529
+ deps.renderer.write(
2530
+ ` ${color.green("\u2713")} Saved ${color.bold(providerId)}/${color.bold(label)}. ${color.dim("Use `wstack --provider " + providerId + ' "<task>"` to launch.')}
2531
+ `
2532
+ );
2533
+ }
2534
+ async function runAuthDirect(deps, opts) {
2535
+ const { providerId } = opts;
2536
+ const providers = await loadProviders(deps);
2537
+ const existing = providers[providerId];
2538
+ if (!existing && !opts.family) {
2539
+ let knownFamily;
2540
+ let knownBase;
2541
+ let knownEnv;
2542
+ try {
2543
+ const k = await deps.modelsRegistry.getProvider(providerId);
2544
+ if (k) {
2545
+ knownFamily = k.family;
2546
+ knownBase = k.apiBase;
2547
+ knownEnv = k.envVars;
2548
+ }
2549
+ } catch {
2550
+ }
2551
+ if (!knownFamily || knownFamily === "unsupported") {
2552
+ deps.renderer.writeError(
2553
+ `Provider "${providerId}" not in catalog. Pass --family <anthropic|openai|openai-compatible|google>.`
2554
+ );
2555
+ return 1;
2556
+ }
2557
+ opts.family = knownFamily;
2558
+ opts.baseUrl ??= knownBase;
2559
+ opts.envVars ??= knownEnv;
2560
+ }
2561
+ const usedLabels = new Set(
2562
+ existing ? normalizeKeys(existing).map((k) => k.label) : []
2563
+ );
2564
+ let label = opts.label ?? "default";
2565
+ if (usedLabels.has(label)) {
2566
+ let n = 2;
2567
+ while (usedLabels.has(`${label}-${n}`)) n++;
2568
+ label = `${label}-${n}`;
2569
+ deps.renderer.writeInfo(`Label collided; saving as "${label}".`);
2570
+ }
2571
+ const apiKey = await readKeyInput(deps, `API key for ${providerId}/${label}`);
2572
+ if (!apiKey) return 1;
2573
+ await mutateProviders(deps, (all) => {
2574
+ const p = all[providerId] ?? { type: providerId };
2575
+ if (!p.type) p.type = providerId;
2576
+ if (!p.family && opts.family) p.family = opts.family;
2577
+ if (!p.baseUrl && opts.baseUrl) p.baseUrl = opts.baseUrl;
2578
+ if (!p.envVars && opts.envVars) p.envVars = opts.envVars;
2579
+ const list = normalizeKeys(p);
2580
+ list.push({ label, apiKey, createdAt: nowIso() });
2581
+ writeKeysBack(p, list);
2582
+ if (!p.activeKey) p.activeKey = label;
2583
+ all[providerId] = p;
2584
+ });
2585
+ deps.renderer.writeInfo(`Stored encrypted key for ${providerId} (label "${label}").`);
2586
+ deps.renderer.writeInfo(`Use: wstack --provider ${providerId} "<task>"`);
2587
+ return 0;
2588
+ }
2589
+ async function readKeyInput(deps, intent) {
2590
+ const key = (await deps.reader.readSecret(` ${color.amber("?")} ${intent} ${color.dim("(hidden, paste OK)")}: `)).trim();
2591
+ if (!key) {
2592
+ deps.renderer.writeError("No key entered.");
2593
+ return void 0;
2594
+ }
2595
+ return key;
2596
+ }
2597
+ async function loadProviders(deps) {
2598
+ let raw;
2599
+ try {
2600
+ raw = await fs6.readFile(deps.globalConfigPath, "utf8");
2601
+ } catch {
2602
+ return {};
2603
+ }
2604
+ let parsed = {};
2605
+ try {
2606
+ parsed = JSON.parse(raw);
2607
+ } catch {
2608
+ return {};
2609
+ }
2610
+ const decrypted = decryptConfigSecrets(parsed, deps.vault);
2611
+ return decrypted.providers ?? {};
2612
+ }
2613
+ async function mutateProviders(deps, mutator) {
2614
+ let raw;
2615
+ try {
2616
+ raw = await fs6.readFile(deps.globalConfigPath, "utf8");
2617
+ } catch {
2618
+ raw = "{}";
2619
+ }
2620
+ let parsed;
2621
+ try {
2622
+ parsed = JSON.parse(raw);
2623
+ } catch {
2624
+ parsed = {};
2625
+ }
2626
+ const decrypted = decryptConfigSecrets(parsed, deps.vault);
2627
+ const providers = decrypted.providers ?? {};
2628
+ mutator(providers);
2629
+ decrypted.providers = providers;
2630
+ const encrypted = encryptConfigSecrets(decrypted, deps.vault);
2631
+ await atomicWrite(deps.globalConfigPath, JSON.stringify(encrypted, null, 2), { mode: 384 });
2632
+ }
2633
+ function normalizeKeys(cfg) {
2634
+ if (Array.isArray(cfg.apiKeys) && cfg.apiKeys.length > 0) {
2635
+ return cfg.apiKeys.map((k) => ({ ...k }));
2636
+ }
2637
+ if (typeof cfg.apiKey === "string" && cfg.apiKey.length > 0) {
2638
+ return [{ label: "default", apiKey: cfg.apiKey, createdAt: "" }];
2639
+ }
2640
+ return [];
2641
+ }
2642
+ function writeKeysBack(cfg, keys) {
2643
+ if (keys.length === 0) {
2644
+ delete cfg.apiKeys;
2645
+ delete cfg.apiKey;
2646
+ delete cfg.activeKey;
2647
+ return;
2648
+ }
2649
+ cfg.apiKeys = keys;
2650
+ const active = keys.find((k) => k.label === cfg.activeKey) ?? keys[0];
2651
+ cfg.apiKey = active.apiKey;
2652
+ if (!cfg.activeKey || !keys.some((k) => k.label === cfg.activeKey)) {
2653
+ cfg.activeKey = active.label;
2654
+ }
2655
+ }
2656
+ function activeLabel(cfg, keys) {
2657
+ if (cfg.activeKey && keys.some((k) => k.label === cfg.activeKey)) return cfg.activeKey;
2658
+ return keys[0]?.label;
2659
+ }
2660
+ function maskedKey(key) {
2661
+ if (!key) return color.dim("\u2014");
2662
+ if (key.length <= 8) return color.dim("\u2022".repeat(key.length));
2663
+ const head = key.slice(0, 4);
2664
+ const tail = key.slice(-4);
2665
+ return `${color.dim(head + "\u2026")}${tail}`;
2666
+ }
2667
+ function nowIso() {
2668
+ return (/* @__PURE__ */ new Date()).toISOString();
2669
+ }
2670
+
2671
+ // src/subcommands/index.ts
2672
+ var subcommands = {
2673
+ init: initCmd,
2674
+ auth: authCmd,
2675
+ // `resume <id>` is special-cased in src/index.ts: it's lifted into
2676
+ // `--resume <id>` so the normal REPL bootstrap runs with a pre-loaded
2677
+ // session. There is no standalone subcommand handler.
2678
+ sessions: sessionsCmd,
2679
+ config: configCmd,
2680
+ tools: toolsCmd,
2681
+ skills: skillsCmd,
2682
+ providers: providersCmd,
2683
+ models: modelsCmd,
2684
+ mcp: mcpCmd,
2685
+ plugin: pluginCmd,
2686
+ diag: diagCmd,
2687
+ doctor: doctorCmd,
2688
+ export: exportCmd,
2689
+ usage: usageCmd,
2690
+ version: versionCmd,
2691
+ help: helpCmd,
2692
+ projects: projectsCmd
2693
+ };
2694
+ async function authCmd(args, deps) {
2695
+ const flags = parseAuthFlags(args);
2696
+ const menuDeps = {
2697
+ renderer: deps.renderer,
2698
+ reader: deps.reader,
2699
+ modelsRegistry: deps.modelsRegistry,
2700
+ vault: deps.vault,
2701
+ globalConfigPath: deps.paths.globalConfig
2702
+ };
2703
+ if (flags.positional.length === 0) {
2704
+ return runAuthMenu(menuDeps);
2705
+ }
2706
+ return runAuthDirect(menuDeps, {
2707
+ providerId: flags.positional[0],
2708
+ label: flags.label,
2709
+ family: flags.family,
2710
+ baseUrl: flags.baseUrl,
2711
+ envVars: flags.envVars
2712
+ });
2713
+ }
2714
+ function parseAuthFlags(args) {
2715
+ const out = { positional: [] };
2716
+ for (let i = 0; i < args.length; i++) {
1671
2717
  const a = args[i];
1672
- if (a === "--family") {
2718
+ if (a === "--label") {
2719
+ const v = args[++i];
2720
+ if (v) out.label = v;
2721
+ } else if (a === "--family") {
1673
2722
  const v = args[++i];
1674
2723
  if (v) out.family = v;
1675
2724
  } else if (a === "--base-url") {
@@ -1731,7 +2780,7 @@ async function initCmd(_args, deps) {
1731
2780
  } else {
1732
2781
  deps.renderer.writeInfo(`Found API key in env (${provider.envVars.join(" / ")}).`);
1733
2782
  }
1734
- await fs2.mkdir(deps.paths.globalRoot, { recursive: true });
2783
+ await fs6.mkdir(deps.paths.globalRoot, { recursive: true });
1735
2784
  const config = {
1736
2785
  version: 1,
1737
2786
  provider: providerId,
@@ -1739,10 +2788,10 @@ async function initCmd(_args, deps) {
1739
2788
  };
1740
2789
  if (apiKey) config.apiKey = apiKey;
1741
2790
  await atomicWrite(deps.paths.globalConfig, JSON.stringify(config, null, 2));
1742
- await fs2.mkdir(path2.join(deps.projectRoot, ".wrongstack"), { recursive: true });
1743
- const agentsFile = path2.join(deps.projectRoot, ".wrongstack", "AGENTS.md");
2791
+ await fs6.mkdir(path5.join(deps.projectRoot, ".wrongstack"), { recursive: true });
2792
+ const agentsFile = path5.join(deps.projectRoot, ".wrongstack", "AGENTS.md");
1744
2793
  try {
1745
- await fs2.access(agentsFile);
2794
+ await fs6.access(agentsFile);
1746
2795
  } catch {
1747
2796
  await atomicWrite(
1748
2797
  agentsFile,
@@ -1874,25 +2923,42 @@ async function modelsCmd(args, deps) {
1874
2923
  deps.renderer.writeError("Usage: wstack models <provider> | refresh");
1875
2924
  return 1;
1876
2925
  }
1877
- const provider = await deps.modelsRegistry.getProvider(providerId);
2926
+ let lookupId = providerId;
2927
+ const savedAlias = deps.config.providers?.[providerId];
2928
+ if (savedAlias?.type && savedAlias.type !== providerId) {
2929
+ lookupId = savedAlias.type;
2930
+ }
2931
+ const provider = await deps.modelsRegistry.getProvider(lookupId);
1878
2932
  if (!provider) {
1879
- deps.renderer.writeError(`Provider "${providerId}" not in catalog.`);
2933
+ deps.renderer.writeError(
2934
+ lookupId !== providerId ? `Alias "${providerId}" points at catalog id "${lookupId}" which is not in the cache.` : `Provider "${providerId}" not in catalog.`
2935
+ );
1880
2936
  return 1;
1881
2937
  }
2938
+ if (lookupId !== providerId) {
2939
+ deps.renderer.write(color.dim(`(showing catalog models for "${lookupId}" via alias "${providerId}")
2940
+ `));
2941
+ }
1882
2942
  deps.renderer.write(`${color.bold(provider.name)} ${color.dim(`(${provider.id})`)}
1883
2943
  `);
1884
2944
  if (provider.doc) deps.renderer.write(color.dim(`Docs: ${provider.doc}
1885
2945
  `));
1886
- const sorted = [...provider.models].sort(
2946
+ const userModels = deps.config.providers?.[providerId]?.models;
2947
+ const catalogById = new Map(provider.models.map((m) => [m.id, m]));
2948
+ const sorted = userModels && userModels.length > 0 ? userModels.map((id) => catalogById.get(id) ?? { id, name: id }) : [...provider.models].sort(
1887
2949
  (a, b) => (b.release_date ?? "").localeCompare(a.release_date ?? "")
1888
2950
  );
2951
+ if (userModels && userModels.length > 0) {
2952
+ deps.renderer.write(color.dim(`(${userModels.length} model(s) from your saved config)
2953
+ `));
2954
+ }
1889
2955
  for (const m of sorted) {
1890
2956
  const caps = [];
1891
- if (m.tool_call) caps.push("tools");
1892
- if (m.reasoning) caps.push("reasoning");
1893
- if (m.modalities?.input?.includes("image")) caps.push("vision");
1894
- const ctx = m.limit?.context ? `${(m.limit.context / 1e3).toFixed(0)}k` : "?";
1895
- const cost = m.cost?.input !== void 0 ? `$${m.cost.input}/$${m.cost.output ?? "?"}` : "";
2957
+ if ("tool_call" in m && m.tool_call) caps.push("tools");
2958
+ if ("reasoning" in m && m.reasoning) caps.push("reasoning");
2959
+ if ("modalities" in m && m.modalities?.input?.includes("image")) caps.push("vision");
2960
+ const ctx = "limit" in m && m.limit?.context ? `${(m.limit.context / 1e3).toFixed(0)}k` : "?";
2961
+ const cost = "cost" in m && m.cost?.input !== void 0 ? `$${m.cost.input}/$${m.cost.output ?? "?"}` : "";
1896
2962
  deps.renderer.write(
1897
2963
  ` ${m.id.padEnd(40)} ${color.dim(ctx.padStart(6))} ${color.dim(cost.padEnd(14))} ${color.dim(caps.join(","))}
1898
2964
  `
@@ -1914,16 +2980,46 @@ async function mcpCmd(args, deps) {
1914
2980
  const servers = deps.config.mcpServers ?? {};
1915
2981
  if (Object.keys(servers).length === 0) {
1916
2982
  deps.renderer.write("No MCP servers configured.\n");
2983
+ deps.renderer.write("Use `wstack mcp add <name>` or set mcpServers in your config.\n");
1917
2984
  return 0;
1918
2985
  }
1919
2986
  for (const [name, cfg] of Object.entries(servers)) {
2987
+ const status = cfg.enabled === false ? "disabled" : "enabled";
2988
+ const desc = cfg.description ? ` # ${cfg.description}` : "";
1920
2989
  deps.renderer.write(
1921
- ` ${name.padEnd(20)} ${cfg.transport} ${cfg.enabled === false ? "disabled" : "enabled"}
2990
+ ` ${name.padEnd(20)} ${cfg.transport.padEnd(16)} ${status}${desc}
1922
2991
  `
1923
2992
  );
1924
2993
  }
1925
2994
  return 0;
1926
2995
  }
2996
+ if (sub === "add") {
2997
+ const name = args[1];
2998
+ if (!name) {
2999
+ deps.renderer.writeError("Usage: wstack mcp add <name>\n");
3000
+ deps.renderer.write("Available servers:\n");
3001
+ for (const [sname, scfg] of Object.entries(deps.config.mcpServers ?? {})) {
3002
+ deps.renderer.write(` ${sname.padEnd(20)} ${scfg.description ?? scfg.transport}
3003
+ `);
3004
+ }
3005
+ if (Object.keys(deps.config.mcpServers ?? {}).length === 0) {
3006
+ deps.renderer.write(
3007
+ " filesystem filesystem (read/write/navigate)\n github github (issues, PRs, repos)\n context7 context7 (codebase docs & Q&A)\n brave-search brave search (web search)\n block block (Postgres via SQL)\n everart everart (AI image generation)\n slack slack (messaging & channels)\n aws aws (EC2, S3, Lambda, IAM)\n google-maps google-maps (directions, geocoding)\n sentinel sentinel (security vulnerabilities)\n"
3008
+ );
3009
+ }
3010
+ deps.renderer.write("\nRun `wstack mcp add <name> --enable` to enable immediately.\n");
3011
+ return 1;
3012
+ }
3013
+ return addMcpServer(args, deps);
3014
+ }
3015
+ if (sub === "remove") {
3016
+ const name = args[1];
3017
+ if (!name) {
3018
+ deps.renderer.writeError("Usage: wstack mcp remove <name>\n");
3019
+ return 1;
3020
+ }
3021
+ return removeMcpServer(name, deps);
3022
+ }
1927
3023
  if (sub === "restart") {
1928
3024
  deps.renderer.writeWarning("mcp restart is only available in REPL mode.");
1929
3025
  return 0;
@@ -1931,6 +3027,70 @@ async function mcpCmd(args, deps) {
1931
3027
  deps.renderer.writeError(`Unknown mcp subcommand: ${sub}`);
1932
3028
  return 1;
1933
3029
  }
3030
+ async function addMcpServer(args, deps) {
3031
+ const name = args[1];
3032
+ const enable = args.includes("--enable") || args.includes("-e");
3033
+ const builtIn = {
3034
+ filesystem: { name: "filesystem", transport: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "."], permission: "confirm", description: "Read, write, and navigate the local filesystem" },
3035
+ github: { name: "github", transport: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-github"], permission: "confirm", description: "GitHub API \u2014 issues, PRs, repos, search" },
3036
+ "context7": { name: "context7", transport: "streamable-http", url: "https://server.context7.ai/mcp", permission: "confirm", description: "Codebase-aware documentation and Q&A" },
3037
+ "brave-search": { name: "brave-search", transport: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-brave-search"], permission: "confirm", description: "Web search (Brave)" },
3038
+ block: { name: "block", transport: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-block"], permission: "confirm", description: "Postgres database via SQL" },
3039
+ everart: { name: "everart", transport: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-everart"], permission: "confirm", description: "AI image generation" },
3040
+ slack: { name: "slack", transport: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-slack"], permission: "confirm", description: "Slack messaging & channels" },
3041
+ aws: { name: "aws", transport: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-aws"], permission: "confirm", description: "AWS \u2014 EC2, S3, Lambda, IAM" },
3042
+ "google-maps": { name: "google-maps", transport: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-google-maps"], permission: "confirm", description: "Google Maps \u2014 directions, geocoding, places" },
3043
+ sentinel: { name: "sentinel", transport: "streamable-http", url: "https://mcp.sentinel.ai", permission: "deny", description: "Security vulnerability scanning" }
3044
+ };
3045
+ const factory = builtIn[name];
3046
+ if (!factory) {
3047
+ deps.renderer.writeError(`Unknown server "${name}". Run \`wstack mcp add\` without args to see available servers.
3048
+ `);
3049
+ return 1;
3050
+ }
3051
+ const serverCfg = { ...factory };
3052
+ if (!enable) serverCfg.enabled = false;
3053
+ let existing = {};
3054
+ try {
3055
+ const raw = await fs6.readFile(deps.paths.globalConfig, "utf8");
3056
+ existing = JSON.parse(raw);
3057
+ } catch {
3058
+ }
3059
+ const mcpServers = existing.mcpServers ?? {};
3060
+ if (mcpServers[name]) {
3061
+ deps.renderer.writeWarning(`Server "${name}" already in config. Updating.
3062
+ `);
3063
+ }
3064
+ mcpServers[name] = serverCfg;
3065
+ existing.mcpServers = mcpServers;
3066
+ await atomicWrite(deps.paths.globalConfig, JSON.stringify(existing, null, 2));
3067
+ const verb = enable ? "Enabled" : "Added (disabled \u2014 set enabled:true to activate)";
3068
+ deps.renderer.writeInfo(`${verb} "${name}" (${serverCfg.transport}). Config written to ${deps.paths.globalConfig}.
3069
+ `);
3070
+ return 0;
3071
+ }
3072
+ async function removeMcpServer(name, deps) {
3073
+ let existing = {};
3074
+ try {
3075
+ const raw = await fs6.readFile(deps.paths.globalConfig, "utf8");
3076
+ existing = JSON.parse(raw);
3077
+ } catch {
3078
+ deps.renderer.writeError("No config file found.\n");
3079
+ return 1;
3080
+ }
3081
+ const mcpServers = existing.mcpServers ?? {};
3082
+ if (!mcpServers[name]) {
3083
+ deps.renderer.writeError(`Server "${name}" not in config.
3084
+ `);
3085
+ return 1;
3086
+ }
3087
+ delete mcpServers[name];
3088
+ existing.mcpServers = mcpServers;
3089
+ await atomicWrite(deps.paths.globalConfig, JSON.stringify(existing, null, 2));
3090
+ deps.renderer.writeInfo(`Removed "${name}" from config.
3091
+ `);
3092
+ return 0;
3093
+ }
1934
3094
  async function pluginCmd(args, deps) {
1935
3095
  const sub = args[0];
1936
3096
  if (!sub || sub === "list") {
@@ -1964,7 +3124,7 @@ async function diagCmd(_args, deps) {
1964
3124
  ` modelsCache: ${deps.paths.modelsCache}`,
1965
3125
  ` cacheAge: ${isFinite(age) ? `${Math.round(age / 60)}m` : "never"}`,
1966
3126
  ` node: ${process.version}`,
1967
- ` os: ${os2.platform()} ${os2.release()}`,
3127
+ ` os: ${os3.platform()} ${os3.release()}`,
1968
3128
  ` provider: ${cfg.provider ?? "<unset>"}`,
1969
3129
  ` model: ${cfg.model ?? "<unset>"}`,
1970
3130
  ` tools: ${deps.toolRegistry?.list().length ?? 0}`,
@@ -1974,6 +3134,182 @@ async function diagCmd(_args, deps) {
1974
3134
  deps.renderer.write(lines.join("\n") + "\n");
1975
3135
  return 0;
1976
3136
  }
3137
+ async function doctorCmd(_args, deps) {
3138
+ const checks = [];
3139
+ const cfg = deps.config;
3140
+ if (!cfg.provider) {
3141
+ checks.push({ name: "provider", status: "fail", detail: "no provider configured \u2014 run `wstack init` or `wstack auth`" });
3142
+ } else {
3143
+ checks.push({ name: "provider", status: "ok", detail: cfg.provider });
3144
+ }
3145
+ if (!cfg.model) {
3146
+ checks.push({ name: "model", status: "fail", detail: "no model configured \u2014 run `wstack init`" });
3147
+ } else {
3148
+ checks.push({ name: "model", status: "ok", detail: cfg.model });
3149
+ }
3150
+ if (cfg.provider) {
3151
+ const providerCfg = cfg.providers?.[cfg.provider];
3152
+ const hasVaultKey = typeof providerCfg?.apiKey === "string" && providerCfg.apiKey.length > 0;
3153
+ const envHit = providerCfg?.envVars?.some((v) => process.env[v]) ?? false;
3154
+ if (hasVaultKey || envHit) {
3155
+ checks.push({
3156
+ name: "api key",
3157
+ status: "ok",
3158
+ detail: hasVaultKey ? "found in vault" : "found in env"
3159
+ });
3160
+ } else {
3161
+ checks.push({
3162
+ name: "api key",
3163
+ status: "fail",
3164
+ detail: `no key for "${cfg.provider}" in vault or env \u2014 run \`wstack auth ${cfg.provider}\``
3165
+ });
3166
+ }
3167
+ }
3168
+ try {
3169
+ const age = await deps.modelsRegistry.ageSeconds();
3170
+ if (!isFinite(age)) {
3171
+ checks.push({ name: "models cache", status: "warn", detail: "never fetched \u2014 run `wstack models refresh`" });
3172
+ } else if (age > 7 * 24 * 3600) {
3173
+ checks.push({
3174
+ name: "models cache",
3175
+ status: "warn",
3176
+ detail: `${Math.round(age / 86400)} days old \u2014 run \`wstack models refresh\``
3177
+ });
3178
+ } else {
3179
+ checks.push({ name: "models cache", status: "ok", detail: `${Math.round(age / 60)}m old` });
3180
+ }
3181
+ } catch (err) {
3182
+ checks.push({
3183
+ name: "models cache",
3184
+ status: "warn",
3185
+ detail: `read failed: ${err instanceof Error ? err.message : String(err)}`
3186
+ });
3187
+ }
3188
+ try {
3189
+ await fs6.access(deps.paths.secretsKey);
3190
+ checks.push({ name: "secret vault", status: "ok", detail: deps.paths.secretsKey });
3191
+ } catch {
3192
+ checks.push({
3193
+ name: "secret vault",
3194
+ status: "warn",
3195
+ detail: "not yet initialized (created lazily on first encrypt)"
3196
+ });
3197
+ }
3198
+ try {
3199
+ await fs6.mkdir(deps.paths.projectSessions, { recursive: true });
3200
+ const probe = path5.join(deps.paths.projectSessions, `.probe-${Date.now()}`);
3201
+ await fs6.writeFile(probe, "");
3202
+ await fs6.unlink(probe);
3203
+ checks.push({ name: "sessions writable", status: "ok", detail: deps.paths.projectSessions });
3204
+ } catch (err) {
3205
+ checks.push({
3206
+ name: "sessions writable",
3207
+ status: "fail",
3208
+ detail: `cannot write to ${deps.paths.projectSessions}: ${err instanceof Error ? err.message : String(err)}`
3209
+ });
3210
+ }
3211
+ const mcpEntries = Object.entries(cfg.mcpServers ?? {});
3212
+ for (const [name, srv] of mcpEntries) {
3213
+ if (!srv.enabled) continue;
3214
+ if ((srv.transport === "sse" || srv.transport === "streamable-http") && !srv.url) {
3215
+ checks.push({ name: `mcp:${name}`, status: "fail", detail: "transport requires url" });
3216
+ } else if (srv.transport === "stdio" && !srv.command) {
3217
+ checks.push({ name: `mcp:${name}`, status: "fail", detail: "stdio transport requires command" });
3218
+ } else {
3219
+ checks.push({ name: `mcp:${name}`, status: "ok", detail: `${srv.transport} ${srv.command ?? srv.url ?? ""}`.trim() });
3220
+ }
3221
+ }
3222
+ const major = Number.parseInt(process.version.replace(/^v/, "").split(".")[0] ?? "0", 10);
3223
+ if (major < 22) {
3224
+ checks.push({ name: "node", status: "fail", detail: `${process.version} (need \u226522)` });
3225
+ } else {
3226
+ checks.push({ name: "node", status: "ok", detail: process.version });
3227
+ }
3228
+ deps.renderer.write(color.bold("WrongStack doctor\n\n"));
3229
+ let failed = 0;
3230
+ let warned = 0;
3231
+ for (const c of checks) {
3232
+ const icon = c.status === "ok" ? color.green("\u2713") : c.status === "warn" ? color.amber("\u25CF") : color.red("\u2717");
3233
+ deps.renderer.write(` ${icon} ${c.name.padEnd(20)} ${color.dim(c.detail)}
3234
+ `);
3235
+ if (c.status === "fail") failed++;
3236
+ if (c.status === "warn") warned++;
3237
+ }
3238
+ deps.renderer.write("\n");
3239
+ if (failed > 0) {
3240
+ deps.renderer.write(color.red(`${failed} failed, ${warned} warning${warned === 1 ? "" : "s"}
3241
+ `));
3242
+ return 1;
3243
+ }
3244
+ if (warned > 0) {
3245
+ deps.renderer.write(color.amber(`All checks passed (${warned} warning${warned === 1 ? "" : "s"})
3246
+ `));
3247
+ return 0;
3248
+ }
3249
+ deps.renderer.write(color.green("All checks passed.\n"));
3250
+ return 0;
3251
+ }
3252
+ async function exportCmd(args, deps) {
3253
+ if (!deps.sessionStore) {
3254
+ deps.renderer.writeError("No session store configured.");
3255
+ return 1;
3256
+ }
3257
+ let format = "markdown";
3258
+ let output;
3259
+ let includeTools = true;
3260
+ let includeDiagnostics = true;
3261
+ let sessionId;
3262
+ for (let i = 0; i < args.length; i++) {
3263
+ const a = args[i];
3264
+ if (a === "--format" || a === "-f") {
3265
+ const v = args[++i];
3266
+ if (v !== "markdown" && v !== "json" && v !== "text") {
3267
+ deps.renderer.writeError(`Unknown --format ${v}. Use markdown, json, or text.`);
3268
+ return 1;
3269
+ }
3270
+ format = v;
3271
+ } else if (a === "--out" || a === "-o") {
3272
+ output = args[++i];
3273
+ } else if (a === "--no-tools") {
3274
+ includeTools = false;
3275
+ } else if (a === "--no-diagnostics") {
3276
+ includeDiagnostics = false;
3277
+ } else if (a.startsWith("-")) {
3278
+ deps.renderer.writeError(`Unknown flag: ${a}`);
3279
+ return 1;
3280
+ } else if (!sessionId) {
3281
+ sessionId = a;
3282
+ }
3283
+ }
3284
+ if (!sessionId) {
3285
+ deps.renderer.writeError("Usage: wstack export <sessionId> [--format markdown|json|text] [--out <file>] [--no-tools] [--no-diagnostics]");
3286
+ return 1;
3287
+ }
3288
+ const reader = new DefaultSessionReader({ store: deps.sessionStore });
3289
+ let rendered;
3290
+ try {
3291
+ rendered = await reader.export(sessionId, {
3292
+ format,
3293
+ includeTools,
3294
+ includeDiagnostics
3295
+ });
3296
+ } catch (err) {
3297
+ deps.renderer.writeError(
3298
+ `Export failed: ${err instanceof Error ? err.message : String(err)}`
3299
+ );
3300
+ return 1;
3301
+ }
3302
+ if (output) {
3303
+ await fs6.mkdir(path5.dirname(path5.resolve(deps.cwd, output)), { recursive: true });
3304
+ await fs6.writeFile(path5.resolve(deps.cwd, output), rendered, "utf8");
3305
+ deps.renderer.write(`Wrote ${rendered.length} bytes to ${output}
3306
+ `);
3307
+ } else {
3308
+ deps.renderer.write(rendered);
3309
+ if (!rendered.endsWith("\n")) deps.renderer.write("\n");
3310
+ }
3311
+ return 0;
3312
+ }
1977
3313
  async function usageCmd(_args, deps) {
1978
3314
  if (!deps.sessionStore) return 0;
1979
3315
  const list = await deps.sessionStore.list(100);
@@ -1985,7 +3321,7 @@ async function usageCmd(_args, deps) {
1985
3321
  }
1986
3322
  async function versionCmd(_args, deps) {
1987
3323
  deps.renderer.write(
1988
- `WrongStack ${CLI_VERSION} (apiVersion ${API_VERSION}, node ${process.version}, ${os2.platform()})
3324
+ `WrongStack ${CLI_VERSION} (apiVersion ${API_VERSION}, node ${process.version}, ${os3.platform()})
1989
3325
  `
1990
3326
  );
1991
3327
  return 0;
@@ -1999,7 +3335,8 @@ async function helpCmd(_args, deps) {
1999
3335
  " wstack resume [<id>] Resume a session",
2000
3336
  " wstack sessions List recent sessions",
2001
3337
  " wstack init Pick provider + model from models.dev",
2002
- " wstack auth <provider> Store API key (encrypted at rest)",
3338
+ " wstack auth Interactive key manager (list/add/update/delete)",
3339
+ " wstack auth <provider> Append one key for a provider (encrypted at rest)",
2003
3340
  " wstack resume <id> Resume a session (loads transcript + appends)",
2004
3341
  " wstack config [show|edit] Show or edit effective config",
2005
3342
  " wstack tools List registered tools",
@@ -2011,6 +3348,8 @@ async function helpCmd(_args, deps) {
2011
3348
  " wstack plugin [list] List plugins",
2012
3349
  " wstack projects List projects tracked in ~/.wrongstack/projects/",
2013
3350
  " wstack diag Full diagnostics",
3351
+ " wstack doctor Health checks (config, keys, MCP, node)",
3352
+ " wstack export <id> [opts] Render a session (--format markdown|json|text, --out <file>)",
2014
3353
  " wstack usage Token + cost summary",
2015
3354
  " wstack version Print version",
2016
3355
  "",
@@ -2021,9 +3360,9 @@ async function helpCmd(_args, deps) {
2021
3360
  return 0;
2022
3361
  }
2023
3362
  async function projectsCmd(_args, deps) {
2024
- const projectsRoot = path2.join(deps.paths.globalRoot, "projects");
3363
+ const projectsRoot = path5.join(deps.paths.globalRoot, "projects");
2025
3364
  try {
2026
- const entries = await fs2.readdir(projectsRoot);
3365
+ const entries = await fs6.readdir(projectsRoot);
2027
3366
  if (entries.length === 0) {
2028
3367
  deps.renderer.write("No projects tracked.\n");
2029
3368
  return 0;
@@ -2031,7 +3370,7 @@ async function projectsCmd(_args, deps) {
2031
3370
  for (const hash of entries) {
2032
3371
  try {
2033
3372
  const meta = JSON.parse(
2034
- await fs2.readFile(path2.join(projectsRoot, hash, "meta.json"), "utf8")
3373
+ await fs6.readFile(path5.join(projectsRoot, hash, "meta.json"), "utf8")
2035
3374
  );
2036
3375
  deps.renderer.write(
2037
3376
  ` ${color.dim(hash)} ${color.dim(meta.lastSeen ?? "")} ${meta.root ?? "?"}
@@ -2078,7 +3417,8 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
2078
3417
  "no-alt-screen",
2079
3418
  "alt-screen",
2080
3419
  "output-json",
2081
- "prompt"
3420
+ "prompt",
3421
+ "metrics"
2082
3422
  ]);
2083
3423
  function parseArgs(argv) {
2084
3424
  const flags = {};
@@ -2116,83 +3456,32 @@ function parseArgs(argv) {
2116
3456
  }
2117
3457
  return { flags, positional };
2118
3458
  }
2119
- function flagsToConfigPatch(flags) {
2120
- const patch = {};
2121
- if (typeof flags["provider"] === "string") patch.provider = flags["provider"];
2122
- if (typeof flags["model"] === "string") patch.model = flags["model"];
2123
- if (typeof flags["cwd"] === "string") patch.cwd = flags["cwd"];
2124
- if (typeof flags["log-level"] === "string") {
2125
- patch.log = { level: flags["log-level"] };
2126
- } else if (flags["verbose"]) {
2127
- patch.log = { level: "debug" };
2128
- } else if (flags["trace"]) {
2129
- patch.log = { level: "trace" };
2130
- }
2131
- if (flags["yolo"]) patch.yolo = true;
2132
- if (flags["no-features"]) {
2133
- patch.features = {
2134
- mcp: false,
2135
- plugins: false,
2136
- memory: false,
2137
- modelsRegistry: false,
2138
- skills: false
2139
- };
2140
- }
2141
- return patch;
2142
- }
2143
3459
  function resolveBundledSkillsDir() {
2144
3460
  try {
2145
3461
  const req2 = createRequire(import.meta.url);
2146
3462
  const corePkg = req2.resolve("@wrongstack/core/package.json");
2147
- return path2.join(path2.dirname(corePkg), "skills");
3463
+ return path5.join(path5.dirname(corePkg), "skills");
2148
3464
  } catch {
2149
3465
  return void 0;
2150
3466
  }
2151
3467
  }
2152
- async function ensureProjectMeta(paths, projectRoot) {
2153
- try {
2154
- await fs2.mkdir(paths.projectDir, { recursive: true });
2155
- const meta = {
2156
- hash: paths.projectHash,
2157
- root: projectRoot,
2158
- lastSeen: (/* @__PURE__ */ new Date()).toISOString()
2159
- };
2160
- await fs2.writeFile(paths.projectMeta, JSON.stringify(meta, null, 2));
2161
- } catch {
2162
- }
2163
- }
2164
3468
  async function main(argv) {
2165
3469
  const { flags, positional } = parseArgs(argv);
2166
- const cwd = typeof flags["cwd"] === "string" ? path2.resolve(flags["cwd"]) : process.cwd();
2167
- const pathResolver = new DefaultPathResolver(cwd);
2168
- const projectRoot = pathResolver.projectRoot;
2169
- const userHome = os2.homedir();
2170
- const wpaths = resolveWstackPaths({ projectRoot, userHome });
2171
- await ensureProjectMeta(wpaths, projectRoot);
2172
3470
  if (positional[0] === "resume" && positional[1] && !subcommands["__noop_resume_marker"]) {
2173
3471
  flags["resume"] = positional[1];
2174
3472
  positional.splice(0, 2);
2175
3473
  }
2176
- const vault = new DefaultSecretVault({ keyFile: wpaths.secretsKey });
2177
- for (const file of [wpaths.globalConfig, wpaths.projectLocalConfig]) {
2178
- try {
2179
- const { migrated } = await migratePlaintextSecrets(file, vault);
2180
- if (migrated > 0) {
2181
- process.stderr.write(`[wstack] Encrypted ${migrated} plaintext secret(s) in ${file}
2182
- `);
2183
- }
2184
- } catch {
2185
- }
2186
- }
2187
- const configLoader = new DefaultConfigLoader({ paths: wpaths, vault });
2188
- let config;
3474
+ let boot;
2189
3475
  try {
2190
- config = await configLoader.load({ cliFlags: flagsToConfigPatch(flags) });
3476
+ boot = await bootConfig(flags);
2191
3477
  } catch (err) {
2192
3478
  process.stderr.write(`Config error: ${err instanceof Error ? err.message : String(err)}
2193
3479
  `);
2194
3480
  return 2;
2195
3481
  }
3482
+ const { paths, config: _config, vault } = boot;
3483
+ let config = _config;
3484
+ const { cwd, projectRoot, userHome, wpaths, pathResolver } = paths;
2196
3485
  const logger = new DefaultLogger({
2197
3486
  level: config.log.level,
2198
3487
  file: wpaths.logFile
@@ -2259,7 +3548,7 @@ async function main(argv) {
2259
3548
  } else {
2260
3549
  const prevProvider = config.provider;
2261
3550
  const prevModel = config.model;
2262
- config = Object.freeze({ ...config, provider: picked.provider, model: picked.model });
3551
+ config = patchConfig(config, { provider: picked.provider, model: picked.model });
2263
3552
  if (picked.provider !== prevProvider || picked.model !== prevModel) {
2264
3553
  const saved = await saveToGlobalConfig(
2265
3554
  wpaths.globalConfig,
@@ -2294,15 +3583,21 @@ async function main(argv) {
2294
3583
  flags["no-tui"] = true;
2295
3584
  }
2296
3585
  if (choices.yolo !== config.yolo) {
2297
- config = Object.freeze({ ...config, yolo: choices.yolo });
3586
+ config = patchConfig(config, { yolo: choices.yolo });
2298
3587
  }
2299
3588
  }
2300
- const resolvedProvider = await modelsRegistry.getProvider(config.provider).catch(() => void 0);
3589
+ const savedProviderCfg = config.providers?.[config.provider];
3590
+ let resolvedProvider = await modelsRegistry.getProvider(config.provider).catch(() => void 0);
3591
+ if (!resolvedProvider && savedProviderCfg?.type && savedProviderCfg.type !== config.provider) {
3592
+ resolvedProvider = await modelsRegistry.getProvider(savedProviderCfg.type).catch(() => void 0);
3593
+ }
2301
3594
  if (!resolvedProvider) {
2302
- logger.warn(
2303
- `Provider "${config.provider}" not found in models.dev. Continuing with raw config.`
2304
- );
2305
- } else if (resolvedProvider.family === "unsupported") {
3595
+ if (!savedProviderCfg?.family) {
3596
+ logger.warn(
3597
+ `Provider "${config.provider}" not found in models.dev. Continuing with raw config.`
3598
+ );
3599
+ }
3600
+ } else if (resolvedProvider.family === "unsupported" && !savedProviderCfg?.family) {
2306
3601
  process.stderr.write(
2307
3602
  `Provider "${config.provider}" uses an unsupported wire family (${resolvedProvider.npm}). Install a plugin to enable it, or pick a different provider.
2308
3603
  `
@@ -2311,6 +3606,8 @@ async function main(argv) {
2311
3606
  return 2;
2312
3607
  }
2313
3608
  const container = new Container();
3609
+ const configStore = new DefaultConfigStore(config);
3610
+ container.bind(TOKENS.ConfigStore, () => configStore);
2314
3611
  container.bind(TOKENS.Logger, () => logger);
2315
3612
  container.bind(TOKENS.PathResolver, () => pathResolver);
2316
3613
  container.bind(TOKENS.SecretScrubber, () => new DefaultSecretScrubber());
@@ -2385,6 +3682,68 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
2385
3682
  }
2386
3683
  const events = new EventBus();
2387
3684
  events.setLogger(logger);
3685
+ let metricsSink;
3686
+ let healthRegistry;
3687
+ let metricsServerHandle;
3688
+ const metricsPortFlag = flags["metrics-port"];
3689
+ const metricsPort = typeof metricsPortFlag === "string" && metricsPortFlag.length > 0 ? Number.parseInt(metricsPortFlag, 10) : void 0;
3690
+ if (metricsPort !== void 0 && !flags.metrics) flags.metrics = true;
3691
+ if (flags.metrics) {
3692
+ metricsSink = new InMemoryMetricsSink();
3693
+ wireMetricsToEvents(events, metricsSink);
3694
+ healthRegistry = new DefaultHealthRegistry();
3695
+ healthRegistry.register({
3696
+ name: "session-store",
3697
+ check: async () => {
3698
+ try {
3699
+ await fs6.access(wpaths.projectSessions);
3700
+ return { status: "healthy" };
3701
+ } catch (e) {
3702
+ return { status: "unhealthy", detail: e instanceof Error ? e.message : "access denied" };
3703
+ }
3704
+ }
3705
+ });
3706
+ healthRegistry.register({
3707
+ name: "provider",
3708
+ check: async () => ({
3709
+ status: "healthy",
3710
+ data: { id: config.provider, model: config.model }
3711
+ })
3712
+ });
3713
+ const dumpMetrics = () => {
3714
+ if (!metricsSink) return;
3715
+ try {
3716
+ const out = path5.join(wpaths.projectSessions, "metrics.json");
3717
+ const snap = metricsSink.snapshot();
3718
+ writeFileSync(out, JSON.stringify(snap, null, 2));
3719
+ } catch {
3720
+ }
3721
+ };
3722
+ process.on("exit", dumpMetrics);
3723
+ process.on("SIGINT", () => {
3724
+ dumpMetrics();
3725
+ process.exit(130);
3726
+ });
3727
+ if (metricsPort !== void 0 && Number.isFinite(metricsPort)) {
3728
+ try {
3729
+ metricsServerHandle = await startMetricsServer({
3730
+ port: metricsPort,
3731
+ host: process.env.METRICS_HOST ?? "127.0.0.1",
3732
+ sink: metricsSink,
3733
+ // V2-C: mount /healthz on the same listener so k8s probes can
3734
+ // hit one endpoint per pod for both observability and liveness.
3735
+ healthRegistry
3736
+ });
3737
+ logger.info(`metrics endpoint listening on ${metricsServerHandle.url} (healthz on same port)`);
3738
+ process.on("exit", () => {
3739
+ void metricsServerHandle?.close().catch(() => {
3740
+ });
3741
+ });
3742
+ } catch (err) {
3743
+ logger.warn(`metrics endpoint failed to start: ${err instanceof Error ? err.message : String(err)}`);
3744
+ }
3745
+ }
3746
+ }
2388
3747
  const spinner = new Spinner();
2389
3748
  let lastInputTokens = 0;
2390
3749
  events.on("provider.response", (e) => {
@@ -2442,13 +3801,11 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
2442
3801
  };
2443
3802
  let provider;
2444
3803
  try {
2445
- if (config.features.modelsRegistry) {
2446
- provider = providerRegistry.create({ ...providerConfig, type: config.provider });
3804
+ const cfgWithType = { ...providerConfig, type: config.provider };
3805
+ if (config.features.modelsRegistry && providerRegistry.has(config.provider)) {
3806
+ provider = providerRegistry.create(cfgWithType);
2447
3807
  } else {
2448
- provider = makeProviderFromConfig(config.provider, {
2449
- ...providerConfig,
2450
- type: config.provider
2451
- });
3808
+ provider = makeProviderFromConfig(config.provider, cfgWithType);
2452
3809
  }
2453
3810
  } catch (err) {
2454
3811
  process.stderr.write(
@@ -2513,13 +3870,21 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
2513
3870
  }
2514
3871
  await recoveryLock.write(session.id).catch(() => void 0);
2515
3872
  const attachments = new DefaultAttachmentStore({
2516
- spoolDir: path2.join(wpaths.projectSessions, session.id, "attachments")
3873
+ spoolDir: path5.join(wpaths.projectSessions, session.id, "attachments")
2517
3874
  });
2518
3875
  const queueStore = new QueueStore({
2519
- dir: path2.join(wpaths.projectSessions, session.id)
3876
+ dir: path5.join(wpaths.projectSessions, session.id)
2520
3877
  });
2521
3878
  const tokenCounter = container.resolve(TOKENS.TokenCounter);
2522
3879
  const stats = new SessionStats(events, tokenCounter);
3880
+ const errorRing = [];
3881
+ events.on("error", (e) => {
3882
+ const err = e.err;
3883
+ const code2 = err && typeof err === "object" && "code" in err && typeof err.code === "string" ? err.code : "UNKNOWN";
3884
+ const message = e.err instanceof Error ? e.err.message : String(e.err);
3885
+ errorRing.push({ ts: (/* @__PURE__ */ new Date()).toISOString(), phase: e.phase, code: code2, message });
3886
+ if (errorRing.length > 5) errorRing.shift();
3887
+ });
2523
3888
  const ctxSignal = new AbortController().signal;
2524
3889
  const context = new Context({
2525
3890
  systemPrompt,
@@ -2535,6 +3900,26 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
2535
3900
  context.messages.push(...restoredMessages);
2536
3901
  }
2537
3902
  const pipelines = createDefaultPipelines();
3903
+ const installBoundary = (p) => {
3904
+ p.setErrorHandler((ev) => {
3905
+ const fromPlugin = !!ev.owner && ev.owner !== "core";
3906
+ logger.error(
3907
+ `Pipeline middleware "${ev.middleware}" crashed (owner=${ev.owner ?? "unknown"}); ${fromPlugin ? "swallowed" : "rethrown"}`,
3908
+ ev.err
3909
+ );
3910
+ events.emit("error", {
3911
+ err: ev.err instanceof Error ? ev.err : new Error(String(ev.err)),
3912
+ phase: `pipeline:${ev.middleware}`
3913
+ });
3914
+ return fromPlugin ? "swallow" : "rethrow";
3915
+ });
3916
+ };
3917
+ installBoundary(pipelines.request);
3918
+ installBoundary(pipelines.response);
3919
+ installBoundary(pipelines.toolCall);
3920
+ installBoundary(pipelines.userInput);
3921
+ installBoundary(pipelines.assistantOutput);
3922
+ installBoundary(pipelines.contextWindow);
2538
3923
  const compactor = container.resolve(TOKENS.Compactor);
2539
3924
  const resolvedCaps = await capabilitiesFor(modelsRegistry, config.provider, context.model).catch(
2540
3925
  () => void 0
@@ -2589,7 +3974,8 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
2589
3974
  maxIterations: config.tools.maxIterations,
2590
3975
  iterationTimeoutMs: config.tools.iterationTimeoutMs,
2591
3976
  executionStrategy: config.tools.defaultExecutionStrategy,
2592
- perIterationOutputCapBytes: config.tools.perIterationOutputCapBytes
3977
+ perIterationOutputCapBytes: config.tools.perIterationOutputCapBytes,
3978
+ confirmAwaiter: makeConfirmAwaiter(reader)
2593
3979
  });
2594
3980
  const mcpRegistry = new MCPRegistry({ toolRegistry, events, log: logger });
2595
3981
  if (config.features.mcp) {
@@ -2617,6 +4003,11 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
2617
4003
  const { default: createApi2 } = await Promise.resolve().then(() => (init_plugin_api_factory(), plugin_api_factory_exports));
2618
4004
  await loadPlugins(resolvedPlugins, {
2619
4005
  log: logger,
4006
+ // Each plugin's `configSchema` is validated against the matching
4007
+ // `Config.extensions[name]` subtree before its `setup()` runs.
4008
+ // The plugin then reads the same data through `api.config.extensions`
4009
+ // (or, once L1-B lands, via `ConfigStore.getExtension(name)`).
4010
+ pluginOptions: config.extensions ?? {},
2620
4011
  apiFactory: (plugin) => createApi2(plugin.name, {
2621
4012
  container,
2622
4013
  events,
@@ -2631,6 +4022,73 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
2631
4022
  });
2632
4023
  }
2633
4024
  }
4025
+ const buildPickableProviders = async () => {
4026
+ const overlay = config.providers ?? {};
4027
+ let catalog = [];
4028
+ try {
4029
+ catalog = await modelsRegistry.listProviders();
4030
+ } catch {
4031
+ }
4032
+ const catalogById = new Map(catalog.map((p) => [p.id, p]));
4033
+ const hasKey = (id) => {
4034
+ const entry = overlay[id];
4035
+ const envHit = catalogById.get(id)?.envVars.some((v) => !!process.env[v]);
4036
+ if (envHit) return true;
4037
+ if (!entry) return false;
4038
+ if (typeof entry.apiKey === "string" && entry.apiKey.length > 0) return true;
4039
+ if (Array.isArray(entry.apiKeys) && entry.apiKeys.some((k) => k?.apiKey)) return true;
4040
+ return false;
4041
+ };
4042
+ const seen = /* @__PURE__ */ new Set();
4043
+ const out = [];
4044
+ for (const [id, cfg] of Object.entries(overlay)) {
4045
+ if (!hasKey(id)) continue;
4046
+ seen.add(id);
4047
+ const catalogType = cfg.type && cfg.type !== id ? cfg.type : id;
4048
+ const inherited = catalogById.get(catalogType);
4049
+ const family = cfg.family ?? inherited?.family ?? "unsupported";
4050
+ if (family === "unsupported") continue;
4051
+ const models = cfg.models && cfg.models.length > 0 ? [...cfg.models] : (inherited?.models ?? []).map((m) => m.id);
4052
+ out.push({ id, family, models });
4053
+ }
4054
+ for (const p of catalog) {
4055
+ if (seen.has(p.id)) continue;
4056
+ if (p.family === "unsupported") continue;
4057
+ if (!hasKey(p.id)) continue;
4058
+ out.push({ id: p.id, family: p.family, models: p.models.map((m) => m.id) });
4059
+ }
4060
+ return out;
4061
+ };
4062
+ const switchProviderAndModel = (providerId, modelId) => {
4063
+ try {
4064
+ const newCfg = config.providers?.[providerId] ?? {
4065
+ type: providerId,
4066
+ apiKey: config.apiKey,
4067
+ baseUrl: config.baseUrl
4068
+ };
4069
+ const cfgWithType = { ...newCfg, type: providerId };
4070
+ const newProvider = config.features.modelsRegistry && providerRegistry.has(providerId) ? providerRegistry.create(cfgWithType) : makeProviderFromConfig(providerId, cfgWithType);
4071
+ context.provider = newProvider;
4072
+ context.model = modelId;
4073
+ config = patchConfig(config, { provider: providerId, model: modelId });
4074
+ configStore.update({ provider: providerId, model: modelId });
4075
+ return null;
4076
+ } catch (err) {
4077
+ return err instanceof Error ? err.message : String(err);
4078
+ }
4079
+ };
4080
+ const multiAgentHost = new MultiAgentHost({
4081
+ container,
4082
+ toolRegistry,
4083
+ providerRegistry,
4084
+ configStore,
4085
+ events,
4086
+ systemPromptBuilder: promptBuilder,
4087
+ session,
4088
+ tokenCounter,
4089
+ projectRoot,
4090
+ cwd
4091
+ });
2634
4092
  const slashCmds = buildBuiltinSlashCommands({
2635
4093
  registry: slashRegistry,
2636
4094
  toolRegistry,
@@ -2641,53 +4099,56 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
2641
4099
  renderer,
2642
4100
  memoryStore,
2643
4101
  context,
4102
+ metricsSink,
4103
+ healthRegistry,
4104
+ onSpawn: async (description) => {
4105
+ const { subagentId, taskId } = await multiAgentHost.spawn(description);
4106
+ return `Spawned subagent ${subagentId} for task ${taskId}. Use /agents to track progress.`;
4107
+ },
4108
+ onAgents: () => {
4109
+ const s = multiAgentHost.status();
4110
+ const lines = [s.summary];
4111
+ for (const p of s.pending) {
4112
+ lines.push(` pending ${p.taskId.slice(0, 8)} \u2192 ${p.description.slice(0, 60)}`);
4113
+ }
4114
+ for (const r of s.completed) {
4115
+ lines.push(
4116
+ ` ${r.status === "success" ? color.green("\u2713") : color.red("\u2717")} ${r.taskId.slice(0, 8)} ${r.iterations}it ${r.toolCalls}tc ${r.durationMs}ms`
4117
+ );
4118
+ }
4119
+ return lines.join("\n");
4120
+ },
2644
4121
  onExit: () => {
2645
4122
  void mcpRegistry.stopAll();
2646
4123
  },
2647
4124
  onClear: () => {
4125
+ if (flags.tui && !flags["no-tui"]) return;
2648
4126
  try {
2649
4127
  process.stdout.write("\x1B[2J\x1B[3J\x1B[H");
2650
4128
  } catch {
2651
4129
  }
2652
4130
  },
2653
- onSwitchModel: (name) => {
2654
- context.model = name;
2655
- },
2656
- onSwitchProvider: (name) => {
2657
- try {
2658
- const newCfg = config.providers?.[name] ?? {
2659
- type: name,
2660
- apiKey: config.apiKey,
2661
- baseUrl: config.baseUrl
2662
- };
2663
- const newProvider = providerRegistry.create({ ...newCfg, type: name });
2664
- context.provider = newProvider;
2665
- config = Object.freeze({ ...config, provider: name });
2666
- } catch (err) {
2667
- renderer.writeError(
2668
- `Cannot switch to "${name}": ${err instanceof Error ? err.message : err}`
2669
- );
2670
- }
2671
- },
2672
4131
  onDiag: () => {
2673
4132
  const u = tokenCounter.total();
2674
4133
  const cost = tokenCounter.estimateCost();
2675
- renderer.write(
2676
- [
2677
- `${color.bold("WrongStack diag")}`,
2678
- ` provider: ${config.provider} / ${context.model}`,
2679
- ` projectRoot: ${projectRoot}`,
2680
- ` tokens: in ${u.input} out ${u.output} cacheR ${u.cacheRead ?? 0}`,
2681
- ` cost: $${cost.total.toFixed(4)}`,
2682
- ` tools: ${toolRegistry.list().length}`,
2683
- ` mcpServers: ${mcpRegistry.list().length}`,
2684
- ""
2685
- ].join("\n")
2686
- );
4134
+ const errSection = errorRing.length === 0 ? [] : [
4135
+ "",
4136
+ `${color.bold("Recent errors")} (last ${errorRing.length}):`,
4137
+ ...errorRing.map((e) => ` [${e.ts}] ${e.phase} ${e.code} \u2014 ${e.message}`)
4138
+ ];
4139
+ const liveCfg = configStore.get();
4140
+ return [
4141
+ `${color.bold("WrongStack diag")}`,
4142
+ ` provider: ${liveCfg.provider} / ${context.model}`,
4143
+ ` projectRoot: ${projectRoot}`,
4144
+ ` tokens: in ${u.input} out ${u.output} cacheR ${u.cacheRead ?? 0}`,
4145
+ ` cost: $${cost.total.toFixed(4)}`,
4146
+ ` tools: ${toolRegistry.list().length}`,
4147
+ ` mcpServers: ${mcpRegistry.list().length}`,
4148
+ ...errSection
4149
+ ].join("\n");
2687
4150
  },
2688
- onStats: () => {
2689
- stats.render(renderer);
2690
- }
4151
+ onStats: () => stats.format()
2691
4152
  });
2692
4153
  for (const cmd of slashCmds) slashRegistry.register(cmd);
2693
4154
  let code = 0;
@@ -2723,16 +4184,27 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
2723
4184
  const json = JSON.stringify({
2724
4185
  status: result.status,
2725
4186
  finalText: result.finalText ?? null,
2726
- error: result.error instanceof Error ? result.error.message : result.error ?? null,
4187
+ error: result.error ? {
4188
+ code: result.error.code,
4189
+ subsystem: result.error.subsystem,
4190
+ severity: result.error.severity,
4191
+ recoverable: result.error.recoverable,
4192
+ message: result.error.message,
4193
+ context: result.error.context ?? null
4194
+ } : null,
2727
4195
  usage
2728
4196
  });
2729
4197
  process.stdout.write(json + "\n");
2730
4198
  } else {
2731
4199
  if (result.status === "failed") {
2732
4200
  code = 1;
2733
- renderer.writeError(
2734
- "Failed: " + (result.error instanceof Error ? result.error.message : String(result.error))
2735
- );
4201
+ const err = result.error;
4202
+ if (err) {
4203
+ const tag = err.recoverable ? " (recoverable)" : "";
4204
+ renderer.writeError(`Failed [${err.severity}]${tag}: ${err.describe()}`);
4205
+ } else {
4206
+ renderer.writeError("Failed.");
4207
+ }
2736
4208
  } else if (result.status === "aborted") {
2737
4209
  code = 130;
2738
4210
  renderer.writeWarning("Aborted.");
@@ -2743,13 +4215,16 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
2743
4215
  if (result.finalText) renderer.write("\n" + result.finalText + "\n");
2744
4216
  renderer.write(
2745
4217
  "\n" + color.dim(
2746
- `[in: ${fmtTok4(usage.input)} out: ${fmtTok4(usage.output)} iters: ${usage.iterations} cost: ${usage.cost.toFixed(4)} ${(usage.elapsedMs / 1e3).toFixed(1)}s]`
4218
+ `[in: ${fmtTok(usage.input)} out: ${fmtTok(usage.output)} iters: ${usage.iterations} cost: ${usage.cost.toFixed(4)} ${(usage.elapsedMs / 1e3).toFixed(1)}s]`
2747
4219
  ) + "\n"
2748
4220
  );
2749
4221
  }
2750
4222
  } else if (flags.tui && !flags["no-tui"]) {
2751
4223
  const { runTui } = await import('@wrongstack/tui');
2752
4224
  renderer.setSilent(true);
4225
+ const banneredFamily = savedProviderCfg?.family ?? resolvedProvider?.family;
4226
+ const banneredKey = savedProviderCfg?.apiKey ?? config.apiKey ?? (resolvedProvider?.envVars ?? savedProviderCfg?.envVars ?? []).map((v) => process.env[v]).find((v) => !!v);
4227
+ const banneredKeyTail = banneredKey && banneredKey.length >= 3 ? banneredKey.slice(-3) : void 0;
2753
4228
  try {
2754
4229
  code = await runTui({
2755
4230
  agent,
@@ -2763,6 +4238,10 @@ Try \`wstack models refresh\` once you have network access, or run with --no-fea
2763
4238
  yolo: !!config.yolo,
2764
4239
  appVersion: CLI_VERSION,
2765
4240
  provider: config.provider,
4241
+ family: banneredFamily,
4242
+ keyTail: banneredKeyTail,
4243
+ getPickableProviders: buildPickableProviders,
4244
+ switchProviderAndModel,
2766
4245
  effectiveMaxContext,
2767
4246
  // Opt-in: alt-screen disables the terminal's native scrollback,
2768
4247
  // so we default to false. `--no-alt-screen` is kept as a no-op
@@ -2836,11 +4315,6 @@ async function promptRecovery(reader, renderer, abandoned, autoRecover) {
2836
4315
  );
2837
4316
  return answer;
2838
4317
  }
2839
- function fmtTok4(n) {
2840
- if (n < 1e3) return String(n);
2841
- if (n < 1e6) return `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k`;
2842
- return `${(n / 1e6).toFixed(1)}M`;
2843
- }
2844
4318
  var isMain = import.meta.url === `file://${process.argv[1]?.replace(/\\/g, "/")}` || process.argv[1]?.endsWith("/cli/dist/index.js") || process.argv[1]?.endsWith("\\cli\\dist\\index.js");
2845
4319
  if (isMain) {
2846
4320
  main(process.argv.slice(2)).then(