balchemy 0.2.1 → 0.2.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
@@ -2,62 +2,27 @@
2
2
  /**
3
3
  * balchemy CLI entry point.
4
4
  *
5
- * On launch:
6
- * - If ~/.balchemy/agents.enc has saved agents offer resume, choose, or create/connect
7
- * - If no saved agent → run wizard
8
- *
9
- * Sub-commands:
10
- * (no args) Resume cached agent or run wizard
11
- * init / --init Force run wizard (ignore cache)
12
- * start [config] Start from agent.config.yaml
13
- * docker [outDir] Generate Docker files
14
- * list List saved agents
15
- *
16
- * Flags:
17
- * --help, -h Show help
18
- * --version, -v Show version
19
- * --no-color Disable colored output
5
+ * Public terminal surface for setup, local agent context, config validation,
6
+ * Docker file generation, and the Ink cockpit.
20
7
  */
21
- import * as path from "path";
22
- import * as readline from "readline";
23
- import { createRequire } from "module";
24
- import { loadAgent, listAgents, setActiveAgent, } from "./agent-store.js";
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import * as readline from "node:readline";
11
+ import { createRequire } from "node:module";
12
+ import { clearAgent, getStorePath, listAgents, loadAgent, setActiveAgent, } from "./agent-store.js";
25
13
  import { C, setNoColor } from "./colors.js";
26
- // ── Version ──────────────────────────────────────────────────────────────────
14
+ import { commandKey, isNonInteractive, parseCliArgs, } from "./cli-options.js";
15
+ import { compactValue, createReporter, endpointHost, jsonEnvelope, } from "./output.js";
16
+ import { renderTerminalError, terminalErrorToJson, TerminalError, toTerminalError, } from "./errors.js";
27
17
  const require = createRequire(import.meta.url);
28
18
  const CLI_VERSION = require("../package.json").version ?? "unknown";
29
- // ── Argv parsing ─────────────────────────────────────────────────────────────
30
- const rawArgs = process.argv.slice(2);
31
- const flags = new Set();
32
- const positional = [];
33
- for (const arg of rawArgs) {
34
- if (arg === "--no-color") {
35
- setNoColor();
36
- flags.add(arg);
37
- }
38
- else if (arg === "--help" || arg === "-h")
39
- flags.add("--help");
40
- else if (arg === "--version" || arg === "-v")
41
- flags.add("--version");
42
- else if (arg === "--init")
43
- positional.push("init");
44
- else
45
- positional.push(arg);
46
- }
47
- const cmd = positional[0];
48
- const args = positional.slice(1);
49
- // ── Helpers ──────────────────────────────────────────────────────────────────
50
- function stderr(msg) {
51
- process.stderr.write(msg);
52
- }
53
- function printSummaryBlock(title, rows) {
54
- const maxLabel = rows.reduce((acc, row) => Math.max(acc, row.label.length), 0);
55
- process.stdout.write(` ${C.T}${title}${C.R}\n`);
56
- for (const row of rows) {
57
- process.stdout.write(` ${C.D}${row.label.padEnd(maxLabel)}${C.R} ${row.value}\n`);
58
- }
59
- process.stdout.write(` ${C.D}${"-".repeat(54)}${C.R}\n`);
19
+ const parsed = parseCliArgs(process.argv.slice(2));
20
+ if (parsed.flags.noColor) {
21
+ setNoColor();
60
22
  }
23
+ const reporter = createReporter(parsed.flags);
24
+ const cmd = commandKey(parsed.commandPath);
25
+ const args = parsed.args;
61
26
  function ask(rl, question, defaultVal = "") {
62
27
  return new Promise((resolve) => {
63
28
  const hint = defaultVal ? ` ${C.D}[${defaultVal}]${C.R}` : "";
@@ -66,11 +31,6 @@ function ask(rl, question, defaultVal = "") {
66
31
  });
67
32
  });
68
33
  }
69
- function compactValue(value, head = 28, tail = 8) {
70
- if (value.length <= head + tail + 3)
71
- return value;
72
- return `${value.slice(0, head)}...${value.slice(-tail)}`;
73
- }
74
34
  function normalizeChoice(value) {
75
35
  const trimmed = value.trim().toLowerCase();
76
36
  if (/^([0-9a-z])\1+$/.test(trimmed)) {
@@ -78,6 +38,14 @@ function normalizeChoice(value) {
78
38
  }
79
39
  return trimmed;
80
40
  }
41
+ function printSummaryBlock(title, rows) {
42
+ const maxLabel = rows.reduce((acc, row) => Math.max(acc, row.label.length), 0);
43
+ reporter.write(` ${C.T}${title}${C.R}\n`);
44
+ for (const row of rows) {
45
+ reporter.write(` ${C.D}${row.label.padEnd(maxLabel)}${C.R} ${row.value}\n`);
46
+ }
47
+ reporter.write(` ${C.D}${"-".repeat(54)}${C.R}\n`);
48
+ }
81
49
  function mostRecentAgent(agents) {
82
50
  if (agents.length === 0)
83
51
  return null;
@@ -87,8 +55,30 @@ function mostRecentAgent(agents) {
87
55
  return (Number.isFinite(bTime) ? bTime : 0) - (Number.isFinite(aTime) ? aTime : 0);
88
56
  })[0] ?? null;
89
57
  }
90
- // ── Sub-commands ─────────────────────────────────────────────────────────────
58
+ function ensureInteractive(commandName) {
59
+ if (parsed.flags.json) {
60
+ throw new TerminalError({
61
+ code: "TERMINAL_UNSUPPORTED_TUI",
62
+ title: "Interactive terminal required",
63
+ cause: `${commandName} launches an interactive TUI and cannot run with --json.`,
64
+ fix: "Use a plain CLI command for automation, such as balchemy config validate --json or balchemy agent current --json.",
65
+ commandSuggestion: "balchemy doctor --json",
66
+ exitCode: 2,
67
+ });
68
+ }
69
+ if (parsed.flags.ci || !process.stdin.isTTY || !process.stdout.isTTY) {
70
+ throw new TerminalError({
71
+ code: parsed.flags.ci ? "CI_PROMPT_BLOCKED" : "TERMINAL_UNSUPPORTED_TUI",
72
+ title: "Interactive terminal required",
73
+ cause: `${commandName} needs a TTY, but this session is non-interactive.`,
74
+ fix: "Run the command in a terminal, or use non-interactive validation commands in CI.",
75
+ commandSuggestion: "balchemy config validate --ci --json",
76
+ exitCode: 2,
77
+ });
78
+ }
79
+ }
91
80
  async function startSavedAgent(agent) {
81
+ ensureInteractive("balchemy start");
92
82
  setActiveAgent(agent.publicId);
93
83
  const { startTui } = await import("./tui/start.js");
94
84
  await startTui({
@@ -108,7 +98,7 @@ async function startSavedAgent(agent) {
108
98
  });
109
99
  }
110
100
  async function runWizardFromCwd() {
111
- // Use the new Ink-based wizard; old readline wizard kept as wizard.ts fallback
101
+ ensureInteractive("balchemy init");
112
102
  const { startWizard } = await import("./tui/start-wizard.js");
113
103
  await startWizard(process.cwd());
114
104
  }
@@ -125,7 +115,6 @@ async function chooseSavedAgent(rl, agents) {
125
115
  }
126
116
  return agents.find((agent) => agent.publicId.toLowerCase() === answer) ?? null;
127
117
  }
128
- // ── Update check ─────────────────────────────────────────────────────────────
129
118
  function parseSemver(value) {
130
119
  const match = value.match(/^(\d+)\.(\d+)\.(\d+)/);
131
120
  if (!match)
@@ -145,101 +134,124 @@ function isNewerVersion(latest, current) {
145
134
  }
146
135
  return false;
147
136
  }
148
- async function checkForUpdate() {
137
+ function shouldCheckForUpdate(flags) {
138
+ if (flags.json || flags.ci || flags.help || flags.version || isNonInteractive(flags))
139
+ return false;
140
+ return cmd === "" || cmd === "init" || cmd === "start";
141
+ }
142
+ async function checkForUpdate(flags) {
143
+ if (!shouldCheckForUpdate(flags))
144
+ return;
149
145
  try {
150
146
  const res = await fetch("https://registry.npmjs.org/balchemy/latest", {
151
147
  signal: AbortSignal.timeout(3000),
152
148
  });
153
149
  if (!res.ok)
154
- return false;
150
+ return;
155
151
  const data = (await res.json());
156
152
  const latest = data.version;
157
- if (!latest)
158
- return false;
159
- if (isNewerVersion(latest, CLI_VERSION)) {
160
- stderr(`\n ${C.G}Update available${C.R} ${C.D}${CLI_VERSION}${C.R} ${C.T}${latest}${C.R}\n`);
161
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
162
- const answer = await ask(rl, `${C.W}Update now?${C.R} (Y/n)`, "y");
163
- rl.close();
164
- if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") {
165
- // Detect if running via npx (no global binary to re-exec)
166
- const isNpx = Boolean(process.env.npm_execpath?.includes("npx") ||
167
- process.env._?.includes("npx") ||
168
- process.env.npm_command === "exec");
169
- stderr(` Updating to ${C.T}${latest}${C.R}...\n`);
170
- const { execSync } = await import("child_process");
171
- try {
172
- execSync(`npm install -g balchemy@${latest}`, { stdio: "inherit" });
173
- if (isNpx) {
174
- stderr(`\n ${C.T}Updated!${C.R} Run ${C.W}balchemy${C.R} to use the new version.\n\n`);
175
- }
176
- else {
177
- stderr(`\n ${C.T}Updated!${C.R} Restarting...\n\n`);
178
- const { execFileSync } = await import("child_process");
179
- execFileSync("balchemy", process.argv.slice(2), { stdio: "inherit" });
180
- process.exit(0);
181
- }
182
- }
183
- catch {
184
- stderr(` ${C.D}Update failed. Continuing with ${CLI_VERSION}.${C.R}\n\n`);
185
- }
186
- }
187
- else {
188
- stderr(` ${C.D}Skipped.${C.R}\n\n`);
189
- }
190
- return true;
191
- }
192
- return false;
153
+ if (!latest || !isNewerVersion(latest, CLI_VERSION))
154
+ return;
155
+ reporter.warn(`\n ${C.G}Update available${C.R} ${C.D}${CLI_VERSION}${C.R} → ${C.T}${latest}${C.R}\n`);
156
+ reporter.warn(` ${C.D}Run ${C.W}npm install -g balchemy@${latest}${C.D} yourself if you want to update.${C.R}\n\n`);
193
157
  }
194
158
  catch {
195
- return false;
159
+ return;
196
160
  }
197
161
  }
198
- // ── Help & version ───────────────────────────────────────────────────────────
199
162
  function printHelp() {
200
- process.stdout.write(`
163
+ reporter.write(`
201
164
  ${C.G}B${C.T}alchemy ${C.W}Agent CLI${C.R} ${C.D}v${CLI_VERSION}${C.R}
202
- ${C.D}Deploy and manage autonomous AI trading agents.${C.R}
165
+ ${C.D}Create, inspect, run, and safely supervise Balchemy agents from the terminal.${C.R}
203
166
 
204
167
  ${C.W}USAGE${C.R}
205
- balchemy Resume saved agent or run setup wizard
206
- balchemy init Force a fresh setup wizard
207
- balchemy start [config] Start from an existing config file
208
- balchemy docker [outDir] Generate Docker files for deployment
209
- balchemy list List saved agents
168
+ balchemy Resume saved agent or run setup wizard
169
+ balchemy init Run setup wizard
170
+ balchemy start [config] Start the live Ink cockpit
171
+ balchemy list List saved agents
172
+ balchemy docker [outDir] Generate Docker files
210
173
 
211
- ${C.W}FLAGS${C.R}
212
- -h, --help Show this help
213
- -v, --version Show version
214
- --no-color Disable colored output
174
+ ${C.W}COMMANDS${C.R}
175
+ agent list List saved agents (alias: list)
176
+ agent current Show active agent context
177
+ agent use <publicId> Switch active saved agent
178
+ auth status Show local auth/context status
179
+ auth login Run interactive setup wizard (alias: init)
180
+ auth logout Clear active context; credential removal stays explicit
181
+ context current Show active agent context (alias: agent current)
182
+ config validate [config] Validate config and env references
183
+ config list [config] Show non-secret effective config
184
+ doctor [config] Run read-only local CLI checks
185
+ version Show CLI version
215
186
 
216
- ${C.W}SHORTCUTS (in cockpit)${C.R}
217
- Ctrl+S Open settings
218
- Ctrl+L Clear chat
219
- Ctrl+N Switch agent
220
- Ctrl+Q Quit
221
- PgUp / PgDn Scroll chat history
187
+ ${C.W}GLOBAL FLAGS${C.R}
188
+ -h, --help Show this help
189
+ -v, --version Show version
190
+ --json Emit valid JSON only; never enters TUI
191
+ -q, --quiet Suppress non-essential human output
192
+ --verbose Show expanded non-secret details
193
+ --debug Show redacted technical details
194
+ --ci Non-interactive mode; fail closed on prompts
195
+ --dry-run Preview file/action plans without writing
196
+ -y, --yes Approve safe non-overwrite prompts only
197
+ --force Allow explicit local file overwrites
198
+ --no-color Disable ANSI color
222
199
 
223
- ${C.W}LINKS${C.R}
224
- ${C.D}Platform${C.R} https://balchemy.ai
225
- ${C.D}Docs${C.R} https://balchemy.ai/hub/docs
226
- ${C.D}npm${C.R} https://www.npmjs.com/package/balchemy
200
+ ${C.W}SAFETY${C.R}
201
+ --yes never approves live trades, wallet/key mutations, credential deletion, global installs, or file overwrites.
202
+ Use --dry-run before file-writing commands when reviewing changes.
203
+
204
+ ${C.W}SHORTCUTS (cockpit)${C.R}
205
+ Ctrl+S Open settings
206
+ Ctrl+L Clear chat
207
+ Ctrl+N Switch agent
208
+ Ctrl+Q Quit
209
+ PgUp / PgDn Scroll chat history
210
+ Esc in TRADE CHECK Cancel; never approve
211
+ Esc Back, close overlay, or cancel
212
+ ? Keyboard help overlay when available
227
213
 
228
214
  `);
229
215
  }
230
216
  function printVersion() {
231
- process.stdout.write(`balchemy ${CLI_VERSION}\n`);
217
+ if (parsed.flags.json) {
218
+ reporter.json(jsonEnvelope({
219
+ ok: true,
220
+ command: "version",
221
+ version: CLI_VERSION,
222
+ data: { name: "balchemy", version: CLI_VERSION },
223
+ }));
224
+ return;
225
+ }
226
+ reporter.write(`balchemy ${CLI_VERSION}\n`);
232
227
  }
233
- // ── Typo suggestion ──────────────────────────────────────────────────────────
234
- const KNOWN_COMMANDS = ["init", "start", "docker", "list"];
228
+ const KNOWN_COMMANDS = [
229
+ "init",
230
+ "start",
231
+ "docker",
232
+ "list",
233
+ "agent list",
234
+ "agent current",
235
+ "agent use",
236
+ "auth status",
237
+ "config validate",
238
+ "config list",
239
+ "doctor",
240
+ "version",
241
+ "context current",
242
+ "context status",
243
+ "tui",
244
+ "auth login",
245
+ "auth logout",
246
+ ];
235
247
  function levenshtein(a, b) {
236
248
  const matrix = [];
237
- for (let i = 0; i <= a.length; i++)
249
+ for (let i = 0; i <= a.length; i += 1)
238
250
  matrix[i] = [i];
239
- for (let j = 0; j <= b.length; j++)
251
+ for (let j = 0; j <= b.length; j += 1)
240
252
  matrix[0][j] = j;
241
- for (let i = 1; i <= a.length; i++) {
242
- for (let j = 1; j <= b.length; j++) {
253
+ for (let i = 1; i <= a.length; i += 1) {
254
+ for (let j = 1; j <= b.length; j += 1) {
243
255
  const cost = a[i - 1] === b[j - 1] ? 0 : 1;
244
256
  matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
245
257
  }
@@ -249,183 +261,621 @@ function levenshtein(a, b) {
249
261
  function suggestCommand(input) {
250
262
  let best = null;
251
263
  let bestDist = Infinity;
252
- for (const cmd of KNOWN_COMMANDS) {
253
- const dist = levenshtein(input.toLowerCase(), cmd);
254
- if (dist < bestDist && dist <= 2) {
264
+ for (const knownCommand of KNOWN_COMMANDS) {
265
+ const dist = levenshtein(input.toLowerCase(), knownCommand);
266
+ if (dist < bestDist && dist <= 3) {
255
267
  bestDist = dist;
256
- best = cmd;
268
+ best = knownCommand;
257
269
  }
258
270
  }
259
271
  return best;
260
272
  }
261
- // ── List command ─────────────────────────────────────────────────────────────
262
- function printAgentList() {
273
+ function agentToJson(agent, activePublicId) {
274
+ return {
275
+ publicId: agent.publicId,
276
+ active: activePublicId === agent.publicId,
277
+ name: agent.name ?? null,
278
+ endpointHost: endpointHost(agent.mcpEndpoint),
279
+ mcpEndpoint: agent.mcpEndpoint,
280
+ llmProvider: agent.llmProvider,
281
+ llmModel: agent.llmModel ?? null,
282
+ mode: agent.shadowMode ? "shadow" : "live",
283
+ strategy: agent.strategy,
284
+ createdAt: agent.createdAt,
285
+ credentials: {
286
+ apiKey: "[redacted]",
287
+ llmApiKey: "[redacted]",
288
+ redacted: true,
289
+ },
290
+ };
291
+ }
292
+ function agentListData() {
293
+ const agents = listAgents();
294
+ const active = loadAgent();
295
+ return {
296
+ activePublicId: active?.publicId ?? null,
297
+ storePath: getStorePath(),
298
+ agents: agents.map((agent) => agentToJson(agent, active?.publicId ?? null)),
299
+ };
300
+ }
301
+ function printAgentList(commandName) {
263
302
  const agents = listAgents();
264
303
  const active = loadAgent();
304
+ if (parsed.flags.json) {
305
+ reporter.json(jsonEnvelope({
306
+ ok: true,
307
+ command: commandName,
308
+ version: CLI_VERSION,
309
+ data: agentListData(),
310
+ }));
311
+ return;
312
+ }
265
313
  if (agents.length === 0) {
266
- process.stdout.write(` ${C.D}No saved agents. Run ${C.W}balchemy${C.D} to create one.${C.R}\n`);
314
+ reporter.write(` ${C.D}No saved agents. Run ${C.W}balchemy init${C.D} to create one or ${C.W}balchemy auth status${C.D} to inspect context.${C.R}\n`);
267
315
  return;
268
316
  }
269
- process.stdout.write(`\n ${C.T}Saved agents${C.R} ${C.D}(${agents.length})${C.R}\n`);
270
- process.stdout.write(` ${C.D}${"-".repeat(54)}${C.R}\n`);
317
+ reporter.write(`\n ${C.T}Saved agents${C.R} ${C.D}(${agents.length})${C.R}\n`);
318
+ reporter.write(` ${C.D}${"-".repeat(54)}${C.R}\n`);
271
319
  for (const agent of agents) {
272
320
  const isActive = active?.publicId === agent.publicId;
273
- const marker = isActive ? `${C.OK}▸${C.R}` : ` `;
321
+ const marker = isActive ? `${C.OK}>${C.R}` : " ";
274
322
  const id = compactValue(agent.publicId, 20, 8);
275
323
  const endpoint = compactValue(agent.mcpEndpoint, 30, 10);
276
324
  const model = agent.llmModel ?? "default";
277
- const mode = agent.shadowMode ? `${C.G}shadow${C.R}` : `${C.OK}live${C.R}`;
278
- process.stdout.write(` ${marker} ${C.W}${id}${C.R}\n`);
279
- process.stdout.write(` ${C.D}Endpoint${C.R} ${endpoint}\n`);
280
- process.stdout.write(` ${C.D}Model${C.R} ${model} ${mode}\n`);
281
- process.stdout.write(` ${C.D}Created${C.R} ${agent.createdAt}\n`);
282
- process.stdout.write(` ${C.D}${"-".repeat(54)}${C.R}\n`);
325
+ const mode = agent.shadowMode ? `${C.G}shadow${C.R}` : `${C.OK}LIVE${C.R}`;
326
+ reporter.write(` ${marker} ${C.W}${id}${C.R}\n`);
327
+ reporter.write(` ${C.D}Endpoint${C.R} ${endpoint}\n`);
328
+ reporter.write(` ${C.D}Host${C.R} ${endpointHost(agent.mcpEndpoint)}\n`);
329
+ reporter.write(` ${C.D}Model${C.R} ${model} ${mode}\n`);
330
+ reporter.write(` ${C.D}Created${C.R} ${agent.createdAt}\n`);
331
+ reporter.write(` ${C.D}${"-".repeat(54)}${C.R}\n`);
283
332
  }
284
- process.stdout.write("\n");
333
+ reporter.write(`\n ${C.D}Next${C.R} ${C.W}balchemy start${C.R} ${C.D}or${C.R} ${C.W}balchemy agent use <publicId>${C.R}\n\n`);
285
334
  }
286
- // ── Main ─────────────────────────────────────────────────────────────────────
287
- async function main() {
288
- // Handle flags before anything else
289
- if (flags.has("--help")) {
290
- printHelp();
335
+ function printAgentCurrent() {
336
+ const active = loadAgent();
337
+ const data = {
338
+ active: active ? agentToJson(active, active.publicId) : null,
339
+ hasActiveAgent: Boolean(active),
340
+ storePath: getStorePath(),
341
+ };
342
+ if (parsed.flags.json) {
343
+ reporter.json(jsonEnvelope({ ok: true, command: "agent.current", version: CLI_VERSION, data }));
291
344
  return;
292
345
  }
293
- if (flags.has("--version")) {
294
- printVersion();
346
+ if (!active) {
347
+ reporter.write(` ${C.D}No active agent. Run ${C.W}balchemy list${C.D} or ${C.W}balchemy init${C.D}.${C.R}\n`);
295
348
  return;
296
349
  }
297
- // Check for updates — if updated, the process re-execs and exits
298
- await checkForUpdate();
299
- switch (cmd) {
300
- case "init": {
301
- // Force wizard ignore cache
302
- await runWizardFromCwd();
303
- break;
350
+ printSummaryBlock("Active agent", [
351
+ { label: "Agent", value: active.publicId },
352
+ { label: "Endpoint", value: compactValue(active.mcpEndpoint, 42, 12) },
353
+ { label: "Host", value: endpointHost(active.mcpEndpoint) },
354
+ { label: "Provider", value: active.llmProvider },
355
+ { label: "Model", value: active.llmModel ?? "default" },
356
+ { label: "Mode", value: active.shadowMode ? "Shadow" : "LIVE" },
357
+ { label: "Saved", value: active.createdAt },
358
+ { label: "Secrets", value: "stored encrypted locally, redacted in CLI output" },
359
+ ]);
360
+ }
361
+ function useAgent(publicId) {
362
+ if (!publicId) {
363
+ throw new TerminalError({
364
+ code: "UNKNOWN_COMMAND",
365
+ title: "Missing agent publicId",
366
+ cause: "balchemy agent use requires a saved agent publicId.",
367
+ fix: "Run balchemy list, then pass the exact publicId to agent use.",
368
+ commandSuggestion: "balchemy list",
369
+ exitCode: 2,
370
+ });
371
+ }
372
+ if (!setActiveAgent(publicId)) {
373
+ throw new TerminalError({
374
+ code: "UNKNOWN_COMMAND",
375
+ title: "Saved agent not found",
376
+ cause: `No saved agent matches ${publicId}.`,
377
+ fix: "Run balchemy list and choose one of the saved publicIds.",
378
+ commandSuggestion: "balchemy list",
379
+ exitCode: 2,
380
+ });
381
+ }
382
+ const active = loadAgent();
383
+ if (parsed.flags.json) {
384
+ reporter.json(jsonEnvelope({
385
+ ok: true,
386
+ command: "agent.use",
387
+ version: CLI_VERSION,
388
+ data: { active: active ? agentToJson(active, active.publicId) : null },
389
+ }));
390
+ return;
391
+ }
392
+ reporter.write(` ${C.OK}Active agent:${C.R} ${C.W}${publicId}${C.R}\n`);
393
+ }
394
+ function authLogoutCommand() {
395
+ if (parsed.flags.force) {
396
+ clearAgent();
397
+ if (parsed.flags.json) {
398
+ reporter.json(jsonEnvelope({
399
+ ok: true,
400
+ command: "auth.logout",
401
+ version: CLI_VERSION,
402
+ data: { activeCleared: true, credentialsRemoved: false, storePath: getStorePath() },
403
+ }));
404
+ return;
304
405
  }
305
- case "start": {
306
- const configPath = args[0] ?? path.join(process.cwd(), "agent.config.yaml");
307
- const resolvedPath = path.resolve(configPath);
308
- const dotenv = await import("dotenv");
309
- const envPath = path.join(path.dirname(resolvedPath), ".env");
310
- const fs = await import("fs");
311
- if (fs.existsSync(envPath)) {
312
- dotenv.config({ path: envPath });
313
- }
314
- else {
315
- dotenv.config();
316
- }
317
- const { loadConfig } = await import("./config-loader.js");
318
- const config = loadConfig(resolvedPath);
319
- const publicId = config.mcpEndpoint.split("/").filter(Boolean).pop() ?? "unknown";
320
- const { startTui } = await import("./tui/start.js");
321
- await startTui({
322
- mcpEndpoint: config.mcpEndpoint,
323
- apiKey: config.apiKey,
324
- llmProvider: config.llmProvider,
325
- llmApiKey: config.llmApiKey,
326
- llmModel: config.llmModel,
327
- llmBaseUrl: config.llmBaseUrl,
328
- maxDailyLlmCost: config.maxDailyLlmCost,
329
- llmTimeoutMs: config.llmTimeoutMs,
330
- publicId,
331
- strategy: "custom",
332
- shadowMode: false,
333
- behaviorRules: config.behaviorRules,
334
- autoSeedSubscriptions: false,
406
+ reporter.write(` ${C.OK}Active agent cleared.${C.R} ${C.D}Saved encrypted credentials were not removed.${C.R}\n`);
407
+ return;
408
+ }
409
+ throw new TerminalError({
410
+ code: "FILE_OVERWRITE_CONFIRMATION_REQUIRED",
411
+ title: "Logout confirmation required",
412
+ cause: "auth logout clears local active context and must be explicit.",
413
+ fix: "Run balchemy auth status to inspect context, then rerun with --force to clear only the active selection.",
414
+ commandSuggestion: "balchemy auth status",
415
+ docsHint: "Run balchemy auth logout --help for local credential safety notes.",
416
+ exitCode: 4,
417
+ });
418
+ }
419
+ function printAuthStatus() {
420
+ const agents = listAgents();
421
+ const active = loadAgent();
422
+ const data = {
423
+ authenticated: Boolean(active),
424
+ activePublicId: active?.publicId ?? null,
425
+ endpointHost: active ? endpointHost(active.mcpEndpoint) : null,
426
+ savedAgentCount: agents.length,
427
+ encryptedStorePath: getStorePath(),
428
+ credentialsStored: agents.length > 0,
429
+ secrets: {
430
+ apiKeys: agents.length > 0 ? "[redacted]" : null,
431
+ redacted: true,
432
+ },
433
+ };
434
+ if (parsed.flags.json) {
435
+ reporter.json(jsonEnvelope({ ok: true, command: "auth.status", version: CLI_VERSION, data }));
436
+ return;
437
+ }
438
+ printSummaryBlock("Auth status", [
439
+ { label: "Active", value: active ? active.publicId : "none" },
440
+ { label: "Host", value: active ? endpointHost(active.mcpEndpoint) : "none" },
441
+ { label: "Saved", value: String(agents.length) },
442
+ { label: "Store", value: getStorePath() },
443
+ { label: "Secrets", value: agents.length > 0 ? "encrypted locally, redacted" : "none" },
444
+ ]);
445
+ }
446
+ async function loadConfigWithEnv(configPath) {
447
+ const resolvedPath = path.resolve(configPath);
448
+ const dotenv = await import("dotenv");
449
+ const envPath = path.join(path.dirname(resolvedPath), ".env");
450
+ if (fs.existsSync(envPath)) {
451
+ dotenv.config({ path: envPath });
452
+ }
453
+ else {
454
+ dotenv.config();
455
+ }
456
+ const { loadConfig } = await import("./config-loader.js");
457
+ return {
458
+ config: loadConfig(resolvedPath),
459
+ resolvedPath,
460
+ envPath: fs.existsSync(envPath) ? envPath : null,
461
+ };
462
+ }
463
+ function configToJson(config, resolvedPath, envPath) {
464
+ return {
465
+ path: resolvedPath,
466
+ envPath,
467
+ mcpEndpoint: config.mcpEndpoint,
468
+ endpointHost: endpointHost(config.mcpEndpoint),
469
+ apiKey: "[redacted]",
470
+ llm: {
471
+ provider: config.llmProvider,
472
+ model: config.llmModel ?? null,
473
+ baseUrl: config.llmBaseUrl ?? null,
474
+ apiKey: "[redacted]",
475
+ maxDailyUsd: config.maxDailyLlmCost ?? null,
476
+ timeoutMs: config.llmTimeoutMs ?? null,
477
+ },
478
+ webhook: {
479
+ port: config.webhookPort ?? null,
480
+ secret: config.webhookSecret ? "[redacted]" : null,
481
+ },
482
+ behaviorRulesConfigured: Boolean(config.behaviorRules || config.behaviorRulesPath),
483
+ redacted: true,
484
+ };
485
+ }
486
+ async function validateConfigCommand(commandName) {
487
+ const configPath = args[0] ?? path.join(process.cwd(), "agent.config.yaml");
488
+ const { config, resolvedPath, envPath } = await loadConfigWithEnv(configPath);
489
+ const data = configToJson(config, resolvedPath, envPath);
490
+ if (parsed.flags.json) {
491
+ reporter.json(jsonEnvelope({ ok: true, command: commandName, version: CLI_VERSION, data }));
492
+ return;
493
+ }
494
+ printSummaryBlock(commandName === "config.list" ? "Effective config" : "Config valid", [
495
+ { label: "Config", value: resolvedPath },
496
+ { label: "Env", value: envPath ?? "not found; process env used" },
497
+ { label: "Endpoint", value: compactValue(config.mcpEndpoint, 42, 12) },
498
+ { label: "Host", value: endpointHost(config.mcpEndpoint) },
499
+ { label: "Provider", value: config.llmProvider },
500
+ { label: "Model", value: config.llmModel ?? "default" },
501
+ { label: "LLM cap", value: `$${(config.maxDailyLlmCost ?? 5).toFixed(2)} / day` },
502
+ { label: "Secrets", value: "resolved and redacted" },
503
+ ]);
504
+ }
505
+ function initDryRun() {
506
+ const yamlPath = path.join(process.cwd(), "agent.config.yaml");
507
+ const envPath = path.join(process.cwd(), ".env");
508
+ const gitignorePath = path.join(process.cwd(), ".gitignore");
509
+ const gitignore = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8") : "";
510
+ const plan = [
511
+ {
512
+ action: fs.existsSync(yamlPath) ? "overwrite" : "create",
513
+ path: yamlPath,
514
+ exists: fs.existsSync(yamlPath),
515
+ wouldOverwrite: fs.existsSync(yamlPath),
516
+ containsSecret: false,
517
+ },
518
+ {
519
+ action: fs.existsSync(envPath) ? "overwrite" : "create",
520
+ path: envPath,
521
+ exists: fs.existsSync(envPath),
522
+ wouldOverwrite: fs.existsSync(envPath),
523
+ containsSecret: true,
524
+ },
525
+ {
526
+ action: gitignore.includes(".env") ? "skip" : (fs.existsSync(gitignorePath) ? "append" : "create"),
527
+ path: gitignorePath,
528
+ exists: fs.existsSync(gitignorePath),
529
+ wouldOverwrite: false,
530
+ containsSecret: false,
531
+ },
532
+ ];
533
+ if (parsed.flags.json) {
534
+ reporter.json(jsonEnvelope({
535
+ ok: true,
536
+ command: "init.preview",
537
+ version: CLI_VERSION,
538
+ data: { dryRun: true, outDir: process.cwd(), plan },
539
+ }));
540
+ return;
541
+ }
542
+ reporter.write(`\n ${C.T}Setup file preview${C.R} ${C.D}(dry-run; no files written)${C.R}\n`);
543
+ for (const item of plan) {
544
+ const record = item;
545
+ reporter.write(` ${record.action.toUpperCase().padEnd(9)} ${record.path}${record.containsSecret ? " contains secrets" : ""}\n`);
546
+ }
547
+ reporter.write(`\n ${C.D}Run${C.R} ${C.W}balchemy init${C.R} ${C.D}in an interactive terminal to continue.${C.R}\n\n`);
548
+ }
549
+ function dockerPlanToJson(plan, dryRun) {
550
+ return {
551
+ outDir: plan.outDir,
552
+ dryRun,
553
+ hasOverwrites: plan.hasOverwrites,
554
+ plan: plan.files.map((file) => ({
555
+ filename: file.filename,
556
+ path: file.path,
557
+ action: file.action,
558
+ exists: file.exists,
559
+ wouldOverwrite: file.wouldOverwrite,
560
+ containsSecret: file.containsSecret,
561
+ })),
562
+ };
563
+ }
564
+ function renderDockerPlan(plan, dryRun) {
565
+ reporter.write(`\n ${C.T}Docker generation ${dryRun ? "preview" : "plan"}${C.R}${dryRun ? ` ${C.D}(dry-run; no files written)${C.R}` : ""}\n`);
566
+ for (const file of plan.files) {
567
+ const label = file.action.toUpperCase().padEnd(9);
568
+ const suffix = file.wouldOverwrite ? ` ${C.W}requires confirmation${C.R}` : "";
569
+ reporter.write(` ${label} ${file.path}${suffix}\n`);
570
+ }
571
+ }
572
+ async function dockerCommand() {
573
+ const outDir = path.resolve(args[0] ?? process.cwd());
574
+ const { buildDockerPlan, generateDocker } = await import("./docker-gen.js");
575
+ const plan = buildDockerPlan(outDir);
576
+ if (parsed.flags.json) {
577
+ if (!parsed.flags.dryRun && plan.hasOverwrites && !parsed.flags.force) {
578
+ throw new TerminalError({
579
+ code: "FILE_OVERWRITE_CONFIRMATION_REQUIRED",
580
+ title: "Overwrite confirmation required",
581
+ cause: "Docker generation would overwrite existing files.",
582
+ fix: "Run with --dry-run to inspect or pass --force after reviewing the target paths.",
583
+ commandSuggestion: `balchemy docker ${outDir} --dry-run --json`,
584
+ exitCode: 4,
335
585
  });
336
- break;
337
586
  }
338
- case "docker": {
339
- const outDir = args[0] ?? process.cwd();
340
- const { generateDocker } = await import("./docker-gen.js");
341
- await generateDocker(outDir);
342
- process.stdout.write(`Docker files written to ${outDir}\n`);
343
- break;
587
+ const written = parsed.flags.dryRun ? plan : await generateDocker(outDir, { force: parsed.flags.force });
588
+ reporter.json(jsonEnvelope({
589
+ ok: true,
590
+ command: parsed.flags.dryRun ? "docker.generate.preview" : "docker.generate",
591
+ version: CLI_VERSION,
592
+ data: dockerPlanToJson(written, parsed.flags.dryRun),
593
+ }));
594
+ return;
595
+ }
596
+ renderDockerPlan(plan, parsed.flags.dryRun);
597
+ if (parsed.flags.dryRun) {
598
+ reporter.write(`\n ${C.D}Run without --dry-run to write files. Use --force only after reviewing overwrites.${C.R}\n\n`);
599
+ return;
600
+ }
601
+ if (plan.hasOverwrites && !parsed.flags.force) {
602
+ if (parsed.flags.ci || isNonInteractive(parsed.flags)) {
603
+ throw new TerminalError({
604
+ code: "FILE_OVERWRITE_CONFIRMATION_REQUIRED",
605
+ title: "Overwrite confirmation required",
606
+ cause: "Docker generation would overwrite files, and this session cannot prompt.",
607
+ fix: "Run balchemy docker --dry-run to inspect, then rerun with --force if the overwrites are intended.",
608
+ commandSuggestion: `balchemy docker ${outDir} --dry-run`,
609
+ exitCode: 4,
610
+ });
344
611
  }
345
- case "list": {
346
- printAgentList();
347
- break;
612
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY && process.stdout.isTTY });
613
+ const answer = await ask(rl, `${C.W}Type overwrite to replace existing Docker files${C.R}`);
614
+ rl.close();
615
+ if (answer !== "overwrite") {
616
+ throw new TerminalError({
617
+ code: "FILE_OVERWRITE_CONFIRMATION_REQUIRED",
618
+ title: "Docker generation cancelled",
619
+ cause: "Overwrite confirmation was not provided.",
620
+ fix: "No files were changed. Rerun with --dry-run to inspect the plan.",
621
+ commandSuggestion: `balchemy docker ${outDir} --dry-run`,
622
+ exitCode: 4,
623
+ });
348
624
  }
349
- case undefined: {
350
- // Default: choose from saved agents or run wizard.
351
- const agents = listAgents();
352
- const active = loadAgent();
353
- const last = active ?? mostRecentAgent(agents);
354
- if (last) {
355
- // Show last/active agent info and ask what to do.
356
- const { renderLogo } = await import("./terminal-logo.js");
357
- process.stdout.write(renderLogo(20));
358
- process.stdout.write(`\n ${C.G}B${C.T}alchemy ${C.W}Agent${C.R}\n`);
359
- process.stdout.write(` ${C.D}Saved agents ready${C.R}\n\n`);
360
- printSummaryBlock(active ? "Last session" : "Most recent session", [
361
- { label: "Agent", value: last.publicId },
362
- { label: "Endpoint", value: compactValue(last.mcpEndpoint, 42, 12) },
363
- { label: "Model", value: last.llmModel ?? "default" },
364
- { label: "Strategy", value: compactValue(last.strategy, 42, 8) },
365
- { label: "Mode", value: last.shadowMode ? "Shadow" : "LIVE" },
366
- { label: "Saved", value: last.createdAt },
367
- ]);
368
- const actions = [
369
- { label: "y", value: "Resume this agent" },
370
- ...(agents.length > 1 ? [{ label: "list", value: "Choose another saved agent" }] : []),
371
- { label: "new", value: "Create a new agent or connect existing credentials" },
372
- ];
373
- printSummaryBlock("Available actions", actions);
374
- process.stdout.write("\n");
375
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
376
- try {
377
- const choice = normalizeChoice(await ask(rl, `${C.W}Action?${C.R} (y${agents.length > 1 ? "/list" : ""}/new)`, "y"));
378
- if (choice === "y" || choice === "yes" || choice === "resume" || choice === "last") {
379
- await startSavedAgent(last);
380
- }
381
- else if (Number.isInteger(Number(choice))
382
- && Number(choice) >= 1
383
- && Number(choice) <= agents.length) {
384
- await startSavedAgent(agents[Number(choice) - 1]);
385
- }
386
- else if (agents.length > 1
387
- && (choice === "list" || choice === "choose" || choice === "select" || choice === "agents")) {
388
- const selected = await chooseSavedAgent(rl, agents);
389
- if (!selected) {
390
- stderr(` ${C.D}No matching saved agent. Starting setup instead.${C.R}\n\n`);
391
- await runWizardFromCwd();
392
- }
393
- else {
394
- await startSavedAgent(selected);
395
- }
396
- }
397
- else if (choice === "new" || choice === "n") {
398
- await runWizardFromCwd();
399
- }
400
- else {
401
- stderr(` ${C.D}Unknown action. Starting setup instead.${C.R}\n\n`);
402
- await runWizardFromCwd();
403
- }
404
- }
405
- finally {
406
- rl.close();
407
- }
625
+ }
626
+ await generateDocker(outDir, { force: parsed.flags.force || plan.hasOverwrites });
627
+ reporter.write(`\n ${C.OK}Docker files written to${C.R} ${outDir}\n`);
628
+ reporter.write(`\n ${C.W}Next steps${C.R}\n`);
629
+ reporter.write(` 1. Copy .env.example to .env and fill in your credentials\n`);
630
+ reporter.write(` 2. Place agent.config.yaml in the same directory\n`);
631
+ reporter.write(` 3. Run: docker compose up -d\n\n`);
632
+ }
633
+ async function doctorCommand() {
634
+ const configPath = args[0] ?? path.join(process.cwd(), "agent.config.yaml");
635
+ const checks = [];
636
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
637
+ checks.push({
638
+ name: "node-version",
639
+ status: nodeMajor >= 18 ? "ok" : "error",
640
+ detail: `Node ${process.versions.node}; required >=18`,
641
+ });
642
+ checks.push({
643
+ name: "agent-store",
644
+ status: fs.existsSync(getStorePath()) ? "ok" : "warn",
645
+ detail: fs.existsSync(getStorePath()) ? `Encrypted store found at ${getStorePath()}` : `No encrypted store at ${getStorePath()}`,
646
+ });
647
+ checks.push({
648
+ name: "terminal",
649
+ status: process.stdout.isTTY ? "ok" : "warn",
650
+ detail: process.stdout.isTTY ? "stdout is interactive" : "stdout is non-interactive; TUI commands will be blocked",
651
+ });
652
+ checks.push({
653
+ name: "config-file",
654
+ status: fs.existsSync(configPath) ? "ok" : "warn",
655
+ detail: fs.existsSync(configPath) ? `Config found at ${path.resolve(configPath)}` : `Config not found at ${path.resolve(configPath)}`,
656
+ });
657
+ if (fs.existsSync(configPath)) {
658
+ try {
659
+ const loaded = await loadConfigWithEnv(configPath);
660
+ checks.push({
661
+ name: "config-validate",
662
+ status: "ok",
663
+ detail: `Config resolves for ${endpointHost(loaded.config.mcpEndpoint)}`,
664
+ });
665
+ }
666
+ catch (err) {
667
+ checks.push({
668
+ name: "config-validate",
669
+ status: "error",
670
+ detail: toTerminalError(err).cause,
671
+ });
672
+ }
673
+ }
674
+ const hasErrors = checks.some((check) => check.status === "error");
675
+ if (parsed.flags.json) {
676
+ reporter.json(jsonEnvelope({
677
+ ok: !hasErrors,
678
+ command: "doctor",
679
+ version: CLI_VERSION,
680
+ data: { checks: checks, configPath: path.resolve(configPath) },
681
+ }));
682
+ if (hasErrors)
683
+ process.exitCode = 2;
684
+ return;
685
+ }
686
+ reporter.write(`\n ${C.T}Balchemy CLI doctor${C.R}\n`);
687
+ for (const check of checks) {
688
+ const label = check.status === "ok" ? `${C.OK}OK${C.R}` : check.status === "warn" ? `${C.G}WARN${C.R}` : `${C.ERR}ERROR${C.R}`;
689
+ reporter.write(` ${label.padEnd(18)} ${check.name.padEnd(16)} ${check.detail}\n`);
690
+ }
691
+ reporter.write("\n");
692
+ if (hasErrors)
693
+ process.exitCode = 2;
694
+ }
695
+ async function startFromConfig() {
696
+ ensureInteractive("balchemy start");
697
+ const configPath = args[0] ?? path.join(process.cwd(), "agent.config.yaml");
698
+ const { config } = await loadConfigWithEnv(configPath);
699
+ const publicId = config.mcpEndpoint.split("/").filter(Boolean).pop() ?? "unknown";
700
+ const { startTui } = await import("./tui/start.js");
701
+ await startTui({
702
+ mcpEndpoint: config.mcpEndpoint,
703
+ apiKey: config.apiKey,
704
+ llmProvider: config.llmProvider,
705
+ llmApiKey: config.llmApiKey,
706
+ llmModel: config.llmModel,
707
+ llmBaseUrl: config.llmBaseUrl,
708
+ maxDailyLlmCost: config.maxDailyLlmCost,
709
+ llmTimeoutMs: config.llmTimeoutMs,
710
+ publicId,
711
+ strategy: "custom",
712
+ shadowMode: false,
713
+ behaviorRules: config.behaviorRules,
714
+ autoSeedSubscriptions: false,
715
+ });
716
+ }
717
+ async function defaultLauncher() {
718
+ if (parsed.flags.json) {
719
+ printAgentCurrent();
720
+ return;
721
+ }
722
+ ensureInteractive("balchemy");
723
+ const agents = listAgents();
724
+ const active = loadAgent();
725
+ const last = active ?? mostRecentAgent(agents);
726
+ if (!last) {
727
+ await runWizardFromCwd();
728
+ return;
729
+ }
730
+ const { renderLogo } = await import("./terminal-logo.js");
731
+ reporter.write(renderLogo(20));
732
+ reporter.write(`\n ${C.G}B${C.T}alchemy ${C.W}Agent${C.R}\n`);
733
+ reporter.write(` ${C.D}Saved agents ready${C.R}\n\n`);
734
+ printSummaryBlock(active ? "Last session" : "Most recent session", [
735
+ { label: "Agent", value: last.publicId },
736
+ { label: "Endpoint", value: compactValue(last.mcpEndpoint, 42, 12) },
737
+ { label: "Host", value: endpointHost(last.mcpEndpoint) },
738
+ { label: "Model", value: last.llmModel ?? "default" },
739
+ { label: "Strategy", value: compactValue(last.strategy, 42, 8) },
740
+ { label: "Mode", value: last.shadowMode ? "Shadow" : "LIVE" },
741
+ { label: "Saved", value: last.createdAt },
742
+ ]);
743
+ const actions = [
744
+ { label: "y", value: "Resume this agent" },
745
+ ...(agents.length > 1 ? [{ label: "list", value: "Choose another saved agent" }] : []),
746
+ { label: "new", value: "Create a new agent or connect existing credentials" },
747
+ ];
748
+ printSummaryBlock("Available actions", actions);
749
+ reporter.write("\n");
750
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY && process.stdout.isTTY });
751
+ try {
752
+ const choice = normalizeChoice(await ask(rl, `${C.W}Action?${C.R} (y${agents.length > 1 ? "/list" : ""}/new)`, "y"));
753
+ if (choice === "y" || choice === "yes" || choice === "resume" || choice === "last") {
754
+ await startSavedAgent(last);
755
+ }
756
+ else if (Number.isInteger(Number(choice))
757
+ && Number(choice) >= 1
758
+ && Number(choice) <= agents.length) {
759
+ await startSavedAgent(agents[Number(choice) - 1]);
760
+ }
761
+ else if (agents.length > 1
762
+ && (choice === "list" || choice === "choose" || choice === "select" || choice === "agents")) {
763
+ const selected = await chooseSavedAgent(rl, agents);
764
+ if (!selected) {
765
+ reporter.warn(` ${C.D}No matching saved agent. Starting setup instead.${C.R}\n\n`);
766
+ await runWizardFromCwd();
408
767
  }
409
768
  else {
410
- // No saved agent — run wizard.
411
- await runWizardFromCwd();
769
+ await startSavedAgent(selected);
412
770
  }
413
- break;
414
771
  }
415
- default: {
416
- const suggestion = suggestCommand(cmd);
417
- stderr(` ${C.ERR}Unknown command:${C.R} ${cmd}\n`);
418
- if (suggestion) {
419
- stderr(` ${C.D}Did you mean${C.R} ${C.W}balchemy ${suggestion}${C.R}${C.D}?${C.R}\n`);
772
+ else if (choice === "new" || choice === "n") {
773
+ await runWizardFromCwd();
774
+ }
775
+ else {
776
+ reporter.warn(` ${C.D}Unknown action. Starting setup instead.${C.R}\n\n`);
777
+ await runWizardFromCwd();
778
+ }
779
+ }
780
+ finally {
781
+ rl.close();
782
+ }
783
+ }
784
+ async function main() {
785
+ if (parsed.unknownFlags.length > 0) {
786
+ throw new TerminalError({
787
+ code: "UNKNOWN_FLAG",
788
+ title: "Unknown flag",
789
+ cause: `Unsupported flag${parsed.unknownFlags.length > 1 ? "s" : ""}: ${parsed.unknownFlags.join(", ")}`,
790
+ fix: "Remove the unsupported flag or run help to see supported global flags.",
791
+ commandSuggestion: "balchemy --help",
792
+ exitCode: 2,
793
+ });
794
+ }
795
+ if (parsed.flags.help || cmd === "help") {
796
+ printHelp();
797
+ return;
798
+ }
799
+ if (parsed.flags.version || cmd === "version") {
800
+ printVersion();
801
+ return;
802
+ }
803
+ await checkForUpdate(parsed.flags);
804
+ switch (cmd) {
805
+ case "init":
806
+ if (parsed.flags.dryRun) {
807
+ initDryRun();
808
+ return;
420
809
  }
421
- stderr(` ${C.D}Run${C.R} ${C.W}balchemy --help${C.R} ${C.D}for available commands.${C.R}\n\n`);
422
- process.exit(1);
810
+ await runWizardFromCwd();
811
+ return;
812
+ case "start":
813
+ await startFromConfig();
814
+ return;
815
+ case "docker":
816
+ await dockerCommand();
817
+ return;
818
+ case "list":
819
+ case "agent list":
820
+ printAgentList(cmd === "list" ? "agent.list" : "agent.list");
821
+ return;
822
+ case "agent current":
823
+ printAgentCurrent();
824
+ return;
825
+ case "auth login":
826
+ if (parsed.flags.dryRun) {
827
+ initDryRun();
828
+ return;
829
+ }
830
+ await runWizardFromCwd();
831
+ return;
832
+ case "agent use":
833
+ useAgent(args[0]);
834
+ return;
835
+ case "auth status":
836
+ printAuthStatus();
837
+ return;
838
+ case "auth logout":
839
+ authLogoutCommand();
840
+ return;
841
+ case "config validate":
842
+ await validateConfigCommand("config.validate");
843
+ return;
844
+ case "config list":
845
+ await validateConfigCommand("config.list");
846
+ return;
847
+ case "doctor":
848
+ await doctorCommand();
849
+ return;
850
+ case "":
851
+ await defaultLauncher();
852
+ return;
853
+ default: {
854
+ const suggestion = suggestCommand(cmd || (parsed.commandPath[0] ?? ""));
855
+ throw new TerminalError({
856
+ code: "UNKNOWN_COMMAND",
857
+ title: "Unknown command",
858
+ cause: `Unknown command: ${cmd || parsed.commandPath.join(" ")}`,
859
+ fix: suggestion ? `Did you mean balchemy ${suggestion}?` : "Run balchemy --help for available commands.",
860
+ commandSuggestion: suggestion ? `balchemy ${suggestion}` : "balchemy --help",
861
+ exitCode: 2,
862
+ });
423
863
  }
424
864
  }
425
865
  }
426
866
  main().catch((err) => {
427
- stderr(`${C.ERR}Error:${C.R} ${err instanceof Error ? err.message : String(err)}\n`);
428
- stderr(`${C.D}Run${C.R} ${C.W}balchemy --help${C.R} ${C.D}for usage information.${C.R}\n`);
429
- process.exit(1);
867
+ const terminalError = toTerminalError(err);
868
+ if (parsed.flags.json) {
869
+ reporter.json(jsonEnvelope({
870
+ ok: false,
871
+ command: cmd || "root",
872
+ version: CLI_VERSION,
873
+ error: terminalErrorToJson(terminalError),
874
+ }));
875
+ }
876
+ else {
877
+ renderTerminalError(reporter, terminalError);
878
+ }
879
+ process.exit(terminalError.exitCode);
430
880
  });
431
881
  //# sourceMappingURL=index.js.map