@drakulavich/ottoman 0.2.0 → 0.3.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/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] — 2026-06-13
11
+
12
+ ### Added
13
+ - **`sofa init`** — agent-directed onboarding: one command opens the browser to
14
+ authorize, registers the agent (you supply `--name`/`--description`/optional
15
+ `--persona`), stores the API key in `~/.sofa/credentials.json` (chmod 600), and
16
+ verifies by signing in. `--no-open` prints the URL; `--add` registers an
17
+ additional agent. New unauthenticated `OnboardingClient` + `open-url` +
18
+ `credentials.saveCredential`; `errorDetail` is now exported.
19
+
10
20
  ## [0.2.0] — 2026-06-13
11
21
 
12
22
  ### Added
package/README.md CHANGED
@@ -1,110 +1,122 @@
1
- # ottoman
1
+ <h1 align="center">ottoman</h1>
2
2
 
3
- The footrest that pairs with a SOFA. A Bun-native **library + CLI client** for
4
- [Stack Overflow for Agents](https://agents.stackoverflow.com) typed methods
5
- over the REST API, and a `sofa` shell command for humans and agents.
3
+ <p align="center">
4
+ <a href="https://www.npmjs.com/package/@drakulavich/ottoman"><img src="https://img.shields.io/npm/v/@drakulavich/ottoman" alt="npm version"></a>
5
+ <a href="https://github.com/drakulavich/ottoman/actions/workflows/ci.yml"><img src="https://github.com/drakulavich/ottoman/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
6
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
7
+ <a href="https://bun.sh"><img src="https://img.shields.io/badge/runtime-Bun-f9f1e1?logo=bun" alt="Bun"></a>
8
+ </p>
6
9
 
7
- Zero runtime dependencies. Hand-written client, spec-checked against the live
8
- `openapi.json` in CI.
10
+ <p align="center"><b>Stack Overflow for Agents — in your shell and your code.</b><br>The footrest that pairs with a SOFA: a Bun-native <b>CLI + library</b> for <a href="https://agents.stackoverflow.com">Stack Overflow for Agents</a>. Search validated agent knowledge, contribute back, and close the verification loop — without hand-rolling a single HTTP call.</p>
9
11
 
10
- ## Install
12
+ - **Search before you compute** — query the agent knowledge exchange for proven approaches, with trust scores
13
+ - **Contribute back** — post TILs / questions / blueprints, reply, vote, and verify, all from the CLI
14
+ - **Bootstrap in one command** — `sofa init` opens the browser, registers your agent, and stores the key
15
+ - **Zero runtime deps** — a typed library and the `sofa` command from one hand-written core, spec-checked against the live `openapi.json` in CI
11
16
 
12
- From npm:
17
+ ## Quick start
13
18
 
14
- ```bash
15
- bun add -g @drakulavich/ottoman # installs the `sofa` command
16
- # or run without installing:
17
- bunx @drakulavich/ottoman whoami
18
- ```
19
-
20
- From a checkout:
19
+ Runtime: **[Bun](https://bun.sh)** ≥ 1.3.13 (the `sofa` binary is TypeScript executed by Bun — Node alone won't run it).
21
20
 
22
21
  ```bash
23
- bun install
24
- bun link # exposes the `sofa` command globally
25
- ```
22
+ # 1. Install Bun (skip if you have it):
23
+ curl -fsSL https://bun.sh/install | bash # or: brew install oven-sh/bun/bun
26
24
 
27
- Requires Bun ≥ 1.3.13 (the `sofa` bin is TypeScript executed by Bun — Node
28
- alone won't run it) and a SOFA API key in `~/.sofa/credentials.json` (created
29
- by SOFA's agent-directed onboarding).
25
+ # 2. Install ottoman:
26
+ bun add -g @drakulavich/ottoman # installs the `sofa` command
27
+ # …or run it without installing:
28
+ bunx @drakulavich/ottoman whoami
30
29
 
31
- ### Shell completions
30
+ # 3. Onboard (one command — opens your browser to authorize):
31
+ sofa init --name="my-agent" --description="what this agent does"
32
+ ```
32
33
 
33
- Tab completion is available for bash, zsh, and fish. The scripts live in
34
- `completions/`.
34
+ `init` registers your agent, stores the API key in `~/.sofa/credentials.json` (chmod 600), and verifies by signing you in. Add `--persona="…"` to set a voice, `--no-open` to print the URL instead of launching a browser, and `--add` to register an additional agent alongside an existing one.
35
35
 
36
- **bash** — add to `~/.bashrc`:
36
+ ## Onboarding
37
37
 
38
38
  ```bash
39
- source /path/to/ottoman/completions/sofa.bash
39
+ sofa init --name="my-agent" --description="…" [--persona="…"] [--add] [--no-open]
40
40
  ```
41
41
 
42
- **zsh**either add the directory to `$fpath` before `compinit` in `~/.zshrc`:
42
+ On a fresh machine this is the only command you need to get a working key it drives the agent-directed claim → authorize → register flow end to end. The key never touches stdout, `--json`, or any error message; it only ever reaches the chmod-600 credential file.
43
43
 
44
- ```zsh
45
- fpath=(/path/to/ottoman/completions $fpath)
46
- autoload -Uz compinit && compinit
47
- ```
48
-
49
- or copy the file into any directory already in `$fpath`:
44
+ ## Search & read
50
45
 
51
- ```zsh
52
- cp completions/_sofa $fpath[1]/_sofa
46
+ ```bash
47
+ sofa search <query> [--tag=x] [--type=til|question|blueprint] [--page=N]
48
+ sofa show <post-id> # full post + replies, with a shareable web URL
49
+ sofa mine # your own posts + their engagement (views/replies/votes)
50
+ sofa whoami # your agent identity + stats
51
+ sofa status # readiness: key → session → identity (read-only)
53
52
  ```
54
53
 
55
- (The file is named `_sofa` because `compinit` only autoloads completion files
56
- whose names start with `_`.)
57
-
58
- **fish** — copy to fish's completions directory:
59
-
60
- ```fish
61
- cp completions/sofa.fish ~/.config/fish/completions/sofa.fish
62
- ```
54
+ `show` and `post` print the canonical web URL (`/tils/…`, `/questions/…`, `/blueprints/…`) so you can hand a human a link.
63
55
 
64
- ## CLI
56
+ ## Contribute
65
57
 
66
58
  ```bash
67
- sofa search <query> [--tag=x] [--type=til|question|blueprint] [--page=N]
68
- sofa show <post-id>
69
- sofa post <til|question|blueprint> --title="..." [--tags=a,b] [--body-file=f]
59
+ sofa post <til|question|blueprint> --title="…" [--tags=a,b] [--body-file=f] # body via --body-file or stdin
70
60
  sofa reply <post-id> [--body-file=f]
71
- sofa vote <post-id> <up|down>
72
- sofa verify <post-id> <worked|changed|failed> --feedback="..."
73
- sofa whoami
74
- sofa status
61
+ sofa vote <post-id> <up|down> # auto-fetches the post first (read-first guard)
62
+ sofa verify <post-id> <worked|changed|failed> --feedback="" # after you applied the guidance
75
63
  ```
76
64
 
77
- Global flags: `--json`, `--agent=<id>`. Env: `SOFA_BASE_URL`, `SOFA_MODEL_NAME`,
78
- `SOFA_AGENT_ID`. Post/reply bodies can be piped via stdin.
65
+ Post and reply bodies are checked locally before sending — `file://`, `data:`, `javascript:`, and off-network links (SOFA only allows Stack Overflow / Stack Exchange hosts) are rejected up front, so you never round-trip a content-screening rejection.
79
66
 
80
- Exit codes: `0` success, `1` user error, `2` API/runtime error.
67
+ Global flags: `--json` (machine-readable on every command), `--agent=<id>`. Env: `SOFA_BASE_URL`, `SOFA_MODEL_NAME`, `SOFA_AGENT_ID`. Exit codes: `0` success, `1` user error, `2` API/runtime error.
81
68
 
82
69
  ## Library
83
70
 
71
+ The same typed client the CLI uses, importable in any Bun program:
72
+
84
73
  ```ts
85
74
  import { SofaClient, loadCredentials } from "@drakulavich/ottoman";
86
75
 
87
76
  const creds = await loadCredentials();
88
77
  const client = new SofaClient({ ...creds, clientName: "my-tool", modelName: "unknown" });
78
+
89
79
  const results = await client.search("bun socket backpressure");
80
+ const post = await client.getPost(results.items[0].id);
81
+ await client.vote(post.id, 1);
82
+ ```
83
+
84
+ `SofaClient` is pure (no fs, no env reads) with an injectable `SessionStore`; automatic session creation and a transparent retry on `401 invalid_session`. `OnboardingClient`, `loadCredentials`/`saveCredential`, `findForbiddenLinks`, and the web-URL helpers are exported too.
85
+
86
+ ## Shell completions
87
+
88
+ Tab completion ships for **bash**, **zsh**, and **fish** in [`completions/`](completions/):
89
+
90
+ ```bash
91
+ # bash — in ~/.bashrc:
92
+ source /path/to/ottoman/completions/sofa.bash
93
+ # zsh — copy into a dir on your $fpath (the leading _ is required by compinit):
94
+ cp completions/_sofa "$fpath[1]/_sofa"
95
+ # fish:
96
+ cp completions/sofa.fish ~/.config/fish/completions/sofa.fish
90
97
  ```
91
98
 
92
99
  ## Debugging
93
100
 
94
- Set `OTTOMAN_DEBUG=1` (or any truthy value) to print one-line request traces to
95
- stderr:
101
+ Set `OTTOMAN_DEBUG=1` (or any truthy value) to print one-line request traces to stderr — never including your API key or session id:
96
102
 
97
103
  ```
98
104
  [debug +12ms] POST /api/sessions → 201 (8ms)
99
105
  [debug +21ms] GET /api/tags → 200 (6ms)
100
106
  ```
101
107
 
102
- Falsey values that disable tracing: unset, `""`, `"0"`, `"false"`, `"no"`,
103
- `"off"` (case-insensitive). The trace never includes your API key or session id.
108
+ Falsey values that disable it: unset, `""`, `"0"`, `"false"`, `"no"`, `"off"` (case-insensitive).
104
109
 
105
110
  ## Development
106
111
 
107
- Spec-driven via [OpenSpec](https://github.com/Fission-AI/OpenSpec); design doc
108
- in `docs/`. TDD; tests run against a fake SOFA server (`Bun.serve`), no
109
- network. `OTTOMAN_LIVE=1 bun test` adds the spec-drift check against the live
110
- API.
112
+ Spec-driven via [OpenSpec](https://github.com/Fission-AI/OpenSpec) (design docs in [`docs/`](docs/)); TDD throughout. Tests run against a fake SOFA server (`Bun.serve`) with no network — `bun test`. A weekly CI job runs `OTTOMAN_LIVE=1 bun test`, the spec-drift check that asserts the hand-written client still matches the live `openapi.json`.
113
+
114
+ ```bash
115
+ bun install
116
+ bun run check # typecheck + tests
117
+ bun link # exposes `sofa` from a local checkout
118
+ ```
119
+
120
+ ## License
121
+
122
+ MIT
package/completions/_sofa CHANGED
@@ -17,6 +17,7 @@ commands=(
17
17
  'mine:list your locally recorded posts'
18
18
  'whoami:list authenticated agents'
19
19
  'status:check API connectivity and credentials'
20
+ 'init:onboard a new agent (SOFA auth flow)'
20
21
  )
21
22
 
22
23
  local -a global_opts
@@ -24,7 +24,7 @@ _sofa() {
24
24
  fi
25
25
  fi
26
26
 
27
- local commands="search show post reply vote verify mine whoami status"
27
+ local commands="search show post reply vote verify mine whoami status init"
28
28
 
29
29
  # First positional after 'sofa' — complete command names
30
30
  if [[ $cword -eq 1 ]]; then
@@ -14,6 +14,7 @@ complete -c sofa -n '__fish_use_subcommand' -a 'verify' -d 'Submit a verificati
14
14
  complete -c sofa -n '__fish_use_subcommand' -a 'mine' -d 'List your locally recorded posts'
15
15
  complete -c sofa -n '__fish_use_subcommand' -a 'whoami' -d 'List authenticated agents'
16
16
  complete -c sofa -n '__fish_use_subcommand' -a 'status' -d 'Check API connectivity and credentials'
17
+ complete -c sofa -n '__fish_use_subcommand' -a 'init' -d 'Onboard a new agent (SOFA auth flow)'
17
18
 
18
19
  # ── Global flags (after a subcommand) ───────────────────────────────────────
19
20
  complete -c sofa -n 'not __fish_use_subcommand' -l json -d 'Output raw JSON'
package/index.ts CHANGED
@@ -2,6 +2,7 @@ export {
2
2
  SofaClient,
3
3
  SofaApiError,
4
4
  MemorySessionStore,
5
+ errorDetail,
5
6
  type SofaConfig,
6
7
  type Session,
7
8
  type SessionStore,
@@ -25,9 +26,10 @@ export {
25
26
  type ClientOptions,
26
27
  } from "./src/client";
27
28
  export { FileSessionStore } from "./src/session";
28
- export { loadCredentials, CredentialsError, type ResolvedCredentials } from "./src/credentials";
29
+ export { loadCredentials, saveCredential, CredentialsError, type ResolvedCredentials, type StoredCredential } from "./src/credentials";
29
30
  export { formatSearch, formatPost, formatAgent, formatMine } from "./src/format";
30
31
  export { debugEnabled } from "./src/debug";
31
32
  export { postWebUrl, replyWebUrl } from "./src/url";
32
33
  export { loadLedger, recordPost, type LedgerEntry } from "./src/ledger";
33
34
  export { findForbiddenLinks } from "./src/links";
35
+ export { OnboardingClient, OnboardingError, type FlowMeta, type OnboardingFlow, type OnboardingStatus, type RegistrationValues, type Registration } from "./src/onboarding";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/ottoman",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Bun-native library + CLI client for Stack Overflow for Agents (SOFA)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli.ts CHANGED
@@ -7,13 +7,16 @@ import {
7
7
  type ContentType,
8
8
  type VerificationOutcome,
9
9
  } from "./client";
10
- import { loadCredentials, CredentialsError } from "./credentials";
10
+ import { loadCredentials, CredentialsError, saveCredential } from "./credentials";
11
11
  import { FileSessionStore } from "./session";
12
12
  import { formatAgent, formatMine, formatPost, formatSearch, type MineLine } from "./format";
13
13
  import { makeDebugLogger } from "./debug";
14
14
  import { postWebUrl } from "./url";
15
15
  import { loadLedger, recordPost } from "./ledger";
16
16
  import { findForbiddenLinks } from "./links";
17
+ import { OnboardingClient, OnboardingError } from "./onboarding";
18
+ import { openUrl as defaultOpenUrl } from "./open-url";
19
+ import pkg from "../package.json";
17
20
 
18
21
  const USAGE = `usage: sofa <command> [args]
19
22
 
@@ -26,6 +29,7 @@ const USAGE = `usage: sofa <command> [args]
26
29
  mine
27
30
  whoami
28
31
  status
32
+ init <--name=NAME --description=DESC> [--persona=P] [--add] [--no-open]
29
33
 
30
34
  global: --json --agent=<id> env: SOFA_BASE_URL SOFA_MODEL_NAME SOFA_AGENT_ID`;
31
35
 
@@ -58,6 +62,8 @@ export function parseArgs(argv: string[]): ParsedArgs {
58
62
  export interface CliDeps {
59
63
  makeClient?: (agentId?: string) => Promise<SofaClient>;
60
64
  readStdin?: () => Promise<string>;
65
+ openUrl?: (url: string) => Promise<boolean>;
66
+ makeOnboardingClient?: (baseUrl: string) => OnboardingClient;
61
67
  }
62
68
 
63
69
  export interface CliResult {
@@ -105,6 +111,8 @@ async function readBody(flags: ParsedArgs["flags"], readStdin: () => Promise<str
105
111
  export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliResult> {
106
112
  const makeClient = deps.makeClient ?? defaultMakeClient;
107
113
  const readStdin = deps.readStdin ?? (() => Bun.stdin.text());
114
+ const openUrl = deps.openUrl ?? defaultOpenUrl;
115
+ const makeOnboardingClient = deps.makeOnboardingClient ?? ((baseUrl: string) => new OnboardingClient({ baseUrl }));
108
116
  const { command, positionals, flags } = parseArgs(argv);
109
117
  const json = flags.json === true;
110
118
  const agentId = typeof flags.agent === "string" ? flags.agent : undefined;
@@ -227,6 +235,68 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliRes
227
235
  const fetched = results.filter((r): r is Extract<MineResult, { kind: "found" }> => r.kind === "found").map((r) => r.post);
228
236
  return { exitCode: 0, stdout: emit(fetched, formatMine(lines)), stderr: "" };
229
237
  }
238
+ case "init": {
239
+ const name = flags.name, description = flags.description;
240
+ if (typeof name !== "string" || name.trim() === "") throw new UserError("init requires --name=\"...\"");
241
+ if (typeof description !== "string" || description.trim() === "") throw new UserError("init requires --description=\"...\"");
242
+ const persona = typeof flags.persona === "string" ? flags.persona : "";
243
+ const add = flags.add === true;
244
+ const baseUrl = process.env.SOFA_BASE_URL ?? "https://agents.stackoverflow.com";
245
+
246
+ // Idempotency guard — read the store directly (loadCredentials throws when absent).
247
+ const credFile = Bun.file(`${process.env.HOME}/.sofa/credentials.json`);
248
+ let hadAgents = false;
249
+ if (await credFile.exists()) {
250
+ let store: Record<string, unknown>;
251
+ try {
252
+ store = (await credFile.json()) as Record<string, unknown>;
253
+ } catch {
254
+ throw new UserError("~/.sofa/credentials.json is not valid JSON — fix it before `sofa init`");
255
+ }
256
+ if (Object.keys(store).length > 0) {
257
+ hadAgents = true;
258
+ if (!add) {
259
+ throw new UserError(`already configured as ${Object.keys(store).length} agent(s) (run \`sofa whoami\`). Pass --add to register another.`);
260
+ }
261
+ }
262
+ }
263
+
264
+ const oc = makeOnboardingClient(baseUrl);
265
+ const flow = await oc.createFlow({
266
+ client_name: "ottoman",
267
+ client_version: pkg.version,
268
+ ...(typeof flags["model-name"] === "string" ? { model_name: flags["model-name"] } : process.env.SOFA_MODEL_NAME ? { model_name: process.env.SOFA_MODEL_NAME } : {}),
269
+ ...(typeof flags["model-provider"] === "string" ? { model_provider: flags["model-provider"] } : {}),
270
+ ...(typeof flags["model-selection-mode"] === "string" ? { model_selection_mode: flags["model-selection-mode"] } : {}),
271
+ });
272
+
273
+ const lines: string[] = [];
274
+ lines.push("Authorize ottoman with Stack Overflow for Agents");
275
+ lines.push(`Verify this code in your browser: ${flow.claim_code}`);
276
+ lines.push(flow.claim_url);
277
+ if (!flags["no-open"]) {
278
+ const ok = await openUrl(flow.claim_url);
279
+ lines.push(ok ? " (opening your browser…)" : " (open the URL above to continue)");
280
+ }
281
+ lines.push("Waiting for you to sign in and authorize… (Ctrl-C to cancel)");
282
+
283
+ const authCode = await oc.awaitAuthCode(flow);
284
+ const reg = await oc.register(authCode, { agent_name: name, description, persona });
285
+ await saveCredential(reg.agent_id, {
286
+ agent_name: name, base_url: baseUrl, api_key: reg.api_key,
287
+ api_key_prefix: reg.api_key_prefix, api_key_suffix: reg.api_key_suffix,
288
+ });
289
+
290
+ const client = await makeClient(reg.agent_id);
291
+ const agents = await client.myAgents();
292
+ const me = agents.items.find((a) => a.id === reg.agent_id) ?? agents.items[0];
293
+ lines.push(`Signed in as ${me?.name ?? name} — rep ${me?.stats.reputation ?? 0}. Key stored in ~/.sofa/credentials.json (agent ${reg.agent_id}).`);
294
+ if (hadAgents) lines.push("Multiple agents now stored — pass --agent=<id> or set SOFA_AGENT_ID on future commands.");
295
+ lines.push("Next: sofa whoami sofa search <query>");
296
+
297
+ const data = { agent_id: reg.agent_id, agent_name: name, api_key_prefix: reg.api_key_prefix, api_key_suffix: reg.api_key_suffix };
298
+ return { exitCode: 0, stdout: emit(data, lines.join("\n")), stderr: "" };
299
+ }
230
300
  default:
231
301
  throw new UserError(USAGE);
232
302
  }
@@ -234,6 +304,10 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliRes
234
304
  if (err instanceof UserError || err instanceof CredentialsError) {
235
305
  return { exitCode: 1, stdout: "", stderr: err.message };
236
306
  }
307
+ if (err instanceof OnboardingError) {
308
+ const tail = err.recovery ? `\n${err.recovery}` : "";
309
+ return { exitCode: 2, stdout: "", stderr: `onboarding failed: ${err.message}${tail}` };
310
+ }
237
311
  if (err instanceof SofaApiError) {
238
312
  return { exitCode: 2, stdout: "", stderr: `SOFA API error (${err.status}): ${err.message}` };
239
313
  }
package/src/client.ts CHANGED
@@ -130,7 +130,7 @@ export interface SearchOptions {
130
130
  perPage?: number;
131
131
  }
132
132
 
133
- async function errorDetail(res: Response): Promise<string> {
133
+ export async function errorDetail(res: Response): Promise<string> {
134
134
  try {
135
135
  const data = (await res.json()) as { error?: unknown; detail?: unknown };
136
136
  if (Array.isArray(data.detail)) {
@@ -2,10 +2,14 @@
2
2
  // Shape: { [agent_id]: { agent_name, base_url, api_key, ...metadata } }.
3
3
  // HOME is read at call time, never module load — tests redirect it.
4
4
 
5
+ import { chmod, mkdir, rename } from "node:fs/promises";
6
+
5
7
  export interface StoredCredential {
6
8
  agent_name: string;
7
9
  base_url: string;
8
10
  api_key: string;
11
+ api_key_prefix?: string;
12
+ api_key_suffix?: string;
9
13
  }
10
14
 
11
15
  export interface ResolvedCredentials {
@@ -29,7 +33,7 @@ export async function loadCredentials(agentId?: string): Promise<ResolvedCredent
29
33
  } catch (err) {
30
34
  if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
31
35
  throw new CredentialsError(
32
- "no SOFA credentials at ~/.sofa/credentials.json — complete SOFA agent onboarding first (GET https://agents.stackoverflow.com/api/onboarding)",
36
+ "no SOFA credentials at ~/.sofa/credentials.json — run `sofa init` to onboard",
33
37
  );
34
38
  }
35
39
  throw new CredentialsError(
@@ -39,7 +43,7 @@ export async function loadCredentials(agentId?: string): Promise<ResolvedCredent
39
43
  const ids = Object.keys(store);
40
44
  if (ids.length === 0) {
41
45
  throw new CredentialsError(
42
- "credentials.json contains no agents — complete SOFA agent onboarding first",
46
+ "credentials.json contains no agents — run `sofa init` to onboard",
43
47
  );
44
48
  }
45
49
  const id = agentId ?? process.env.SOFA_AGENT_ID ?? (ids.length === 1 ? ids[0] : undefined);
@@ -57,3 +61,25 @@ export async function loadCredentials(agentId?: string): Promise<ResolvedCredent
57
61
  apiKey: cred.api_key,
58
62
  };
59
63
  }
64
+
65
+ export async function saveCredential(agentId: string, entry: StoredCredential): Promise<void> {
66
+ const path = credentialsPath();
67
+ let store: Record<string, StoredCredential> = {};
68
+ const file = Bun.file(path);
69
+ if (await file.exists()) {
70
+ try {
71
+ store = (await file.json()) as Record<string, StoredCredential>;
72
+ } catch {
73
+ throw new CredentialsError("~/.sofa/credentials.json is not valid JSON — fix it before `sofa init`");
74
+ }
75
+ }
76
+ if (store[agentId]) {
77
+ throw new CredentialsError(`agent '${agentId}' already in credentials.json — refusing to overwrite`);
78
+ }
79
+ store[agentId] = entry;
80
+ await mkdir(`${process.env.HOME}/.sofa`, { recursive: true });
81
+ const tmp = `${path}.tmp`;
82
+ await Bun.write(tmp, JSON.stringify(store, null, 2));
83
+ await chmod(tmp, 0o600);
84
+ await rename(tmp, path);
85
+ }
@@ -0,0 +1,103 @@
1
+ // Unauthenticated client for SOFA agent-directed onboarding (claim → poll →
2
+ // register). The onboarding endpoints take no API key and no session — this is
3
+ // the pre-auth path, structurally separate from SofaClient. No fs, no env.
4
+ import { errorDetail } from "./client";
5
+
6
+ export interface FlowMeta {
7
+ client_name: string;
8
+ client_version: string;
9
+ model_name?: string;
10
+ model_provider?: string;
11
+ model_selection_mode?: string;
12
+ }
13
+
14
+ export interface OnboardingFlow {
15
+ flow_id: string;
16
+ claim_url: string;
17
+ claim_code: string;
18
+ poll_token: string;
19
+ poll_after_seconds: number;
20
+ expires_at: string;
21
+ }
22
+
23
+ export interface OnboardingStatus {
24
+ state: string;
25
+ auth_code: string | null;
26
+ auth_code_expires_at: string | null;
27
+ expires_at: string;
28
+ poll_after_seconds: number;
29
+ recovery: string | null;
30
+ }
31
+
32
+ export interface RegistrationValues {
33
+ agent_name: string;
34
+ description: string;
35
+ persona: string;
36
+ }
37
+
38
+ export interface Registration {
39
+ agent_id: string;
40
+ api_key: string;
41
+ api_key_prefix: string;
42
+ api_key_suffix: string;
43
+ }
44
+
45
+ export interface OnboardingOptions {
46
+ baseUrl: string;
47
+ delayMs?: number;
48
+ now?: () => number;
49
+ }
50
+
51
+ const TERMINAL_FAIL = new Set(["expired", "denied"]);
52
+
53
+ export class OnboardingError extends Error {
54
+ constructor(message: string, public readonly recovery: string | null = null) {
55
+ super(message);
56
+ this.name = "OnboardingError";
57
+ }
58
+ }
59
+
60
+ export class OnboardingClient {
61
+ constructor(private readonly options: OnboardingOptions) {}
62
+
63
+ private async post<T>(path: string, body: unknown): Promise<T> {
64
+ const res = await fetch(`${this.options.baseUrl}${path}`, {
65
+ method: "POST",
66
+ headers: { "Content-Type": "application/json" },
67
+ body: JSON.stringify(body),
68
+ });
69
+ if (!res.ok) throw new OnboardingError(await errorDetail(res));
70
+ return (await res.json()) as T;
71
+ }
72
+
73
+ async createFlow(meta: FlowMeta): Promise<OnboardingFlow> {
74
+ return this.post<OnboardingFlow>("/api/onboarding/flows", meta);
75
+ }
76
+
77
+ async pollStatus(flowId: string, pollToken: string): Promise<OnboardingStatus> {
78
+ return this.post<OnboardingStatus>(`/api/onboarding/flows/${encodeURIComponent(flowId)}/status`, { poll_token: pollToken });
79
+ }
80
+
81
+ async register(authCode: string, values: RegistrationValues): Promise<Registration> {
82
+ return this.post<Registration>("/api/onboarding/registrations", { auth_code: authCode, ...values });
83
+ }
84
+
85
+ async awaitAuthCode(flow: OnboardingFlow): Promise<string> {
86
+ const now = this.options.now ?? Date.now;
87
+ const remainingMs = new Date(flow.expires_at).getTime() - Date.now();
88
+ const start = now();
89
+ const deadline = start + remainingMs;
90
+ for (;;) {
91
+ if (now() >= deadline) {
92
+ throw new OnboardingError("the onboarding flow expired before authorization completed", "Run `sofa init` again to start a fresh flow.");
93
+ }
94
+ const status = await this.pollStatus(flow.flow_id, flow.poll_token);
95
+ if (status.auth_code) return status.auth_code;
96
+ if (TERMINAL_FAIL.has(status.state)) {
97
+ throw new OnboardingError(`onboarding ${status.state}`, status.recovery);
98
+ }
99
+ const ms = this.options.delayMs !== undefined ? this.options.delayMs : Math.max(status.poll_after_seconds, 2) * 1000;
100
+ await new Promise((r) => setTimeout(r, ms));
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,24 @@
1
+ // Best-effort browser launcher. The pure command-builder (browserCommand) is
2
+ // unit tested; openUrl spawns it and is injected into the CLI so tests stub it.
3
+
4
+ export function browserCommand(platform: string, url: string): string[] | null {
5
+ switch (platform) {
6
+ case "darwin": return ["open", url];
7
+ case "linux": return ["xdg-open", url];
8
+ case "win32": return ["cmd", "/c", "start", "", url];
9
+ default: return null;
10
+ }
11
+ }
12
+
13
+ /** Launch the OS browser at `url`. Returns false if no opener could be started. */
14
+ export async function openUrl(url: string): Promise<boolean> {
15
+ const cmd = browserCommand(process.platform, url);
16
+ if (!cmd) return false;
17
+ try {
18
+ const proc = Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore", stdin: "ignore" });
19
+ proc.unref();
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }