pi-simocracy 0.1.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-simocracy",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension: load a Simocracy sim into your chat — see its pixel-art sprite render inline in the terminal and roleplay with it.",
5
5
  "type": "module",
6
6
  "author": "David Dao <david@gainforest.earth> (https://github.com/daviddao)",
@@ -43,6 +43,8 @@
43
43
  "@mariozechner/pi-tui": ">=0.58.0"
44
44
  },
45
45
  "dependencies": {
46
+ "@atproto/api": "^0.19.11",
47
+ "@atproto/oauth-client-node": "^0.3.17",
46
48
  "pngjs": "^7.0.0",
47
49
  "typebox": "^1.1.24"
48
50
  },
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Loopback HTTP server that catches the OAuth redirect after the user
3
+ * authorizes pi-simocracy in their browser. Pattern adapted from
4
+ * pi-mono's anthropic.ts.
5
+ *
6
+ * The server listens on 127.0.0.1:53682 (overridable via
7
+ * `PI_SIMOCRACY_OAUTH_PORT`), accepts a single `/callback` GET, and
8
+ * resolves with the URLSearchParams the OAuth client needs.
9
+ */
10
+
11
+ import { createServer, type Server } from "node:http";
12
+
13
+ import { oauthErrorHtml, oauthSuccessHtml } from "./pages.ts";
14
+
15
+ export const CALLBACK_HOST = process.env.PI_SIMOCRACY_OAUTH_HOST ?? "127.0.0.1";
16
+ export const CALLBACK_PORT = Number(process.env.PI_SIMOCRACY_OAUTH_PORT ?? "53682");
17
+ export const CALLBACK_PATH = "/callback";
18
+ export const CALLBACK_REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`;
19
+
20
+ export interface CallbackHandle {
21
+ server: Server;
22
+ redirectUri: string;
23
+ /** Resolves with the params from the redirect URL (or null if cancelled). */
24
+ waitForParams: () => Promise<URLSearchParams | null>;
25
+ cancel: () => void;
26
+ close: () => void;
27
+ }
28
+
29
+ export async function startCallbackServer(): Promise<CallbackHandle> {
30
+ return new Promise((resolve, reject) => {
31
+ let settle: ((value: URLSearchParams | null) => void) | undefined;
32
+ const wait = new Promise<URLSearchParams | null>((resolveWait) => {
33
+ let settled = false;
34
+ settle = (v) => {
35
+ if (settled) return;
36
+ settled = true;
37
+ resolveWait(v);
38
+ };
39
+ });
40
+
41
+ const server = createServer((req, res) => {
42
+ try {
43
+ const url = new URL(req.url ?? "", `http://${CALLBACK_HOST}:${CALLBACK_PORT}`);
44
+ if (url.pathname !== CALLBACK_PATH) {
45
+ res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
46
+ res.end(oauthErrorHtml("Callback route not found."));
47
+ return;
48
+ }
49
+ const params = url.searchParams;
50
+ const error = params.get("error");
51
+ if (error) {
52
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
53
+ res.end(
54
+ oauthErrorHtml(
55
+ "ATProto sign-in did not complete.",
56
+ `Error: ${error}${params.get("error_description") ? `\n${params.get("error_description")}` : ""}`,
57
+ ),
58
+ );
59
+ settle?.(null);
60
+ return;
61
+ }
62
+ if (!params.get("code")) {
63
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
64
+ res.end(oauthErrorHtml("Missing `code` parameter on callback."));
65
+ settle?.(null);
66
+ return;
67
+ }
68
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
69
+ res.end(
70
+ oauthSuccessHtml(
71
+ "Sign-in complete. You can close this browser tab and return to the terminal.",
72
+ ),
73
+ );
74
+ settle?.(params);
75
+ } catch {
76
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
77
+ res.end("Internal error in callback server");
78
+ }
79
+ });
80
+
81
+ server.on("error", (err) => reject(err));
82
+
83
+ server.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
84
+ resolve({
85
+ server,
86
+ redirectUri: CALLBACK_REDIRECT_URI,
87
+ waitForParams: () => wait,
88
+ cancel: () => settle?.(null),
89
+ close: () => server.close(),
90
+ });
91
+ });
92
+ });
93
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Handlers for `/sim login`, `/sim logout`, `/sim whoami`.
3
+ *
4
+ * These power the ATProto / Bluesky sign-in flow used by `--apply`
5
+ * subcommands that write records to the user's PDS. They are dispatched
6
+ * from inside the `/sim` slash command in `src/index.ts` rather than
7
+ * registered as top-level slash commands, because pi itself ships a
8
+ * built-in `/login` (Anthropic OAuth) and `/logout` — colliding with
9
+ * those would emit "Skipping in autocomplete" warnings on every boot
10
+ * and confuse users about which account they're signing into.
11
+ *
12
+ * `/sim login` runs the loopback OAuth flow described in
13
+ * https://atproto.com/guides/oauth-cli-tutorial:
14
+ * 1. Start a localhost server on 127.0.0.1:53682/callback.
15
+ * 2. Build the authorize URL via `oauthClient.authorize(handle)`.
16
+ * 3. Open the URL in the user's default browser; also print it as a
17
+ * fallback in case the browser can't be opened (SSH, etc.).
18
+ * 4. Wait for the `/callback` GET, exchange the code via
19
+ * `oauthClient.callback(searchParams)` (DPoP-bound).
20
+ * 5. Persist the auth record (DID + handle) to
21
+ * ~/.config/pi-simocracy/auth.json so subsequent commands can
22
+ * call `getAuthenticatedAgent()` from `src/writes.ts`.
23
+ */
24
+
25
+ import { exec } from "node:child_process";
26
+ import { platform } from "node:os";
27
+
28
+ import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
29
+
30
+ import { resolveHandle } from "../simocracy.ts";
31
+ import { startCallbackServer } from "./callback-server.ts";
32
+ import { getOAuthClient } from "./oauth.ts";
33
+ import { clearAuth, readAuth, writeAuth } from "./storage.ts";
34
+
35
+ export async function runLogin(
36
+ ctx: ExtensionCommandContext,
37
+ arg: string,
38
+ ): Promise<void> {
39
+ const handleArg = arg.trim();
40
+ let handle: string;
41
+ if (handleArg) {
42
+ handle = handleArg.replace(/^@/, "");
43
+ } else {
44
+ const prompt = await ctx.ui.input(
45
+ "Sign in with ATProto / Bluesky — your handle",
46
+ "alice.bsky.social",
47
+ );
48
+ if (!prompt?.trim()) {
49
+ ctx.ui.notify("Cancelled.", "info");
50
+ return;
51
+ }
52
+ handle = prompt.trim().replace(/^@/, "");
53
+ }
54
+
55
+ ctx.ui.notify(
56
+ `Signing in with ATProto / Bluesky as @${handle}. Starting loopback OAuth flow on 127.0.0.1:53682… (this is NOT Anthropic auth — pi's built-in /login does that.)`,
57
+ "info",
58
+ );
59
+
60
+ let callback: Awaited<ReturnType<typeof startCallbackServer>>;
61
+ try {
62
+ callback = await startCallbackServer();
63
+ } catch (err) {
64
+ ctx.ui.notify(`Could not bind callback server: ${(err as Error).message}`, "error");
65
+ return;
66
+ }
67
+
68
+ try {
69
+ const client = getOAuthClient();
70
+ let authUrl: URL;
71
+ try {
72
+ authUrl = await client.authorize(handle, {
73
+ scope: "atproto transition:generic",
74
+ });
75
+ } catch (err) {
76
+ ctx.ui.notify(
77
+ `Could not start OAuth: ${(err as Error).message}. Check the handle and try again.`,
78
+ "error",
79
+ );
80
+ return;
81
+ }
82
+
83
+ ctx.ui.notify(
84
+ `Opening ${authUrl.origin} in your browser — grant pi-simocracy access to your ATProto repo. If the browser doesn't open automatically, paste this URL: ${authUrl.toString()}`,
85
+ "info",
86
+ );
87
+ openInBrowser(authUrl.toString());
88
+
89
+ const params = await callback.waitForParams();
90
+ if (!params) {
91
+ ctx.ui.notify("Sign-in cancelled.", "info");
92
+ return;
93
+ }
94
+
95
+ let result;
96
+ try {
97
+ result = await client.callback(params);
98
+ } catch (err) {
99
+ ctx.ui.notify(`Token exchange failed: ${(err as Error).message}`, "error");
100
+ return;
101
+ }
102
+
103
+ const did = result.session.did;
104
+ const handleResolved = await resolveHandle(did).catch(() => null);
105
+ writeAuth({ did, handle: handleResolved, lastLogin: new Date().toISOString() });
106
+
107
+ ctx.ui.notify(
108
+ handleResolved
109
+ ? `🔐 Signed in to ATProto as @${handleResolved} (${did}). You can now use /sim interview --apply and /sim train apply --apply to write to your PDS.`
110
+ : `🔐 Signed in to ATProto as ${did}. You can now use /sim interview --apply and /sim train apply --apply to write to your PDS.`,
111
+ "info",
112
+ );
113
+ } finally {
114
+ callback.close();
115
+ }
116
+ }
117
+
118
+ export async function runLogout(ctx: ExtensionCommandContext): Promise<void> {
119
+ const auth = readAuth();
120
+ if (!auth) {
121
+ ctx.ui.notify("Not signed into ATProto. (Note: this is separate from pi's Anthropic /login.)", "info");
122
+ return;
123
+ }
124
+ clearAuth();
125
+ ctx.ui.notify(
126
+ `Signed out of ATProto ${auth.handle ? `@${auth.handle}` : auth.did}. Local OAuth tokens cleared from ~/.config/pi-simocracy/auth.json. (Pi's Anthropic session is unaffected.)`,
127
+ "info",
128
+ );
129
+ }
130
+
131
+ export async function runWhoami(ctx: ExtensionCommandContext): Promise<void> {
132
+ const auth = readAuth();
133
+ if (!auth) {
134
+ ctx.ui.notify(
135
+ "Not signed into ATProto. Run `/sim login <handle>` (e.g. `/sim login alice.bsky.social`) to sign in with your Bluesky / ATProto account. This is separate from pi's built-in `/login` (Anthropic).",
136
+ "info",
137
+ );
138
+ return;
139
+ }
140
+ ctx.ui.notify(
141
+ auth.handle
142
+ ? `Signed into ATProto as @${auth.handle} (${auth.did}) since ${auth.lastLogin}. Use /sim interview --apply or /sim train apply --apply to write records to your PDS.`
143
+ : `Signed into ATProto as ${auth.did} since ${auth.lastLogin}. Use /sim interview --apply or /sim train apply --apply to write records to your PDS.`,
144
+ "info",
145
+ );
146
+ }
147
+
148
+ function openInBrowser(url: string): void {
149
+ const escaped = url.replace(/"/g, '\\"');
150
+ const command =
151
+ platform() === "darwin"
152
+ ? `open "${escaped}"`
153
+ : platform() === "win32"
154
+ ? `start "" "${escaped}"`
155
+ : `xdg-open "${escaped}"`;
156
+ exec(command, () => {
157
+ /* best effort — failure is fine, user has the URL printed */
158
+ });
159
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * NodeOAuthClient singleton for the loopback OAuth flow.
3
+ *
4
+ * Builds client metadata via `buildAtprotoLoopbackClientMetadata` so
5
+ * we don't need a hosted client_metadata.json — the redirect URI is
6
+ * the loopback URL the callback server listens on. The client is
7
+ * cached process-wide because building it does some PKCE crypto and
8
+ * registering can fail on subsequent calls.
9
+ */
10
+
11
+ import {
12
+ NodeOAuthClient,
13
+ type NodeOAuthClientOptions,
14
+ } from "@atproto/oauth-client-node";
15
+
16
+ import { CALLBACK_REDIRECT_URI } from "./callback-server.ts";
17
+ import { sessionStore, stateStore } from "./storage.ts";
18
+
19
+ const SCOPES = "atproto transition:generic";
20
+
21
+ let cached: NodeOAuthClient | null = null;
22
+
23
+ export function getOAuthClient(): NodeOAuthClient {
24
+ if (cached) return cached;
25
+ // Note: NodeOAuthClient builds the loopback client metadata
26
+ // internally when client_id starts with `http://localhost`.
27
+ // We pass the loopback URL so it picks the loopback flow.
28
+ const clientId =
29
+ `http://localhost?` +
30
+ new URLSearchParams({
31
+ scope: SCOPES,
32
+ redirect_uri: CALLBACK_REDIRECT_URI,
33
+ }).toString();
34
+
35
+ const options: NodeOAuthClientOptions = {
36
+ clientMetadata: {
37
+ client_id: clientId,
38
+ client_name: "pi-simocracy",
39
+ redirect_uris: [CALLBACK_REDIRECT_URI as `http://127.0.0.1:${string}`],
40
+ scope: SCOPES,
41
+ grant_types: ["authorization_code", "refresh_token"],
42
+ response_types: ["code"],
43
+ application_type: "native",
44
+ token_endpoint_auth_method: "none",
45
+ dpop_bound_access_tokens: true,
46
+ },
47
+ stateStore,
48
+ sessionStore,
49
+ };
50
+
51
+ cached = new NodeOAuthClient(options);
52
+ return cached;
53
+ }
54
+
55
+ export const OAUTH_SCOPES = SCOPES;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * HTML pages served by the loopback callback server.
3
+ *
4
+ * Pattern adapted from pi-mono's `oauth-page.ts` (MIT) — same dark
5
+ * card style, no third-party assets, escapes user-supplied strings.
6
+ */
7
+
8
+ function escapeHtml(value: string): string {
9
+ return value
10
+ .replaceAll("&", "&amp;")
11
+ .replaceAll("<", "&lt;")
12
+ .replaceAll(">", "&gt;")
13
+ .replaceAll('"', "&quot;")
14
+ .replaceAll("'", "&#39;");
15
+ }
16
+
17
+ function renderPage(opts: {
18
+ title: string;
19
+ heading: string;
20
+ message: string;
21
+ details?: string;
22
+ }): string {
23
+ const title = escapeHtml(opts.title);
24
+ const heading = escapeHtml(opts.heading);
25
+ const message = escapeHtml(opts.message);
26
+ const details = opts.details ? escapeHtml(opts.details) : undefined;
27
+ return `<!doctype html>
28
+ <html lang="en">
29
+ <head>
30
+ <meta charset="utf-8" />
31
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
32
+ <title>${title}</title>
33
+ <style>
34
+ :root {
35
+ --text: #fafafa;
36
+ --text-dim: #a1a1aa;
37
+ --page-bg: #09090b;
38
+ --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
39
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
40
+ }
41
+ * { box-sizing: border-box; }
42
+ html { color-scheme: dark; }
43
+ body {
44
+ margin: 0;
45
+ min-height: 100vh;
46
+ display: flex;
47
+ align-items: center;
48
+ justify-content: center;
49
+ padding: 24px;
50
+ background: var(--page-bg);
51
+ color: var(--text);
52
+ font-family: var(--font-sans);
53
+ text-align: center;
54
+ }
55
+ main {
56
+ width: 100%;
57
+ max-width: 560px;
58
+ display: flex;
59
+ flex-direction: column;
60
+ align-items: center;
61
+ justify-content: center;
62
+ }
63
+ h1 { margin: 0 0 10px; font-size: 28px; line-height: 1.15; font-weight: 650; }
64
+ p { margin: 0; line-height: 1.7; color: var(--text-dim); font-size: 15px; }
65
+ .details {
66
+ margin-top: 16px;
67
+ font-family: var(--font-mono);
68
+ font-size: 13px;
69
+ color: var(--text-dim);
70
+ white-space: pre-wrap;
71
+ word-break: break-word;
72
+ }
73
+ </style>
74
+ </head>
75
+ <body>
76
+ <main>
77
+ <h1>${heading}</h1>
78
+ <p>${message}</p>
79
+ ${details ? `<div class="details">${details}</div>` : ""}
80
+ </main>
81
+ </body>
82
+ </html>`;
83
+ }
84
+
85
+ export function oauthSuccessHtml(message: string): string {
86
+ return renderPage({
87
+ title: "Signed in",
88
+ heading: "Signed in",
89
+ message,
90
+ });
91
+ }
92
+
93
+ export function oauthErrorHtml(message: string, details?: string): string {
94
+ return renderPage({
95
+ title: "Sign-in failed",
96
+ heading: "Sign-in failed",
97
+ message,
98
+ details,
99
+ });
100
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * File-backed StateStore + SessionStore for the ATProto loopback
3
+ * OAuth flow, plus a single-DID `auth.json` that records who's
4
+ * currently signed in (so `/whoami` works without round-tripping the
5
+ * session store).
6
+ *
7
+ * Files live in the platform's XDG config dir:
8
+ * ~/.config/pi-simocracy/auth.json — { did, handle, lastLogin }
9
+ * ~/.config/pi-simocracy/oauth-state.json — OAuth state map
10
+ * ~/.config/pi-simocracy/oauth-sessions.json — OAuth session map
11
+ *
12
+ * The session/state stores serialize per-call to keep the file
13
+ * authoritative — these stores are accessed once per command, not in
14
+ * a hot path, so locking isn't needed.
15
+ */
16
+
17
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+
21
+ import type {
22
+ NodeSavedSession,
23
+ NodeSavedSessionStore,
24
+ NodeSavedState,
25
+ NodeSavedStateStore,
26
+ } from "@atproto/oauth-client-node";
27
+
28
+ const DATA_DIR = process.env.XDG_CONFIG_HOME
29
+ ? join(process.env.XDG_CONFIG_HOME, "pi-simocracy")
30
+ : join(homedir(), ".config", "pi-simocracy");
31
+
32
+ const STATE_FILE = join(DATA_DIR, "oauth-state.json");
33
+ const SESSION_FILE = join(DATA_DIR, "oauth-sessions.json");
34
+ const AUTH_FILE = join(DATA_DIR, "auth.json");
35
+
36
+ export interface AuthRecord {
37
+ did: string;
38
+ handle: string | null;
39
+ lastLogin: string;
40
+ }
41
+
42
+ function ensureDir(): void {
43
+ mkdirSync(DATA_DIR, { recursive: true });
44
+ }
45
+
46
+ function readMap<V>(path: string): Record<string, V> {
47
+ if (!existsSync(path)) return {};
48
+ try {
49
+ return JSON.parse(readFileSync(path, "utf8")) as Record<string, V>;
50
+ } catch (err) {
51
+ console.error(`[pi-simocracy] Could not parse ${path}:`, (err as Error).message);
52
+ return {};
53
+ }
54
+ }
55
+
56
+ function writeMap<V>(path: string, value: Record<string, V>): void {
57
+ ensureDir();
58
+ writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
59
+ }
60
+
61
+ export const stateStore: NodeSavedStateStore = {
62
+ get(key: string) {
63
+ const map = readMap<NodeSavedState>(STATE_FILE);
64
+ return map[key];
65
+ },
66
+ set(key: string, value: NodeSavedState) {
67
+ const map = readMap<NodeSavedState>(STATE_FILE);
68
+ map[key] = value;
69
+ writeMap(STATE_FILE, map);
70
+ },
71
+ del(key: string) {
72
+ const map = readMap<NodeSavedState>(STATE_FILE);
73
+ delete map[key];
74
+ writeMap(STATE_FILE, map);
75
+ },
76
+ };
77
+
78
+ export const sessionStore: NodeSavedSessionStore = {
79
+ get(key: string) {
80
+ const map = readMap<NodeSavedSession>(SESSION_FILE);
81
+ return map[key];
82
+ },
83
+ set(key: string, value: NodeSavedSession) {
84
+ const map = readMap<NodeSavedSession>(SESSION_FILE);
85
+ map[key] = value;
86
+ writeMap(SESSION_FILE, map);
87
+ },
88
+ del(key: string) {
89
+ const map = readMap<NodeSavedSession>(SESSION_FILE);
90
+ delete map[key];
91
+ writeMap(SESSION_FILE, map);
92
+ },
93
+ };
94
+
95
+ export function readAuth(): AuthRecord | null {
96
+ if (!existsSync(AUTH_FILE)) return null;
97
+ try {
98
+ const parsed = JSON.parse(readFileSync(AUTH_FILE, "utf8")) as Partial<AuthRecord>;
99
+ if (!parsed.did) return null;
100
+ return {
101
+ did: parsed.did,
102
+ handle: parsed.handle ?? null,
103
+ lastLogin: parsed.lastLogin ?? new Date().toISOString(),
104
+ };
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ export function writeAuth(record: AuthRecord): void {
111
+ ensureDir();
112
+ writeFileSync(AUTH_FILE, JSON.stringify(record, null, 2), "utf8");
113
+ }
114
+
115
+ export function clearAuth(): void {
116
+ if (existsSync(AUTH_FILE)) rmSync(AUTH_FILE, { force: true });
117
+ if (existsSync(SESSION_FILE)) rmSync(SESSION_FILE, { force: true });
118
+ if (existsSync(STATE_FILE)) rmSync(STATE_FILE, { force: true });
119
+ }
120
+
121
+ export const AUTH_DIR = DATA_DIR;