pi-simocracy 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,13 +33,17 @@ doesn't need it — it just rewrites pi's system prompt.
33
33
 
34
34
  ## Slash commands
35
35
 
36
- | Command | What it does |
37
- |------------------|-------------------------------------------------------------|
38
- | `/sim <name>` | Load a sim by name (fuzzy search). Multiple matches → picker. |
39
- | `/sim <at-uri>` | Load a sim by AT-URI directly (no search). |
40
- | `/sim status` | Show which sim is currently loaded. |
41
- | `/sim unload` | Drop the persona and break character cleanly. |
42
- | `/sim help` | Print usage. |
36
+ | Command | What it does |
37
+ |-------------------|-------------------------------------------------------------|
38
+ | `/sim <name>` | Load a sim by name (fuzzy search). Multiple matches → picker. |
39
+ | `/sim <at-uri>` | Load a sim by AT-URI directly (no search). |
40
+ | `/sim status` | Show which sim is currently loaded. |
41
+ | `/sim unload` | Drop the persona and break character cleanly. |
42
+ | `/sim login [handle]` | Sign in to **ATProto / Bluesky** via loopback OAuth (NOT Anthropic — pi's built-in `/login` is what does that). Required before pi can update your sim. |
43
+ | `/sim logout` | Clear the local ATProto OAuth session. |
44
+ | `/sim whoami` | Show the signed-in handle / DID. |
45
+ | `/sim my [name]` | List / pick / fuzzy-load sims you own on your PDS. Single match auto-loads; ambiguous matches open a picker. Requires `/sim login`. |
46
+ | `/sim help` | Print usage. |
43
47
 
44
48
  Examples:
45
49
 
@@ -47,20 +51,48 @@ Examples:
47
51
  /sim mr meow
48
52
  /sim Marie Curie
49
53
  /sim at://did:plc:qc42fmqqlsmdq7jiypiiigww/org.simocracy.sim/3mfo6vwfaka24
54
+ /sim login alice.bsky.social
55
+ /sim my
50
56
  /sim unload
51
57
  ```
52
58
 
53
59
  ---
54
60
 
61
+ ## Editing a sim's constitution / speaking style
62
+
63
+ There is no slash-command pipeline for this. Once you've signed in via
64
+ `/sim login` and loaded a sim you own (`/sim my`, then pick), just
65
+ **describe the change you want to pi**:
66
+
67
+ ```
68
+ > add a red line about animal welfare to the constitution
69
+ > rewrite the speaking style to drop the lenny faces and be more concise
70
+ > shorten the constitution to ~300 words and emphasise renewable energy
71
+ ```
72
+
73
+ Pi rewrites the constitution and/or speaking style itself, then calls
74
+ the `simocracy_update_sim` tool to persist the result. The tool refuses
75
+ to run if you're not signed in or you don't own the loaded sim. The
76
+ new persona takes effect on the next reply — no reload needed.
77
+
78
+ Writing goes directly to your PDS via
79
+ `com.atproto.repo.createRecord` / `putRecord` against the
80
+ `org.simocracy.agents` (constitution) and `org.simocracy.style`
81
+ (speaking style) collections — the same lexicons simocracy.org reads
82
+ back.
83
+
84
+ ---
85
+
55
86
  ## LLM-callable tools
56
87
 
57
88
  The same actions are exposed to pi as tools, so the model can drive them itself:
58
89
 
59
- | Tool | Use when |
60
- |--------------------------|-----------------------------------------------------------------|
61
- | `simocracy_load_sim` | Load a sim into the current session (sets the persona). |
62
- | `simocracy_unload_sim` | Stop roleplaying. |
63
- | `simocracy_chat` | Send one message to a sim and get a quoted reply, **without** changing the active session persona. Useful for "ask Mr Meow what he thinks of this PR." Requires `OPENROUTER_API_KEY`. |
90
+ | Tool | Use when |
91
+ |-------------------------|-----------------------------------------------------------------|
92
+ | `simocracy_load_sim` | Load a sim into the current session (sets the persona). |
93
+ | `simocracy_unload_sim` | Stop roleplaying. |
94
+ | `simocracy_chat` | Send one message to a sim and get a quoted reply, **without** changing the active session persona. Useful for "ask Mr Meow what he thinks of this PR." Requires `OPENROUTER_API_KEY`. |
95
+ | `simocracy_update_sim` | Write a new constitution (`shortDescription` + `description`) and/or speaking `style` for the **loaded** sim to your PDS. Requires `/sim login` AND ownership of the loaded sim. |
64
96
 
65
97
  ---
66
98
 
@@ -98,9 +130,12 @@ keeps the terminal it's already running in.
98
130
  ```
99
131
  src/
100
132
  ├── index.ts # extension entry: slash command, tools, persona injection
101
- ├── simocracy.ts # indexer + PDS client (read-only)
133
+ ├── persona.ts # buildSimPrompt(sim) the system-prompt fragment
134
+ ├── simocracy.ts # indexer + PDS client (read-only fetchers)
135
+ ├── writes.ts # PDS writers + ownership / sign-in preconditions
102
136
  ├── png-to-ansi.ts # RGBA half-block ANSI renderer
103
- └── openrouter.ts # minimal OpenRouter client (only used by simocracy_chat)
137
+ ├── openrouter.ts # minimal OpenRouter client (only used by simocracy_chat)
138
+ └── auth/ # ATProto OAuth loopback flow + session storage
104
139
  demo/
105
140
  └── sim-load.tape # vhs tape — render with `vhs demo/sim-load.tape`
106
141
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-simocracy",
3
- "version": "0.1.1",
3
+ "version": "0.3.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}). Pi can now update the constitution / speaking style of any sim you own via the simocracy_update_sim tool — just load one of your sims with /sim my and ask pi for the change you want.`
110
+ : `🔐 Signed in to ATProto as ${did}. Pi can now update the constitution / speaking style of any sim you own via the simocracy_update_sim tool — just load one of your sims with /sim my and ask pi for the change you want.`,
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}. Pi can update the constitution / speaking style of any sim you own (loaded via /sim my) by calling the simocracy_update_sim tool.`
143
+ : `Signed into ATProto as ${auth.did} since ${auth.lastLogin}. Pi can update the constitution / speaking style of any sim you own (loaded via /sim my) by calling the simocracy_update_sim tool.`,
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;