faberwright 0.4.1 → 0.4.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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  *Faber — Latin for craftsman. Yours lives at `faber`.*
6
6
  * Read: [Faber Medium](https://medium.com/@jshial25/why-should-an-ai-coding-agent-read-hundreds-of-files-to-answer-one-question-d6369d29dfa5?postPublishedType=repub)
7
7
  ## Code Graph
8
- <img width="636" height="573" alt="Screenshot 2026-08-09 at 2 58 30PM" src="https://github.com/user-attachments/assets/176e5093-8e29-452f-a749-d06a561c583a" />
8
+ <img width="636" height="573" alt="Screenshot 2026-08-09 at 2 58 30 PM" src="https://github.com/user-attachments/assets/176e5093-8e29-452f-a749-d06a561c583a" />
9
9
 
10
10
  ## Demo
11
11
  <img width="1240" height="700" alt="terminal_demo_compressed" src="https://github.com/user-attachments/assets/f78a8f68-130e-4f37-90f2-7a7089fa2653" />
@@ -21,7 +21,7 @@ faber # start the REPL (approval mode is on by default)
21
21
  1. **Give it a real task.** Try `add a comment explaining what the main entry file does`
22
22
  2. **Approve the diff** with the arrow keys and Enter. Cursor starts on Yes; "Always this session" grants trust. Shell commands ask too.
23
23
  3. **See the safety net.** `/history` lists every task with the files it touched.
24
- 4. **Undo it.** `/undo` reverts the task, and if you change your mind changed your mind? `/redo` brings it back. Nothing is ever lost in either direction.
24
+ 4. **Undo it.** `/undo` reverts the task, and `/redo` brings it back. Nothing is ever lost in either direction.
25
25
  5. **Come back tomorrow.** `faber --resume` continues the conversation, or just ask *"what did we do last time?"* — it searches past sessions itself.
26
26
 
27
27
  That loop of task, approve, inspect, revert is the whole trust model. Everything else is detail.
@@ -30,7 +30,7 @@ That loop of task, approve, inspect, revert is the whole trust model. Everything
30
30
 
31
31
  **It has a map, not just eyes.** The code graph stores call and import *edges*, incrementally updated in milliseconds. One ~50-token query (`trace_path(main, saveUser)` → `main → startServer → handleSignup → saveUser`) replaces reading thousands of tokens of files. `/map main` prints the call tree — the "trace it from main" ritual every programmer does, automated.
32
32
 
33
- **Every model you can reach, including the coding ones.** Claude through the Anthropic API or your own AWS account, OpenAI through both of its APIs — chat completions and the Responses API that the codex family requires — plus anything OpenAI-compatible, and local models through Ollama for no key and no cost. Faber reads which endpoint each model needs and routes there itself, so `gpt-5.3-codex` and `claude-opus-5` are both just entries in the same list, priced side by side.
33
+ **Every model you can reach, including the coding ones.** Claude through the Anthropic API, your own AWS account, or Microsoft Foundry on Azure. OpenAI through both of its APIs — chat completions and the Responses API that the codex family requires — directly or through your Azure subscription. Plus anything OpenAI-compatible, and local models through Ollama for no key and no cost. Faber reads which endpoint each model needs and routes there itself, so `gpt-5.3-codex` and `claude-opus-5` are both just entries in the same list, priced side by side.
34
34
 
35
35
  **It's honest about money.** Prompt caching marks the stable prefix of every request, so repeat loop iterations pay ~10% for tokens already sent. Every task is costed and recorded as it runs, so `/usage` is a ledger of what you actually spent rather than an estimate — broken down by time window and by model, with the cheapest option always visible next to the one you're using.
36
36
 
@@ -40,9 +40,45 @@ That loop of task, approve, inspect, revert is the whole trust model. Everything
40
40
 
41
41
  **You can steer it mid-flight.** See it going the wrong way? Just type `use TypeScript, not JavaScript` — and your guidance is injected at the next loop iteration. No cancelling, no wasted tokens.
42
42
 
43
+ ## Upgrading from Codewright
44
+
45
+ Faber is Codewright renamed, after the npm name was taken between building and releasing. Existing `.codewright/` state directories are adopted automatically; `CW_*` environment variables still work alongside `FABER_*`.
46
+
43
47
  ## Requirements & install
44
48
 
45
- You need Node.js 22.5 or newer. Faber uses Node's built-in SQLite, so there are no native dependencies to compile and installs don't fail on a missing toolchain. The only runtime dependencies are two small pure-JS packages.
49
+ Faber needs **Node.js 22.5 or newer**. It uses Node's built-in SQLite, so there are no native dependencies to compile and installs don't fail on a missing toolchain. The only runtime dependencies are two small pure-JS packages.
50
+
51
+ Check your version:
52
+
53
+ ```bash
54
+ node --version
55
+ ```
56
+
57
+ If it prints `v22.5.0` or higher, skip ahead to Install.
58
+
59
+ ### Installing Node.js
60
+
61
+ The recommended way is **nvm**, which lets you switch versions per project without touching your system:
62
+
63
+ ```bash
64
+ # macOS / Linux
65
+ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
66
+ # restart your terminal, or: source ~/.zshrc
67
+ nvm install 22
68
+ nvm use 22
69
+ ```
70
+
71
+ Alternatives:
72
+
73
+ ```bash
74
+ brew install node # macOS, Homebrew
75
+ winget install OpenJS.NodeJS # Windows
76
+ sudo apt install nodejs npm # Debian/Ubuntu — often ships an older version; prefer nvm
77
+ ```
78
+
79
+ Or download an installer from [nodejs.org](https://nodejs.org). Already on Node but below 22.5? `nvm install 22 && nvm use 22`, or `brew upgrade node`.
80
+
81
+ ### Install Faber
46
82
 
47
83
  ```bash
48
84
  npm install -g faberwright
@@ -93,10 +129,18 @@ Setup is skipped entirely when there's no terminal attached, so scripts and CI n
93
129
 
94
130
  ## Features
95
131
 
96
- | Feature|Description |
132
+ | | |
97
133
  |---|---|
98
134
  | **Streaming agent loop** | Text renders as generated, with a live heartbeat (`✳ Working… 14s`) that never interleaves with output and ends in `✳ Worked for 14s`. Plan → tool → observe → adjust, until done. Hard iteration cap. |
99
135
  | **Code graph** | Symbols *and* call/import edges, incrementally maintained (only changed files re-parse). Agent tools: `who_calls` (blast radius), `calls_from` (dependencies), `trace_path` (workflow chain). A compact repo map of the most-connected symbols orients every task. Edges are static hints — dynamic dispatch/DI/events aren't captured; the agent reads code where precision matters. |
136
+ | **Approval by default** | Arrow-key menu on every file edit (colored diff) and every shell command. `--auto` / `/auto` / `FABER_APPROVAL=auto` opts into autonomy. |
137
+ | **Two OpenAI APIs** | Chat completions and the Responses API, chosen per model from the endpoint each one publishes. The codex family works without configuration. |
138
+ | **Guided setup** | First run walks through vendor, route, credential and model, then remembers it. Later launches verify the setup can reach a model before opening the prompt. Nothing is saved until setup finishes, so an interrupted run leaves no half-configured state. |
139
+ | **Credentials** | Keys live in `~/.faber/credentials.json`, owner-only, outside every repository. Faber checks that what you paste looks like a key before storing it, hides it as you type, and never prints more than a masked fragment. Environment variables keep working and take precedence. |
140
+ | **Interactive choices** | Genuinely ambiguous request? The agent presents 2–4 options plus "Chat more about this instead" before writing code. |
141
+ | **Mid-task steering** | Type while it works; guidance is injected at the next loop boundary. If the model finishes while steering is queued, the task continues instead. Steering markers carry a per-task nonce, so hostile file contents can't impersonate you. |
142
+ | **Paste as chips** | Raw-mode composer: pasting a 50-line block renders only a chip — `[pasted #1 +50 lines]` — never the code itself, while the full text is expanded into the message on Enter. Paste the same block again to expand it visibly. Paste, type, paste again: one submission. Full line editing: arrows, Home/End, forward-delete, Ctrl-A/E/K/U, Up/Down history; long drafts wrap across rows with exact cursor tracking — backspace and arrows travel across wrap boundaries. Chips are atomic — one arrow step, one backspace. Works at the prompt and while steering. |
143
+ | **Reversible history** | `/history` lists tasks with files touched; `/restore <id>` jumps anywhere; `/undo` / `/redo` — restores are never destructive. |
100
144
  | **Git-aware** | Warns about uncommitted changes at startup. Optional `FABER_GIT=commit`: one commit per completed *task* (never per edit). The agent never commits by default. |
101
145
  | **Two-layer memory** | Short-term: token-budgeted window, auto-summarized past 60k (tool pairs never split). Long-term: SQLite+FTS5 facts/decisions/gotchas + per-file notes, with full lifecycle (`/forget`, `/archive`, `/prune`). |
102
146
  | **Sessions** | Crash-safe JSONL transcripts; `--resume`; last-session digest injected at startup; `recall_sessions` keyword search across history. |
@@ -106,11 +150,6 @@ Setup is skipped entirely when there's no terminal attached, so scripts and CI n
106
150
  | **Usage dashboard** | `/usage` shows a persistent ledger: tasks, tokens, cache rate, cost, and cache *savings* — for this session, today, and all time, plus totals across every project on the machine. Costs use each model's own rates, including its exact cache read/write prices, so a history spanning a model switch stays accurate. Prices ship built in and update with `/usage --refresh-prices`. Records live in `.faber/usage.db` per project and survive restarts. |
107
151
  | **Error recovery** | Transient API errors: backoff + jitter, honors Retry-After. Tool errors return to the model to self-correct. Identical call failing twice → warning; three times → clean abort. Ctrl-C aborts streams *and* running commands; checkpoints survive. |
108
152
  | **Safety rails** | Symlink-resolved path jail, shell denylist, timeouts, output truncation, atomic writes (temp+rename), stale-edit guard. Guardrails, not a sandbox — use a container for untrusted code. |
109
- | **Approval by default** | Arrow-key menu on every file edit (colored diff) and every shell command. `--auto` / `/auto` / `FABER_APPROVAL=auto` opts into autonomy. |
110
- | **Guided setup** | First run walks through vendor, route, credential and model, then remembers it. Later launches verify the setup can reach a model before opening the prompt. Nothing is saved until setup finishes, so an interrupted run leaves no half-configured state. |
111
- | **Credentials** | Keys live in `~/.faber/credentials.json`, owner-only, outside every repository. Faber checks that what you paste looks like a key before storing it, hides it as you type, and never prints more than a masked fragment. Environment variables keep working and take precedence. |
112
- | **Mid-task steering** | Type while it works; guidance is injected at the next loop boundary. If the model finishes while steering is queued, the task continues instead. Steering markers carry a per-task nonce, so hostile file contents can't impersonate you. |
113
- | **Reversible history** | `/history` lists tasks with files touched; `/restore <id>` jumps anywhere; `/undo` / `/redo` — restores are never destructive. |
114
153
 
115
154
  ## Vendors, routes, and models
116
155
 
@@ -122,22 +161,24 @@ Faber separates three choices, so you can change one without redoing the others:
122
161
  | **Route** | how you reach it and who owns auth | `/route` |
123
162
  | **Model** | which model on that route | `/model` (searchable model options) |
124
163
 
125
- Routes available today: **Anthropic API**, **Amazon Bedrock**, **OpenAI API**, **Ollama** (local, no key and no cost), and any **OpenAI-compatible endpoint** (OpenRouter, Groq, Together, vLLM, a gateway). Google Vertex is defined but not yet wired up, and `/route` says so rather than failing at request time.
164
+ Routes available today: **Anthropic API**, **Amazon Bedrock**, **Microsoft Foundry**, **OpenAI API**, **Azure OpenAI**, **Ollama** (local, no key and no cost), and any **OpenAI-compatible endpoint** (OpenRouter, Groq, Together, vLLM, a gateway). Google Vertex is defined but not yet wired up, and `/route` says so rather than failing at request time.
126
165
 
127
166
  On OpenAI, Faber speaks both APIs. Chat completions for most models, and the **Responses API** for the ones that require it, which includes the codex family — the coding-tuned models a coding agent actually wants. You don't choose: each model's endpoint is recorded in the price dataset Faber already downloads, so `gpt-5.3-codex` routes to `/v1/responses` and `gpt-5.5` to `/v1/chat/completions` automatically. If a model is too new to appear in that dataset and the API says it belongs elsewhere, Faber retries on the endpoint it names rather than failing.
128
167
 
129
168
  ### Amazon Bedrock
130
169
 
131
- Faber authenticates two ways, and tries them in this order.
170
+ Setup asks how you want to authenticate, because the choice affects who gets billed.
132
171
 
133
- **With an IAM role, which needs no key at all.** In SageMaker Studio, on EC2, in ECS or Lambda, or on a laptop where you've run `aws configure`, Faber finds your AWS credentials the same way the SDKs do and signs each request with SigV4. Pick Amazon Bedrock during setup, give it a region, and that's the whole configuration. The signing is implemented directly against Node's crypto module rather than pulling in the AWS SDK, so the zero-dependency install still holds, and it's verified against the signature AWS publishes for its own worked example.
172
+ **AWS credentials (IAM role)** needs no key at all. In SageMaker Studio, on EC2, in ECS or Lambda, or on a laptop where you've run `aws configure`, Faber finds your credentials the same way the SDKs do and signs each request with SigV4. The region comes from `AWS_REGION` or `~/.aws/config` when it's there, so usually nothing is asked at all. Signing is implemented directly against Node's crypto module rather than pulling in the AWS SDK, and is verified against the signature AWS publishes for its own worked example.
134
173
 
135
- **With a Bedrock API key**, if you'd rather use one:
174
+ **A Bedrock API key** bills through that key instead:
136
175
 
137
176
  ```bash
138
177
  export BEDROCK_API_KEY=...
139
178
  ```
140
179
 
180
+ Either path must complete: pick the role with no credentials present, or the key without entering one, and setup saves nothing and starts over next time.
181
+
141
182
  Model ids on Bedrock differ by region and deployment, so pin them per alias in your profile instead of relying on the built-in names:
142
183
 
143
184
  ```json
@@ -146,6 +187,28 @@ Model ids on Bedrock differ by region and deployment, so pin them per alias in y
146
187
  "apiKeyEnv": "BEDROCK_API_KEY" }
147
188
  ```
148
189
 
190
+ ### Claude on Azure (Microsoft Foundry)
191
+
192
+ Claude has been generally available in Microsoft Foundry since June 2026, hosted on Azure with your organisation's own authentication, billing and governance — usage appears on the Azure invoice and can count toward a Microsoft Azure Consumption Commitment.
193
+
194
+ It serves the same Messages API as the direct route, so for Faber this is a base URL and an auth header rather than a new protocol: prompt caching, extended thinking and tool streaming all work unchanged. Setup asks for your resource name and takes either a subscription key or a token minted from Entra ID, recognising which you gave it.
195
+
196
+ ```bash
197
+ export AZURE_FOUNDRY_API_KEY=...
198
+ ```
199
+
200
+ A Foundry resource is separate from an Azure OpenAI one and has its own key, so the two never share a credential.
201
+
202
+ ### Azure OpenAI
203
+
204
+ The answer for anyone who wants OpenAI's coding models billed through their employer rather than a personal card: AWS doesn't host OpenAI models, so Bedrock isn't the route for them — Azure is.
205
+
206
+ Setup asks for your resource name rather than a URL, since that's the part people know from the portal, and builds the endpoint from it. Azure addresses **deployments** rather than model ids: you call `my-codex-deployment`, a name someone in your organisation chose, and the deployment decides which model runs. Faber lists the deployments your subscription has created and shows the model behind each one.
207
+
208
+ ```bash
209
+ export AZURE_OPENAI_API_KEY=...
210
+ ```
211
+
149
212
  ### Costs
150
213
 
151
214
  `/usage` is a ledger, not an estimate. Every task's cost is worked out and written down when the task runs, at the rates in effect then, and never recalculated. If a vendor raises prices next month, last month's tasks still show what they actually cost.
@@ -177,9 +240,8 @@ model gpt-5.3-codex which model ran it
177
240
  cost 0.0412 dollars, frozen at run time
178
241
  saved 0.0231 what caching avoided
179
242
  ```
180
- **Cost metrics `/usage`:
181
243
 
182
- <img width="636" height="388" alt="Screenshot 2026-08-10 at 12 25 57AM" src="https://github.com/user-attachments/assets/33ba06ce-69ad-4afe-afcc-6119a897e67a" />
244
+ <img width="636" height="388" alt="Screenshot 2026-08-10 at 12 25 57 AM" src="https://github.com/user-attachments/assets/33ba06ce-69ad-4afe-afcc-6119a897e67a" />
183
245
 
184
246
 
185
247
  Counters and a timestamp. No prompts, no code, no file contents. Around 80 bytes a row, so a year of heavy use is roughly a megabyte, and nothing is ever pruned.
@@ -244,6 +306,8 @@ Flags: `faber [task] [--workspace|-w <dir>] [--resume] [--ask|--auto] [--version
244
306
  | `FABER_ROUTE`, `FABER_REGION` | route id and region, overriding the profile |
245
307
  | `FABER_AUTO_PRICES` | `0` disables the background price refresh |
246
308
  | `BEDROCK_API_KEY` | Bedrock, when you'd rather use a key than an IAM role |
309
+ | `AZURE_OPENAI_API_KEY` | Azure OpenAI, for GPT deployments in your subscription |
310
+ | `AZURE_FOUNDRY_API_KEY` | Microsoft Foundry, for Claude on Azure (a separate resource) |
247
311
  | `AWS_ACCESS_KEY_ID` etc. | picked up automatically for Bedrock's SigV4 signing |
248
312
 
249
313
  Faber keeps three files. `~/.faber/settings.json` holds your profiles, which is the route, model and which environment variable a credential comes from. `~/.faber/credentials.json` holds the keys themselves, owner-only and outside every repository. A project can override the profile with its own `.faber/config.json`, which is useful when one repo should use a cheaper model than the rest.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Deciding whether a line is a command.
3
+ *
4
+ * Kept in its own module because index.ts starts the CLI when imported, so a
5
+ * test that reaches for this function would otherwise boot the whole program
6
+ * and sit at a prompt forever.
7
+ */
8
+ /**
9
+ * How many arguments each command takes. Anything with more is prose.
10
+ *
11
+ * The old rule was "starts with a slash", which read only the first word — so
12
+ * "/undo and /redo how does it work?" reverted the user's files while they
13
+ * were asking a question about them. A command has to match the WHOLE line.
14
+ */
15
+ const COMMAND_ARITY = {
16
+ "/help": 0, "/exit": 0, "/quit": 0, "/undo": 0, "/redo": 0, "/clear": 0,
17
+ "/compact": 0, "/sessions": 0, "/history": 0, "/index": 0, "/setup": 0,
18
+ "/route": 0, "/ask": 0, "/auto": 0, "/verbose": 0, "/concise": 0,
19
+ "/usage": 1, // --refresh-prices
20
+ "/model": 1, // an id, --save or --refresh
21
+ "/profile": 1, // a profile name
22
+ "/map": 1, "/restore": 1, "/forget": 1, "/archive": 1, "/unarchive": 1,
23
+ "/prune": 1, "/memory": 1,
24
+ "/key": 2, // set|rm plus a variable name
25
+ };
26
+ export function looksLikeCommand(line) {
27
+ // Trim first: a pasted or indented line is still a command.
28
+ const parts = line.trim().split(/\s+/);
29
+ if (!parts[0]?.startsWith("/"))
30
+ return false;
31
+ const arity = COMMAND_ARITY[parts[0].toLowerCase()];
32
+ if (arity === undefined)
33
+ return false; // unknown: let /help catch it
34
+ return parts.length - 1 <= arity;
35
+ }
package/dist/index.js CHANGED
@@ -38,6 +38,7 @@ import { Composer } from "./editor.js";
38
38
  import { StatusLine } from "./status.js";
39
39
  import { renderMarkdown, StreamRenderer } from "./markdown.js";
40
40
  import { renderUsagePanel, UsageLedger } from "./usage.js";
41
+ import { looksLikeCommand } from "./commands.js";
41
42
  import { ROUTES, getRoute, describeModel, resolveModel, vendors, baseUrlFor, DEFAULT_REGION } from "./routes.js";
42
43
  import { loadSettings, saveSettings, updateActive, settingsPath } from "./settings.js";
43
44
  import { needsOnboarding, interactive, runOnboarding, reportSetup, setupComplete } from "./onboard.js";
@@ -60,6 +61,18 @@ function renderDiff(diff) {
60
61
  : l.startsWith("@@") ? pc.cyan(l)
61
62
  : pc.dim(l)).join("\n");
62
63
  }
64
+ /**
65
+ * Is this line a command, or a question that happens to mention one?
66
+ *
67
+ * Matching on the first word alone meant "/undo and /redo how does it work?"
68
+ * ran /undo and reverted the user's files. A destructive command must be the
69
+ * whole line — anything trailing means the person is talking, not commanding.
70
+ */
71
+ const NO_ARG_COMMANDS = new Set([
72
+ "/undo", "/redo", "/history", "/clear", "/compact", "/sessions", "/help",
73
+ "/exit", "/quit", "/index", "/memory", "/ask", "/auto", "/verbose",
74
+ "/concise", "/setup", "/route",
75
+ ]);
63
76
  async function main() {
64
77
  const argv = process.argv.slice(2);
65
78
  const flags = new Set(argv.filter((a) => a.startsWith("-")));
@@ -638,7 +651,7 @@ async function main() {
638
651
  }
639
652
  if (!line)
640
653
  continue;
641
- if (line.startsWith("/")) {
654
+ if (line.startsWith("/") && looksLikeCommand(line)) {
642
655
  if (!(await command(line)))
643
656
  break;
644
657
  continue;
@@ -653,6 +666,13 @@ async function main() {
653
666
  }
654
667
  }
655
668
  const HELP = `
669
+ /setup set up again: vendor, route, credential, model
670
+ /route choose a vendor and how to reach it
671
+ /model [id] switch model; lists what your key can use, with prices
672
+ /key [set|rm] show, save or remove an API key (~/.faber, readable only by you)
673
+ /profile [name] list or switch saved profiles
674
+ /usage cost ledger by time window and model (--refresh-prices)
675
+ /verbose | /concise full-depth answers, or short ones (default concise)
656
676
  /index rebuild the code graph (symbols + call edges)
657
677
  /map <symbol> print the call tree from any entry point (e.g. /map main)
658
678
  /memory show active long-term memories (+ file notes)
package/dist/llm.js CHANGED
@@ -7,6 +7,8 @@ const RETRYABLE = new Set([408, 409, 429, 500, 502, 503, 504, 529]);
7
7
  * is not a useful thing to tell someone whose key demonstrably works — the
8
8
  * status code or error usually says exactly what went wrong.
9
9
  */
10
+ /** Azure pins the API surface by date; this one covers tools and streaming. */
11
+ export const AZURE_API_VERSION = "2024-10-21";
10
12
  let lastListError;
11
13
  export function lastModelListError() { return lastListError; }
12
14
  /** AWS credentials discovered once per process for SigV4 routes. */
@@ -45,12 +47,20 @@ export class LLMClient {
45
47
  * models route, which says nothing about the key and must not block setup.
46
48
  */
47
49
  async verifyKey() {
50
+ if (this.config.route === "bedrock") {
51
+ // The mantle endpoint has no listing to probe, so treat a working
52
+ // credential discovery as sufficient; a bad one fails on first use.
53
+ return (await this.listBedrockModels()).length ? "ok" : "unreachable";
54
+ }
48
55
  const anthropic = this.config.provider === "anthropic";
49
56
  const url = anthropic ? `${this.config.baseUrl}/v1/models?limit=1`
50
57
  : `${this.config.baseUrl}/models`;
58
+ const auth = await this.authHeaders(url, "", "GET");
51
59
  const headers = anthropic
52
- ? { "x-api-key": this.config.apiKey, "anthropic-version": "2023-06-01" }
53
- : { authorization: `Bearer ${this.config.apiKey}` };
60
+ ? { ...auth, "anthropic-version": "2023-06-01" }
61
+ : this.config.route === "azure-openai"
62
+ ? { "api-key": this.config.apiKey ?? "" }
63
+ : (this.config.apiKey ? { authorization: `Bearer ${this.config.apiKey}` } : auth);
54
64
  try {
55
65
  const ctl = new AbortController();
56
66
  const timer = setTimeout(() => ctl.abort(), 8000);
@@ -64,15 +74,87 @@ export class LLMClient {
64
74
  return "unreachable";
65
75
  }
66
76
  }
77
+ /**
78
+ * Bedrock has no Messages-style listing: the mantle endpoint serves
79
+ * /v1/messages but returns 404 for /v1/models. AWS's own ListFoundationModels
80
+ * is the catalogue, on a different host and signed as a normal AWS call.
81
+ */
82
+ async listBedrockModels() {
83
+ const region = this.config.region ?? "us-east-1";
84
+ const url = this.config.bedrockCatalogUrl
85
+ ?? `https://bedrock.${region}.amazonaws.com/foundation-models`;
86
+ try {
87
+ const auth = await this.authHeaders(url, "", "GET");
88
+ const ctl = new AbortController();
89
+ const timer = setTimeout(() => ctl.abort(), 8000);
90
+ const res = await fetch(url, { headers: auth, signal: ctl.signal });
91
+ clearTimeout(timer);
92
+ if (!res.ok) {
93
+ lastListError = `HTTP ${res.status} from ListFoundationModels`;
94
+ return [];
95
+ }
96
+ const body = await res.json();
97
+ return (body.modelSummaries ?? [])
98
+ .filter((m) => typeof m.modelId === "string"
99
+ && /anthropic/i.test(m.providerName ?? m.modelId)
100
+ // skip image and embedding variants; we only run text
101
+ && (!m.outputModalities || m.outputModalities.includes("TEXT")))
102
+ .map((m) => ({ id: m.modelId, name: m.modelName }));
103
+ }
104
+ catch (e) {
105
+ lastListError = e instanceof Error ? e.message : String(e);
106
+ return [];
107
+ }
108
+ }
109
+ /**
110
+ * Azure exposes the deployments a subscription has created, not OpenAI's
111
+ * catalogue: you address `my-gpt5-codex`, a name someone chose, and the
112
+ * underlying model is a property of it.
113
+ */
114
+ async listAzureDeployments() {
115
+ const base = this.config.baseUrl.replace(/\/openai\/deployments\/[^/]+\/?$/, "");
116
+ const url = `${base}/openai/deployments?api-version=${AZURE_API_VERSION}`;
117
+ try {
118
+ const ctl = new AbortController();
119
+ const timer = setTimeout(() => ctl.abort(), 8000);
120
+ const res = await fetch(url, {
121
+ headers: { "api-key": this.config.apiKey ?? "" }, signal: ctl.signal,
122
+ });
123
+ clearTimeout(timer);
124
+ if (!res.ok) {
125
+ lastListError = `HTTP ${res.status} from ${url}`;
126
+ return [];
127
+ }
128
+ const body = await res.json();
129
+ return (body.data ?? [])
130
+ .filter((d) => typeof d.id === "string")
131
+ // the deployment name is what you call; the model is what it runs
132
+ .map((d) => ({ id: d.id, name: d.model }));
133
+ }
134
+ catch (e) {
135
+ lastListError = e instanceof Error ? e.message : String(e);
136
+ return [];
137
+ }
138
+ }
67
139
  async listModels(signal) {
140
+ if (this.config.route === "bedrock")
141
+ return this.listBedrockModels();
142
+ if (this.config.route === "azure-openai")
143
+ return this.listAzureDeployments();
68
144
  lastListError = undefined;
69
145
  const anthropic = this.config.provider === "anthropic";
70
146
  const url = anthropic
71
147
  ? `${this.config.baseUrl}/v1/models?limit=100`
72
148
  : `${this.config.baseUrl}/models`;
149
+ // Sign when the route authenticates with AWS credentials. Sending an
150
+ // empty x-api-key here failed silently, which left setup with no models
151
+ // and a default that doesn't exist on Bedrock.
152
+ const auth = await this.authHeaders(url, "", "GET");
73
153
  const headers = anthropic
74
- ? { "x-api-key": this.config.apiKey, "anthropic-version": "2023-06-01" }
75
- : { authorization: `Bearer ${this.config.apiKey}` };
154
+ ? { ...auth, "anthropic-version": "2023-06-01" }
155
+ : this.config.route === "azure-openai"
156
+ ? { "api-key": this.config.apiKey ?? "" }
157
+ : (this.config.apiKey ? { authorization: `Bearer ${this.config.apiKey}` } : auth);
76
158
  try {
77
159
  const ctl = new AbortController();
78
160
  const timer = setTimeout(() => ctl.abort(), 6000);
@@ -105,7 +187,14 @@ export class LLMClient {
105
187
  * Auth for one request. An API key is a header; AWS credentials mean signing
106
188
  * the whole request, which is how an IAM role authenticates with no key.
107
189
  */
108
- async authHeaders(url, body) {
190
+ async authHeaders(url, body, method = "POST") {
191
+ // Foundry authenticates the Azure way: an api-key header, or a bearer
192
+ // token if one was minted from Entra ID.
193
+ if (this.config.route === "foundry" && this.config.apiKey) {
194
+ return /^ey[A-Za-z0-9_-]+\./.test(this.config.apiKey)
195
+ ? { authorization: `Bearer ${this.config.apiKey}` } // an Entra token
196
+ : { "api-key": this.config.apiKey };
197
+ }
109
198
  if (this.config.apiKey)
110
199
  return { "x-api-key": this.config.apiKey };
111
200
  if (this.config.route !== "bedrock")
@@ -118,7 +207,7 @@ export class LLMClient {
118
207
  "or `aws configure`.");
119
208
  }
120
209
  return signRequest({
121
- method: "POST",
210
+ method,
122
211
  url,
123
212
  body,
124
213
  region: this.config.region ?? "us-east-1",
@@ -256,8 +345,19 @@ export class LLMClient {
256
345
  function: { name: t.name, description: t.description, parameters: t.input_schema },
257
346
  }));
258
347
  }
259
- const res = await this.post(`${this.config.baseUrl}/chat/completions`, {
260
- Authorization: `Bearer ${this.config.apiKey}`,
348
+ // Azure authenticates with api-key rather than a bearer token, and pins
349
+ // the API version as a query parameter.
350
+ const azure = this.config.route === "azure-openai";
351
+ // On Azure the deployment name goes in the path, and the model field in
352
+ // the body is ignored — the deployment decides which model runs.
353
+ const chatUrl = azure
354
+ ? `${this.config.baseUrl}/deployments/${encodeURIComponent(modelOverride ?? this.config.model)}` +
355
+ `/chat/completions?api-version=${AZURE_API_VERSION}`
356
+ : `${this.config.baseUrl}/chat/completions`;
357
+ const res = await this.post(chatUrl, {
358
+ ...(azure
359
+ ? { "api-key": this.config.apiKey }
360
+ : { Authorization: `Bearer ${this.config.apiKey}` }),
261
361
  "content-type": "application/json",
262
362
  }, body, signal);
263
363
  let text = "";
package/dist/onboard.js CHANGED
@@ -10,6 +10,8 @@ const KEY_SOURCE = {
10
10
  ANTHROPIC_API_KEY: "https://console.anthropic.com/settings/keys",
11
11
  OPENAI_API_KEY: "https://platform.openai.com/api-keys",
12
12
  BEDROCK_API_KEY: "the AWS console (Bedrock → API keys)",
13
+ AZURE_OPENAI_API_KEY: "the Azure portal (your OpenAI resource → Keys and Endpoint)",
14
+ AZURE_FOUNDRY_API_KEY: "the Azure portal (your Foundry resource → Keys and Endpoint)",
13
15
  };
14
16
  export function needsOnboarding() {
15
17
  return !hasAnySettingsFile();
@@ -33,8 +35,12 @@ export async function setupComplete(cfg) {
33
35
  if (!route.implemented) {
34
36
  return { complete: false, missing: `${route.label} isn't wired up yet`, fix: "/route" };
35
37
  }
36
- if (!cfg.model)
38
+ // Not merely "is it set": an interrupted prompt used to store control
39
+ // characters, which are non-empty and so passed every naive check.
40
+ // eslint-disable-next-line no-control-regex
41
+ if (!cfg.model || !cfg.model.trim() || /[\x00-\x1f]/.test(cfg.model)) {
37
42
  return { complete: false, missing: "no model chosen", fix: "/model" };
43
+ }
38
44
  if (route.needsBaseUrl && !cfg.baseUrl) {
39
45
  return { complete: false, missing: "no endpoint URL for this route", fix: "/route" };
40
46
  }
@@ -87,6 +93,40 @@ async function readKey(rl, envName, promptText) {
87
93
  }
88
94
  return key;
89
95
  }
96
+ /**
97
+ * Read a plain answer, treating an interrupt as cancellation.
98
+ *
99
+ * A raw readline prompt collects Ctrl-C as the character \x03 rather than
100
+ * aborting, so pressing it during the region or model question stored control
101
+ * characters as the answer — and since they aren't empty, every completeness
102
+ * check passed and the profile was saved as valid.
103
+ */
104
+ async function askText(rl, prompt) {
105
+ let raw;
106
+ try {
107
+ raw = await rl.question(prompt);
108
+ }
109
+ catch {
110
+ return undefined;
111
+ }
112
+ // Ctrl-C is the only thing that means "stop". Everything else that isn't
113
+ // printable is terminal noise: a paste arrives wrapped in bracketed-paste
114
+ // markers, so rejecting all control characters made pasting an answer
115
+ // abort setup — which is exactly what someone does with a resource name.
116
+ // eslint-disable-next-line no-control-regex
117
+ if (/[\x03\x04]/.test(raw))
118
+ return undefined;
119
+ const cleaned = raw
120
+ // eslint-disable-next-line no-control-regex
121
+ .replace(/\x1b\[20[01]~/g, "")
122
+ // eslint-disable-next-line no-control-regex
123
+ .replace(/\x1b\[[0-9;]*[A-Za-z~]/g, "")
124
+ // eslint-disable-next-line no-control-regex
125
+ .replace(/[\x00-\x1f\x7f]/g, "");
126
+ return cleaned.trim();
127
+ }
128
+ /** Exposed for tests: paste handling here decides whether setup can finish. */
129
+ export const __askTextForTest = askText;
90
130
  /**
91
131
  * Walk vendor -> route -> region/endpoint -> model. Returns the profile it
92
132
  * saved so the caller can report what still needs doing (e.g. an unset key).
@@ -181,13 +221,42 @@ export async function ensureCredential(rl, route, profile) {
181
221
  return {};
182
222
  }
183
223
  if (route.id === "bedrock") {
184
- // Bedrock can sign with an IAM role, so a key may not be needed at all.
224
+ // Two ways in, and which one you pick can matter for billing: an IAM role
225
+ // charges through the account that owns it, a Bedrock API key can belong
226
+ // to a different arrangement entirely. So ask rather than assume, even
227
+ // when one option is obvious from the environment.
185
228
  const { discoverAwsCredentials } = await import("./sigv4.js");
186
229
  const aws = await discoverAwsCredentials();
187
230
  console.log();
188
- console.log(aws
189
- ? pc.dim(` No key needed — signing with AWS credentials from ${aws.source}.`)
190
- : pc.yellow(` No AWS credentials found. Set ${route.keyEnv}, or run where an IAM role is available.`));
231
+ const how = await select(rl, "How should Faber authenticate with AWS?", [
232
+ `AWS credentials (IAM role) ${pc.dim(aws ? `found: ${aws.source}` : "none found here")}`,
233
+ `Bedrock API key ${pc.dim("billed through that key instead")}`,
234
+ ]);
235
+ if (how === 0) {
236
+ if (!aws) {
237
+ // Nothing to sign with, so setup did not succeed and saves nothing.
238
+ console.log(pc.yellow(" No AWS credentials on this machine."));
239
+ console.log(pc.dim(" Run where an IAM role is available (SageMaker, EC2, ECS), or `aws configure`."));
240
+ return { switchedTo: "quit" };
241
+ }
242
+ console.log(pc.dim(` Signing automatically with your AWS role. No key needed.`));
243
+ delete profile.apiKeyEnv; // this route authenticates by signing
244
+ return {};
245
+ }
246
+ console.log();
247
+ console.log("Faber needs a Bedrock API key.");
248
+ const where = KEY_SOURCE[route.keyEnv];
249
+ if (where)
250
+ console.log(pc.dim(` Get one at ${where}`));
251
+ const key = await readKey(rl, route.keyEnv, ` ${route.keyEnv} (hidden): `);
252
+ if (!key) {
253
+ console.log(pc.yellow(" No key entered — setup not completed, nothing saved."));
254
+ return { switchedTo: "quit" };
255
+ }
256
+ saveCredential(route.keyEnv, key);
257
+ profile.apiKeyEnv = route.keyEnv;
258
+ profile.preferStoredKey = true; // an explicit choice beats a role
259
+ console.log(pc.dim(` saved (${maskCredential(key)}) — kept on this computer only`));
191
260
  return {};
192
261
  }
193
262
  // No key anywhere: say where to get one, read it hidden, and if they don't
@@ -256,15 +325,6 @@ async function discoverModels(route, profile) {
256
325
  async function finish(rl, route, base) {
257
326
  const profile = { ...base, route: route.id };
258
327
  const activeRoute = route;
259
- if (route.needsRegion) {
260
- const ans = (await rl.question(`AWS region [${DEFAULT_REGION}]: `)).trim();
261
- profile.region = ans || DEFAULT_REGION;
262
- }
263
- if (route.needsBaseUrl) {
264
- const ans = (await rl.question("Base URL of the OpenAI-compatible endpoint: ")).trim();
265
- if (ans)
266
- profile.baseUrl = ans;
267
- }
268
328
  if (route.keyEnv) {
269
329
  profile.apiKeyEnv = route.keyEnv;
270
330
  const outcome = await ensureCredential(rl, activeRoute, profile);
@@ -274,6 +334,51 @@ async function finish(rl, route, base) {
274
334
  return { profile, route: activeRoute, aborted: true };
275
335
  }
276
336
  }
337
+ if (route.id === "foundry") {
338
+ // Same question as Azure OpenAI: the resource name is what people know.
339
+ const res = await askText(rl, "Azure resource name (from your endpoint URL): ");
340
+ if (res === undefined)
341
+ return { profile, route: activeRoute, aborted: true };
342
+ if (res) {
343
+ profile.baseUrl = /^https?:\/\//.test(res)
344
+ ? res.replace(/\/+$/, "")
345
+ : `https://${res}.services.ai.azure.com/anthropic`;
346
+ }
347
+ }
348
+ else if (route.id === "azure-openai") {
349
+ // Ask for the resource name rather than a URL: it's the part people know
350
+ // from the portal, and the rest of the endpoint is fixed.
351
+ const res = await askText(rl, "Azure resource name (from your endpoint URL): ");
352
+ if (res === undefined)
353
+ return { profile, route: activeRoute, aborted: true };
354
+ if (res) {
355
+ profile.baseUrl = /^https?:\/\//.test(res)
356
+ ? res.replace(/\/+$/, "")
357
+ : `https://${res}.openai.azure.com/openai`;
358
+ }
359
+ }
360
+ else if (route.needsBaseUrl) {
361
+ const ans = await askText(rl, "Base URL of the OpenAI-compatible endpoint: ");
362
+ if (ans === undefined)
363
+ return { profile, route: activeRoute, aborted: true };
364
+ if (ans)
365
+ profile.baseUrl = ans;
366
+ }
367
+ if (route.needsRegion) {
368
+ // Only ask when the environment hasn't already answered.
369
+ const { discoverAwsRegion } = await import("./sigv4.js");
370
+ const found = discoverAwsRegion();
371
+ if (found) {
372
+ profile.region = found.region;
373
+ console.log(pc.dim(` Region ${found.region} (from ${found.source}) · /route to change`));
374
+ }
375
+ else {
376
+ const ans = await askText(rl, `AWS region [${DEFAULT_REGION}]: `);
377
+ if (ans === undefined)
378
+ return { profile, route: activeRoute, aborted: true };
379
+ profile.region = ans || DEFAULT_REGION;
380
+ }
381
+ }
277
382
  // Model choice, with real ids and real prices.
278
383
  //
279
384
  // Aliases like "gpt" or "sonnet" hide what you're actually paying for, and
@@ -329,14 +434,38 @@ async function finish(rl, route, base) {
329
434
  }
330
435
  else {
331
436
  const hint = activeRoute.id === "ollama" ? "qwen2.5-coder" : "";
332
- const ans = (await rl.question(`Model id${hint ? ` [${hint}]` : ""}: `)).trim();
437
+ const ans = await askText(rl, `Model id${hint ? ` [${hint}]` : ""}: `);
438
+ if (ans === undefined)
439
+ return { profile, route: activeRoute, aborted: true };
333
440
  profile.model = ans || hint || undefined;
334
441
  }
442
+ // Nothing is written unless the result is genuinely usable. Every earlier
443
+ // exit already returns aborted, but this is the single place that decides,
444
+ // so a future step can't accidentally save a half-finished profile.
445
+ const check = await setupComplete({
446
+ route: profile.route,
447
+ model: profile.model,
448
+ baseUrl: profile.baseUrl ?? baseUrlFor(activeRoute, profile.region),
449
+ apiKey: activeRoute.keyEnv ? credentialSource(activeRoute.keyEnv)?.value : undefined,
450
+ });
451
+ if (!check.complete) {
452
+ console.log();
453
+ console.log(pc.yellow(`Setup not completed — ${check.missing}.`));
454
+ console.log(pc.dim(" Nothing was saved. Run faber again to start over."));
455
+ return { profile, route: activeRoute, aborted: true };
456
+ }
335
457
  const settings = loadSettings();
336
458
  settings.profiles[settings.activeProfile] = profile;
337
459
  saveSettings(settings);
338
- const missingKeyEnv = activeRoute.keyEnv && !credentialSource(activeRoute.keyEnv)
460
+ // Bedrock signs with an IAM role when one is available, so a missing key is
461
+ // not "one thing left" — saying so contradicts the line printed moments ago.
462
+ let missingKeyEnv = activeRoute.keyEnv && !credentialSource(activeRoute.keyEnv)
339
463
  ? activeRoute.keyEnv : undefined;
464
+ if (missingKeyEnv && activeRoute.id === "bedrock") {
465
+ const { discoverAwsCredentials } = await import("./sigv4.js");
466
+ if (await discoverAwsCredentials())
467
+ missingKeyEnv = undefined;
468
+ }
340
469
  return { profile, route: activeRoute, missingKeyEnv };
341
470
  }
342
471
  /** Summary printed after onboarding, including anything still to be done. */
package/dist/pricing.js CHANGED
@@ -253,6 +253,21 @@ export async function refreshPrices(url = DATASET_URL, opts = {}) {
253
253
  * id, then normalized), then the built-in table. Undefined means "unknown",
254
254
  * which the ledger renders as — rather than guessing.
255
255
  */
256
+ /**
257
+ * Azure's US Data Zone deployments bill at 1.1x. Applying it keeps the ledger
258
+ * honest for teams who chose that deployment for data-residency reasons —
259
+ * under-reporting spend is the one direction a cost tool must not err in.
260
+ */
261
+ export const US_DATA_ZONE_MULTIPLIER = 1.1;
262
+ export function scalePrice(p, factor) {
263
+ const r = (n) => Math.round(n * factor * 1e6) / 1e6;
264
+ return {
265
+ ...p,
266
+ in: r(p.in), out: r(p.out),
267
+ cacheRead: p.cacheRead === undefined ? undefined : r(p.cacheRead),
268
+ cacheWrite: p.cacheWrite === undefined ? undefined : r(p.cacheWrite),
269
+ };
270
+ }
256
271
  export function priceFor(modelId, override) {
257
272
  if (override?.in && override?.out)
258
273
  return { in: override.in, out: override.out };
package/dist/prompt.js CHANGED
@@ -71,6 +71,14 @@ async function selectRaw(rl, question, options, defaultIndex = 0) {
71
71
  stdin.removeListener("data", onData);
72
72
  stdin.setRawMode(wasRaw);
73
73
  guardFn?.(false);
74
+ // Drop anything still buffered — typically the Enter that confirmed this
75
+ // menu. Left in place, readline hands it straight to the next question,
76
+ // which then returns an empty answer before the user can type a
77
+ // character: the prompt appears and vanishes in the same instant.
78
+ try {
79
+ while (stdin.read() !== null) { /* discard */ }
80
+ }
81
+ catch { /* not readable */ }
74
82
  rl.resume();
75
83
  resolve(result);
76
84
  };
package/dist/routes.js CHANGED
@@ -39,6 +39,24 @@ export const ROUTES = [
39
39
  aliasesArePinned: true, // Bedrock model ids differ per region/deployment
40
40
  implemented: true,
41
41
  },
42
+ {
43
+ // Claude hosted on Azure through Microsoft Foundry. It speaks the same
44
+ // Messages API as the direct route, so this is a base URL and an auth
45
+ // header rather than a new wire — the migration Microsoft describes as
46
+ // "swap the base URL and authentication, keep the calls the same".
47
+ id: "foundry",
48
+ vendor: "Anthropic",
49
+ label: "Microsoft Foundry (Azure)",
50
+ hint: "your Azure subscription owns auth and billing",
51
+ wire: "anthropic",
52
+ needsBaseUrl: true,
53
+ // Its own variable: a Foundry resource is separate from an Azure OpenAI
54
+ // one, with its own key. Sharing the name meant a key saved for GPT
55
+ // deployments was offered for Claude, where it cannot work.
56
+ keyEnv: "AZURE_FOUNDRY_API_KEY",
57
+ aliasesArePinned: true, // you address a deployment someone named
58
+ implemented: true,
59
+ },
42
60
  {
43
61
  id: "vertex",
44
62
  vendor: "Anthropic",
@@ -48,6 +66,21 @@ export const ROUTES = [
48
66
  aliasesArePinned: true,
49
67
  implemented: false, // needs Google OAuth
50
68
  },
69
+ {
70
+ // Azure hosts OpenAI's models under a company's own subscription, which is
71
+ // the answer for someone who wants codex billed through work rather than a
72
+ // personal card. Same wire as OpenAI, but the key travels in its own
73
+ // header and the deployment name lives in the URL.
74
+ id: "azure-openai",
75
+ vendor: "OpenAI",
76
+ label: "Azure OpenAI",
77
+ hint: "your Azure subscription owns auth and billing",
78
+ wire: "openai",
79
+ needsBaseUrl: true,
80
+ keyEnv: "AZURE_OPENAI_API_KEY",
81
+ aliasesArePinned: true, // you address deployments, not model ids
82
+ implemented: true,
83
+ },
51
84
  {
52
85
  id: "openai-api",
53
86
  vendor: "OpenAI",
package/dist/sigv4.js CHANGED
@@ -93,7 +93,7 @@ export async function discoverAwsCredentials(profileName = process.env.AWS_PROFI
93
93
  accessKeyId: process.env.AWS_ACCESS_KEY_ID,
94
94
  secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
95
95
  sessionToken: process.env.AWS_SESSION_TOKEN,
96
- source: "environment",
96
+ source: "your environment",
97
97
  };
98
98
  }
99
99
  // 2. container credential endpoint (ECS, SageMaker, CodeBuild)
@@ -130,7 +130,7 @@ async function fetchContainerCredentials(url) {
130
130
  accessKeyId: b.AccessKeyId,
131
131
  secretAccessKey: b.SecretAccessKey,
132
132
  sessionToken: b.Token,
133
- source: "container credentials endpoint",
133
+ source: "this environment's role",
134
134
  };
135
135
  }
136
136
  catch {
@@ -175,3 +175,51 @@ export function readSharedCredentials(profileName = "default", file = path.join(
175
175
  source: `~/.aws/credentials [${wanted}]`,
176
176
  };
177
177
  }
178
+ /**
179
+ * Find the AWS region the way the SDKs do.
180
+ *
181
+ * Credentials don't carry a region, but the endpoint and the signature both
182
+ * need one — so it has to come from somewhere. In SageMaker, Lambda and ECS
183
+ * it's already in the environment, and on a laptop it's usually in the config
184
+ * file, which means asking is normally an unnecessary question.
185
+ */
186
+ export function discoverAwsRegion(profileName = process.env.AWS_PROFILE ?? "default", file = path.join(os.homedir(), ".aws", "config")) {
187
+ const env = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION;
188
+ if (env) {
189
+ return {
190
+ region: env,
191
+ source: process.env.AWS_REGION ? "AWS_REGION" : "AWS_DEFAULT_REGION",
192
+ };
193
+ }
194
+ // ~/.aws/config uses "[profile name]" for everything except default
195
+ let text;
196
+ try {
197
+ text = fs.readFileSync(file, "utf8");
198
+ }
199
+ catch {
200
+ return undefined;
201
+ }
202
+ const wanted = profileName === "default" ? "default" : `profile ${profileName}`;
203
+ let current = "";
204
+ for (const raw of text.split("\n")) {
205
+ const line = raw.split(/[#;]/)[0].trim();
206
+ if (!line)
207
+ continue;
208
+ const header = /^\[(.+)\]$/.exec(line);
209
+ if (header) {
210
+ current = header[1].trim();
211
+ continue;
212
+ }
213
+ if (current !== wanted)
214
+ continue;
215
+ const eq = line.indexOf("=");
216
+ if (eq === -1)
217
+ continue;
218
+ if (line.slice(0, eq).trim().toLowerCase() === "region") {
219
+ const region = line.slice(eq + 1).trim();
220
+ if (region)
221
+ return { region, source: `~/.aws/config [${profileName}]` };
222
+ }
223
+ }
224
+ return undefined;
225
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "faberwright",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Faber: an agentic AI coding assistant for your terminal — streams, edits with diff approval, runs your tests, and remembers your project across sessions.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",