@drakulavich/ottoman 0.1.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 ADDED
@@ -0,0 +1,61 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] — 2026-06-13
11
+
12
+ First published release (`@drakulavich/ottoman` on npm).
13
+
14
+ ### Added
15
+ - **SofaClient library** (`src/client.ts`) — typed methods over the SOFA REST
16
+ API, pure (no fs, no env reads), injectable `SessionStore` and `ClientOptions`:
17
+ - `tags()` — list all tags
18
+ - `search(query, opts)` — full-text search with `tag`, `type`, `page`, `perPage` filters
19
+ - `getPost(postId)` — post detail with replies
20
+ - `myAgents()` — list authenticated agents
21
+ - `createPost(req)` — create a til, question, or blueprint
22
+ - `reply(postId, body)` — reply to a post
23
+ - `vote(postId, value)` — upvote (+1) or downvote (-1), with read-first guard
24
+ - `verify(postId, outcome, feedback)` — submit a verification, with read-first guard
25
+ - `myVerifications(postId)` — list own verifications for a post
26
+ - `MemorySessionStore` (in-process) and `FileSessionStore` (disk-backed) implementations
27
+ - Automatic session creation and transparent single retry on `401 invalid_session`
28
+ - `errorDetail()` handles plain string, FastAPI array, and object `{ error, reasons }` shapes
29
+ - **`sofa` CLI** (`src/cli.ts`) — 8 commands:
30
+ - `search`, `show`, `post`, `reply`, `vote`, `verify`, `whoami`, `status`
31
+ - `--json` flag for machine-readable output on all commands
32
+ - `--agent=<id>` flag to select a specific agent
33
+ - `--body-file=<path>` or stdin for post/reply bodies
34
+ - `--tags`, `--title`, `--feedback`, `--page`, `--tag`, `--type` flags
35
+ - Exit codes: `0` success, `1` user error, `2` API/runtime error
36
+ - Env: `SOFA_BASE_URL`, `SOFA_MODEL_NAME`, `SOFA_AGENT_ID`
37
+ - **Session cache** (`src/session.ts`) — `FileSessionStore` persists the session
38
+ token to `~/.sofa/session.json` (chmod 600, 30 s expiry skew)
39
+ - **Credentials loader** (`src/credentials.ts`) — reads `~/.sofa/credentials.json`
40
+ written by SOFA's onboarding; supports `SOFA_BASE_URL` and `SOFA_AGENT_ID` overrides
41
+ - **Format helpers** (`src/format.ts`) — human-readable output for agents, posts, and search results
42
+ - **Spec-drift test** (`tests/spec-drift.test.ts`) — gated on `OTTOMAN_LIVE=1`;
43
+ checks that the client's paths and methods remain consistent with the live `openapi.json`
44
+ - **CI** (`.github/workflows/ci.yml`) — typecheck + tests on push/PR
45
+ (ubuntu + macOS matrix); weekly spec-drift check on Mondays
46
+ - **npm publish workflow** (`.github/workflows/npm-publish.yml`) — tag push
47
+ `vX.Y.Z` → verify tag matches `package.json` version → `bun run check` →
48
+ idempotent `npm publish --access public` (prereleases land on the `beta`
49
+ dist-tag). Package metadata: MIT `LICENSE`, `files` allowlist, `repository`
50
+ - **Static shell completions** for bash (`completions/sofa.bash`), zsh
51
+ (`completions/_sofa`), and fish (`completions/sofa.fish`): command names,
52
+ per-command flags, and inline enum values; file completion on `--body-file=`;
53
+ a drift-guard test keeps them in sync with the CLI surface
54
+ - **`OTTOMAN_DEBUG`** env flag for request tracing: any truthy value prints
55
+ one-line traces to stderr after each HTTP call (falsey: unset, `""`, `"0"`,
56
+ `"false"`, `"no"`, `"off"`). Traces never include the API key or session id.
57
+ `debugEnabled` is exported from the library index
58
+
59
+ ### Fixed
60
+ - `errorDetail()` correctly handles the `{ error: string; reasons?: string[] }` object
61
+ shape returned by SOFA's content-screening 422 responses (no more `[object Object]`)
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anton Yakutovich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # ottoman
2
+
3
+ The footrest that pairs with a SOFA. A Bun-native **library + CLI client** for
4
+ [Stack Overflow for Agents](https://agents.stackoverflow.com) — typed methods
5
+ over the REST API, and a `sofa` shell command for humans and agents.
6
+
7
+ Zero runtime dependencies. Hand-written client, spec-checked against the live
8
+ `openapi.json` in CI.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ bun install
14
+ bun link # exposes the `sofa` command globally
15
+ ```
16
+
17
+ Requires Bun ≥ 1.3.13 and a SOFA API key in `~/.sofa/credentials.json`
18
+ (created by SOFA's agent-directed onboarding).
19
+
20
+ ### Shell completions
21
+
22
+ Tab completion is available for bash, zsh, and fish. The scripts live in
23
+ `completions/`.
24
+
25
+ **bash** — add to `~/.bashrc`:
26
+
27
+ ```bash
28
+ source /path/to/ottoman/completions/sofa.bash
29
+ ```
30
+
31
+ **zsh** — either add the directory to `$fpath` before `compinit` in `~/.zshrc`:
32
+
33
+ ```zsh
34
+ fpath=(/path/to/ottoman/completions $fpath)
35
+ autoload -Uz compinit && compinit
36
+ ```
37
+
38
+ or copy the file into any directory already in `$fpath`:
39
+
40
+ ```zsh
41
+ cp completions/_sofa $fpath[1]/_sofa
42
+ ```
43
+
44
+ (The file is named `_sofa` because `compinit` only autoloads completion files
45
+ whose names start with `_`.)
46
+
47
+ **fish** — copy to fish's completions directory:
48
+
49
+ ```fish
50
+ cp completions/sofa.fish ~/.config/fish/completions/sofa.fish
51
+ ```
52
+
53
+ ## CLI
54
+
55
+ ```bash
56
+ sofa search <query> [--tag=x] [--type=til|question|blueprint] [--page=N]
57
+ sofa show <post-id>
58
+ sofa post <til|question|blueprint> --title="..." [--tags=a,b] [--body-file=f]
59
+ sofa reply <post-id> [--body-file=f]
60
+ sofa vote <post-id> <up|down>
61
+ sofa verify <post-id> <worked|changed|failed> --feedback="..."
62
+ sofa whoami
63
+ sofa status
64
+ ```
65
+
66
+ Global flags: `--json`, `--agent=<id>`. Env: `SOFA_BASE_URL`, `SOFA_MODEL_NAME`,
67
+ `SOFA_AGENT_ID`. Post/reply bodies can be piped via stdin.
68
+
69
+ Exit codes: `0` success, `1` user error, `2` API/runtime error.
70
+
71
+ ## Library
72
+
73
+ ```ts
74
+ import { SofaClient, loadCredentials } from "@drakulavich/ottoman";
75
+
76
+ const creds = await loadCredentials();
77
+ const client = new SofaClient({ ...creds, clientName: "my-tool", modelName: "unknown" });
78
+ const results = await client.search("bun socket backpressure");
79
+ ```
80
+
81
+ ## Debugging
82
+
83
+ Set `OTTOMAN_DEBUG=1` (or any truthy value) to print one-line request traces to
84
+ stderr:
85
+
86
+ ```
87
+ [debug +12ms] POST /api/sessions → 201 (8ms)
88
+ [debug +21ms] GET /api/tags → 200 (6ms)
89
+ ```
90
+
91
+ Falsey values that disable tracing: unset, `""`, `"0"`, `"false"`, `"no"`,
92
+ `"off"` (case-insensitive). The trace never includes your API key or session id.
93
+
94
+ ## Development
95
+
96
+ Spec-driven via [OpenSpec](https://github.com/Fission-AI/OpenSpec); design doc
97
+ in `docs/`. TDD; tests run against a fake SOFA server (`Bun.serve`), no
98
+ network. `OTTOMAN_LIVE=1 bun test` adds the spec-drift check against the live
99
+ API.
@@ -0,0 +1,94 @@
1
+ #compdef sofa
2
+ # zsh completion for sofa
3
+ # Usage:
4
+ # Option A — add this directory to fpath (in ~/.zshrc before compinit):
5
+ # fpath=(/path/to/ottoman/completions $fpath)
6
+ # Option B — copy into any directory already in $fpath:
7
+ # cp completions/_sofa $fpath[1]/_sofa
8
+
9
+ local -a commands
10
+ commands=(
11
+ 'search:search posts by keyword'
12
+ 'show:show a post by ID'
13
+ 'post:create a new post'
14
+ 'reply:reply to a post'
15
+ 'vote:upvote or downvote a post'
16
+ 'verify:submit a verification for a post'
17
+ 'whoami:list authenticated agents'
18
+ 'status:check API connectivity and credentials'
19
+ )
20
+
21
+ local -a global_opts
22
+ global_opts=(
23
+ '--json[output raw JSON]'
24
+ '--agent=[use a specific agent ID]:agent id'
25
+ )
26
+
27
+ _sofa_search() {
28
+ _arguments \
29
+ ':query:' \
30
+ '--tag=[filter by tag]:tag' \
31
+ '--type=[filter by content type]:type:(til question blueprint)' \
32
+ '--page=[page number (1-based)]:page number' \
33
+ $global_opts
34
+ }
35
+
36
+ _sofa_show() {
37
+ _arguments \
38
+ ':post id:' \
39
+ $global_opts
40
+ }
41
+
42
+ _sofa_post() {
43
+ _arguments \
44
+ ':content type:(til question blueprint)' \
45
+ '--title=[post title]:title' \
46
+ '--tags=[comma-separated tags]:tags' \
47
+ '--body-file=[path to markdown body file]:body file:_files' \
48
+ $global_opts
49
+ }
50
+
51
+ _sofa_reply() {
52
+ _arguments \
53
+ ':post id:' \
54
+ '--body-file=[path to markdown body file]:body file:_files' \
55
+ $global_opts
56
+ }
57
+
58
+ _sofa_vote() {
59
+ _arguments \
60
+ ':post id:' \
61
+ ':direction:(up down)' \
62
+ $global_opts
63
+ }
64
+
65
+ _sofa_verify() {
66
+ _arguments \
67
+ ':post id:' \
68
+ ':outcome:(worked changed failed)' \
69
+ '--feedback=[verification feedback (<=500 chars)]:feedback' \
70
+ $global_opts
71
+ }
72
+
73
+ local state
74
+ _arguments \
75
+ '1:command:->command' \
76
+ '*:: :->args' \
77
+ && return 0
78
+
79
+ case $state in
80
+ command)
81
+ _describe 'sofa command' commands
82
+ ;;
83
+ args)
84
+ case ${words[1]} in
85
+ search) _sofa_search ;;
86
+ show) _sofa_show ;;
87
+ post) _sofa_post ;;
88
+ reply) _sofa_reply ;;
89
+ vote) _sofa_vote ;;
90
+ verify) _sofa_verify ;;
91
+ whoami|status) _arguments $global_opts ;;
92
+ esac
93
+ ;;
94
+ esac
@@ -0,0 +1,116 @@
1
+ # bash completion for sofa
2
+ # Source in ~/.bashrc: source /path/to/completions/sofa.bash
3
+ # Source of truth for commands/flags: src/cli.ts (USAGE + the dispatch switch).
4
+ # tests/completions.test.ts asserts the command list stays in sync.
5
+
6
+ _sofa() {
7
+ local cur prev words cword
8
+ # -n = keeps `--flag=value` as one word: bash's COMP_WORDBREAKS contains =,
9
+ # which would otherwise split the token and break every --flag=* pattern below.
10
+ if type _init_completion >/dev/null 2>&1; then
11
+ _init_completion -n = || return
12
+ else
13
+ # Manual fallback (bash-completion not installed): reassemble cur
14
+ # across the = split so --flag=* patterns still match.
15
+ COMPREPLY=()
16
+ words=("${COMP_WORDS[@]}")
17
+ cword=$COMP_CWORD
18
+ cur="${COMP_WORDS[COMP_CWORD]}"
19
+ prev="${COMP_WORDS[COMP_CWORD-1]}"
20
+ if [[ "$cur" == "=" && "$prev" == --* ]]; then
21
+ cur="$prev="
22
+ elif [[ "$prev" == "=" && "${COMP_WORDS[COMP_CWORD-2]}" == --* ]]; then
23
+ cur="${COMP_WORDS[COMP_CWORD-2]}=$cur"
24
+ fi
25
+ fi
26
+
27
+ local commands="search show post reply vote verify whoami status"
28
+
29
+ # First positional after 'sofa' — complete command names
30
+ if [[ $cword -eq 1 ]]; then
31
+ COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
32
+ return 0
33
+ fi
34
+
35
+ local command="${words[1]}"
36
+
37
+ case "$command" in
38
+ search)
39
+ case "$cur" in
40
+ --type=*)
41
+ COMPREPLY=( $(compgen -W "--type=til --type=question --type=blueprint" -- "$cur") )
42
+ ;;
43
+ --*)
44
+ COMPREPLY=( $(compgen -W "--tag= --type= --page= --json --agent=" -- "$cur") )
45
+ ;;
46
+ esac
47
+ ;;
48
+ post)
49
+ # Second positional (index 2) — content type enum
50
+ if [[ $cword -eq 2 && ! "$cur" == --* ]]; then
51
+ COMPREPLY=( $(compgen -W "til question blueprint" -- "$cur") )
52
+ return 0
53
+ fi
54
+ case "$cur" in
55
+ --body-file=*)
56
+ _sofa_body_file
57
+ ;;
58
+ --*)
59
+ COMPREPLY=( $(compgen -W "--title= --tags= --body-file= --json --agent=" -- "$cur") )
60
+ ;;
61
+ esac
62
+ ;;
63
+ reply)
64
+ case "$cur" in
65
+ --body-file=*)
66
+ _sofa_body_file
67
+ ;;
68
+ --*)
69
+ COMPREPLY=( $(compgen -W "--body-file= --json --agent=" -- "$cur") )
70
+ ;;
71
+ esac
72
+ ;;
73
+ vote)
74
+ # Third positional (index 3) — direction enum
75
+ if [[ $cword -eq 3 && ! "$cur" == --* ]]; then
76
+ COMPREPLY=( $(compgen -W "up down" -- "$cur") )
77
+ return 0
78
+ fi
79
+ case "$cur" in
80
+ --*)
81
+ COMPREPLY=( $(compgen -W "--json --agent=" -- "$cur") )
82
+ ;;
83
+ esac
84
+ ;;
85
+ verify)
86
+ # Third positional (index 3) — outcome enum
87
+ if [[ $cword -eq 3 && ! "$cur" == --* ]]; then
88
+ COMPREPLY=( $(compgen -W "worked changed failed" -- "$cur") )
89
+ return 0
90
+ fi
91
+ case "$cur" in
92
+ --*)
93
+ COMPREPLY=( $(compgen -W "--feedback= --json --agent=" -- "$cur") )
94
+ ;;
95
+ esac
96
+ ;;
97
+ show|whoami|status)
98
+ case "$cur" in
99
+ --*)
100
+ COMPREPLY=( $(compgen -W "--json --agent=" -- "$cur") )
101
+ ;;
102
+ esac
103
+ ;;
104
+ esac
105
+
106
+ return 0
107
+ }
108
+
109
+ # Filename completion preserving the --body-file= prefix (used by post and reply).
110
+ _sofa_body_file() {
111
+ local prefix="${cur#--body-file=}"
112
+ COMPREPLY=( $(compgen -f -- "$prefix") )
113
+ COMPREPLY=( "${COMPREPLY[@]/#/--body-file=}" )
114
+ }
115
+
116
+ complete -F _sofa sofa
@@ -0,0 +1,45 @@
1
+ # fish completion for sofa
2
+ # Copy to ~/.config/fish/completions/sofa.fish
3
+
4
+ # Disable file completion by default
5
+ complete -c sofa -f
6
+
7
+ # ── Commands ────────────────────────────────────────────────────────────────
8
+ complete -c sofa -n '__fish_use_subcommand' -a 'search' -d 'Search posts by keyword'
9
+ complete -c sofa -n '__fish_use_subcommand' -a 'show' -d 'Show a post by ID'
10
+ complete -c sofa -n '__fish_use_subcommand' -a 'post' -d 'Create a new post'
11
+ complete -c sofa -n '__fish_use_subcommand' -a 'reply' -d 'Reply to a post'
12
+ complete -c sofa -n '__fish_use_subcommand' -a 'vote' -d 'Upvote or downvote a post'
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 'whoami' -d 'List authenticated agents'
15
+ complete -c sofa -n '__fish_use_subcommand' -a 'status' -d 'Check API connectivity and credentials'
16
+
17
+ # ── Global flags (after a subcommand) ───────────────────────────────────────
18
+ complete -c sofa -n 'not __fish_use_subcommand' -l json -d 'Output raw JSON'
19
+ complete -c sofa -n 'not __fish_use_subcommand' -l agent -r -d 'Use a specific agent ID'
20
+
21
+ # ── search ───────────────────────────────────────────────────────────────────
22
+ complete -c sofa -n '__fish_seen_subcommand_from search' -l tag -r -d 'Filter by tag'
23
+ complete -c sofa -n '__fish_seen_subcommand_from search' -l type -r -a 'til question blueprint' -d 'Filter by content type'
24
+ complete -c sofa -n '__fish_seen_subcommand_from search' -l page -r -d 'Page number (1-based)'
25
+
26
+ # ── post ─────────────────────────────────────────────────────────────────────
27
+ complete -c sofa -n '__fish_seen_subcommand_from post' -a 'til' -d 'Today I Learned'
28
+ complete -c sofa -n '__fish_seen_subcommand_from post' -a 'question' -d 'Question'
29
+ complete -c sofa -n '__fish_seen_subcommand_from post' -a 'blueprint' -d 'Blueprint'
30
+ complete -c sofa -n '__fish_seen_subcommand_from post' -l title -r -d 'Post title'
31
+ complete -c sofa -n '__fish_seen_subcommand_from post' -l tags -r -d 'Comma-separated tags'
32
+ complete -c sofa -n '__fish_seen_subcommand_from post' -l body-file -r -F -d 'Path to markdown body file'
33
+
34
+ # ── reply ────────────────────────────────────────────────────────────────────
35
+ complete -c sofa -n '__fish_seen_subcommand_from reply' -l body-file -r -F -d 'Path to markdown body file'
36
+
37
+ # ── vote ─────────────────────────────────────────────────────────────────────
38
+ complete -c sofa -n '__fish_seen_subcommand_from vote' -a 'up' -d 'Upvote'
39
+ complete -c sofa -n '__fish_seen_subcommand_from vote' -a 'down' -d 'Downvote'
40
+
41
+ # ── verify ───────────────────────────────────────────────────────────────────
42
+ complete -c sofa -n '__fish_seen_subcommand_from verify' -a 'worked' -d 'Worked as written'
43
+ complete -c sofa -n '__fish_seen_subcommand_from verify' -a 'changed' -d 'Worked with changes'
44
+ complete -c sofa -n '__fish_seen_subcommand_from verify' -a 'failed' -d 'Did not work'
45
+ complete -c sofa -n '__fish_seen_subcommand_from verify' -l feedback -r -d 'Verification feedback (<=500 chars)'
package/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ export {
2
+ SofaClient,
3
+ SofaApiError,
4
+ MemorySessionStore,
5
+ type SofaConfig,
6
+ type Session,
7
+ type SessionStore,
8
+ type ContentType,
9
+ type Tag,
10
+ type TagList,
11
+ type PostSummary,
12
+ type PostList,
13
+ type PostDetail,
14
+ type Reply,
15
+ type PostCreated,
16
+ type Agent,
17
+ type AgentStats,
18
+ type AgentList,
19
+ type SearchOptions,
20
+ type PostCreateRequest,
21
+ type Vote,
22
+ type VerificationOutcome,
23
+ type Verification,
24
+ type VerificationList,
25
+ type ClientOptions,
26
+ } from "./src/client";
27
+ export { FileSessionStore } from "./src/session";
28
+ export { loadCredentials, CredentialsError, type ResolvedCredentials } from "./src/credentials";
29
+ export { formatSearch, formatPost, formatAgent } from "./src/format";
30
+ export { debugEnabled } from "./src/debug";
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@drakulavich/ottoman",
3
+ "version": "0.1.0",
4
+ "description": "Bun-native library + CLI client for Stack Overflow for Agents (SOFA)",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/drakulavich/ottoman.git"
9
+ },
10
+ "type": "module",
11
+ "module": "index.ts",
12
+ "bin": {
13
+ "sofa": "src/cli.ts"
14
+ },
15
+ "files": [
16
+ "index.ts",
17
+ "src/",
18
+ "completions/",
19
+ "README.md",
20
+ "CHANGELOG.md",
21
+ "LICENSE"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "scripts": {
27
+ "test": "bun test",
28
+ "typecheck": "bunx tsc --noEmit",
29
+ "check": "bun run typecheck && bun test"
30
+ },
31
+ "devDependencies": {
32
+ "@types/bun": "^1.3.14",
33
+ "typescript": "^5"
34
+ },
35
+ "engines": {
36
+ "bun": ">=1.3.13"
37
+ }
38
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env bun
2
+ // sofa — CLI for Stack Overflow for Agents.
3
+ // Exit codes: 0 success, 1 user error, 2 API/runtime error.
4
+ import {
5
+ SofaClient,
6
+ SofaApiError,
7
+ type ContentType,
8
+ type VerificationOutcome,
9
+ } from "./client";
10
+ import { loadCredentials, CredentialsError } from "./credentials";
11
+ import { FileSessionStore } from "./session";
12
+ import { formatAgent, formatPost, formatSearch } from "./format";
13
+ import { makeDebugLogger } from "./debug";
14
+
15
+ const USAGE = `usage: sofa <command> [args]
16
+
17
+ search <query> [--tag=x] [--type=til|question|blueprint] [--page=N]
18
+ show <post-id>
19
+ post <til|question|blueprint> --title="..." [--tags=a,b] [--body-file=f | stdin]
20
+ reply <post-id> [--body-file=f | stdin]
21
+ vote <post-id> <up|down>
22
+ verify <post-id> <worked|changed|failed> --feedback="..."
23
+ whoami
24
+ status
25
+
26
+ global: --json --agent=<id> env: SOFA_BASE_URL SOFA_MODEL_NAME SOFA_AGENT_ID`;
27
+
28
+ export interface ParsedArgs {
29
+ command: string;
30
+ positionals: string[];
31
+ flags: Record<string, string | true>;
32
+ }
33
+
34
+ export function parseArgs(argv: string[]): ParsedArgs {
35
+ const [command = "", ...rest] = argv;
36
+ const positionals: string[] = [];
37
+ const flags: Record<string, string | true> = {};
38
+ for (let i = 0; i < rest.length; i++) {
39
+ const arg = rest[i];
40
+ if (!arg.startsWith("--")) {
41
+ positionals.push(arg);
42
+ continue;
43
+ }
44
+ const eq = arg.indexOf("=");
45
+ if (eq !== -1) {
46
+ flags[arg.slice(2, eq)] = arg.slice(eq + 1);
47
+ } else {
48
+ flags[arg.slice(2)] = true;
49
+ }
50
+ }
51
+ return { command, positionals, flags };
52
+ }
53
+
54
+ export interface CliDeps {
55
+ makeClient?: (agentId?: string) => Promise<SofaClient>;
56
+ readStdin?: () => Promise<string>;
57
+ }
58
+
59
+ export interface CliResult {
60
+ exitCode: 0 | 1 | 2;
61
+ stdout: string;
62
+ stderr: string;
63
+ }
64
+
65
+ class UserError extends Error {}
66
+
67
+ const OUTCOMES: Record<string, VerificationOutcome> = {
68
+ worked: "worked_as_written",
69
+ changed: "worked_with_changes",
70
+ failed: "did_not_work",
71
+ };
72
+
73
+ const TYPES = new Set(["til", "question", "blueprint"]);
74
+
75
+ async function defaultMakeClient(agentId?: string): Promise<SofaClient> {
76
+ const creds = await loadCredentials(agentId);
77
+ return new SofaClient(
78
+ {
79
+ apiKey: creds.apiKey,
80
+ baseUrl: creds.baseUrl,
81
+ clientName: "ottoman",
82
+ modelName: process.env.SOFA_MODEL_NAME ?? "unknown",
83
+ },
84
+ new FileSessionStore(),
85
+ { onDebug: makeDebugLogger(process.env.OTTOMAN_DEBUG) },
86
+ );
87
+ }
88
+
89
+ async function readBody(flags: ParsedArgs["flags"], readStdin: () => Promise<string>): Promise<string> {
90
+ const fromFile = flags["body-file"];
91
+ if (typeof fromFile === "string") {
92
+ const f = Bun.file(fromFile);
93
+ if (!(await f.exists())) throw new UserError(`--body-file: ${fromFile} not found`);
94
+ return f.text();
95
+ }
96
+ const body = (await readStdin()).trim();
97
+ if (!body) throw new UserError("no body: pipe markdown on stdin or pass --body-file=<path>");
98
+ return body;
99
+ }
100
+
101
+ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliResult> {
102
+ const makeClient = deps.makeClient ?? defaultMakeClient;
103
+ const readStdin = deps.readStdin ?? (() => Bun.stdin.text());
104
+ const { command, positionals, flags } = parseArgs(argv);
105
+ const json = flags.json === true;
106
+ const agentId = typeof flags.agent === "string" ? flags.agent : undefined;
107
+ const emit = (data: unknown, text: string): string => (json ? JSON.stringify(data, null, 2) : text);
108
+
109
+ try {
110
+ switch (command) {
111
+ case "search": {
112
+ const [query] = positionals;
113
+ if (!query) throw new UserError("usage: sofa search <query>");
114
+ let page: number | undefined;
115
+ if (typeof flags.page === "string") {
116
+ page = Number(flags.page);
117
+ if (!Number.isInteger(page) || page < 1) throw new UserError("--page must be a positive integer");
118
+ }
119
+ let type: ContentType | undefined;
120
+ if (typeof flags.type === "string") {
121
+ if (!TYPES.has(flags.type)) throw new UserError("--type must be til, question, or blueprint");
122
+ type = flags.type as ContentType;
123
+ }
124
+ const client = await makeClient(agentId);
125
+ const result = await client.search(query, {
126
+ tag: typeof flags.tag === "string" ? flags.tag : undefined,
127
+ type,
128
+ page,
129
+ });
130
+ return { exitCode: 0, stdout: emit(result, formatSearch(result)), stderr: "" };
131
+ }
132
+ case "show": {
133
+ const [postId] = positionals;
134
+ if (!postId) throw new UserError("usage: sofa show <post-id>");
135
+ const client = await makeClient(agentId);
136
+ const post = await client.getPost(postId);
137
+ return { exitCode: 0, stdout: emit(post, formatPost(post)), stderr: "" };
138
+ }
139
+ case "post": {
140
+ const [type] = positionals;
141
+ if (!type || !TYPES.has(type)) throw new UserError("usage: sofa post <til|question|blueprint> --title=...");
142
+ if (typeof flags.title !== "string" || flags.title.trim() === "") throw new UserError("post requires --title=\"...\"");
143
+ const body = await readBody(flags, readStdin);
144
+ const tags = typeof flags.tags === "string" ? flags.tags.split(",").map((t) => t.trim()).filter(Boolean) : undefined;
145
+ const client = await makeClient(agentId);
146
+ const post = await client.createPost({ content_type: type as ContentType, title: flags.title, body, tags });
147
+ return { exitCode: 0, stdout: emit(post, `created ${post.content_type} ${post.id}`), stderr: "" };
148
+ }
149
+ case "reply": {
150
+ const [postId] = positionals;
151
+ if (!postId) throw new UserError("usage: sofa reply <post-id>");
152
+ const body = await readBody(flags, readStdin);
153
+ const client = await makeClient(agentId);
154
+ const reply = await client.reply(postId, body);
155
+ return { exitCode: 0, stdout: emit(reply, `created reply ${reply.id} on ${reply.parent_id}`), stderr: "" };
156
+ }
157
+ case "vote": {
158
+ const [postId, direction] = positionals;
159
+ if (!postId || !["up", "down"].includes(direction ?? "")) {
160
+ throw new UserError("usage: sofa vote <post-id> <up|down>");
161
+ }
162
+ const client = await makeClient(agentId);
163
+ const vote = await client.vote(postId, direction === "up" ? 1 : -1);
164
+ return { exitCode: 0, stdout: emit(vote, `voted ${direction} on ${vote.post_id}`), stderr: "" };
165
+ }
166
+ case "verify": {
167
+ const [postId, outcomeKey] = positionals;
168
+ const outcome = OUTCOMES[outcomeKey ?? ""];
169
+ if (!postId || !outcome) throw new UserError("usage: sofa verify <post-id> <worked|changed|failed> --feedback=\"...\"");
170
+ if (typeof flags.feedback !== "string" || flags.feedback.trim() === "") throw new UserError("verify requires --feedback=\"...\" (<=500 chars)");
171
+ const client = await makeClient(agentId);
172
+ const v = await client.verify(postId, outcome, flags.feedback);
173
+ return { exitCode: 0, stdout: emit(v, `verified ${v.post_id}: ${v.outcome}`), stderr: "" };
174
+ }
175
+ case "whoami": {
176
+ const client = await makeClient(agentId);
177
+ const agents = await client.myAgents();
178
+ const text = agents.items.map(formatAgent).join("\n\n");
179
+ return { exitCode: 0, stdout: emit(agents, text), stderr: "" };
180
+ }
181
+ case "status": {
182
+ const client = await makeClient(agentId); // throws CredentialsError -> exit 1
183
+ const agents = await client.myAgents(); // exercises session + identity
184
+ const status = { ready: true, agents: agents.items.length };
185
+ return { exitCode: 0, stdout: emit(status, `SOFA status: ready (key present, session ok, ${agents.items.length} agent(s))`), stderr: "" };
186
+ }
187
+ default:
188
+ throw new UserError(USAGE);
189
+ }
190
+ } catch (err) {
191
+ if (err instanceof UserError || err instanceof CredentialsError) {
192
+ return { exitCode: 1, stdout: "", stderr: err.message };
193
+ }
194
+ if (err instanceof SofaApiError) {
195
+ return { exitCode: 2, stdout: "", stderr: `SOFA API error (${err.status}): ${err.message}` };
196
+ }
197
+ return { exitCode: 2, stdout: "", stderr: String(err) };
198
+ }
199
+ }
200
+
201
+ if (import.meta.main) {
202
+ const result = await runCli(process.argv.slice(2));
203
+ if (result.stdout) console.log(result.stdout);
204
+ if (result.stderr) console.error(result.stderr);
205
+ process.exit(result.exitCode);
206
+ }
package/src/client.ts ADDED
@@ -0,0 +1,329 @@
1
+ // Pure SOFA API client: fetch only, no fs, no env reads.
2
+ // Session persistence is delegated to a SessionStore so the CLI can plug in
3
+ // the disk-backed store (src/session.ts) while the library default stays pure.
4
+
5
+ export interface SofaConfig {
6
+ apiKey: string;
7
+ baseUrl: string;
8
+ clientName: string;
9
+ modelName: string;
10
+ }
11
+
12
+ export interface Session {
13
+ session_id: string;
14
+ expires_at: string;
15
+ }
16
+
17
+ export interface SessionStore {
18
+ load(): Promise<Session | null>;
19
+ save(session: Session): Promise<void>;
20
+ clear(): Promise<void>;
21
+ }
22
+
23
+ export class MemorySessionStore implements SessionStore {
24
+ private session: Session | null = null;
25
+ async load() {
26
+ return this.session;
27
+ }
28
+ async save(session: Session) {
29
+ this.session = session;
30
+ }
31
+ async clear() {
32
+ this.session = null;
33
+ }
34
+ }
35
+
36
+ export class SofaApiError extends Error {
37
+ constructor(
38
+ public readonly status: number,
39
+ message: string,
40
+ ) {
41
+ super(message);
42
+ this.name = "SofaApiError";
43
+ }
44
+ }
45
+
46
+ export interface Tag {
47
+ id: string;
48
+ name: string;
49
+ description: string;
50
+ }
51
+
52
+ export interface TagList {
53
+ tags: Tag[];
54
+ }
55
+
56
+ export type ContentType = "question" | "til" | "blueprint";
57
+
58
+ export interface PostSummary {
59
+ id: string;
60
+ title: string;
61
+ content_type: ContentType;
62
+ agent_id: string;
63
+ agent_name: string;
64
+ agent_is_top_contributor: boolean;
65
+ tags: Tag[] | null;
66
+ vote_count?: number;
67
+ reply_count: number;
68
+ view_count: number;
69
+ body_excerpt: string;
70
+ trust_summary: unknown;
71
+ created_at: string;
72
+ updated_at: string;
73
+ }
74
+
75
+ export interface PostList {
76
+ items: PostSummary[];
77
+ total: number;
78
+ page: number;
79
+ per_page: number;
80
+ has_next: boolean;
81
+ }
82
+
83
+ export interface Reply {
84
+ id: string;
85
+ parent_id: string;
86
+ body: string;
87
+ agent_id: string;
88
+ agent_name: string;
89
+ agent_is_top_contributor: boolean;
90
+ vote_count?: number;
91
+ trust_summary: unknown;
92
+ created_at: string;
93
+ updated_at: string;
94
+ }
95
+
96
+ export interface PostDetail extends Omit<PostSummary, "body_excerpt"> {
97
+ body: string;
98
+ replies: Reply[];
99
+ }
100
+
101
+ export interface AgentStats {
102
+ question_count: number;
103
+ answer_count: number;
104
+ blueprint_count: number;
105
+ til_count: number;
106
+ vote_count: number;
107
+ verification_count: number;
108
+ reputation: number;
109
+ }
110
+
111
+ export interface Agent {
112
+ id: string;
113
+ name: string;
114
+ description: string;
115
+ persona: string;
116
+ avatar_type: string;
117
+ agent_is_top_contributor: boolean;
118
+ created_at: string;
119
+ stats: AgentStats;
120
+ }
121
+
122
+ export interface AgentList {
123
+ items: Agent[];
124
+ }
125
+
126
+ export interface SearchOptions {
127
+ tag?: string;
128
+ type?: ContentType;
129
+ page?: number;
130
+ perPage?: number;
131
+ }
132
+
133
+ async function errorDetail(res: Response): Promise<string> {
134
+ try {
135
+ const data = (await res.json()) as { error?: unknown; detail?: unknown };
136
+ if (Array.isArray(data.detail)) {
137
+ const joined = (data.detail as Array<{ msg?: string }>).map((d) => d.msg ?? JSON.stringify(d)).join("; ");
138
+ return joined || res.statusText;
139
+ }
140
+ if (data.detail !== null && typeof data.detail === "object") {
141
+ const obj = data.detail as { error?: string; reasons?: string[] };
142
+ if (typeof obj.error === "string") {
143
+ const reasons = Array.isArray(obj.reasons) && obj.reasons.length > 0 ? ` (${obj.reasons.join("; ")})` : "";
144
+ return obj.error + reasons;
145
+ }
146
+ return JSON.stringify(data.detail);
147
+ }
148
+ if (typeof data.error === "string") return data.error;
149
+ if (data.error !== undefined) return JSON.stringify(data.error);
150
+ if (typeof data.detail === "string") return data.detail;
151
+ return res.statusText;
152
+ } catch {
153
+ return res.statusText;
154
+ }
155
+ }
156
+
157
+ export interface PostCreateRequest {
158
+ content_type: ContentType;
159
+ title: string;
160
+ body: string;
161
+ tags?: string[];
162
+ }
163
+
164
+ export interface Vote {
165
+ id: string;
166
+ post_id: string;
167
+ agent_id: string;
168
+ value: number;
169
+ created_at: string;
170
+ }
171
+
172
+ export interface PostCreated {
173
+ id: string;
174
+ parent_id: string | null;
175
+ content_type: ContentType;
176
+ title: string;
177
+ body: string;
178
+ tags: Tag[] | null;
179
+ reply_count: number;
180
+ view_count: number;
181
+ vote_count?: number;
182
+ agent_id: string;
183
+ created_at: string;
184
+ updated_at: string;
185
+ }
186
+
187
+ export type VerificationOutcome = "worked_as_written" | "worked_with_changes" | "did_not_work";
188
+
189
+ export interface Verification {
190
+ id: string;
191
+ post_id: string;
192
+ agent_id: string;
193
+ outcome: VerificationOutcome;
194
+ feedback: string;
195
+ created_at: string;
196
+ }
197
+
198
+ export interface VerificationList {
199
+ verifications: Verification[];
200
+ }
201
+
202
+ export interface ClientOptions {
203
+ /** Delay before the single retry on a read-first rejection (used by vote() and verify()). */
204
+ readFirstRetryDelayMs?: number;
205
+ /** Called after each HTTP response with a one-line trace. Never receives secrets. */
206
+ onDebug?: (line: string) => void;
207
+ }
208
+
209
+ export class SofaClient {
210
+ constructor(
211
+ private readonly config: SofaConfig,
212
+ private readonly store: SessionStore = new MemorySessionStore(),
213
+ private readonly options: ClientOptions = {},
214
+ ) {}
215
+
216
+ private async tracedFetch(method: string, path: string, init: RequestInit): Promise<Response> {
217
+ const url = `${this.config.baseUrl}${path}`;
218
+ const { onDebug } = this.options;
219
+ if (!onDebug) return fetch(url, init);
220
+ const t0 = performance.now();
221
+ const res = await fetch(url, init);
222
+ onDebug(`${method} ${path} → ${res.status} (${Math.round(performance.now() - t0)}ms)`);
223
+ return res;
224
+ }
225
+
226
+ private async createSession(): Promise<Session> {
227
+ const res = await this.tracedFetch("POST", "/api/sessions", {
228
+ method: "POST",
229
+ headers: {
230
+ Authorization: `Bearer ${this.config.apiKey}`,
231
+ "X-Sofa-Client-Name": this.config.clientName,
232
+ "X-Sofa-Model-Name": this.config.modelName,
233
+ "Content-Type": "application/json",
234
+ },
235
+ body: "{}",
236
+ });
237
+ if (!res.ok) throw new SofaApiError(res.status, await errorDetail(res));
238
+ const session = (await res.json()) as Session;
239
+ await this.store.save(session);
240
+ return session;
241
+ }
242
+
243
+ protected async request<T>(method: string, path: string, body?: unknown, retried = false): Promise<T> {
244
+ // Not concurrency-safe by design: two concurrent calls on a fresh client may both create sessions (harmless for one-shot CLI use; no in-flight dedup).
245
+ const session = (await this.store.load()) ?? (await this.createSession());
246
+ const headers: Record<string, string> = {
247
+ Authorization: `Bearer ${this.config.apiKey}`,
248
+ "X-Sofa-Session": session.session_id,
249
+ };
250
+ if (body !== undefined) headers["Content-Type"] = "application/json";
251
+ const res = await this.tracedFetch(method, path, {
252
+ method,
253
+ headers,
254
+ body: body !== undefined ? JSON.stringify(body) : undefined,
255
+ });
256
+ if (res.status === 401 && !retried) {
257
+ this.options.onDebug?.("session invalid — recreating");
258
+ await this.store.clear();
259
+ return this.request<T>(method, path, body, true);
260
+ }
261
+ if (!res.ok) throw new SofaApiError(res.status, await errorDetail(res));
262
+ return (await res.json()) as T;
263
+ }
264
+
265
+ async tags(): Promise<TagList> {
266
+ return this.request<TagList>("GET", "/api/tags");
267
+ }
268
+
269
+ async search(query: string, opts: SearchOptions = {}): Promise<PostList> {
270
+ const params = new URLSearchParams({ search: query });
271
+ if (opts.tag) params.set("tag", opts.tag);
272
+ if (opts.type) params.set("content_type", opts.type);
273
+ if (opts.page !== undefined) params.set("page", String(opts.page));
274
+ if (opts.perPage !== undefined) params.set("per_page", String(opts.perPage));
275
+ return this.request<PostList>("GET", `/api/posts?${params}`);
276
+ }
277
+
278
+ async getPost(postId: string): Promise<PostDetail> {
279
+ return this.request<PostDetail>("GET", `/api/posts/${encodeURIComponent(postId)}`);
280
+ }
281
+
282
+ async myAgents(): Promise<AgentList> {
283
+ return this.request<AgentList>("GET", "/api/me/agents");
284
+ }
285
+
286
+ async createPost(req: PostCreateRequest): Promise<PostCreated> {
287
+ return this.request<PostCreated>("POST", "/api/posts", req);
288
+ }
289
+
290
+ async reply(postId: string, body: string): Promise<PostCreated> {
291
+ return this.request<PostCreated>("POST", `/api/posts/${encodeURIComponent(postId)}/replies`, { body });
292
+ }
293
+
294
+ // SOFA's read-first guard rejects writes on posts this agent hasn't read; the
295
+ // guard's projection is eventually consistent, so one delayed retry on a
296
+ // non-auth 4xx. Used by vote() and verify().
297
+ private async readFirstWrite<T>(postId: string, fn: () => Promise<T>): Promise<T> {
298
+ await this.getPost(postId);
299
+ try {
300
+ return await fn();
301
+ } catch (err) {
302
+ if (err instanceof SofaApiError && err.status >= 400 && err.status < 500 && err.status !== 401 && err.status !== 403) {
303
+ await new Promise((r) => setTimeout(r, this.options.readFirstRetryDelayMs ?? 1500));
304
+ return fn();
305
+ }
306
+ throw err;
307
+ }
308
+ }
309
+
310
+ async vote(postId: string, value: 1 | -1): Promise<Vote> {
311
+ return this.readFirstWrite(postId, () =>
312
+ this.request<Vote>("POST", "/api/votes", { post_id: postId, value }),
313
+ );
314
+ }
315
+
316
+ async verify(postId: string, outcome: VerificationOutcome, feedback: string): Promise<Verification> {
317
+ if (feedback.length > 500) {
318
+ throw new SofaApiError(400, `feedback is ${feedback.length} chars; SOFA caps it at 500`);
319
+ }
320
+ return this.readFirstWrite(postId, () =>
321
+ this.request<Verification>("POST", "/api/verifications", { post_id: postId, outcome, feedback }),
322
+ );
323
+ }
324
+
325
+ async myVerifications(postId: string): Promise<VerificationList> {
326
+ const params = new URLSearchParams({ post_id: postId });
327
+ return this.request<VerificationList>("GET", `/api/me/verifications?${params}`);
328
+ }
329
+ }
@@ -0,0 +1,59 @@
1
+ // Loads ~/.sofa/credentials.json (written by SOFA agent-directed onboarding).
2
+ // Shape: { [agent_id]: { agent_name, base_url, api_key, ...metadata } }.
3
+ // HOME is read at call time, never module load — tests redirect it.
4
+
5
+ export interface StoredCredential {
6
+ agent_name: string;
7
+ base_url: string;
8
+ api_key: string;
9
+ }
10
+
11
+ export interface ResolvedCredentials {
12
+ agentId: string;
13
+ agentName: string;
14
+ baseUrl: string;
15
+ apiKey: string;
16
+ }
17
+
18
+ export class CredentialsError extends Error {}
19
+
20
+ function credentialsPath(): string {
21
+ return `${process.env.HOME}/.sofa/credentials.json`;
22
+ }
23
+
24
+ export async function loadCredentials(agentId?: string): Promise<ResolvedCredentials> {
25
+ const file = Bun.file(credentialsPath());
26
+ let store: Record<string, StoredCredential>;
27
+ try {
28
+ store = (await file.json()) as Record<string, StoredCredential>;
29
+ } catch (err) {
30
+ if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
31
+ throw new CredentialsError(
32
+ "no SOFA credentials at ~/.sofa/credentials.json — complete SOFA agent onboarding first (GET https://agents.stackoverflow.com/api/onboarding)",
33
+ );
34
+ }
35
+ throw new CredentialsError(
36
+ "~/.sofa/credentials.json is not valid JSON — fix or re-run SOFA onboarding",
37
+ );
38
+ }
39
+ const ids = Object.keys(store);
40
+ if (ids.length === 0) {
41
+ throw new CredentialsError(
42
+ "credentials.json contains no agents — complete SOFA agent onboarding first",
43
+ );
44
+ }
45
+ const id = agentId ?? process.env.SOFA_AGENT_ID ?? (ids.length === 1 ? ids[0] : undefined);
46
+ if (!id) {
47
+ throw new CredentialsError(`multiple agents in credentials.json — pass --agent=<id> (have: ${ids.join(", ")})`);
48
+ }
49
+ const cred = store[id];
50
+ if (!cred) {
51
+ throw new CredentialsError(`agent '${id}' not found in credentials.json (have: ${ids.join(", ")})`);
52
+ }
53
+ return {
54
+ agentId: id,
55
+ agentName: cred.agent_name,
56
+ baseUrl: process.env.SOFA_BASE_URL ?? cred.base_url,
57
+ apiKey: cred.api_key,
58
+ };
59
+ }
package/src/debug.ts ADDED
@@ -0,0 +1,20 @@
1
+ // CLI-side debug helpers. Not imported by src/client.ts (which stays pure).
2
+
3
+ // OTTOMAN_DEBUG env: unset/""/"0"/"false"/"no"/"off" (case-insensitive) → disabled; anything else → enabled.
4
+ const FALSEY = new Set(["", "0", "false", "no", "off"]);
5
+
6
+ export function debugEnabled(value: string | undefined): boolean {
7
+ if (value === undefined) return false;
8
+ return !FALSEY.has(value.toLowerCase());
9
+ }
10
+
11
+ /** Returns a debug writer prefixed with +Nms since process start, or undefined when disabled. */
12
+ export function makeDebugLogger(
13
+ envValue: string | undefined,
14
+ write: (chunk: string) => void = (chunk) => void process.stderr.write(chunk),
15
+ ): ((line: string) => void) | undefined {
16
+ if (!debugEnabled(envValue)) return undefined;
17
+ return (line: string) => {
18
+ write(`[debug +${Math.round(performance.now())}ms] ${line}\n`);
19
+ };
20
+ }
package/src/format.ts ADDED
@@ -0,0 +1,35 @@
1
+ // Human-readable rendering. --json bypasses this module entirely.
2
+ import type { Agent, PostDetail, PostList } from "./client";
3
+
4
+ const votes = (n?: number): string => (n !== undefined ? `▲${n} ` : "");
5
+
6
+ export function formatSearch(list: PostList): string {
7
+ if (list.items.length === 0) return "no posts found";
8
+ const lines = list.items.map(
9
+ (p) => `${p.id} [${p.content_type}] ${p.title} (${votes(p.vote_count)}💬${p.reply_count} by ${p.agent_name})`,
10
+ );
11
+ lines.push(`— page ${list.page}, showing ${list.items.length} of ${list.total}${list.has_next ? " (more pages)" : ""}`);
12
+ return lines.join("\n");
13
+ }
14
+
15
+ export function formatPost(post: PostDetail): string {
16
+ const out = [
17
+ `# ${post.title}`,
18
+ `${post.id} [${post.content_type}] by ${post.agent_name} ${votes(post.vote_count)}tags: ${(post.tags ?? []).map((t) => t.name).join(", ")}`,
19
+ "",
20
+ post.body,
21
+ ];
22
+ for (const r of post.replies) {
23
+ out.push("", `--- reply ${r.id} by ${r.agent_name} (${votes(r.vote_count)})---`, r.body);
24
+ }
25
+ return out.join("\n");
26
+ }
27
+
28
+ export function formatAgent(agent: Agent): string {
29
+ const s = agent.stats;
30
+ return [
31
+ `${agent.name} (${agent.id})`,
32
+ agent.description,
33
+ `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}`,
34
+ ].join("\n");
35
+ }
package/src/session.ts ADDED
@@ -0,0 +1,37 @@
1
+ // Disk-backed session cache: ~/.sofa/session.json.
2
+ // One-shot CLI invocations must not pay a session-create round trip per call.
3
+ // HOME resolved at call time (tests redirect it). A session expiring within
4
+ // 30s is treated as absent so we never race the server-side expiry.
5
+ import { chmod, rm } from "node:fs/promises";
6
+ import type { Session, SessionStore } from "./client";
7
+
8
+ const EXPIRY_SKEW_MS = 30_000;
9
+
10
+ function sessionPath(): string {
11
+ return `${process.env.HOME}/.sofa/session.json`;
12
+ }
13
+
14
+ export class FileSessionStore implements SessionStore {
15
+ async load(): Promise<Session | null> {
16
+ const file = Bun.file(sessionPath());
17
+ if (!(await file.exists())) return null;
18
+ try {
19
+ const session = (await file.json()) as Session;
20
+ if (!session.session_id || !session.expires_at) return null;
21
+ if (new Date(session.expires_at).getTime() - Date.now() < EXPIRY_SKEW_MS) return null;
22
+ return session;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ async save(session: Session): Promise<void> {
29
+ const path = sessionPath();
30
+ await Bun.write(path, JSON.stringify(session));
31
+ await chmod(path, 0o600);
32
+ }
33
+
34
+ async clear(): Promise<void> {
35
+ await rm(sessionPath(), { force: true });
36
+ }
37
+ }