@drakulavich/ottoman 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] — 2026-06-13
11
+
12
+ ### Added
13
+ - **Web URL in `show`/`post` text output** (issue #8) — `show` appends a `\n<url>` line
14
+ (e.g. `https://agents.stackoverflow.com/tils/<id>`) in text mode; `post` text output
15
+ becomes `created <type> <id>\n<url>`. `--json` output is unchanged. New `src/url.ts`
16
+ exports `postWebUrl` and `replyWebUrl`.
17
+ - **`sofa mine` command with local post ledger** (issue #9) — `post` now records each
18
+ successfully created post to `~/.sofa/posts.json` (chmod 600). `mine` loads the ledger,
19
+ fetches each post via the API, and renders title, type, vote/reply/view counts. Deleted
20
+ posts (404) are shown as `<deleted>` instead of crashing. `--json` emits the fetched
21
+ `PostDetail` array. New `src/ledger.ts` exports `recordPost`, `loadLedger`, and `LedgerEntry`.
22
+ - **Client-side link preflight** (issue #10) — `post` and `reply` now run
23
+ `findForbiddenLinks` on the body before sending. `file://`, `data:`, and `javascript:`
24
+ are always rejected; navigable URLs (`http://`, `https://`, `ftp://`, `ws://`, etc.) must
25
+ resolve to the SO/SE network (stackoverflow.com, stackexchange.com, and friends). Violations
26
+ exit 1 before any network call. New `src/links.ts` exports `findForbiddenLinks`.
27
+ - The publish workflow now creates a GitHub Release (`gh release create
28
+ --generate-notes`) after a successful npm publish, so tags and Releases stay
29
+ in sync automatically. Idempotent; prereleases are marked as such.
30
+
31
+ ## [0.1.1] — 2026-06-13
32
+
33
+ ### Added
34
+ - npm provenance: the publish workflow now runs `npm publish --provenance`
35
+ (sigstore attestation linking the package to this repo + workflow run);
36
+ enabled by making the repository public
37
+ - README: install-from-npm instructions (`bun add -g` / `bunx`)
38
+
39
+ ### Changed
40
+ - Repository is now public (github.com/drakulavich/ottoman)
41
+
10
42
  ## [0.1.0] — 2026-06-13
11
43
 
12
44
  First published release (`@drakulavich/ottoman` on npm).
package/README.md CHANGED
@@ -9,13 +9,24 @@ Zero runtime dependencies. Hand-written client, spec-checked against the live
9
9
 
10
10
  ## Install
11
11
 
12
+ From npm:
13
+
14
+ ```bash
15
+ bun add -g @drakulavich/ottoman # installs the `sofa` command
16
+ # or run without installing:
17
+ bunx @drakulavich/ottoman whoami
18
+ ```
19
+
20
+ From a checkout:
21
+
12
22
  ```bash
13
23
  bun install
14
24
  bun link # exposes the `sofa` command globally
15
25
  ```
16
26
 
17
- Requires Bun ≥ 1.3.13 and a SOFA API key in `~/.sofa/credentials.json`
18
- (created by SOFA's agent-directed onboarding).
27
+ Requires Bun ≥ 1.3.13 (the `sofa` bin is TypeScript executed by Bun — Node
28
+ alone won't run it) and a SOFA API key in `~/.sofa/credentials.json` (created
29
+ by SOFA's agent-directed onboarding).
19
30
 
20
31
  ### Shell completions
21
32
 
package/completions/_sofa CHANGED
@@ -14,6 +14,7 @@ commands=(
14
14
  'reply:reply to a post'
15
15
  'vote:upvote or downvote a post'
16
16
  'verify:submit a verification for a post'
17
+ 'mine:list your locally recorded posts'
17
18
  'whoami:list authenticated agents'
18
19
  'status:check API connectivity and credentials'
19
20
  )
@@ -88,7 +89,7 @@ case $state in
88
89
  reply) _sofa_reply ;;
89
90
  vote) _sofa_vote ;;
90
91
  verify) _sofa_verify ;;
91
- whoami|status) _arguments $global_opts ;;
92
+ mine|whoami|status) _arguments $global_opts ;;
92
93
  esac
93
94
  ;;
94
95
  esac
@@ -24,7 +24,7 @@ _sofa() {
24
24
  fi
25
25
  fi
26
26
 
27
- local commands="search show post reply vote verify whoami status"
27
+ local commands="search show post reply vote verify mine whoami status"
28
28
 
29
29
  # First positional after 'sofa' — complete command names
30
30
  if [[ $cword -eq 1 ]]; then
@@ -94,7 +94,7 @@ _sofa() {
94
94
  ;;
95
95
  esac
96
96
  ;;
97
- show|whoami|status)
97
+ show|mine|whoami|status)
98
98
  case "$cur" in
99
99
  --*)
100
100
  COMPREPLY=( $(compgen -W "--json --agent=" -- "$cur") )
@@ -11,6 +11,7 @@ complete -c sofa -n '__fish_use_subcommand' -a 'post' -d 'Create a new post'
11
11
  complete -c sofa -n '__fish_use_subcommand' -a 'reply' -d 'Reply to a post'
12
12
  complete -c sofa -n '__fish_use_subcommand' -a 'vote' -d 'Upvote or downvote a post'
13
13
  complete -c sofa -n '__fish_use_subcommand' -a 'verify' -d 'Submit a verification for a post'
14
+ complete -c sofa -n '__fish_use_subcommand' -a 'mine' -d 'List your locally recorded posts'
14
15
  complete -c sofa -n '__fish_use_subcommand' -a 'whoami' -d 'List authenticated agents'
15
16
  complete -c sofa -n '__fish_use_subcommand' -a 'status' -d 'Check API connectivity and credentials'
16
17
 
package/index.ts CHANGED
@@ -26,5 +26,8 @@ export {
26
26
  } from "./src/client";
27
27
  export { FileSessionStore } from "./src/session";
28
28
  export { loadCredentials, CredentialsError, type ResolvedCredentials } from "./src/credentials";
29
- export { formatSearch, formatPost, formatAgent } from "./src/format";
29
+ export { formatSearch, formatPost, formatAgent, formatMine } from "./src/format";
30
30
  export { debugEnabled } from "./src/debug";
31
+ export { postWebUrl, replyWebUrl } from "./src/url";
32
+ export { loadLedger, recordPost, type LedgerEntry } from "./src/ledger";
33
+ export { findForbiddenLinks } from "./src/links";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/ottoman",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Bun-native library + CLI client for Stack Overflow for Agents (SOFA)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli.ts CHANGED
@@ -9,8 +9,11 @@ import {
9
9
  } from "./client";
10
10
  import { loadCredentials, CredentialsError } from "./credentials";
11
11
  import { FileSessionStore } from "./session";
12
- import { formatAgent, formatPost, formatSearch } from "./format";
12
+ import { formatAgent, formatMine, formatPost, formatSearch, type MineLine } from "./format";
13
13
  import { makeDebugLogger } from "./debug";
14
+ import { postWebUrl } from "./url";
15
+ import { loadLedger, recordPost } from "./ledger";
16
+ import { findForbiddenLinks } from "./links";
14
17
 
15
18
  const USAGE = `usage: sofa <command> [args]
16
19
 
@@ -20,6 +23,7 @@ const USAGE = `usage: sofa <command> [args]
20
23
  reply <post-id> [--body-file=f | stdin]
21
24
  vote <post-id> <up|down>
22
25
  verify <post-id> <worked|changed|failed> --feedback="..."
26
+ mine
23
27
  whoami
24
28
  status
25
29
 
@@ -134,22 +138,34 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliRes
134
138
  if (!postId) throw new UserError("usage: sofa show <post-id>");
135
139
  const client = await makeClient(agentId);
136
140
  const post = await client.getPost(postId);
137
- return { exitCode: 0, stdout: emit(post, formatPost(post)), stderr: "" };
141
+ const webUrl = postWebUrl(client.baseUrl, post.content_type, post.id);
142
+ return { exitCode: 0, stdout: emit(post, `${formatPost(post)}\n${webUrl}`), stderr: "" };
138
143
  }
139
144
  case "post": {
140
145
  const [type] = positionals;
141
146
  if (!type || !TYPES.has(type)) throw new UserError("usage: sofa post <til|question|blueprint> --title=...");
142
147
  if (typeof flags.title !== "string" || flags.title.trim() === "") throw new UserError("post requires --title=\"...\"");
143
148
  const body = await readBody(flags, readStdin);
149
+ const violations = findForbiddenLinks(body);
150
+ if (violations.length > 0) throw new UserError(`post body has links SOFA will reject:\n - ${violations.join("\n - ")}`);
144
151
  const tags = typeof flags.tags === "string" ? flags.tags.split(",").map((t) => t.trim()).filter(Boolean) : undefined;
145
152
  const client = await makeClient(agentId);
146
153
  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: "" };
154
+ let ledgerWarning = "";
155
+ try {
156
+ await recordPost({ id: post.id, content_type: post.content_type, title: post.title, created_at: post.created_at });
157
+ } catch (err) {
158
+ ledgerWarning = `warning: could not record post to local ledger: ${err instanceof Error ? err.message : String(err)}`;
159
+ }
160
+ const webUrl = postWebUrl(client.baseUrl, post.content_type, post.id);
161
+ return { exitCode: 0, stdout: emit(post, `created ${post.content_type} ${post.id}\n${webUrl}`), stderr: ledgerWarning };
148
162
  }
149
163
  case "reply": {
150
164
  const [postId] = positionals;
151
165
  if (!postId) throw new UserError("usage: sofa reply <post-id>");
152
166
  const body = await readBody(flags, readStdin);
167
+ const violations = findForbiddenLinks(body);
168
+ if (violations.length > 0) throw new UserError(`post body has links SOFA will reject:\n - ${violations.join("\n - ")}`);
153
169
  const client = await makeClient(agentId);
154
170
  const reply = await client.reply(postId, body);
155
171
  return { exitCode: 0, stdout: emit(reply, `created reply ${reply.id} on ${reply.parent_id}`), stderr: "" };
@@ -184,6 +200,33 @@ export async function runCli(argv: string[], deps: CliDeps = {}): Promise<CliRes
184
200
  const status = { ready: true, agents: agents.items.length };
185
201
  return { exitCode: 0, stdout: emit(status, `SOFA status: ready (key present, session ok, ${agents.items.length} agent(s))`), stderr: "" };
186
202
  }
203
+ case "mine": {
204
+ const entries = await loadLedger();
205
+ if (entries.length === 0) {
206
+ return { exitCode: 0, stdout: emit([], "no posts recorded yet"), stderr: "" };
207
+ }
208
+ const client = await makeClient(agentId);
209
+ type MineResult = { kind: "found"; post: import("./client").PostDetail } | { kind: "deleted"; entry: typeof entries[number] };
210
+ const results = await Promise.all(
211
+ entries.map(async (entry): Promise<MineResult> => {
212
+ try {
213
+ return { kind: "found", post: await client.getPost(entry.id) };
214
+ } catch (err) {
215
+ if (err instanceof SofaApiError && err.status === 404) {
216
+ return { kind: "deleted", entry };
217
+ }
218
+ throw err;
219
+ }
220
+ }),
221
+ );
222
+ const lines: MineLine[] = results.map((r) =>
223
+ r.kind === "found"
224
+ ? { id: r.post.id, title: r.post.title, content_type: r.post.content_type, vote_count: r.post.vote_count, reply_count: r.post.reply_count, view_count: r.post.view_count, trust_summary: r.post.trust_summary }
225
+ : { id: r.entry.id, title: r.entry.title, content_type: r.entry.content_type, reply_count: 0, view_count: 0, trust_summary: null, deleted: true },
226
+ );
227
+ const fetched = results.filter((r): r is Extract<MineResult, { kind: "found" }> => r.kind === "found").map((r) => r.post);
228
+ return { exitCode: 0, stdout: emit(fetched, formatMine(lines)), stderr: "" };
229
+ }
187
230
  default:
188
231
  throw new UserError(USAGE);
189
232
  }
package/src/client.ts CHANGED
@@ -213,6 +213,10 @@ export class SofaClient {
213
213
  private readonly options: ClientOptions = {},
214
214
  ) {}
215
215
 
216
+ get baseUrl(): string {
217
+ return this.config.baseUrl;
218
+ }
219
+
216
220
  private async tracedFetch(method: string, path: string, init: RequestInit): Promise<Response> {
217
221
  const url = `${this.config.baseUrl}${path}`;
218
222
  const { onDebug } = this.options;
package/src/format.ts CHANGED
@@ -1,6 +1,17 @@
1
1
  // Human-readable rendering. --json bypasses this module entirely.
2
2
  import type { Agent, PostDetail, PostList } from "./client";
3
3
 
4
+ export interface MineLine {
5
+ id: string;
6
+ title: string;
7
+ content_type: string;
8
+ vote_count?: number;
9
+ reply_count: number;
10
+ view_count: number;
11
+ trust_summary: unknown;
12
+ deleted?: boolean;
13
+ }
14
+
4
15
  const votes = (n?: number): string => (n !== undefined ? `▲${n} ` : "");
5
16
 
6
17
  export function formatSearch(list: PostList): string {
@@ -25,6 +36,19 @@ export function formatPost(post: PostDetail): string {
25
36
  return out.join("\n");
26
37
  }
27
38
 
39
+ export function formatMine(lines: MineLine[]): string {
40
+ if (lines.length === 0) return "no posts recorded yet";
41
+ return lines
42
+ .map((p) => {
43
+ if (p.deleted) return `<deleted> (${p.id})`;
44
+ const ts = p.trust_summary !== null && p.trust_summary !== undefined
45
+ ? ` trust:${JSON.stringify(p.trust_summary)}`
46
+ : "";
47
+ return `${p.id} [${p.content_type}] ${p.title} (${votes(p.vote_count)}💬${p.reply_count} 👁${p.view_count}${ts})`;
48
+ })
49
+ .join("\n");
50
+ }
51
+
28
52
  export function formatAgent(agent: Agent): string {
29
53
  const s = agent.stats;
30
54
  return [
package/src/ledger.ts ADDED
@@ -0,0 +1,43 @@
1
+ // Local post ledger: ~/.sofa/posts.json — tracks posts created by this agent.
2
+ // HOME resolved at call time (tests redirect it). Mirrors FileSessionStore pattern.
3
+ import { chmod, mkdir, rename } from "node:fs/promises";
4
+
5
+ export interface LedgerEntry {
6
+ id: string;
7
+ content_type: string;
8
+ title: string;
9
+ created_at: string;
10
+ }
11
+
12
+ function ledgerPath(): string {
13
+ return `${process.env.HOME}/.sofa/posts.json`;
14
+ }
15
+
16
+ export async function loadLedger(): Promise<LedgerEntry[]> {
17
+ const file = Bun.file(ledgerPath());
18
+ if (!(await file.exists())) return [];
19
+ try {
20
+ const data = (await file.json()) as unknown;
21
+ if (!Array.isArray(data)) return [];
22
+ return data as LedgerEntry[];
23
+ } catch {
24
+ return [];
25
+ }
26
+ }
27
+
28
+ export async function recordPost(entry: LedgerEntry): Promise<void> {
29
+ // Concurrent `sofa post` invocations are last-writer-wins by design — acceptable
30
+ // for a local convenience ledger where races are vanishingly rare.
31
+ const path = ledgerPath();
32
+ const tmp = `${path}.tmp`;
33
+ const existing = await loadLedger();
34
+ if (existing.some((e) => e.id === entry.id)) return;
35
+ const updated = [...existing, entry];
36
+ await mkdir(`${process.env.HOME}/.sofa`, { recursive: true });
37
+ // Write to a temp file, chmod it, then atomically rename so a mid-write crash
38
+ // never leaves a truncated ledger (loadLedger's corrupt-tolerance would silently
39
+ // swallow a partial file and lose all recorded posts).
40
+ await Bun.write(tmp, JSON.stringify(updated, null, 2));
41
+ await chmod(tmp, 0o600);
42
+ await rename(tmp, path);
43
+ }
package/src/links.ts ADDED
@@ -0,0 +1,77 @@
1
+ // Pure client-side link preflight — no fs, no env reads.
2
+ // Checks post/reply body text for URLs that SOFA will reject.
3
+
4
+ // Allowed SO/SE network hosts (case-insensitive). Subdomains are also allowed.
5
+ const ALLOWED_HOSTS = [
6
+ "agents.stackoverflow.com",
7
+ "stackoverflow.com",
8
+ "stackexchange.com",
9
+ "serverfault.com",
10
+ "superuser.com",
11
+ "askubuntu.com",
12
+ "stackapps.com",
13
+ "mathoverflow.net",
14
+ ];
15
+
16
+ /** Returns true if the given hostname is in the SO/SE allowlist (exact or subdomain). */
17
+ function isAllowedHost(host: string): boolean {
18
+ const h = host.toLowerCase();
19
+ for (const allowed of ALLOWED_HOSTS) {
20
+ if (h === allowed || h.endsWith(`.${allowed}`)) return true;
21
+ }
22
+ return false;
23
+ }
24
+
25
+ // Dangerous non-navigable schemes that are always rejected regardless of host.
26
+ const DANGEROUS_SCHEMES = /^(file|data|javascript):/i;
27
+
28
+ // Navigable URL schemes that require an allowlist check.
29
+ const NAVIGABLE_SCHEME = /^(https?|ftps?|sftp|wss?):/i;
30
+
31
+ // Regex to find scheme: occurrences in text.
32
+ // Matches scheme: followed by anything that looks like a URL (no whitespace, no closing paren/bracket).
33
+ const URL_PATTERN = /([a-zA-Z][a-zA-Z0-9+\-.]*):(?:\/\/)?([^\s)>\]"]*)/g;
34
+
35
+ /**
36
+ * Extract the true hostname from a navigable URL's authority segment.
37
+ * Normalises to https:// so the URL constructor accepts any navigable scheme.
38
+ * Fail-closed: returns "" (not on allowlist → flagged) if the URL is malformed.
39
+ */
40
+ function extractHost(rest: string): string {
41
+ try {
42
+ return new URL(`https://${rest.replace(/^\/\//, "")}`).hostname;
43
+ } catch {
44
+ return "";
45
+ }
46
+ }
47
+
48
+ export function findForbiddenLinks(text: string): string[] {
49
+ const violations: string[] = [];
50
+ let match: RegExpExecArray | null;
51
+ URL_PATTERN.lastIndex = 0;
52
+
53
+ while ((match = URL_PATTERN.exec(text)) !== null) {
54
+ const full = match[0];
55
+ const scheme = match[1];
56
+ const rest = match[2];
57
+
58
+ if (DANGEROUS_SCHEMES.test(`${scheme}:`)) {
59
+ // Use scheme: (not scheme://) — data: and javascript: have no // prefix
60
+ violations.push(`${scheme.toLowerCase()}: URLs are not allowed (SOFA rejects them): ${full}`);
61
+ continue;
62
+ }
63
+
64
+ if (NAVIGABLE_SCHEME.test(`${scheme}:`)) {
65
+ const host = extractHost(rest);
66
+
67
+ if (!isAllowedHost(host)) {
68
+ violations.push(
69
+ `off-network link not allowed: ${scheme.toLowerCase()}://${rest.replace(/^\/\//, "")} (only Stack Overflow / Stack Exchange hosts permitted)`,
70
+ );
71
+ }
72
+ }
73
+ // Other schemes (mailto:, tel:, etc.) are ignored
74
+ }
75
+
76
+ return violations;
77
+ }
package/src/url.ts ADDED
@@ -0,0 +1,21 @@
1
+ // Pure URL helpers for SOFA web URLs — no fs, no env reads.
2
+ import type { ContentType } from "./client";
3
+
4
+ const PLURAL: Record<ContentType, string> = {
5
+ til: "tils",
6
+ question: "questions",
7
+ blueprint: "blueprints",
8
+ };
9
+
10
+ export function postWebUrl(baseUrl: string, contentType: ContentType, id: string): string {
11
+ return `${baseUrl.replace(/\/$/, "")}/${PLURAL[contentType]}/${id}`;
12
+ }
13
+
14
+ export function replyWebUrl(
15
+ baseUrl: string,
16
+ parentContentType: ContentType,
17
+ parentId: string,
18
+ replyId: string,
19
+ ): string {
20
+ return `${baseUrl.replace(/\/$/, "")}/${PLURAL[parentContentType]}/${parentId}#reply-${replyId}`;
21
+ }