faberwright 0.3.1 → 0.4.1

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
@@ -32,12 +32,18 @@ import { Agent } from "./agent.js";
32
32
  import { FatalError, CancelledError } from "./errors.js";
33
33
  import { SessionStore } from "./memory/sessions.js";
34
34
  import { isRepo, isDirty, ensureStateIgnored } from "./git.js";
35
- import { select, setSelectGuard } from "./prompt.js";
35
+ import { select, setSelectGuard, readSecret } from "./prompt.js";
36
36
  import { createComposedInput, restore, describeComposed } from "./input.js";
37
37
  import { Composer } from "./editor.js";
38
38
  import { StatusLine } from "./status.js";
39
39
  import { renderMarkdown, StreamRenderer } from "./markdown.js";
40
- import { renderUsagePanel } from "./usage.js";
40
+ import { renderUsagePanel, UsageLedger } from "./usage.js";
41
+ import { ROUTES, getRoute, describeModel, resolveModel, vendors, baseUrlFor, DEFAULT_REGION } from "./routes.js";
42
+ import { loadSettings, saveSettings, updateActive, settingsPath } from "./settings.js";
43
+ import { needsOnboarding, interactive, runOnboarding, reportSetup, setupComplete } from "./onboard.js";
44
+ import { saveCredential, deleteCredential, listCredentialNames, getCredential, maskCredential, credentialsPath, resolveCredential, looksLikeKey, } from "./credentials.js";
45
+ import { readCache, writeCache, clearCache, buildPicker } from "./models.js";
46
+ import { refreshPrices, priceFor, readPriceCache } from "./pricing.js";
41
47
  /** Version comes from package.json — one source of truth for banner and --version. */
42
48
  const VERSION = (() => {
43
49
  try {
@@ -150,14 +156,64 @@ async function main() {
150
156
  const totalIn = u.input + u.cacheRead + u.cacheWrite;
151
157
  const cachePct = totalIn > 0 ? Math.round((u.cacheRead / totalIn) * 100) : 0;
152
158
  let line = `tokens: ${k(totalIn)} in (${cachePct}% cached) / ${k(u.output)} out · ${u.calls} call${u.calls === 1 ? "" : "s"}`;
153
- const pIn = Number(process.env.CW_PRICE_IN), pOut = Number(process.env.CW_PRICE_OUT);
154
- if (pIn > 0 && pOut > 0) { // $/Mtok: cache reads ~0.1x, cache writes ~1.25x
155
- const usd = (u.input * pIn + u.cacheRead * pIn * 0.1 + u.cacheWrite * pIn * 1.25 + u.output * pOut) / 1e6;
159
+ const usd = UsageLedger.cost(u, priceFor(agent.model, { in: config.priceIn, out: config.priceOut }));
160
+ if (usd !== undefined)
156
161
  line += ` · ~$${usd.toFixed(3)}`;
157
- }
158
162
  console.log(pc.dim(line));
159
163
  },
160
164
  };
165
+ // Setup must be verified BEFORE the agent is built: the LLM client throws
166
+ // on a missing key, which would pre-empt the guided fix with a raw error.
167
+ // Every launch checks that setup is COMPLETE, not merely that a settings
168
+ // file exists. What counts depends on the route, so a Bedrock profile with
169
+ // an IAM role passes while an Anthropic profile with no key does not —
170
+ // and the gap is fixed here rather than surfacing as a 401 mid-task.
171
+ if (needsOnboarding() && interactive()) {
172
+ console.log(pc.cyan(`Faber v${VERSION}`));
173
+ const setup = await runOnboarding(rl, { first: true });
174
+ reportSetup(setup);
175
+ // Aborted setup saved nothing, so there is no model to reach — exit here
176
+ // rather than letting the LLM client throw a less useful error.
177
+ if (setup.aborted) {
178
+ rl.close();
179
+ process.exitCode = 1;
180
+ return;
181
+ }
182
+ config = loadConfig(config.workspace);
183
+ }
184
+ else {
185
+ const state = await setupComplete(config);
186
+ if (!state.complete) {
187
+ if (!interactive()) {
188
+ console.error(pc.red(`Setup isn't finished — ${state.missing}.`));
189
+ console.error(pc.dim(` Run faber interactively to complete setup, or: ${state.fix}`));
190
+ rl.close();
191
+ process.exitCode = 1;
192
+ return;
193
+ }
194
+ // Start over from the top rather than resuming at the missing step:
195
+ // someone without a key for this route usually wants a DIFFERENT route,
196
+ // and resuming would trap them on the one that just failed.
197
+ console.log(pc.cyan(`Faber v${VERSION}`));
198
+ console.log(pc.yellow(`Setup isn't finished — ${state.missing}. Starting over.`));
199
+ const setup = await runOnboarding(rl, { first: true });
200
+ reportSetup(setup);
201
+ if (setup.aborted) {
202
+ rl.close();
203
+ process.exitCode = 1;
204
+ return;
205
+ }
206
+ config = loadConfig(config.workspace);
207
+ const after = await setupComplete(config);
208
+ if (!after.complete) {
209
+ console.log(pc.yellow(`Setup still incomplete — ${after.missing}.`));
210
+ console.log(pc.dim(` ${after.fix}, then run faber again.`));
211
+ rl.close();
212
+ process.exitCode = 1;
213
+ return;
214
+ }
215
+ }
216
+ }
161
217
  const resumeId = flags.has("--resume") ? SessionStore.latestId(config.sessionsDir) : undefined;
162
218
  let agent;
163
219
  try {
@@ -326,8 +382,191 @@ async function main() {
326
382
  agent.shortTerm.clear();
327
383
  console.log("Short-term memory cleared.");
328
384
  break;
385
+ case "/model": {
386
+ const route = getRoute(config.route) ?? ROUTES[0];
387
+ if (args[0] === "--save") {
388
+ updateActive({ model: agent.model });
389
+ console.log(`Saved ${agent.model} as the default for profile "${config.profileName}".`);
390
+ break;
391
+ }
392
+ if (args[0] && args[0] !== "--refresh") { // direct: /model haiku
393
+ const id = resolveModel(args[0], route, config.modelPins);
394
+ agent.setModel(id);
395
+ console.log(`Model for this session: ${describeModel(id, route, config.modelPins)}`);
396
+ console.log(pc.dim(" /model --save make it the profile default"));
397
+ break;
398
+ }
399
+ // With no price data at all — first run, or the cache was discarded
400
+ // because an older Faber wrote it — wait for the fetch rather than
401
+ // rendering a menu that says "price unknown" for everything. The
402
+ // background refresh is fine when we already have rates to show.
403
+ if (!readPriceCache()) {
404
+ process.stdout.write(pc.dim(" fetching prices… "));
405
+ await refreshPrices(undefined, { baseUrl: config.baseUrl });
406
+ process.stdout.write("\r\x1b[2K");
407
+ }
408
+ // Ask the provider what this key can actually use; cached for a day.
409
+ if (args[0] === "--refresh")
410
+ clearCache(route.id);
411
+ let discovered = readCache(route.id);
412
+ if (!discovered) {
413
+ process.stdout.write(pc.dim(" fetching available models… "));
414
+ discovered = await agent.llm.listModels();
415
+ process.stdout.write("\r\x1b[2K");
416
+ if (discovered.length)
417
+ writeCache(route.id, discovered);
418
+ }
419
+ const entries = buildPicker(route, discovered ?? [], agent.model);
420
+ if (!entries.length) {
421
+ console.log(`No model list available for ${route.label}. Set one with: /model <id>`);
422
+ break;
423
+ }
424
+ // Show the rate here too. /model is where people switch models to save
425
+ // money, so hiding the price is exactly backwards.
426
+ const width = Math.min(34, Math.max(...entries.map((e) => e.label.length)) + 2);
427
+ const labels = entries.map((e) => {
428
+ const id = resolveModel(e.value, route, config.modelPins);
429
+ const p = priceFor(id, { in: config.priceIn, out: config.priceOut });
430
+ const cost = p ? `$${p.in}/$${p.out} per Mtok` : "price unknown";
431
+ return `${e.label.padEnd(width)}${pc.dim(cost.padEnd(22))}${pc.dim(e.blurb)}`;
432
+ });
433
+ const curIdx = entries.findIndex((e) => e.value === agent.model || resolveModel(e.value, route, config.modelPins) === agent.model);
434
+ const pick = await select(rl, "Select model (this session)", labels, curIdx < 0 ? 0 : curIdx);
435
+ const chosen = entries[pick];
436
+ const id = resolveModel(chosen.value, route, config.modelPins);
437
+ agent.setModel(id);
438
+ console.log(`Model for this session: ${describeModel(id, route, config.modelPins)}`);
439
+ console.log(pc.dim(" /model --save keep it · /model --refresh re-check the provider"));
440
+ break;
441
+ }
442
+ case "/key": {
443
+ const route = getRoute(config.route) ?? ROUTES[0];
444
+ const name = args[1] ?? route.keyEnv ?? "ANTHROPIC_API_KEY";
445
+ if (args[0] === "set") {
446
+ const val = await readSecret(rl, ` ${name} (hidden): `);
447
+ if (!val) {
448
+ console.log("Nothing entered.");
449
+ break;
450
+ }
451
+ const problem = looksLikeKey(name, val);
452
+ if (problem) {
453
+ console.log(pc.yellow(` ${problem}.`));
454
+ const ok = await select(rl, "Save it anyway?", ["No, discard it", "Yes, save it"]);
455
+ if (ok === 0) {
456
+ console.log("Discarded.");
457
+ break;
458
+ }
459
+ }
460
+ saveCredential(name, val);
461
+ console.log(`Saved ${name} (${maskCredential(val)}) — kept on this computer only.`);
462
+ console.log(pc.dim(` ${credentialsPath()}, readable only by you — restart faber to use it`));
463
+ }
464
+ else if (args[0] === "rm") {
465
+ console.log(deleteCredential(name) ? `Removed ${name}.` : `No stored ${name}.`);
466
+ }
467
+ else {
468
+ const names = listCredentialNames();
469
+ if (!names.length)
470
+ console.log(`No stored credentials. Add one with: /key set [${name}]`);
471
+ for (const n of names) {
472
+ const stored = getCredential(n);
473
+ const shadowed = process.env[n] ? pc.dim(" (env var takes precedence)") : "";
474
+ console.log(` ${n.padEnd(24)} ${maskCredential(stored)}${shadowed}`);
475
+ }
476
+ console.log(pc.dim(` ${credentialsPath()}`));
477
+ }
478
+ break;
479
+ }
480
+ case "/setup": {
481
+ const setup = await runOnboarding(rl);
482
+ reportSetup(setup);
483
+ if (!setup.aborted) {
484
+ // Apply in this session. Telling the user to restart left the
485
+ // running agent on the old route, so /model would then list models
486
+ // for the provider they had just switched away from.
487
+ config = loadConfig(config.workspace);
488
+ agent.reconfigure(config);
489
+ console.log(pc.dim(` now using ${describeModel(config.model, getRoute(config.route) ?? ROUTES[0], config.modelPins)} on ${getRoute(config.route)?.label}`));
490
+ }
491
+ break;
492
+ }
493
+ case "/route": {
494
+ const groups = vendors();
495
+ const vi = await select(rl, "Select vendor", groups.map((g) => g.vendor));
496
+ const rs = groups[vi].routes;
497
+ const ri = await select(rl, "Select route", rs.map((r) => `${r.label.padEnd(28)} ${pc.dim(r.hint)}${r.implemented ? "" : pc.yellow(" (not yet wired)")}`));
498
+ const chosen = rs[ri];
499
+ if (!chosen.implemented) {
500
+ console.log(pc.yellow(`${chosen.label} isn't wired up yet — see the roadmap in the README.`));
501
+ break;
502
+ }
503
+ let region;
504
+ if (chosen.needsRegion) {
505
+ const ans = (await rl.question(`AWS region [${DEFAULT_REGION}]: `)).trim();
506
+ region = ans || DEFAULT_REGION;
507
+ }
508
+ let baseUrl = chosen.baseUrl;
509
+ if (chosen.needsBaseUrl) {
510
+ baseUrl = (await rl.question("Base URL (OpenAI-compatible): ")).trim() || undefined;
511
+ }
512
+ updateActive({ route: chosen.id, baseUrl, region, apiKeyEnv: chosen.keyEnv });
513
+ console.log(`Route set to ${chosen.label}.`);
514
+ const url = baseUrl ?? baseUrlFor(chosen, region);
515
+ if (url)
516
+ console.log(pc.dim(` endpoint: ${url}`));
517
+ if (chosen.keyEnv && !resolveCredential(chosen.keyEnv)) {
518
+ if (chosen.id === "bedrock") {
519
+ const { discoverAwsCredentials } = await import("./sigv4.js");
520
+ const aws = await discoverAwsCredentials();
521
+ console.log(aws
522
+ ? pc.dim(` no ${chosen.keyEnv} needed — signing with AWS credentials from ${aws.source}`)
523
+ : pc.yellow(` set ${chosen.keyEnv}, or run where an IAM role is available (SageMaker, ECS, EC2)`));
524
+ }
525
+ else {
526
+ console.log(pc.yellow(` ${chosen.keyEnv} is not set.`));
527
+ }
528
+ }
529
+ if (chosen.aliasesArePinned) {
530
+ console.log(pc.dim(" model ids differ on this route — set one with: /model <id>"));
531
+ }
532
+ config = loadConfig(config.workspace);
533
+ agent.reconfigure(config);
534
+ console.log(pc.dim(` now on ${getRoute(config.route)?.label}`));
535
+ break;
536
+ }
537
+ case "/profile": {
538
+ const st = loadSettings();
539
+ if (!args[0]) {
540
+ for (const [name, p] of Object.entries(st.profiles)) {
541
+ const mark = name === st.activeProfile ? pc.cyan("❯") : " ";
542
+ const r = getRoute(p.route);
543
+ console.log(`${mark} ${name.padEnd(12)} ${(r?.label ?? p.route).padEnd(26)} ${pc.dim(p.model ?? "")}`);
544
+ }
545
+ console.log(pc.dim(` ${settingsPath()}`));
546
+ console.log(pc.dim(" switch with: /profile <name>"));
547
+ break;
548
+ }
549
+ if (!st.profiles[args[0]]) {
550
+ console.log(`No profile "${args[0]}".`);
551
+ break;
552
+ }
553
+ st.activeProfile = args[0];
554
+ saveSettings(st);
555
+ config = loadConfig(config.workspace);
556
+ agent.reconfigure(config);
557
+ console.log(`Active profile: ${args[0]} — ${getRoute(config.route)?.label}, ${config.model}.`);
558
+ break;
559
+ }
329
560
  case "/usage": {
330
- console.log(renderUsagePanel(agent.usage, Number(process.env.CW_PRICE_IN) || undefined, Number(process.env.CW_PRICE_OUT) || undefined));
561
+ if (args[0] === "--refresh-prices") {
562
+ process.stdout.write(pc.dim(" fetching current prices… "));
563
+ const n = await refreshPrices(undefined, { baseUrl: config.baseUrl, force: true });
564
+ process.stdout.write("\r\x1b[2K");
565
+ console.log(n === "unchanged" ? "Prices confirmed current."
566
+ : typeof n === "number" ? `Prices updated (${n} models).`
567
+ : pc.yellow("Couldn't fetch prices — keeping the rates already known."));
568
+ }
569
+ console.log(renderUsagePanel(agent.usage, { in: config.priceIn, out: config.priceOut }));
331
570
  break;
332
571
  }
333
572
  case "/verbose":
@@ -356,7 +595,11 @@ async function main() {
356
595
  return;
357
596
  }
358
597
  console.log(pc.cyan(`Faber v${VERSION} — agentic coding assistant`));
359
- console.log(pc.dim(`workspace: ${config.workspace}\nmodel: ${config.model} (${config.provider}) approval: ${approvalMode}`));
598
+ console.log(pc.dim(`workspace: ${config.workspace}\n` +
599
+ `model: ${describeModel(config.model, getRoute(config.route) ?? ROUTES[0], config.modelPins)}` +
600
+ ` route: ${getRoute(config.route)?.label ?? config.route}` +
601
+ (config.profileName !== "default" ? ` profile: ${config.profileName}` : "") +
602
+ ` approval: ${approvalMode}`));
360
603
  if (isRepo(config.workspace) && isDirty(config.workspace)) {
361
604
  console.log(pc.yellow("Git: you have uncommitted changes — consider committing before letting me edit."));
362
605
  }
@@ -426,7 +669,7 @@ const HELP = `
426
669
  /restore <id> jump files back to before a specific task
427
670
  /clear clear short-term conversation memory
428
671
  /ask | /auto approval mode: preview diffs and commands (default) / apply freely
429
- env: CW_APPROVAL=auto to change default; CW_GIT=commit for one git commit per task
672
+ env: FABER_APPROVAL=auto to change default; FABER_GIT=commit for one git commit per task
430
673
  /exit quit
431
674
  `;
432
675
  main().catch((e) => { console.error(pc.red(String(e?.stack ?? e))); process.exit(1); });