faberwright 0.3.0 → 0.4.0

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 } 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,164 @@ 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
+ // Ask the provider what this key can actually use; cached for a day.
400
+ if (args[0] === "--refresh")
401
+ clearCache(route.id);
402
+ let discovered = readCache(route.id);
403
+ if (!discovered) {
404
+ process.stdout.write(pc.dim(" fetching available models… "));
405
+ discovered = await agent.llm.listModels();
406
+ process.stdout.write("\r\x1b[2K");
407
+ if (discovered.length)
408
+ writeCache(route.id, discovered);
409
+ }
410
+ const entries = buildPicker(route, discovered ?? [], agent.model);
411
+ if (!entries.length) {
412
+ console.log(`No model list available for ${route.label}. Set one with: /model <id>`);
413
+ break;
414
+ }
415
+ const width = Math.min(34, Math.max(...entries.map((e) => e.label.length)) + 2);
416
+ const labels = entries.map((e) => `${e.label.padEnd(width)}${pc.dim(e.blurb)}${e.live ? pc.dim(" ·live") : ""}`);
417
+ const curIdx = entries.findIndex((e) => e.value === agent.model || resolveModel(e.value, route, config.modelPins) === agent.model);
418
+ const pick = await select(rl, "Select model (this session)", labels, curIdx < 0 ? 0 : curIdx);
419
+ const chosen = entries[pick];
420
+ const id = resolveModel(chosen.value, route, config.modelPins);
421
+ agent.setModel(id);
422
+ console.log(`Model for this session: ${describeModel(id, route, config.modelPins)}`);
423
+ console.log(pc.dim(" /model --save keep it · /model --refresh re-check the provider"));
424
+ break;
425
+ }
426
+ case "/key": {
427
+ const route = getRoute(config.route) ?? ROUTES[0];
428
+ const name = args[1] ?? route.keyEnv ?? "ANTHROPIC_API_KEY";
429
+ if (args[0] === "set") {
430
+ const val = await readSecret(rl, ` ${name} (hidden): `);
431
+ if (!val) {
432
+ console.log("Nothing entered.");
433
+ break;
434
+ }
435
+ const problem = looksLikeKey(name, val);
436
+ if (problem) {
437
+ console.log(pc.yellow(` ${problem}.`));
438
+ const ok = await select(rl, "Save it anyway?", ["No, discard it", "Yes, save it"]);
439
+ if (ok === 0) {
440
+ console.log("Discarded.");
441
+ break;
442
+ }
443
+ }
444
+ saveCredential(name, val);
445
+ console.log(`Saved ${name} (${maskCredential(val)}) — kept on this computer only.`);
446
+ console.log(pc.dim(` ${credentialsPath()}, readable only by you — restart faber to use it`));
447
+ }
448
+ else if (args[0] === "rm") {
449
+ console.log(deleteCredential(name) ? `Removed ${name}.` : `No stored ${name}.`);
450
+ }
451
+ else {
452
+ const names = listCredentialNames();
453
+ if (!names.length)
454
+ console.log(`No stored credentials. Add one with: /key set [${name}]`);
455
+ for (const n of names) {
456
+ const stored = getCredential(n);
457
+ const shadowed = process.env[n] ? pc.dim(" (env var takes precedence)") : "";
458
+ console.log(` ${n.padEnd(24)} ${maskCredential(stored)}${shadowed}`);
459
+ }
460
+ console.log(pc.dim(` ${credentialsPath()}`));
461
+ }
462
+ break;
463
+ }
464
+ case "/setup": {
465
+ const setup = await runOnboarding(rl);
466
+ reportSetup(setup);
467
+ console.log(pc.dim(" restart faber to apply"));
468
+ break;
469
+ }
470
+ case "/route": {
471
+ const groups = vendors();
472
+ const vi = await select(rl, "Select vendor", groups.map((g) => g.vendor));
473
+ const rs = groups[vi].routes;
474
+ 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)")}`));
475
+ const chosen = rs[ri];
476
+ if (!chosen.implemented) {
477
+ console.log(pc.yellow(`${chosen.label} isn't wired up yet — see the roadmap in the README.`));
478
+ break;
479
+ }
480
+ let region;
481
+ if (chosen.needsRegion) {
482
+ const ans = (await rl.question(`AWS region [${DEFAULT_REGION}]: `)).trim();
483
+ region = ans || DEFAULT_REGION;
484
+ }
485
+ let baseUrl = chosen.baseUrl;
486
+ if (chosen.needsBaseUrl) {
487
+ baseUrl = (await rl.question("Base URL (OpenAI-compatible): ")).trim() || undefined;
488
+ }
489
+ updateActive({ route: chosen.id, baseUrl, region, apiKeyEnv: chosen.keyEnv });
490
+ console.log(`Route set to ${chosen.label}.`);
491
+ const url = baseUrl ?? baseUrlFor(chosen, region);
492
+ if (url)
493
+ console.log(pc.dim(` endpoint: ${url}`));
494
+ if (chosen.keyEnv && !resolveCredential(chosen.keyEnv)) {
495
+ if (chosen.id === "bedrock") {
496
+ const { discoverAwsCredentials } = await import("./sigv4.js");
497
+ const aws = await discoverAwsCredentials();
498
+ console.log(aws
499
+ ? pc.dim(` no ${chosen.keyEnv} needed — signing with AWS credentials from ${aws.source}`)
500
+ : pc.yellow(` set ${chosen.keyEnv}, or run where an IAM role is available (SageMaker, ECS, EC2)`));
501
+ }
502
+ else {
503
+ console.log(pc.yellow(` ${chosen.keyEnv} is not set.`));
504
+ }
505
+ }
506
+ if (chosen.aliasesArePinned) {
507
+ console.log(pc.dim(" model ids differ on this route — set one with: /model <id>"));
508
+ }
509
+ console.log(pc.dim(" restart faber to apply"));
510
+ break;
511
+ }
512
+ case "/profile": {
513
+ const st = loadSettings();
514
+ if (!args[0]) {
515
+ for (const [name, p] of Object.entries(st.profiles)) {
516
+ const mark = name === st.activeProfile ? pc.cyan("❯") : " ";
517
+ const r = getRoute(p.route);
518
+ console.log(`${mark} ${name.padEnd(12)} ${(r?.label ?? p.route).padEnd(26)} ${pc.dim(p.model ?? "")}`);
519
+ }
520
+ console.log(pc.dim(` ${settingsPath()}`));
521
+ console.log(pc.dim(" switch with: /profile <name>"));
522
+ break;
523
+ }
524
+ if (!st.profiles[args[0]]) {
525
+ console.log(`No profile "${args[0]}".`);
526
+ break;
527
+ }
528
+ st.activeProfile = args[0];
529
+ saveSettings(st);
530
+ console.log(`Active profile: ${args[0]}. Restart faber to apply.`);
531
+ break;
532
+ }
329
533
  case "/usage": {
330
- console.log(renderUsagePanel(agent.usage, Number(process.env.CW_PRICE_IN) || undefined, Number(process.env.CW_PRICE_OUT) || undefined));
534
+ if (args[0] === "--refresh-prices") {
535
+ process.stdout.write(pc.dim(" fetching current prices… "));
536
+ const n = await refreshPrices(undefined, { baseUrl: config.baseUrl, force: true });
537
+ process.stdout.write("\r\x1b[2K");
538
+ console.log(n === "unchanged" ? "Prices confirmed current."
539
+ : typeof n === "number" ? `Prices updated (${n} models).`
540
+ : pc.yellow("Couldn't fetch prices — keeping the rates already known."));
541
+ }
542
+ console.log(renderUsagePanel(agent.usage, { in: config.priceIn, out: config.priceOut }));
331
543
  break;
332
544
  }
333
545
  case "/verbose":
@@ -356,7 +568,11 @@ async function main() {
356
568
  return;
357
569
  }
358
570
  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}`));
571
+ console.log(pc.dim(`workspace: ${config.workspace}\n` +
572
+ `model: ${describeModel(config.model, getRoute(config.route) ?? ROUTES[0], config.modelPins)}` +
573
+ ` route: ${getRoute(config.route)?.label ?? config.route}` +
574
+ (config.profileName !== "default" ? ` profile: ${config.profileName}` : "") +
575
+ ` approval: ${approvalMode}`));
360
576
  if (isRepo(config.workspace) && isDirty(config.workspace)) {
361
577
  console.log(pc.yellow("Git: you have uncommitted changes — consider committing before letting me edit."));
362
578
  }
@@ -426,7 +642,7 @@ const HELP = `
426
642
  /restore <id> jump files back to before a specific task
427
643
  /clear clear short-term conversation memory
428
644
  /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
645
+ env: FABER_APPROVAL=auto to change default; FABER_GIT=commit for one git commit per task
430
646
  /exit quit
431
647
  `;
432
648
  main().catch((e) => { console.error(pc.red(String(e?.stack ?? e))); process.exit(1); });
package/dist/llm.js CHANGED
@@ -1,14 +1,88 @@
1
+ import { signRequest, discoverAwsCredentials } from "./sigv4.js";
1
2
  import { FatalError, TransientAPIError, withRetries, CancelledError } from "./errors.js";
2
3
  const RETRYABLE = new Set([408, 409, 429, 500, 502, 503, 504, 529]);
4
+ /** AWS credentials discovered once per process for SigV4 routes. */
5
+ let awsCredsPromise;
3
6
  export class LLMClient {
4
7
  config;
5
8
  constructor(config) {
6
9
  this.config = config;
10
+ // Local servers (Ollama, LM Studio, vLLM on localhost) accept any bearer
11
+ // token, so requiring a key there would block the one route that is free.
12
+ const isLocal = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/.test(config.baseUrl);
13
+ // Routes that sign with AWS credentials need no API key at all — in
14
+ // SageMaker Studio, ECS or Lambda the execution role supplies them.
15
+ if (!config.apiKey && config.route === "bedrock")
16
+ return;
17
+ if (!config.apiKey && isLocal) {
18
+ this.config = { ...config, apiKey: "local" };
19
+ return;
20
+ }
7
21
  if (!config.apiKey) {
8
22
  const v = config.provider === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
9
- throw new FatalError(`No API key found. Set ${v} or add it to .faber/config.json.`);
23
+ throw new FatalError(`No API key found. Set ${v}, or run /route to pick a local model that doesn't need one.`);
24
+ }
25
+ }
26
+ /**
27
+ * Ask the provider which models this credential can actually use.
28
+ * Anthropic: GET /v1/models -> { data: [{ id, display_name }] }, newest first.
29
+ * OpenAI-compatible (incl. Bedrock mantle, Ollama): GET /models, same shape
30
+ * minus display_name. Returns [] on any failure — this is a convenience,
31
+ * never a blocker, so an offline or restricted key just falls back.
32
+ */
33
+ async listModels(signal) {
34
+ const anthropic = this.config.provider === "anthropic";
35
+ const url = anthropic
36
+ ? `${this.config.baseUrl}/v1/models?limit=100`
37
+ : `${this.config.baseUrl}/models`;
38
+ const headers = anthropic
39
+ ? { "x-api-key": this.config.apiKey, "anthropic-version": "2023-06-01" }
40
+ : { authorization: `Bearer ${this.config.apiKey}` };
41
+ try {
42
+ const ctl = new AbortController();
43
+ const timer = setTimeout(() => ctl.abort(), 6000);
44
+ signal?.addEventListener("abort", () => ctl.abort(), { once: true });
45
+ const res = await fetch(url, { headers, signal: ctl.signal });
46
+ clearTimeout(timer);
47
+ if (!res.ok)
48
+ return [];
49
+ const body = await res.json();
50
+ return (body.data ?? [])
51
+ .filter((m) => typeof m.id === "string")
52
+ .map((m) => ({ id: m.id, name: m.display_name }));
10
53
  }
54
+ catch {
55
+ return []; // offline, no permission, or an endpoint without the route
56
+ }
57
+ }
58
+ /**
59
+ * Auth for one request. An API key is a header; AWS credentials mean signing
60
+ * the whole request, which is how an IAM role authenticates with no key.
61
+ */
62
+ async authHeaders(url, body) {
63
+ if (this.config.apiKey)
64
+ return { "x-api-key": this.config.apiKey };
65
+ if (this.config.route !== "bedrock")
66
+ return {};
67
+ awsCredsPromise ??= discoverAwsCredentials();
68
+ const creds = await awsCredsPromise;
69
+ if (!creds) {
70
+ throw new FatalError("No AWS credentials found for the Bedrock route. Set BEDROCK_API_KEY, " +
71
+ "or run in an environment with an IAM role (SageMaker, ECS, EC2) " +
72
+ "or `aws configure`.");
73
+ }
74
+ return signRequest({
75
+ method: "POST",
76
+ url,
77
+ body,
78
+ region: this.config.region ?? "us-east-1",
79
+ service: "bedrock",
80
+ credentials: creds,
81
+ headers: { "content-type": "application/json" },
82
+ });
11
83
  }
84
+ /** Switch model at runtime (/model). Cache prefixes are per-model. */
85
+ setModel(id) { this.config = { ...this.config, model: id }; }
12
86
  complete(system, messages, tools, onText, signal, modelOverride) {
13
87
  return withRetries(() => this.config.provider === "anthropic"
14
88
  ? this.anthropicStream(system, messages, tools, onText, signal, modelOverride)
@@ -41,8 +115,10 @@ export class LLMClient {
41
115
  };
42
116
  if (tools.length)
43
117
  body.tools = cachedTools;
44
- const res = await this.post(`${this.config.baseUrl}/v1/messages`, {
45
- "x-api-key": this.config.apiKey,
118
+ const url = `${this.config.baseUrl}/v1/messages`;
119
+ const authHeaders = await this.authHeaders(url, JSON.stringify(body));
120
+ const res = await this.post(url, {
121
+ ...authHeaders,
46
122
  "anthropic-version": "2023-06-01",
47
123
  "content-type": "application/json",
48
124
  }, body, signal);
package/dist/models.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Model discovery: ask the provider what this credential can actually use,
3
+ * cache it, and fall back to the built-in list when that isn't possible.
4
+ *
5
+ * Why bother: hardcoded model lists go stale the moment a provider ships
6
+ * something new, and they can't know which models YOUR account is entitled to.
7
+ * A live list is always current and always accurate for the caller.
8
+ *
9
+ * Discovery is strictly a convenience. Every failure path — offline, a key
10
+ * without models:list permission, an endpoint that doesn't implement it —
11
+ * falls back to the static aliases rather than blocking the picker.
12
+ */
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+ import { modelsForRoute } from "./routes.js";
17
+ const TTL_MS = 24 * 60 * 60 * 1000; // a day: new models are rare, staleness is cheap
18
+ function cacheFile(routeId) {
19
+ return path.join(os.homedir(), ".faber", "cache", `models-${routeId}.json`);
20
+ }
21
+ export function readCache(routeId, now = Date.now()) {
22
+ try {
23
+ const raw = JSON.parse(fs.readFileSync(cacheFile(routeId), "utf8"));
24
+ if (!Array.isArray(raw.models) || now - raw.fetchedAt > TTL_MS)
25
+ return undefined;
26
+ return raw.models;
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ export function writeCache(routeId, models, now = Date.now()) {
33
+ try {
34
+ const f = cacheFile(routeId);
35
+ fs.mkdirSync(path.dirname(f), { recursive: true });
36
+ fs.writeFileSync(f, JSON.stringify({ fetchedAt: now, models }, null, 2));
37
+ }
38
+ catch { /* cache is best-effort */ }
39
+ }
40
+ export function clearCache(routeId) {
41
+ try {
42
+ fs.unlinkSync(cacheFile(routeId));
43
+ }
44
+ catch { /* already gone */ }
45
+ }
46
+ /**
47
+ * Build the /model menu: built-in aliases first (stable names people type),
48
+ * then anything discovered that an alias doesn't already cover.
49
+ * Provider lists arrive newest-first, and that order is preserved.
50
+ */
51
+ export function buildPicker(route, discovered, currentId) {
52
+ const aliases = modelsForRoute(route);
53
+ const entries = aliases.map((a) => ({
54
+ value: a.alias,
55
+ label: a.alias,
56
+ blurb: a.blurb,
57
+ live: false,
58
+ }));
59
+ const covered = new Set(aliases.map((a) => a.id));
60
+ for (const m of discovered) {
61
+ if (covered.has(m.id))
62
+ continue;
63
+ entries.push({ value: m.id, label: m.id, blurb: m.name ?? "", live: true });
64
+ }
65
+ // if the model in use is neither an alias nor discovered, keep it visible
66
+ if (!entries.some((e) => e.value === currentId) && !covered.has(currentId)) {
67
+ entries.push({ value: currentId, label: currentId, blurb: "current", live: false });
68
+ }
69
+ return entries;
70
+ }