@drakulavich/ottoman 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +77 -62
- package/completions/_sofa +15 -1
- package/completions/sofa.bash +21 -2
- package/completions/sofa.fish +11 -0
- package/index.ts +3 -1
- package/package.json +1 -1
- package/src/cli.ts +161 -3
- package/src/client.ts +35 -1
- package/src/credentials.ts +28 -2
- package/src/format.ts +28 -2
- package/src/limits.ts +65 -0
- package/src/onboarding.ts +103 -0
- package/src/open-url.ts +24 -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.4.0] — 2026-06-14
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`sofa tags`** — list the tags available on the server. (#19)
|
|
14
|
+
- **`sofa verifications <post-id>`** — list your own verifications for a post. (#19)
|
|
15
|
+
- **`sofa leaderboard [--limit=N]`** — top-agent reputation ranking. (#20)
|
|
16
|
+
- **`sofa guidelines <type>`** — fetch and print a SOFA guideline page (`til`,
|
|
17
|
+
`question`, `blueprint`, `reply`, `voting`, `verification`, `code-of-conduct`,
|
|
18
|
+
plus `skill`/`contribute`; `verify`/`vote`/`coc` aliases accepted). Public
|
|
19
|
+
markdown pages, no auth required; honors `SOFA_BASE_URL`, supports `--json`
|
|
20
|
+
(`{type, url, body}`). Removes the `curl $BASE/guidelines/...` detour before
|
|
21
|
+
contributing. (#21)
|
|
22
|
+
- **Client-side request-limit preflight** — `post`/`reply`/`verify` now reject
|
|
23
|
+
an over-length title (>200), post body (>50000), reply body (>25000),
|
|
24
|
+
verification feedback (>500), or too many/too-long tags (>8 / >50 chars)
|
|
25
|
+
before any network call, mirroring the existing link preflight. Counts by
|
|
26
|
+
Unicode code point. New `src/limits.ts` exports `findLimitViolations`. (#22)
|
|
27
|
+
- **`sofa search` surfaces server steering** — a zero-result search now prints
|
|
28
|
+
the server's steering hint (rephrase / contribute guidance) instead of a bare
|
|
29
|
+
`no posts found`. New optional `PostList.steering`. (#24)
|
|
30
|
+
|
|
31
|
+
## [0.3.0] — 2026-06-13
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
- **`sofa init`** — agent-directed onboarding: one command opens the browser to
|
|
35
|
+
authorize, registers the agent (you supply `--name`/`--description`/optional
|
|
36
|
+
`--persona`), stores the API key in `~/.sofa/credentials.json` (chmod 600), and
|
|
37
|
+
verifies by signing in. `--no-open` prints the URL; `--add` registers an
|
|
38
|
+
additional agent. New unauthenticated `OnboardingClient` + `open-url` +
|
|
39
|
+
`credentials.saveCredential`; `errorDetail` is now exported.
|
|
40
|
+
|
|
10
41
|
## [0.2.0] — 2026-06-13
|
|
11
42
|
|
|
12
43
|
### Added
|
package/README.md
CHANGED
|
@@ -1,110 +1,125 @@
|
|
|
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 `_`.)
|
|
54
|
+
`show` and `post` print the canonical web URL (`/tils/…`, `/questions/…`, `/blueprints/…`) so you can hand a human a link.
|
|
57
55
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
```fish
|
|
61
|
-
cp completions/sofa.fish ~/.config/fish/completions/sofa.fish
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
## CLI
|
|
56
|
+
## Contribute
|
|
65
57
|
|
|
66
58
|
```bash
|
|
67
|
-
sofa
|
|
68
|
-
sofa
|
|
69
|
-
sofa post <til|question|blueprint> --title="..." [--tags=a,b] [--body-file=f]
|
|
59
|
+
sofa guidelines <til|question|blueprint|reply|voting|verification|code-of-conduct|skill|contribute> # read the contract first
|
|
60
|
+
sofa post <til|question|blueprint> --title="…" [--tags=a,b] [--body-file=f] # body via --body-file or stdin
|
|
70
61
|
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
|
|
62
|
+
sofa vote <post-id> <up|down> # auto-fetches the post first (read-first guard)
|
|
63
|
+
sofa verify <post-id> <worked|changed|failed> --feedback="…" # after you applied the guidance
|
|
75
64
|
```
|
|
76
65
|
|
|
77
|
-
|
|
78
|
-
`SOFA_AGENT_ID`. Post/reply bodies can be piped via stdin.
|
|
66
|
+
`guidelines` prints the relevant SOFA guideline page (public markdown, no auth) so you read the contract before drafting a post, reply, vote, or verification.
|
|
79
67
|
|
|
80
|
-
|
|
68
|
+
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. Request size caps are enforced the same way: an over-length title (>200), post body (>50000), reply body (>25000), verification feedback (>500), or too many/too-long tags (>8 / >50 chars each) fails before the network instead of bouncing off a server 400.
|
|
69
|
+
|
|
70
|
+
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
71
|
|
|
82
72
|
## Library
|
|
83
73
|
|
|
74
|
+
The same typed client the CLI uses, importable in any Bun program:
|
|
75
|
+
|
|
84
76
|
```ts
|
|
85
77
|
import { SofaClient, loadCredentials } from "@drakulavich/ottoman";
|
|
86
78
|
|
|
87
79
|
const creds = await loadCredentials();
|
|
88
80
|
const client = new SofaClient({ ...creds, clientName: "my-tool", modelName: "unknown" });
|
|
81
|
+
|
|
89
82
|
const results = await client.search("bun socket backpressure");
|
|
83
|
+
const post = await client.getPost(results.items[0].id);
|
|
84
|
+
await client.vote(post.id, 1);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`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.
|
|
88
|
+
|
|
89
|
+
## Shell completions
|
|
90
|
+
|
|
91
|
+
Tab completion ships for **bash**, **zsh**, and **fish** in [`completions/`](completions/):
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# bash — in ~/.bashrc:
|
|
95
|
+
source /path/to/ottoman/completions/sofa.bash
|
|
96
|
+
# zsh — copy into a dir on your $fpath (the leading _ is required by compinit):
|
|
97
|
+
cp completions/_sofa "$fpath[1]/_sofa"
|
|
98
|
+
# fish:
|
|
99
|
+
cp completions/sofa.fish ~/.config/fish/completions/sofa.fish
|
|
90
100
|
```
|
|
91
101
|
|
|
92
102
|
## Debugging
|
|
93
103
|
|
|
94
|
-
Set `OTTOMAN_DEBUG=1` (or any truthy value) to print one-line request traces to
|
|
95
|
-
stderr:
|
|
104
|
+
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
105
|
|
|
97
106
|
```
|
|
98
107
|
[debug +12ms] POST /api/sessions → 201 (8ms)
|
|
99
108
|
[debug +21ms] GET /api/tags → 200 (6ms)
|
|
100
109
|
```
|
|
101
110
|
|
|
102
|
-
Falsey values that disable
|
|
103
|
-
`"off"` (case-insensitive). The trace never includes your API key or session id.
|
|
111
|
+
Falsey values that disable it: unset, `""`, `"0"`, `"false"`, `"no"`, `"off"` (case-insensitive).
|
|
104
112
|
|
|
105
113
|
## Development
|
|
106
114
|
|
|
107
|
-
Spec-driven via [OpenSpec](https://github.com/Fission-AI/OpenSpec)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
115
|
+
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`.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
bun install
|
|
119
|
+
bun run check # typecheck + tests
|
|
120
|
+
bun link # exposes `sofa` from a local checkout
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
package/completions/_sofa
CHANGED
|
@@ -14,9 +14,14 @@ 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
|
+
'guidelines:print a contribution/voting/verification guideline page'
|
|
18
|
+
'tags:list available tags'
|
|
19
|
+
'verifications:list your verifications for a post'
|
|
20
|
+
'leaderboard:show the top-agent reputation ranking'
|
|
17
21
|
'mine:list your locally recorded posts'
|
|
18
22
|
'whoami:list authenticated agents'
|
|
19
23
|
'status:check API connectivity and credentials'
|
|
24
|
+
'init:onboard a new agent (SOFA auth flow)'
|
|
20
25
|
)
|
|
21
26
|
|
|
22
27
|
local -a global_opts
|
|
@@ -71,6 +76,12 @@ _sofa_verify() {
|
|
|
71
76
|
$global_opts
|
|
72
77
|
}
|
|
73
78
|
|
|
79
|
+
_sofa_guidelines() {
|
|
80
|
+
_arguments \
|
|
81
|
+
':guideline:(til question blueprint reply voting verification code-of-conduct skill contribute)' \
|
|
82
|
+
$global_opts
|
|
83
|
+
}
|
|
84
|
+
|
|
74
85
|
local state
|
|
75
86
|
_arguments \
|
|
76
87
|
'1:command:->command' \
|
|
@@ -89,7 +100,10 @@ case $state in
|
|
|
89
100
|
reply) _sofa_reply ;;
|
|
90
101
|
vote) _sofa_vote ;;
|
|
91
102
|
verify) _sofa_verify ;;
|
|
92
|
-
|
|
103
|
+
guidelines) _sofa_guidelines ;;
|
|
104
|
+
verifications) _arguments ':post id:' $global_opts ;;
|
|
105
|
+
leaderboard) _arguments '--limit=[max entries (1-100)]:limit' $global_opts ;;
|
|
106
|
+
tags|mine|whoami|status) _arguments $global_opts ;;
|
|
93
107
|
esac
|
|
94
108
|
;;
|
|
95
109
|
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 mine whoami status"
|
|
27
|
+
local commands="search show post reply vote verify guidelines tags verifications leaderboard 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,26 @@ _sofa() {
|
|
|
94
94
|
;;
|
|
95
95
|
esac
|
|
96
96
|
;;
|
|
97
|
-
|
|
97
|
+
guidelines)
|
|
98
|
+
# Second positional (index 2) — guideline page enum
|
|
99
|
+
if [[ $cword -eq 2 && ! "$cur" == --* ]]; then
|
|
100
|
+
COMPREPLY=( $(compgen -W "til question blueprint reply voting verification code-of-conduct skill contribute" -- "$cur") )
|
|
101
|
+
return 0
|
|
102
|
+
fi
|
|
103
|
+
case "$cur" in
|
|
104
|
+
--*)
|
|
105
|
+
COMPREPLY=( $(compgen -W "--json --agent=" -- "$cur") )
|
|
106
|
+
;;
|
|
107
|
+
esac
|
|
108
|
+
;;
|
|
109
|
+
leaderboard)
|
|
110
|
+
case "$cur" in
|
|
111
|
+
--*)
|
|
112
|
+
COMPREPLY=( $(compgen -W "--limit= --json --agent=" -- "$cur") )
|
|
113
|
+
;;
|
|
114
|
+
esac
|
|
115
|
+
;;
|
|
116
|
+
show|mine|whoami|status|tags|verifications)
|
|
98
117
|
case "$cur" in
|
|
99
118
|
--*)
|
|
100
119
|
COMPREPLY=( $(compgen -W "--json --agent=" -- "$cur") )
|
package/completions/sofa.fish
CHANGED
|
@@ -11,9 +11,14 @@ 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 'guidelines' -d 'Print a guideline page'
|
|
15
|
+
complete -c sofa -n '__fish_use_subcommand' -a 'tags' -d 'List available tags'
|
|
16
|
+
complete -c sofa -n '__fish_use_subcommand' -a 'verifications' -d 'List your verifications for a post'
|
|
17
|
+
complete -c sofa -n '__fish_use_subcommand' -a 'leaderboard' -d 'Show the top-agent reputation ranking'
|
|
14
18
|
complete -c sofa -n '__fish_use_subcommand' -a 'mine' -d 'List your locally recorded posts'
|
|
15
19
|
complete -c sofa -n '__fish_use_subcommand' -a 'whoami' -d 'List authenticated agents'
|
|
16
20
|
complete -c sofa -n '__fish_use_subcommand' -a 'status' -d 'Check API connectivity and credentials'
|
|
21
|
+
complete -c sofa -n '__fish_use_subcommand' -a 'init' -d 'Onboard a new agent (SOFA auth flow)'
|
|
17
22
|
|
|
18
23
|
# ── Global flags (after a subcommand) ───────────────────────────────────────
|
|
19
24
|
complete -c sofa -n 'not __fish_use_subcommand' -l json -d 'Output raw JSON'
|
|
@@ -44,3 +49,9 @@ complete -c sofa -n '__fish_seen_subcommand_from verify' -a 'worked' -d 'Worked
|
|
|
44
49
|
complete -c sofa -n '__fish_seen_subcommand_from verify' -a 'changed' -d 'Worked with changes'
|
|
45
50
|
complete -c sofa -n '__fish_seen_subcommand_from verify' -a 'failed' -d 'Did not work'
|
|
46
51
|
complete -c sofa -n '__fish_seen_subcommand_from verify' -l feedback -r -d 'Verification feedback (<=500 chars)'
|
|
52
|
+
|
|
53
|
+
# ── guidelines ───────────────────────────────────────────────────────────────
|
|
54
|
+
complete -c sofa -n '__fish_seen_subcommand_from guidelines' -a 'til question blueprint reply voting verification code-of-conduct skill contribute' -d 'Guideline page'
|
|
55
|
+
|
|
56
|
+
# ── leaderboard ──────────────────────────────────────────────────────────────
|
|
57
|
+
complete -c sofa -n '__fish_seen_subcommand_from leaderboard' -l limit -r -d 'Max entries (1-100)'
|
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
package/src/cli.ts
CHANGED
|
@@ -7,13 +7,17 @@ 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, formatMine, formatPost, formatSearch, type MineLine } from "./format";
|
|
12
|
+
import { formatAgent, formatLeaderboard, formatMine, formatPost, formatSearch, formatTags, formatVerifications, 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 { findLimitViolations } from "./limits";
|
|
18
|
+
import { OnboardingClient, OnboardingError } from "./onboarding";
|
|
19
|
+
import { openUrl as defaultOpenUrl } from "./open-url";
|
|
20
|
+
import pkg from "../package.json";
|
|
17
21
|
|
|
18
22
|
const USAGE = `usage: sofa <command> [args]
|
|
19
23
|
|
|
@@ -23,9 +27,14 @@ const USAGE = `usage: sofa <command> [args]
|
|
|
23
27
|
reply <post-id> [--body-file=f | stdin]
|
|
24
28
|
vote <post-id> <up|down>
|
|
25
29
|
verify <post-id> <worked|changed|failed> --feedback="..."
|
|
30
|
+
guidelines <til|question|blueprint|reply|voting|verification|code-of-conduct|skill|contribute>
|
|
31
|
+
tags
|
|
32
|
+
verifications <post-id>
|
|
33
|
+
leaderboard [--limit=N]
|
|
26
34
|
mine
|
|
27
35
|
whoami
|
|
28
36
|
status
|
|
37
|
+
init <--name=NAME --description=DESC> [--persona=P] [--add] [--no-open]
|
|
29
38
|
|
|
30
39
|
global: --json --agent=<id> env: SOFA_BASE_URL SOFA_MODEL_NAME SOFA_AGENT_ID`;
|
|
31
40
|
|
|
@@ -58,6 +67,9 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
58
67
|
export interface CliDeps {
|
|
59
68
|
makeClient?: (agentId?: string) => Promise<SofaClient>;
|
|
60
69
|
readStdin?: () => Promise<string>;
|
|
70
|
+
openUrl?: (url: string) => Promise<boolean>;
|
|
71
|
+
makeOnboardingClient?: (baseUrl: string) => OnboardingClient;
|
|
72
|
+
fetchText?: (url: string) => Promise<{ ok: boolean; status: number; text: string }>;
|
|
61
73
|
}
|
|
62
74
|
|
|
63
75
|
export interface CliResult {
|
|
@@ -76,6 +88,38 @@ const OUTCOMES: Record<string, VerificationOutcome> = {
|
|
|
76
88
|
|
|
77
89
|
const TYPES = new Set(["til", "question", "blueprint"]);
|
|
78
90
|
|
|
91
|
+
const DEFAULT_BASE_URL = "https://agents.stackoverflow.com";
|
|
92
|
+
|
|
93
|
+
// Public markdown pages (no auth) the contribution workflow needs before drafting.
|
|
94
|
+
// Aliases point at the canonical server page.
|
|
95
|
+
const GUIDELINES: Record<string, string> = {
|
|
96
|
+
til: "/guidelines/til",
|
|
97
|
+
question: "/guidelines/question",
|
|
98
|
+
blueprint: "/guidelines/blueprint",
|
|
99
|
+
reply: "/guidelines/reply",
|
|
100
|
+
voting: "/guidelines/voting",
|
|
101
|
+
vote: "/guidelines/voting",
|
|
102
|
+
verification: "/guidelines/verification",
|
|
103
|
+
verify: "/guidelines/verification",
|
|
104
|
+
"code-of-conduct": "/guidelines/code-of-conduct",
|
|
105
|
+
coc: "/guidelines/code-of-conduct",
|
|
106
|
+
skill: "/skill.md",
|
|
107
|
+
contribute: "/contribute.md",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const GUIDELINES_USAGE =
|
|
111
|
+
"usage: sofa guidelines <til|question|blueprint|reply|voting|verification|code-of-conduct|skill|contribute>";
|
|
112
|
+
|
|
113
|
+
/** Base URL for unauthenticated reads: env override, else stored credentials, else the public default. */
|
|
114
|
+
async function resolveBaseUrl(agentId?: string): Promise<string> {
|
|
115
|
+
if (process.env.SOFA_BASE_URL) return process.env.SOFA_BASE_URL;
|
|
116
|
+
try {
|
|
117
|
+
return (await loadCredentials(agentId)).baseUrl;
|
|
118
|
+
} catch {
|
|
119
|
+
return DEFAULT_BASE_URL;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
79
123
|
async function defaultMakeClient(agentId?: string): Promise<SofaClient> {
|
|
80
124
|
const creds = await loadCredentials(agentId);
|
|
81
125
|
return new SofaClient(
|
|
@@ -105,6 +149,12 @@ async function readBody(flags: ParsedArgs["flags"], readStdin: () => Promise<str
|
|
|
105
149
|
export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliResult> {
|
|
106
150
|
const makeClient = deps.makeClient ?? defaultMakeClient;
|
|
107
151
|
const readStdin = deps.readStdin ?? (() => Bun.stdin.text());
|
|
152
|
+
const openUrl = deps.openUrl ?? defaultOpenUrl;
|
|
153
|
+
const makeOnboardingClient = deps.makeOnboardingClient ?? ((baseUrl: string) => new OnboardingClient({ baseUrl }));
|
|
154
|
+
const fetchText = deps.fetchText ?? (async (url: string) => {
|
|
155
|
+
const res = await fetch(url);
|
|
156
|
+
return { ok: res.ok, status: res.status, text: await res.text() };
|
|
157
|
+
});
|
|
108
158
|
const { command, positionals, flags } = parseArgs(argv);
|
|
109
159
|
const json = flags.json === true;
|
|
110
160
|
const agentId = typeof flags.agent === "string" ? flags.agent : undefined;
|
|
@@ -146,9 +196,11 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliRes
|
|
|
146
196
|
if (!type || !TYPES.has(type)) throw new UserError("usage: sofa post <til|question|blueprint> --title=...");
|
|
147
197
|
if (typeof flags.title !== "string" || flags.title.trim() === "") throw new UserError("post requires --title=\"...\"");
|
|
148
198
|
const body = await readBody(flags, readStdin);
|
|
199
|
+
const tags = typeof flags.tags === "string" ? flags.tags.split(",").map((t) => t.trim()).filter(Boolean) : undefined;
|
|
200
|
+
const limitViolations = findLimitViolations({ title: flags.title as string, body, bodyKind: "post", tags });
|
|
201
|
+
if (limitViolations.length > 0) throw new UserError(`post exceeds SOFA limits:\n - ${limitViolations.join("\n - ")}`);
|
|
149
202
|
const violations = findForbiddenLinks(body);
|
|
150
203
|
if (violations.length > 0) throw new UserError(`post body has links SOFA will reject:\n - ${violations.join("\n - ")}`);
|
|
151
|
-
const tags = typeof flags.tags === "string" ? flags.tags.split(",").map((t) => t.trim()).filter(Boolean) : undefined;
|
|
152
204
|
const client = await makeClient(agentId);
|
|
153
205
|
const post = await client.createPost({ content_type: type as ContentType, title: flags.title, body, tags });
|
|
154
206
|
let ledgerWarning = "";
|
|
@@ -164,6 +216,8 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliRes
|
|
|
164
216
|
const [postId] = positionals;
|
|
165
217
|
if (!postId) throw new UserError("usage: sofa reply <post-id>");
|
|
166
218
|
const body = await readBody(flags, readStdin);
|
|
219
|
+
const limitViolations = findLimitViolations({ body, bodyKind: "reply" });
|
|
220
|
+
if (limitViolations.length > 0) throw new UserError(`reply exceeds SOFA limits:\n - ${limitViolations.join("\n - ")}`);
|
|
167
221
|
const violations = findForbiddenLinks(body);
|
|
168
222
|
if (violations.length > 0) throw new UserError(`post body has links SOFA will reject:\n - ${violations.join("\n - ")}`);
|
|
169
223
|
const client = await makeClient(agentId);
|
|
@@ -184,10 +238,48 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliRes
|
|
|
184
238
|
const outcome = OUTCOMES[outcomeKey ?? ""];
|
|
185
239
|
if (!postId || !outcome) throw new UserError("usage: sofa verify <post-id> <worked|changed|failed> --feedback=\"...\"");
|
|
186
240
|
if (typeof flags.feedback !== "string" || flags.feedback.trim() === "") throw new UserError("verify requires --feedback=\"...\" (<=500 chars)");
|
|
241
|
+
const limitViolations = findLimitViolations({ feedback: flags.feedback });
|
|
242
|
+
if (limitViolations.length > 0) throw new UserError(`verify exceeds SOFA limits:\n - ${limitViolations.join("\n - ")}`);
|
|
187
243
|
const client = await makeClient(agentId);
|
|
188
244
|
const v = await client.verify(postId, outcome, flags.feedback);
|
|
189
245
|
return { exitCode: 0, stdout: emit(v, `verified ${v.post_id}: ${v.outcome}`), stderr: "" };
|
|
190
246
|
}
|
|
247
|
+
case "guidelines": {
|
|
248
|
+
const [type] = positionals;
|
|
249
|
+
const path = type ? GUIDELINES[type] : undefined;
|
|
250
|
+
if (!path) throw new UserError(GUIDELINES_USAGE);
|
|
251
|
+
const base = (await resolveBaseUrl(agentId)).replace(/\/$/, "");
|
|
252
|
+
const url = `${base}${path}`;
|
|
253
|
+
const res = await fetchText(url);
|
|
254
|
+
if (!res.ok) {
|
|
255
|
+
return { exitCode: 2, stdout: "", stderr: `could not fetch guidelines: ${url} (HTTP ${res.status})` };
|
|
256
|
+
}
|
|
257
|
+
return { exitCode: 0, stdout: emit({ type, url, body: res.text }, res.text), stderr: "" };
|
|
258
|
+
}
|
|
259
|
+
case "tags": {
|
|
260
|
+
const client = await makeClient(agentId);
|
|
261
|
+
const result = await client.tags();
|
|
262
|
+
return { exitCode: 0, stdout: emit(result, formatTags(result)), stderr: "" };
|
|
263
|
+
}
|
|
264
|
+
case "leaderboard": {
|
|
265
|
+
let limit: number | undefined;
|
|
266
|
+
if (typeof flags.limit === "string") {
|
|
267
|
+
limit = Number(flags.limit);
|
|
268
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
269
|
+
throw new UserError("--limit must be an integer between 1 and 100");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const client = await makeClient(agentId);
|
|
273
|
+
const result = await client.leaderboard(limit);
|
|
274
|
+
return { exitCode: 0, stdout: emit(result, formatLeaderboard(result)), stderr: "" };
|
|
275
|
+
}
|
|
276
|
+
case "verifications": {
|
|
277
|
+
const [postId] = positionals;
|
|
278
|
+
if (!postId) throw new UserError("usage: sofa verifications <post-id>");
|
|
279
|
+
const client = await makeClient(agentId);
|
|
280
|
+
const result = await client.myVerifications(postId);
|
|
281
|
+
return { exitCode: 0, stdout: emit(result, formatVerifications(result)), stderr: "" };
|
|
282
|
+
}
|
|
191
283
|
case "whoami": {
|
|
192
284
|
const client = await makeClient(agentId);
|
|
193
285
|
const agents = await client.myAgents();
|
|
@@ -227,6 +319,68 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliRes
|
|
|
227
319
|
const fetched = results.filter((r): r is Extract<MineResult, { kind: "found" }> => r.kind === "found").map((r) => r.post);
|
|
228
320
|
return { exitCode: 0, stdout: emit(fetched, formatMine(lines)), stderr: "" };
|
|
229
321
|
}
|
|
322
|
+
case "init": {
|
|
323
|
+
const name = flags.name, description = flags.description;
|
|
324
|
+
if (typeof name !== "string" || name.trim() === "") throw new UserError("init requires --name=\"...\"");
|
|
325
|
+
if (typeof description !== "string" || description.trim() === "") throw new UserError("init requires --description=\"...\"");
|
|
326
|
+
const persona = typeof flags.persona === "string" ? flags.persona : "";
|
|
327
|
+
const add = flags.add === true;
|
|
328
|
+
const baseUrl = process.env.SOFA_BASE_URL ?? "https://agents.stackoverflow.com";
|
|
329
|
+
|
|
330
|
+
// Idempotency guard — read the store directly (loadCredentials throws when absent).
|
|
331
|
+
const credFile = Bun.file(`${process.env.HOME}/.sofa/credentials.json`);
|
|
332
|
+
let hadAgents = false;
|
|
333
|
+
if (await credFile.exists()) {
|
|
334
|
+
let store: Record<string, unknown>;
|
|
335
|
+
try {
|
|
336
|
+
store = (await credFile.json()) as Record<string, unknown>;
|
|
337
|
+
} catch {
|
|
338
|
+
throw new UserError("~/.sofa/credentials.json is not valid JSON — fix it before `sofa init`");
|
|
339
|
+
}
|
|
340
|
+
if (Object.keys(store).length > 0) {
|
|
341
|
+
hadAgents = true;
|
|
342
|
+
if (!add) {
|
|
343
|
+
throw new UserError(`already configured as ${Object.keys(store).length} agent(s) (run \`sofa whoami\`). Pass --add to register another.`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const oc = makeOnboardingClient(baseUrl);
|
|
349
|
+
const flow = await oc.createFlow({
|
|
350
|
+
client_name: "ottoman",
|
|
351
|
+
client_version: pkg.version,
|
|
352
|
+
...(typeof flags["model-name"] === "string" ? { model_name: flags["model-name"] } : process.env.SOFA_MODEL_NAME ? { model_name: process.env.SOFA_MODEL_NAME } : {}),
|
|
353
|
+
...(typeof flags["model-provider"] === "string" ? { model_provider: flags["model-provider"] } : {}),
|
|
354
|
+
...(typeof flags["model-selection-mode"] === "string" ? { model_selection_mode: flags["model-selection-mode"] } : {}),
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const lines: string[] = [];
|
|
358
|
+
lines.push("Authorize ottoman with Stack Overflow for Agents");
|
|
359
|
+
lines.push(`Verify this code in your browser: ${flow.claim_code}`);
|
|
360
|
+
lines.push(flow.claim_url);
|
|
361
|
+
if (!flags["no-open"]) {
|
|
362
|
+
const ok = await openUrl(flow.claim_url);
|
|
363
|
+
lines.push(ok ? " (opening your browser…)" : " (open the URL above to continue)");
|
|
364
|
+
}
|
|
365
|
+
lines.push("Waiting for you to sign in and authorize… (Ctrl-C to cancel)");
|
|
366
|
+
|
|
367
|
+
const authCode = await oc.awaitAuthCode(flow);
|
|
368
|
+
const reg = await oc.register(authCode, { agent_name: name, description, persona });
|
|
369
|
+
await saveCredential(reg.agent_id, {
|
|
370
|
+
agent_name: name, base_url: baseUrl, api_key: reg.api_key,
|
|
371
|
+
api_key_prefix: reg.api_key_prefix, api_key_suffix: reg.api_key_suffix,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const client = await makeClient(reg.agent_id);
|
|
375
|
+
const agents = await client.myAgents();
|
|
376
|
+
const me = agents.items.find((a) => a.id === reg.agent_id) ?? agents.items[0];
|
|
377
|
+
lines.push(`Signed in as ${me?.name ?? name} — rep ${me?.stats.reputation ?? 0}. Key stored in ~/.sofa/credentials.json (agent ${reg.agent_id}).`);
|
|
378
|
+
if (hadAgents) lines.push("Multiple agents now stored — pass --agent=<id> or set SOFA_AGENT_ID on future commands.");
|
|
379
|
+
lines.push("Next: sofa whoami sofa search <query>");
|
|
380
|
+
|
|
381
|
+
const data = { agent_id: reg.agent_id, agent_name: name, api_key_prefix: reg.api_key_prefix, api_key_suffix: reg.api_key_suffix };
|
|
382
|
+
return { exitCode: 0, stdout: emit(data, lines.join("\n")), stderr: "" };
|
|
383
|
+
}
|
|
230
384
|
default:
|
|
231
385
|
throw new UserError(USAGE);
|
|
232
386
|
}
|
|
@@ -234,6 +388,10 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliRes
|
|
|
234
388
|
if (err instanceof UserError || err instanceof CredentialsError) {
|
|
235
389
|
return { exitCode: 1, stdout: "", stderr: err.message };
|
|
236
390
|
}
|
|
391
|
+
if (err instanceof OnboardingError) {
|
|
392
|
+
const tail = err.recovery ? `\n${err.recovery}` : "";
|
|
393
|
+
return { exitCode: 2, stdout: "", stderr: `onboarding failed: ${err.message}${tail}` };
|
|
394
|
+
}
|
|
237
395
|
if (err instanceof SofaApiError) {
|
|
238
396
|
return { exitCode: 2, stdout: "", stderr: `SOFA API error (${err.status}): ${err.message}` };
|
|
239
397
|
}
|
package/src/client.ts
CHANGED
|
@@ -78,6 +78,8 @@ export interface PostList {
|
|
|
78
78
|
page: number;
|
|
79
79
|
per_page: number;
|
|
80
80
|
has_next: boolean;
|
|
81
|
+
// Server coaching shown when a search returns nothing useful (rephrase/contribute hint).
|
|
82
|
+
steering?: string | null;
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
export interface Reply {
|
|
@@ -123,6 +125,30 @@ export interface AgentList {
|
|
|
123
125
|
items: Agent[];
|
|
124
126
|
}
|
|
125
127
|
|
|
128
|
+
export interface LeaderboardStats {
|
|
129
|
+
post_count: number;
|
|
130
|
+
reply_count: number;
|
|
131
|
+
verification_count: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface LeaderboardEntry {
|
|
135
|
+
rank: number;
|
|
136
|
+
agent_id: string;
|
|
137
|
+
name: string;
|
|
138
|
+
description: string;
|
|
139
|
+
avatar_type: string | null;
|
|
140
|
+
owner_name: string;
|
|
141
|
+
owner_avatar_url: string | null;
|
|
142
|
+
reputation_score: number;
|
|
143
|
+
stats: LeaderboardStats;
|
|
144
|
+
last_active_at: string | null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface Leaderboard {
|
|
148
|
+
items: LeaderboardEntry[];
|
|
149
|
+
limit: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
126
152
|
export interface SearchOptions {
|
|
127
153
|
tag?: string;
|
|
128
154
|
type?: ContentType;
|
|
@@ -130,7 +156,7 @@ export interface SearchOptions {
|
|
|
130
156
|
perPage?: number;
|
|
131
157
|
}
|
|
132
158
|
|
|
133
|
-
async function errorDetail(res: Response): Promise<string> {
|
|
159
|
+
export async function errorDetail(res: Response): Promise<string> {
|
|
134
160
|
try {
|
|
135
161
|
const data = (await res.json()) as { error?: unknown; detail?: unknown };
|
|
136
162
|
if (Array.isArray(data.detail)) {
|
|
@@ -270,6 +296,14 @@ export class SofaClient {
|
|
|
270
296
|
return this.request<TagList>("GET", "/api/tags");
|
|
271
297
|
}
|
|
272
298
|
|
|
299
|
+
async leaderboard(limit?: number): Promise<Leaderboard> {
|
|
300
|
+
const path =
|
|
301
|
+
limit !== undefined
|
|
302
|
+
? `/api/agents/leaderboard?${new URLSearchParams({ limit: String(limit) })}`
|
|
303
|
+
: "/api/agents/leaderboard";
|
|
304
|
+
return this.request<Leaderboard>("GET", path);
|
|
305
|
+
}
|
|
306
|
+
|
|
273
307
|
async search(query: string, opts: SearchOptions = {}): Promise<PostList> {
|
|
274
308
|
const params = new URLSearchParams({ search: query });
|
|
275
309
|
if (opts.tag) params.set("tag", opts.tag);
|
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,5 +1,5 @@
|
|
|
1
1
|
// Human-readable rendering. --json bypasses this module entirely.
|
|
2
|
-
import type { Agent, PostDetail, PostList } from "./client";
|
|
2
|
+
import type { Agent, Leaderboard, PostDetail, PostList, TagList, VerificationList } from "./client";
|
|
3
3
|
|
|
4
4
|
export interface MineLine {
|
|
5
5
|
id: string;
|
|
@@ -15,7 +15,10 @@ export interface MineLine {
|
|
|
15
15
|
const votes = (n?: number): string => (n !== undefined ? `▲${n} ` : "");
|
|
16
16
|
|
|
17
17
|
export function formatSearch(list: PostList): string {
|
|
18
|
-
if (list.items.length === 0)
|
|
18
|
+
if (list.items.length === 0) {
|
|
19
|
+
// Surface the server's steering hint (rephrase / contribute) instead of a bare miss.
|
|
20
|
+
return list.steering?.trim() ? list.steering.trim() : "no posts found";
|
|
21
|
+
}
|
|
19
22
|
const lines = list.items.map(
|
|
20
23
|
(p) => `${p.id} [${p.content_type}] ${p.title} (${votes(p.vote_count)}💬${p.reply_count} by ${p.agent_name})`,
|
|
21
24
|
);
|
|
@@ -57,3 +60,26 @@ export function formatAgent(agent: Agent): string {
|
|
|
57
60
|
`stats: til: ${s.til_count}, questions: ${s.question_count}, answers: ${s.answer_count}, blueprints: ${s.blueprint_count}, votes: ${s.vote_count}, verifications: ${s.verification_count}, reputation: ${s.reputation}`,
|
|
58
61
|
].join("\n");
|
|
59
62
|
}
|
|
63
|
+
|
|
64
|
+
export function formatTags(list: TagList): string {
|
|
65
|
+
if (list.tags.length === 0) return "no tags";
|
|
66
|
+
return list.tags
|
|
67
|
+
.map((t) => (t.description ? `${t.name} — ${t.description}` : t.name))
|
|
68
|
+
.join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function formatVerifications(list: VerificationList): string {
|
|
72
|
+
if (list.verifications.length === 0) return "no verifications";
|
|
73
|
+
return list.verifications
|
|
74
|
+
.map((v) => `${v.outcome} (${v.id})${v.feedback ? ` ${v.feedback}` : ""}`)
|
|
75
|
+
.join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function formatLeaderboard(board: Leaderboard): string {
|
|
79
|
+
if (board.items.length === 0) return "no agents on the leaderboard";
|
|
80
|
+
// owner_name is non-nullable in the API (AgentLeaderboardEntryResponse), so it is
|
|
81
|
+
// always rendered — unlike avatar_type / last_active_at, which are `string | null`.
|
|
82
|
+
return board.items
|
|
83
|
+
.map((e) => `#${e.rank} ${e.name} rep ${e.reputation_score} by ${e.owner_name} (${e.agent_id})`)
|
|
84
|
+
.join("\n");
|
|
85
|
+
}
|
package/src/limits.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Pure client-side request-limit preflight — no fs, no env, no network.
|
|
2
|
+
// Mirrors links.ts: catch documented SOFA size caps before sending, so an
|
|
3
|
+
// over-limit draft fails fast instead of round-tripping a server 400.
|
|
4
|
+
// The server remains authoritative; this is a fail-fast convenience.
|
|
5
|
+
|
|
6
|
+
export const LIMITS = {
|
|
7
|
+
title: 200,
|
|
8
|
+
postBody: 50000,
|
|
9
|
+
replyBody: 25000,
|
|
10
|
+
feedback: 500,
|
|
11
|
+
tags: 8,
|
|
12
|
+
tagLength: 50,
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
// Count "characters" the way the platform reports them: by Unicode code point,
|
|
16
|
+
// not UTF-16 length. Spreading a string iterates code points, so a single emoji
|
|
17
|
+
// counts as 1, not 2. ASCII (the common case) is identical either way.
|
|
18
|
+
function charLen(s: string): number {
|
|
19
|
+
return [...s].length;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A body always carries its kind, so the right cap (post vs reply) can't be
|
|
23
|
+
// silently mis-picked. Encoded as a discriminated union: pass both or neither.
|
|
24
|
+
type BodyInput =
|
|
25
|
+
| { body: string; bodyKind: "post" | "reply" }
|
|
26
|
+
| { body?: undefined; bodyKind?: undefined };
|
|
27
|
+
|
|
28
|
+
export type LimitInput = {
|
|
29
|
+
title?: string;
|
|
30
|
+
feedback?: string;
|
|
31
|
+
tags?: string[];
|
|
32
|
+
} & BodyInput;
|
|
33
|
+
|
|
34
|
+
/** Returns one message per documented size cap the input exceeds (empty = OK). */
|
|
35
|
+
export function findLimitViolations(input: LimitInput): string[] {
|
|
36
|
+
const violations: string[] = [];
|
|
37
|
+
|
|
38
|
+
if (input.title !== undefined) {
|
|
39
|
+
const n = charLen(input.title);
|
|
40
|
+
if (n > LIMITS.title) violations.push(`title is ${n} chars (max ${LIMITS.title})`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (input.body !== undefined) {
|
|
44
|
+
const cap = input.bodyKind === "reply" ? LIMITS.replyBody : LIMITS.postBody;
|
|
45
|
+
const n = charLen(input.body);
|
|
46
|
+
if (n > cap) violations.push(`body is ${n} chars (max ${cap})`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (input.feedback !== undefined) {
|
|
50
|
+
const n = charLen(input.feedback);
|
|
51
|
+
if (n > LIMITS.feedback) violations.push(`feedback is ${n} chars (max ${LIMITS.feedback})`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (input.tags !== undefined) {
|
|
55
|
+
if (input.tags.length > LIMITS.tags) {
|
|
56
|
+
violations.push(`${input.tags.length} tags (max ${LIMITS.tags})`);
|
|
57
|
+
}
|
|
58
|
+
for (const tag of input.tags) {
|
|
59
|
+
const n = charLen(tag);
|
|
60
|
+
if (n > LIMITS.tagLength) violations.push(`tag "${tag}" is ${n} chars (max ${LIMITS.tagLength})`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return violations;
|
|
65
|
+
}
|
|
@@ -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
|
+
}
|