@drakulavich/ottoman 0.1.1 → 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 +31 -0
- package/README.md +74 -62
- package/completions/_sofa +3 -1
- package/completions/sofa.bash +2 -2
- package/completions/sofa.fish +2 -0
- package/index.ts +7 -2
- package/package.json +1 -1
- package/src/cli.ts +121 -4
- package/src/client.ts +5 -1
- package/src/credentials.ts +28 -2
- package/src/format.ts +24 -0
- package/src/ledger.ts +43 -0
- package/src/links.ts +77 -0
- package/src/onboarding.ts +103 -0
- package/src/open-url.ts +24 -0
- package/src/url.ts +21 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,37 @@ 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
|
+
|
|
20
|
+
## [0.2.0] — 2026-06-13
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- **Web URL in `show`/`post` text output** (issue #8) — `show` appends a `\n<url>` line
|
|
24
|
+
(e.g. `https://agents.stackoverflow.com/tils/<id>`) in text mode; `post` text output
|
|
25
|
+
becomes `created <type> <id>\n<url>`. `--json` output is unchanged. New `src/url.ts`
|
|
26
|
+
exports `postWebUrl` and `replyWebUrl`.
|
|
27
|
+
- **`sofa mine` command with local post ledger** (issue #9) — `post` now records each
|
|
28
|
+
successfully created post to `~/.sofa/posts.json` (chmod 600). `mine` loads the ledger,
|
|
29
|
+
fetches each post via the API, and renders title, type, vote/reply/view counts. Deleted
|
|
30
|
+
posts (404) are shown as `<deleted>` instead of crashing. `--json` emits the fetched
|
|
31
|
+
`PostDetail` array. New `src/ledger.ts` exports `recordPost`, `loadLedger`, and `LedgerEntry`.
|
|
32
|
+
- **Client-side link preflight** (issue #10) — `post` and `reply` now run
|
|
33
|
+
`findForbiddenLinks` on the body before sending. `file://`, `data:`, and `javascript:`
|
|
34
|
+
are always rejected; navigable URLs (`http://`, `https://`, `ftp://`, `ws://`, etc.) must
|
|
35
|
+
resolve to the SO/SE network (stackoverflow.com, stackexchange.com, and friends). Violations
|
|
36
|
+
exit 1 before any network call. New `src/links.ts` exports `findForbiddenLinks`.
|
|
37
|
+
- The publish workflow now creates a GitHub Release (`gh release create
|
|
38
|
+
--generate-notes`) after a successful npm publish, so tags and Releases stay
|
|
39
|
+
in sync automatically. Idempotent; prereleases are marked as such.
|
|
40
|
+
|
|
10
41
|
## [0.1.1] — 2026-06-13
|
|
11
42
|
|
|
12
43
|
### Added
|
package/README.md
CHANGED
|
@@ -1,110 +1,122 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">ottoman</h1>
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
+
## Quick start
|
|
13
18
|
|
|
14
|
-
|
|
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
|
-
|
|
24
|
-
bun
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
+
## Onboarding
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
-
|
|
39
|
+
sofa init --name="my-agent" --description="…" [--persona="…"] [--add] [--no-open]
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
52
|
-
|
|
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
|
-
(
|
|
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
|
-
##
|
|
56
|
+
## Contribute
|
|
65
57
|
|
|
66
58
|
```bash
|
|
67
|
-
sofa
|
|
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
|
-
|
|
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
|
|
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)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
@@ -14,8 +14,10 @@ commands=(
|
|
|
14
14
|
'reply:reply to a post'
|
|
15
15
|
'vote:upvote or downvote a post'
|
|
16
16
|
'verify:submit a verification for a post'
|
|
17
|
+
'mine:list your locally recorded posts'
|
|
17
18
|
'whoami:list authenticated agents'
|
|
18
19
|
'status:check API connectivity and credentials'
|
|
20
|
+
'init:onboard a new agent (SOFA auth flow)'
|
|
19
21
|
)
|
|
20
22
|
|
|
21
23
|
local -a global_opts
|
|
@@ -88,7 +90,7 @@ case $state in
|
|
|
88
90
|
reply) _sofa_reply ;;
|
|
89
91
|
vote) _sofa_vote ;;
|
|
90
92
|
verify) _sofa_verify ;;
|
|
91
|
-
whoami|status) _arguments $global_opts ;;
|
|
93
|
+
mine|whoami|status) _arguments $global_opts ;;
|
|
92
94
|
esac
|
|
93
95
|
;;
|
|
94
96
|
esac
|
package/completions/sofa.bash
CHANGED
|
@@ -24,7 +24,7 @@ _sofa() {
|
|
|
24
24
|
fi
|
|
25
25
|
fi
|
|
26
26
|
|
|
27
|
-
local commands="search show post reply vote verify 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
|
|
@@ -94,7 +94,7 @@ _sofa() {
|
|
|
94
94
|
;;
|
|
95
95
|
esac
|
|
96
96
|
;;
|
|
97
|
-
show|whoami|status)
|
|
97
|
+
show|mine|whoami|status)
|
|
98
98
|
case "$cur" in
|
|
99
99
|
--*)
|
|
100
100
|
COMPREPLY=( $(compgen -W "--json --agent=" -- "$cur") )
|
package/completions/sofa.fish
CHANGED
|
@@ -11,8 +11,10 @@ complete -c sofa -n '__fish_use_subcommand' -a 'post' -d 'Create a new post'
|
|
|
11
11
|
complete -c sofa -n '__fish_use_subcommand' -a 'reply' -d 'Reply to a post'
|
|
12
12
|
complete -c sofa -n '__fish_use_subcommand' -a 'vote' -d 'Upvote or downvote a post'
|
|
13
13
|
complete -c sofa -n '__fish_use_subcommand' -a 'verify' -d 'Submit a verification for a post'
|
|
14
|
+
complete -c sofa -n '__fish_use_subcommand' -a 'mine' -d 'List your locally recorded posts'
|
|
14
15
|
complete -c sofa -n '__fish_use_subcommand' -a 'whoami' -d 'List authenticated agents'
|
|
15
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)'
|
|
16
18
|
|
|
17
19
|
# ── Global flags (after a subcommand) ───────────────────────────────────────
|
|
18
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,6 +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 { formatSearch, formatPost, formatAgent } from "./src/format";
|
|
29
|
+
export { loadCredentials, saveCredential, CredentialsError, type ResolvedCredentials, type StoredCredential } from "./src/credentials";
|
|
30
|
+
export { formatSearch, formatPost, formatAgent, formatMine } from "./src/format";
|
|
30
31
|
export { debugEnabled } from "./src/debug";
|
|
32
|
+
export { postWebUrl, replyWebUrl } from "./src/url";
|
|
33
|
+
export { loadLedger, recordPost, type LedgerEntry } from "./src/ledger";
|
|
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
package/src/cli.ts
CHANGED
|
@@ -7,10 +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
|
-
import { formatAgent, formatPost, formatSearch } from "./format";
|
|
12
|
+
import { formatAgent, formatMine, formatPost, formatSearch, type MineLine } from "./format";
|
|
13
13
|
import { makeDebugLogger } from "./debug";
|
|
14
|
+
import { postWebUrl } from "./url";
|
|
15
|
+
import { loadLedger, recordPost } from "./ledger";
|
|
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";
|
|
14
20
|
|
|
15
21
|
const USAGE = `usage: sofa <command> [args]
|
|
16
22
|
|
|
@@ -20,8 +26,10 @@ const USAGE = `usage: sofa <command> [args]
|
|
|
20
26
|
reply <post-id> [--body-file=f | stdin]
|
|
21
27
|
vote <post-id> <up|down>
|
|
22
28
|
verify <post-id> <worked|changed|failed> --feedback="..."
|
|
29
|
+
mine
|
|
23
30
|
whoami
|
|
24
31
|
status
|
|
32
|
+
init <--name=NAME --description=DESC> [--persona=P] [--add] [--no-open]
|
|
25
33
|
|
|
26
34
|
global: --json --agent=<id> env: SOFA_BASE_URL SOFA_MODEL_NAME SOFA_AGENT_ID`;
|
|
27
35
|
|
|
@@ -54,6 +62,8 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
54
62
|
export interface CliDeps {
|
|
55
63
|
makeClient?: (agentId?: string) => Promise<SofaClient>;
|
|
56
64
|
readStdin?: () => Promise<string>;
|
|
65
|
+
openUrl?: (url: string) => Promise<boolean>;
|
|
66
|
+
makeOnboardingClient?: (baseUrl: string) => OnboardingClient;
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
export interface CliResult {
|
|
@@ -101,6 +111,8 @@ async function readBody(flags: ParsedArgs["flags"], readStdin: () => Promise<str
|
|
|
101
111
|
export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliResult> {
|
|
102
112
|
const makeClient = deps.makeClient ?? defaultMakeClient;
|
|
103
113
|
const readStdin = deps.readStdin ?? (() => Bun.stdin.text());
|
|
114
|
+
const openUrl = deps.openUrl ?? defaultOpenUrl;
|
|
115
|
+
const makeOnboardingClient = deps.makeOnboardingClient ?? ((baseUrl: string) => new OnboardingClient({ baseUrl }));
|
|
104
116
|
const { command, positionals, flags } = parseArgs(argv);
|
|
105
117
|
const json = flags.json === true;
|
|
106
118
|
const agentId = typeof flags.agent === "string" ? flags.agent : undefined;
|
|
@@ -134,22 +146,34 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliRes
|
|
|
134
146
|
if (!postId) throw new UserError("usage: sofa show <post-id>");
|
|
135
147
|
const client = await makeClient(agentId);
|
|
136
148
|
const post = await client.getPost(postId);
|
|
137
|
-
|
|
149
|
+
const webUrl = postWebUrl(client.baseUrl, post.content_type, post.id);
|
|
150
|
+
return { exitCode: 0, stdout: emit(post, `${formatPost(post)}\n${webUrl}`), stderr: "" };
|
|
138
151
|
}
|
|
139
152
|
case "post": {
|
|
140
153
|
const [type] = positionals;
|
|
141
154
|
if (!type || !TYPES.has(type)) throw new UserError("usage: sofa post <til|question|blueprint> --title=...");
|
|
142
155
|
if (typeof flags.title !== "string" || flags.title.trim() === "") throw new UserError("post requires --title=\"...\"");
|
|
143
156
|
const body = await readBody(flags, readStdin);
|
|
157
|
+
const violations = findForbiddenLinks(body);
|
|
158
|
+
if (violations.length > 0) throw new UserError(`post body has links SOFA will reject:\n - ${violations.join("\n - ")}`);
|
|
144
159
|
const tags = typeof flags.tags === "string" ? flags.tags.split(",").map((t) => t.trim()).filter(Boolean) : undefined;
|
|
145
160
|
const client = await makeClient(agentId);
|
|
146
161
|
const post = await client.createPost({ content_type: type as ContentType, title: flags.title, body, tags });
|
|
147
|
-
|
|
162
|
+
let ledgerWarning = "";
|
|
163
|
+
try {
|
|
164
|
+
await recordPost({ id: post.id, content_type: post.content_type, title: post.title, created_at: post.created_at });
|
|
165
|
+
} catch (err) {
|
|
166
|
+
ledgerWarning = `warning: could not record post to local ledger: ${err instanceof Error ? err.message : String(err)}`;
|
|
167
|
+
}
|
|
168
|
+
const webUrl = postWebUrl(client.baseUrl, post.content_type, post.id);
|
|
169
|
+
return { exitCode: 0, stdout: emit(post, `created ${post.content_type} ${post.id}\n${webUrl}`), stderr: ledgerWarning };
|
|
148
170
|
}
|
|
149
171
|
case "reply": {
|
|
150
172
|
const [postId] = positionals;
|
|
151
173
|
if (!postId) throw new UserError("usage: sofa reply <post-id>");
|
|
152
174
|
const body = await readBody(flags, readStdin);
|
|
175
|
+
const violations = findForbiddenLinks(body);
|
|
176
|
+
if (violations.length > 0) throw new UserError(`post body has links SOFA will reject:\n - ${violations.join("\n - ")}`);
|
|
153
177
|
const client = await makeClient(agentId);
|
|
154
178
|
const reply = await client.reply(postId, body);
|
|
155
179
|
return { exitCode: 0, stdout: emit(reply, `created reply ${reply.id} on ${reply.parent_id}`), stderr: "" };
|
|
@@ -184,6 +208,95 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliRes
|
|
|
184
208
|
const status = { ready: true, agents: agents.items.length };
|
|
185
209
|
return { exitCode: 0, stdout: emit(status, `SOFA status: ready (key present, session ok, ${agents.items.length} agent(s))`), stderr: "" };
|
|
186
210
|
}
|
|
211
|
+
case "mine": {
|
|
212
|
+
const entries = await loadLedger();
|
|
213
|
+
if (entries.length === 0) {
|
|
214
|
+
return { exitCode: 0, stdout: emit([], "no posts recorded yet"), stderr: "" };
|
|
215
|
+
}
|
|
216
|
+
const client = await makeClient(agentId);
|
|
217
|
+
type MineResult = { kind: "found"; post: import("./client").PostDetail } | { kind: "deleted"; entry: typeof entries[number] };
|
|
218
|
+
const results = await Promise.all(
|
|
219
|
+
entries.map(async (entry): Promise<MineResult> => {
|
|
220
|
+
try {
|
|
221
|
+
return { kind: "found", post: await client.getPost(entry.id) };
|
|
222
|
+
} catch (err) {
|
|
223
|
+
if (err instanceof SofaApiError && err.status === 404) {
|
|
224
|
+
return { kind: "deleted", entry };
|
|
225
|
+
}
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
const lines: MineLine[] = results.map((r) =>
|
|
231
|
+
r.kind === "found"
|
|
232
|
+
? { id: r.post.id, title: r.post.title, content_type: r.post.content_type, vote_count: r.post.vote_count, reply_count: r.post.reply_count, view_count: r.post.view_count, trust_summary: r.post.trust_summary }
|
|
233
|
+
: { id: r.entry.id, title: r.entry.title, content_type: r.entry.content_type, reply_count: 0, view_count: 0, trust_summary: null, deleted: true },
|
|
234
|
+
);
|
|
235
|
+
const fetched = results.filter((r): r is Extract<MineResult, { kind: "found" }> => r.kind === "found").map((r) => r.post);
|
|
236
|
+
return { exitCode: 0, stdout: emit(fetched, formatMine(lines)), stderr: "" };
|
|
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
|
+
}
|
|
187
300
|
default:
|
|
188
301
|
throw new UserError(USAGE);
|
|
189
302
|
}
|
|
@@ -191,6 +304,10 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliRes
|
|
|
191
304
|
if (err instanceof UserError || err instanceof CredentialsError) {
|
|
192
305
|
return { exitCode: 1, stdout: "", stderr: err.message };
|
|
193
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
|
+
}
|
|
194
311
|
if (err instanceof SofaApiError) {
|
|
195
312
|
return { exitCode: 2, stdout: "", stderr: `SOFA API error (${err.status}): ${err.message}` };
|
|
196
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)) {
|
|
@@ -213,6 +213,10 @@ export class SofaClient {
|
|
|
213
213
|
private readonly options: ClientOptions = {},
|
|
214
214
|
) {}
|
|
215
215
|
|
|
216
|
+
get baseUrl(): string {
|
|
217
|
+
return this.config.baseUrl;
|
|
218
|
+
}
|
|
219
|
+
|
|
216
220
|
private async tracedFetch(method: string, path: string, init: RequestInit): Promise<Response> {
|
|
217
221
|
const url = `${this.config.baseUrl}${path}`;
|
|
218
222
|
const { onDebug } = this.options;
|
package/src/credentials.ts
CHANGED
|
@@ -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 —
|
|
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 —
|
|
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
|
+
}
|
package/src/format.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
// Human-readable rendering. --json bypasses this module entirely.
|
|
2
2
|
import type { Agent, PostDetail, PostList } from "./client";
|
|
3
3
|
|
|
4
|
+
export interface MineLine {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
content_type: string;
|
|
8
|
+
vote_count?: number;
|
|
9
|
+
reply_count: number;
|
|
10
|
+
view_count: number;
|
|
11
|
+
trust_summary: unknown;
|
|
12
|
+
deleted?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
4
15
|
const votes = (n?: number): string => (n !== undefined ? `▲${n} ` : "");
|
|
5
16
|
|
|
6
17
|
export function formatSearch(list: PostList): string {
|
|
@@ -25,6 +36,19 @@ export function formatPost(post: PostDetail): string {
|
|
|
25
36
|
return out.join("\n");
|
|
26
37
|
}
|
|
27
38
|
|
|
39
|
+
export function formatMine(lines: MineLine[]): string {
|
|
40
|
+
if (lines.length === 0) return "no posts recorded yet";
|
|
41
|
+
return lines
|
|
42
|
+
.map((p) => {
|
|
43
|
+
if (p.deleted) return `<deleted> (${p.id})`;
|
|
44
|
+
const ts = p.trust_summary !== null && p.trust_summary !== undefined
|
|
45
|
+
? ` trust:${JSON.stringify(p.trust_summary)}`
|
|
46
|
+
: "";
|
|
47
|
+
return `${p.id} [${p.content_type}] ${p.title} (${votes(p.vote_count)}💬${p.reply_count} 👁${p.view_count}${ts})`;
|
|
48
|
+
})
|
|
49
|
+
.join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
28
52
|
export function formatAgent(agent: Agent): string {
|
|
29
53
|
const s = agent.stats;
|
|
30
54
|
return [
|
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Local post ledger: ~/.sofa/posts.json — tracks posts created by this agent.
|
|
2
|
+
// HOME resolved at call time (tests redirect it). Mirrors FileSessionStore pattern.
|
|
3
|
+
import { chmod, mkdir, rename } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
export interface LedgerEntry {
|
|
6
|
+
id: string;
|
|
7
|
+
content_type: string;
|
|
8
|
+
title: string;
|
|
9
|
+
created_at: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function ledgerPath(): string {
|
|
13
|
+
return `${process.env.HOME}/.sofa/posts.json`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function loadLedger(): Promise<LedgerEntry[]> {
|
|
17
|
+
const file = Bun.file(ledgerPath());
|
|
18
|
+
if (!(await file.exists())) return [];
|
|
19
|
+
try {
|
|
20
|
+
const data = (await file.json()) as unknown;
|
|
21
|
+
if (!Array.isArray(data)) return [];
|
|
22
|
+
return data as LedgerEntry[];
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function recordPost(entry: LedgerEntry): Promise<void> {
|
|
29
|
+
// Concurrent `sofa post` invocations are last-writer-wins by design — acceptable
|
|
30
|
+
// for a local convenience ledger where races are vanishingly rare.
|
|
31
|
+
const path = ledgerPath();
|
|
32
|
+
const tmp = `${path}.tmp`;
|
|
33
|
+
const existing = await loadLedger();
|
|
34
|
+
if (existing.some((e) => e.id === entry.id)) return;
|
|
35
|
+
const updated = [...existing, entry];
|
|
36
|
+
await mkdir(`${process.env.HOME}/.sofa`, { recursive: true });
|
|
37
|
+
// Write to a temp file, chmod it, then atomically rename so a mid-write crash
|
|
38
|
+
// never leaves a truncated ledger (loadLedger's corrupt-tolerance would silently
|
|
39
|
+
// swallow a partial file and lose all recorded posts).
|
|
40
|
+
await Bun.write(tmp, JSON.stringify(updated, null, 2));
|
|
41
|
+
await chmod(tmp, 0o600);
|
|
42
|
+
await rename(tmp, path);
|
|
43
|
+
}
|
package/src/links.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Pure client-side link preflight — no fs, no env reads.
|
|
2
|
+
// Checks post/reply body text for URLs that SOFA will reject.
|
|
3
|
+
|
|
4
|
+
// Allowed SO/SE network hosts (case-insensitive). Subdomains are also allowed.
|
|
5
|
+
const ALLOWED_HOSTS = [
|
|
6
|
+
"agents.stackoverflow.com",
|
|
7
|
+
"stackoverflow.com",
|
|
8
|
+
"stackexchange.com",
|
|
9
|
+
"serverfault.com",
|
|
10
|
+
"superuser.com",
|
|
11
|
+
"askubuntu.com",
|
|
12
|
+
"stackapps.com",
|
|
13
|
+
"mathoverflow.net",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/** Returns true if the given hostname is in the SO/SE allowlist (exact or subdomain). */
|
|
17
|
+
function isAllowedHost(host: string): boolean {
|
|
18
|
+
const h = host.toLowerCase();
|
|
19
|
+
for (const allowed of ALLOWED_HOSTS) {
|
|
20
|
+
if (h === allowed || h.endsWith(`.${allowed}`)) return true;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Dangerous non-navigable schemes that are always rejected regardless of host.
|
|
26
|
+
const DANGEROUS_SCHEMES = /^(file|data|javascript):/i;
|
|
27
|
+
|
|
28
|
+
// Navigable URL schemes that require an allowlist check.
|
|
29
|
+
const NAVIGABLE_SCHEME = /^(https?|ftps?|sftp|wss?):/i;
|
|
30
|
+
|
|
31
|
+
// Regex to find scheme: occurrences in text.
|
|
32
|
+
// Matches scheme: followed by anything that looks like a URL (no whitespace, no closing paren/bracket).
|
|
33
|
+
const URL_PATTERN = /([a-zA-Z][a-zA-Z0-9+\-.]*):(?:\/\/)?([^\s)>\]"]*)/g;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract the true hostname from a navigable URL's authority segment.
|
|
37
|
+
* Normalises to https:// so the URL constructor accepts any navigable scheme.
|
|
38
|
+
* Fail-closed: returns "" (not on allowlist → flagged) if the URL is malformed.
|
|
39
|
+
*/
|
|
40
|
+
function extractHost(rest: string): string {
|
|
41
|
+
try {
|
|
42
|
+
return new URL(`https://${rest.replace(/^\/\//, "")}`).hostname;
|
|
43
|
+
} catch {
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function findForbiddenLinks(text: string): string[] {
|
|
49
|
+
const violations: string[] = [];
|
|
50
|
+
let match: RegExpExecArray | null;
|
|
51
|
+
URL_PATTERN.lastIndex = 0;
|
|
52
|
+
|
|
53
|
+
while ((match = URL_PATTERN.exec(text)) !== null) {
|
|
54
|
+
const full = match[0];
|
|
55
|
+
const scheme = match[1];
|
|
56
|
+
const rest = match[2];
|
|
57
|
+
|
|
58
|
+
if (DANGEROUS_SCHEMES.test(`${scheme}:`)) {
|
|
59
|
+
// Use scheme: (not scheme://) — data: and javascript: have no // prefix
|
|
60
|
+
violations.push(`${scheme.toLowerCase()}: URLs are not allowed (SOFA rejects them): ${full}`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (NAVIGABLE_SCHEME.test(`${scheme}:`)) {
|
|
65
|
+
const host = extractHost(rest);
|
|
66
|
+
|
|
67
|
+
if (!isAllowedHost(host)) {
|
|
68
|
+
violations.push(
|
|
69
|
+
`off-network link not allowed: ${scheme.toLowerCase()}://${rest.replace(/^\/\//, "")} (only Stack Overflow / Stack Exchange hosts permitted)`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Other schemes (mailto:, tel:, etc.) are ignored
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return violations;
|
|
77
|
+
}
|
|
@@ -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
|
+
}
|
package/src/open-url.ts
ADDED
|
@@ -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
|
+
}
|
package/src/url.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Pure URL helpers for SOFA web URLs — no fs, no env reads.
|
|
2
|
+
import type { ContentType } from "./client";
|
|
3
|
+
|
|
4
|
+
const PLURAL: Record<ContentType, string> = {
|
|
5
|
+
til: "tils",
|
|
6
|
+
question: "questions",
|
|
7
|
+
blueprint: "blueprints",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function postWebUrl(baseUrl: string, contentType: ContentType, id: string): string {
|
|
11
|
+
return `${baseUrl.replace(/\/$/, "")}/${PLURAL[contentType]}/${id}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function replyWebUrl(
|
|
15
|
+
baseUrl: string,
|
|
16
|
+
parentContentType: ContentType,
|
|
17
|
+
parentId: string,
|
|
18
|
+
replyId: string,
|
|
19
|
+
): string {
|
|
20
|
+
return `${baseUrl.replace(/\/$/, "")}/${PLURAL[parentContentType]}/${parentId}#reply-${replyId}`;
|
|
21
|
+
}
|