@aisystemresources/emdee 0.1.2 → 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.
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: emdee-describe-image
3
+ description: |
4
+ Use whenever you encounter an EMDEE vault doc under IMAGES/ whose summary is
5
+ `_description pending_` or whose title looks like a timestamp
6
+ (`IMAGE-14-19-31`, `PHOTO-2026-06-05-...`). The upload flow creates docs in
7
+ this shape by design — this skill fills in the meaning.
8
+ ---
9
+
10
+ # emdee-describe-image — rename + summarise uploaded images
11
+
12
+ When you see an EMDEE image doc that hasn't been described yet, run this workflow. It uses the existing `get_image` + `rename_doc` + `patch_preamble` MCP tools (or their `emdee` CLI equivalents) — no new server-side dependencies.
13
+
14
+ ## Trigger patterns
15
+
16
+ Any vault doc where:
17
+ - Path matches `IMAGES/**/*.md` OR `images/**/*.md`, and
18
+ - Summary blockquote is exactly `_description pending_`, OR
19
+ - Title matches `IMAGE-\d{2}-\d{2}-\d{2}` or `PHOTO-\d{4}-\d{2}-\d{2}-.*`
20
+
21
+ ## Workflow (4 steps)
22
+
23
+ ### 1. Read the image
24
+
25
+ ```
26
+ get_image(doc_path=<PATH>)
27
+ ```
28
+
29
+ Or via CLI (when SPRINT-091 chunk 4 lands `get-image`):
30
+
31
+ ```
32
+ emdee get-image --path <PATH> --remote
33
+ ```
34
+
35
+ The MCP tool returns the image as a visual content block — you can see it.
36
+
37
+ ### 2. Compose
38
+
39
+ - **Semantic title**, 3-6 words, UPPERCASE-with-HYPHENS. Describe the subject, not the container.
40
+ - Good: `HANDSTAND-BALANCE-DRILL`, `AIDA-WORKSHOP-WHITEBOARD`, `KOBE-BRYANT-CAPS-SPEECH`
41
+ - Bad: `PHOTO`, `IMAGE-OF-MAN-STANDING`, `image-14-19-31` (not uppercase, not descriptive)
42
+ - **One-line summary**, 15-30 words, that a future search would surface. What is this, why was it captured, what does it show?
43
+
44
+ ### 3. Rename
45
+
46
+ ```
47
+ emdee rename-doc --old-path <PATH> --new-title <TITLE> --remote
48
+ ```
49
+
50
+ The tool atomically:
51
+ - Rewrites the H1
52
+ - Moves the file (default: same folder, `<TITLE>.md`)
53
+ - Updates every `[[<old title>]]` wiki-link across the vault
54
+
55
+ ### 4. Replace the summary
56
+
57
+ Fetch the fresh preamble hash:
58
+
59
+ ```
60
+ emdee get-doc --path <NEW-PATH> --remote --json
61
+ ```
62
+
63
+ Take `preamble.content_hash` from the response, then:
64
+
65
+ ```
66
+ emdee patch-preamble --path <NEW-PATH> \
67
+ --body "> <your 15-30 word summary>" \
68
+ --expected-hash <hash> --remote
69
+ ```
70
+
71
+ ## Batch mode
72
+
73
+ If several images need describing:
74
+
75
+ ```
76
+ emdee list-docs --prefix "IMAGES/" --remote | \
77
+ xargs -I{} emdee get-summary --path {} --remote --format text
78
+ ```
79
+
80
+ Filter for `_description pending_` in the output, then run the 4-step workflow per image. Cap at 20 per batch — image content is high-signal but you can misread ambiguous shots at scale.
81
+
82
+ ## Failure modes
83
+
84
+ - **Title collision** — `rename_doc` returns `title_conflict`. Pick a more specific title (add a subject qualifier).
85
+ - **`_description pending_` was already replaced** — someone else already ran this. `emdee get-doc` will show the new summary; skip.
86
+ - **Image is illegible** — set the summary to `> Illegible / unable to describe from image alone.` and flag it in the user-facing report so they can annotate manually.
87
+
88
+ ## What to report at the end
89
+
90
+ For each image processed:
91
+
92
+ ```
93
+ <old-path> → <new-path>
94
+ <title>
95
+ <summary>
96
+ ```
97
+
98
+ So the user can sanity-check without opening each doc.
@@ -0,0 +1,121 @@
1
+ ---
2
+ name: emdee-onboarder
3
+ description: |
4
+ Use when the user's vault is fresh (`emdee list` returns exactly the 5
5
+ virtual system nodes + at most 1 owner node), OR when they explicitly ask
6
+ "how do I start with EMDEE" / "walk me through onboarding". Guides them
7
+ through init → first project → connecting to Claude.
8
+ ---
9
+
10
+ # emdee-onboarder — get a new user from install to first useful doc
11
+
12
+ A new user with EMDEE installed has:
13
+ - The `emdee` CLI on PATH
14
+ - A blank `~/.claude/skills/` (or this skill installed)
15
+ - Nothing yet in their vault (5 virtual system nodes + optionally an owner node)
16
+
17
+ Your job is to walk them from that state to a functional vault with at least one project + one note + Claude wired to it.
18
+
19
+ ## Trigger patterns
20
+
21
+ - `emdee list --remote` returns 5 or 6 paths (the system nodes ± the owner)
22
+ - User says: "walk me through onboarding", "how do I start", "what's next"
23
+ - User just ran `emdee init` and is asking what to do next
24
+
25
+ ## Workflow
26
+
27
+ ### 1. Confirm the vault state
28
+
29
+ ```
30
+ emdee list --remote --format text
31
+ ```
32
+
33
+ You should see roughly:
34
+ ```
35
+ EMDEE.md
36
+ GRAVEYARD.md
37
+ IMAGES.md
38
+ SHARED.md
39
+ VAULT.md
40
+ <OWNER>.md # optional — present if they've run `emdee init --nickname`
41
+ ```
42
+
43
+ If the owner is missing, guide them:
44
+ ```
45
+ emdee init --nickname "Their Name"
46
+ ```
47
+
48
+ This writes ONE file (their owner node) locally. In cloud mode, the owner node is created by the first web sign-in via the nickname prompt.
49
+
50
+ ### 2. Explain the 5-node OS layer
51
+
52
+ Quickly. One breath. See `emdee-conventions` skill.
53
+
54
+ Emphasise: **the 5 system nodes are virtual. You don't create them, you don't edit them (unless you really want to override the default content). You reference them from your own content.**
55
+
56
+ ### 3. Create their first real doc
57
+
58
+ Ask what they want to track first. Most users start with either:
59
+ - A project (they're building something)
60
+ - A person (someone they collaborate with or admire)
61
+ - A concept (something they're learning)
62
+
63
+ Then guide them:
64
+
65
+ **For a project:**
66
+ ```
67
+ emdee create-child --parent-path VAULT.md \
68
+ --title "PROJECT-NAME" \
69
+ --summary "One sentence: what this project is and why." \
70
+ --remote
71
+ ```
72
+
73
+ **For a person:**
74
+ ```
75
+ emdee create-child --parent-path VAULT.md \
76
+ --title "FIRSTNAME-LASTNAME" \
77
+ --summary "How you know them + one thing to remember." \
78
+ --remote
79
+ ```
80
+
81
+ **For a concept:**
82
+ ```
83
+ emdee create-child --parent-path VAULT.md \
84
+ --title "CONCEPT-NAME" \
85
+ --summary "The load-bearing idea in one line." \
86
+ --remote
87
+ ```
88
+
89
+ ### 4. Show them the graph
90
+
91
+ Have them open [emdee.tech](https://emdee.tech) in their browser. They should see EMDEE at the centre, connected to their owner node, connected to the doc they just created. This is the moment onboarding clicks — the graph is EMDEE's superpower.
92
+
93
+ ### 5. Connect Claude
94
+
95
+ Explain that everything they just did via `emdee` CLI can also happen via Claude directly. Two paths:
96
+
97
+ **For Claude Code:** the CLI is already installed. Every future session, Claude can run `emdee` commands directly.
98
+
99
+ **For claude.ai:** they connect the emdee.tech MCP server via the connector panel (link in their emdee.tech sidebar).
100
+
101
+ ### 6. Hand off to conventions
102
+
103
+ Say: "The `emdee-conventions` skill is now the always-loaded reference for how to write to your vault. Everything else — creating docs, linking them, tracking edges — is documented there."
104
+
105
+ Load the conventions skill (if not already loaded) and let it take over.
106
+
107
+ ## What to avoid
108
+
109
+ - **Don't dump the full conventions in step 2.** The user is trying to see one thing work. Ship them from zero → seeing their first graph node in under 5 minutes. Depth comes after.
110
+ - **Don't force `create_child` before they know what to create.** The step-3 question ("what do you want to track first?") is critical. If they say "I don't know yet," don't create a doc — instead, suggest they browse [emdee.tech](https://emdee.tech) in incognito to see the public demo vault. Come back when they have an answer.
111
+ - **Don't reference SPRINTs / LOGS / LEARNINGS in onboarding.** Those are for later. Zero-to-one first.
112
+
113
+ ## Success signal
114
+
115
+ The user, at the end of the onboarding, has:
116
+ - ✅ Their owner node visible
117
+ - ✅ At least one child doc under VAULT with a real summary
118
+ - ✅ Both docs visible in the emdee.tech graph, connected
119
+ - ✅ Understands they can use `emdee <verb>` OR Claude directly
120
+
121
+ If all four are true, hand off to `emdee-conventions` and end the onboarding.
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: emdee-summariser
3
+ description: |
4
+ Use whenever the user asks for a summary refresh, or when `emdee
5
+ list-summary-drift` returns ≥ 1 candidate, or when you notice a doc whose
6
+ summary is stale relative to its content (drifted body, new sections added,
7
+ scope change). Runs the SPRINT-081 batch summariser flow.
8
+ ---
9
+
10
+ # emdee-summariser — refresh drifting doc summaries
11
+
12
+ Docs in EMDEE carry a one-line `> blockquote` summary right below the H1. That summary is what routing sees — search, `get_summary`, cheap enumeration all pivot on it. When the body drifts (new sections, refined framing, changed scope), the summary should catch up. This skill runs that refresh.
13
+
14
+ ## Trigger patterns
15
+
16
+ - User asks: "refresh summaries", "which docs have drifted", "run the summariser"
17
+ - Output of `emdee list-summary-drift` or `emdee drift-batch` returns any candidates
18
+ - You notice: after making substantial edits to a doc's body, its blockquote no longer accurately previews what's inside
19
+
20
+ ## Workflow
21
+
22
+ ### 1. Enumerate drift
23
+
24
+ ```
25
+ emdee list-summary-drift --limit 20 --remote --format text
26
+ ```
27
+
28
+ Returns up to 20 paths, one per line. Each is a doc where:
29
+ - `content_hash_at_summary_write` is null (never baselined), OR
30
+ - `hash(current_content) != content_hash_at_summary_write` (body drifted since summary was written)
31
+
32
+ For a bigger batch, raise `--limit`. Don't run > 50 in one pass — token cost + human review burden compounds.
33
+
34
+ ### 2. Per doc, propose a new summary
35
+
36
+ For each returned path:
37
+
38
+ ```
39
+ emdee get-doc --path <PATH> --remote --full --format text
40
+ ```
41
+
42
+ Read the full body. Compose a new one-line summary (15-40 words) that:
43
+ - **Answers "what is this and why does it exist"** — routing signal
44
+ - **Uses the doc's own vocabulary** — searchers will match on their terms
45
+ - **Mentions the load-bearing thing first** — a scan of the first 20 characters should telegraph the doc's purpose
46
+
47
+ ### 3. Report proposals to the user
48
+
49
+ Do NOT patch yet. Present the full batch as a punch list:
50
+
51
+ ```
52
+ <PATH>
53
+ Current: <old blockquote>
54
+ Proposed: <new blockquote>
55
+
56
+ <PATH>
57
+ Current: ...
58
+ Proposed: ...
59
+ ```
60
+
61
+ The user approves per-doc or in bulk.
62
+
63
+ ### 4. On approval, patch each
64
+
65
+ Get the fresh preamble hash:
66
+
67
+ ```
68
+ emdee get-doc --path <PATH> --remote --json
69
+ ```
70
+
71
+ Take `preamble.content_hash`. Then:
72
+
73
+ ```
74
+ emdee patch-preamble --path <PATH> \
75
+ --body "> <new summary>" \
76
+ --expected-hash <hash> --remote
77
+ ```
78
+
79
+ The patch REPLACES the entire preamble body (blockquote + any intro paragraphs). If the doc has intro paragraphs before the first H2, preserve them by including them in the new `--body`.
80
+
81
+ ## Batching etiquette
82
+
83
+ - **Read all 20 first, then propose, then wait for approval, then patch.** Don't interleave read + patch — you'll bill the user for reads on docs they'll reject.
84
+ - **Sort proposals by importance** (parent hubs first, leaves last). If the user only has time to review 5, they see the highest-signal ones.
85
+ - **If two docs share a common subject** and both need re-summarising, note it: `SEE ALSO: <other-path>`. Helps the user spot when a broader restructure is warranted.
86
+
87
+ ## When to say no
88
+
89
+ - If the drift is small (a typo fix, a minor addition), skip the summary refresh. Only touch docs where the body has shifted enough that the current blockquote actively misleads.
90
+ - If the doc's structure has changed (added new sections that fundamentally change scope), don't just refresh the summary — suggest a broader restructure via `emdee move-doc` or `emdee split-doc`. Escalate to the user.
91
+
92
+ ## Reporting after the batch
93
+
94
+ ```
95
+ Refreshed <N> summaries in <namespace>.
96
+ Skipped <M> where drift was cosmetic.
97
+ Flagged <K> for restructure (see notes).
98
+ ```
@@ -0,0 +1,72 @@
1
+ // SPRINT-091: entrypoint for `emdee login | logout | whoami`.
2
+ //
3
+ // Shelled from bin/emdee.js via `npx tsx`. Kept separate from read-commands
4
+ // so the auth flow's node:http server + browser-open logic doesn't get
5
+ // loaded on every read invocation.
6
+
7
+ import { parseArgs } from "node:util";
8
+ import { login, deleteCreds, loadCreds, whoami, DEFAULT_HOST, NeedsLoginError } from "./auth";
9
+
10
+ async function cmdLogin(argv: string[]): Promise<void> {
11
+ const { values } = parseArgs({ args: argv, options: { host: { type: "string" } }, strict: true });
12
+ const host = values.host ?? DEFAULT_HOST;
13
+ const creds = await login(host);
14
+ let email: string | null = null;
15
+ try {
16
+ const w = await whoami(creds);
17
+ email = w.email;
18
+ } catch {
19
+ // Non-fatal; login succeeded.
20
+ }
21
+ process.stdout.write(
22
+ `Logged in to ${host}${email ? ` as ${email}` : ""}.\n` +
23
+ `Credentials saved to ~/.config/emdee/credentials.json (mode 0600).\n`,
24
+ );
25
+ }
26
+
27
+ async function cmdLogout(): Promise<void> {
28
+ const removed = await deleteCreds();
29
+ process.stdout.write(removed ? "Logged out.\n" : "Already logged out.\n");
30
+ }
31
+
32
+ async function cmdWhoami(): Promise<void> {
33
+ const creds = await loadCreds();
34
+ if (!creds) {
35
+ process.stderr.write("Not logged in. Run `emdee login`.\n");
36
+ process.exit(1);
37
+ }
38
+ try {
39
+ const w = await whoami(creds);
40
+ process.stdout.write(`${w.email ?? "(no email on record)"}\nnamespace: ${w.namespace}\nhost: ${creds.host}\n`);
41
+ } catch (err) {
42
+ if (err instanceof NeedsLoginError) {
43
+ process.stderr.write(`${err.message}\n`);
44
+ process.exit(1);
45
+ }
46
+ throw err;
47
+ }
48
+ }
49
+
50
+ const [, , sub, ...rest] = process.argv;
51
+
52
+ async function main(): Promise<void> {
53
+ switch (sub) {
54
+ case "login":
55
+ await cmdLogin(rest);
56
+ return;
57
+ case "logout":
58
+ await cmdLogout();
59
+ return;
60
+ case "whoami":
61
+ await cmdWhoami();
62
+ return;
63
+ default:
64
+ process.stderr.write(`unknown subcommand: ${sub ?? "(none)"}\nusage: emdee <login|logout|whoami> [--host URL]\n`);
65
+ process.exit(1);
66
+ }
67
+ }
68
+
69
+ main().catch((err) => {
70
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
71
+ process.exit(1);
72
+ });
@@ -0,0 +1,215 @@
1
+ // SPRINT-091: CLI auth — PKCE loopback OAuth against emdee.tech.
2
+ //
3
+ // Follows RFC 8252 (OAuth 2.0 for native apps) + dynamic client registration
4
+ // via /oauth/register (matches Codex + Claude Code patterns already in use).
5
+ // Credentials land in ~/.config/emdee/credentials.json at 0600.
6
+ //
7
+ // Token has a 30-day TTL and there is no refresh grant on the server today,
8
+ // so credentials are simple: access_token + client_id + host + saved_at. When
9
+ // a token expires, the CLI surfaces `run 'emdee login'` — same UX as Codex.
10
+
11
+ import { createHash, randomBytes } from "node:crypto";
12
+ import { readFile, writeFile, mkdir, unlink, chmod, stat } from "node:fs/promises";
13
+ import { createServer, type Server } from "node:http";
14
+ import { spawn } from "node:child_process";
15
+ import path from "node:path";
16
+ import os from "node:os";
17
+
18
+ const CREDS_DIR = path.join(os.homedir(), ".config", "emdee");
19
+ const CREDS_PATH = path.join(CREDS_DIR, "credentials.json");
20
+ export const DEFAULT_HOST = process.env.EMDEE_CLOUD_URL ?? "https://emdee.tech";
21
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
22
+
23
+ export interface Credentials {
24
+ access_token: string;
25
+ client_id: string;
26
+ host: string;
27
+ saved_at: number;
28
+ }
29
+
30
+ export class NeedsLoginError extends Error {
31
+ constructor(message = "Run `emdee login` first.") {
32
+ super(message);
33
+ this.name = "NeedsLoginError";
34
+ }
35
+ }
36
+
37
+ // PKCE helpers per RFC 7636.
38
+ function base64url(buf: Buffer): string {
39
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
40
+ }
41
+
42
+ function generatePkce(): { verifier: string; challenge: string } {
43
+ const verifier = base64url(randomBytes(32));
44
+ const challenge = base64url(createHash("sha256").update(verifier).digest());
45
+ return { verifier, challenge };
46
+ }
47
+
48
+ // File I/O ------------------------------------------------------------------
49
+
50
+ export async function loadCreds(): Promise<Credentials | null> {
51
+ try {
52
+ const text = await readFile(CREDS_PATH, "utf8");
53
+ return JSON.parse(text) as Credentials;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ async function saveCreds(creds: Credentials): Promise<void> {
60
+ await mkdir(CREDS_DIR, { recursive: true });
61
+ await writeFile(CREDS_PATH, JSON.stringify(creds, null, 2), "utf8");
62
+ await chmod(CREDS_PATH, 0o600);
63
+ }
64
+
65
+ export async function deleteCreds(): Promise<boolean> {
66
+ try {
67
+ await stat(CREDS_PATH);
68
+ await unlink(CREDS_PATH);
69
+ return true;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ // Browser open (best-effort per platform) -----------------------------------
76
+
77
+ function openBrowser(url: string): void {
78
+ const platform = process.platform;
79
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
80
+ try {
81
+ spawn(cmd, [url], { stdio: "ignore", detached: true }).unref();
82
+ } catch {
83
+ // Best-effort — user can copy the URL if this fails.
84
+ }
85
+ }
86
+
87
+ // Dynamic client registration ----------------------------------------------
88
+
89
+ async function registerClient(host: string, redirectUri: string): Promise<string> {
90
+ const res = await fetch(`${host}/oauth/register`, {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json" },
93
+ body: JSON.stringify({
94
+ client_name: `emdee-cli (${os.hostname()})`,
95
+ redirect_uris: [redirectUri],
96
+ }),
97
+ });
98
+ if (!res.ok) {
99
+ const body = await res.text().catch(() => "");
100
+ throw new Error(`oauth register failed: ${res.status} ${body}`);
101
+ }
102
+ const body = (await res.json()) as { client_id: string };
103
+ return body.client_id;
104
+ }
105
+
106
+ // Main login flow ----------------------------------------------------------
107
+
108
+ export async function login(host: string = DEFAULT_HOST): Promise<Credentials> {
109
+ const { verifier, challenge } = generatePkce();
110
+ const state = base64url(randomBytes(16));
111
+
112
+ // 1. Bind loopback server on an OS-assigned port so we know the redirect_uri
113
+ // BEFORE we register the client + open the browser.
114
+ let capturedCode: string | null = null;
115
+ let capturedState: string | null = null;
116
+ const server: Server = createServer((req, res) => {
117
+ if (!req.url) return;
118
+ const u = new URL(req.url, "http://localhost");
119
+ if (u.pathname !== "/callback") {
120
+ res.statusCode = 404;
121
+ res.end();
122
+ return;
123
+ }
124
+ capturedCode = u.searchParams.get("code");
125
+ capturedState = u.searchParams.get("state");
126
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
127
+ res.end(
128
+ `<!doctype html><html><body style="font-family:system-ui;padding:2em;text-align:center"><h2>${capturedCode ? "Login complete" : "Login failed"}</h2><p>You can close this tab and return to the terminal.</p></body></html>`
129
+ );
130
+ // Give the browser a beat to render the response before shutting the socket.
131
+ setTimeout(() => server.close(), 100);
132
+ });
133
+ await new Promise<void>((resolve, reject) => {
134
+ server.once("error", reject);
135
+ server.listen(0, "127.0.0.1", () => resolve());
136
+ });
137
+ const port = (server.address() as { port: number }).port;
138
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
139
+
140
+ // 2. Register a fresh client for this login (matches Codex's per-session pattern).
141
+ const clientId = await registerClient(host, redirectUri);
142
+
143
+ // 3. Open browser to authorize.
144
+ const authorizeUrl = new URL(`${host}/oauth/authorize`);
145
+ authorizeUrl.searchParams.set("response_type", "code");
146
+ authorizeUrl.searchParams.set("client_id", clientId);
147
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
148
+ authorizeUrl.searchParams.set("code_challenge", challenge);
149
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
150
+ authorizeUrl.searchParams.set("state", state);
151
+ authorizeUrl.searchParams.set("scope", "mcp");
152
+ const authUrl = authorizeUrl.toString();
153
+ console.error(`Opening browser to authorize:\n${authUrl}\n`);
154
+ openBrowser(authUrl);
155
+
156
+ // 4. Wait for callback (or timeout).
157
+ const code = await new Promise<string>((resolve, reject) => {
158
+ const timer = setTimeout(() => {
159
+ server.close();
160
+ reject(new Error("Login timed out. Retry with `emdee login`."));
161
+ }, LOGIN_TIMEOUT_MS);
162
+ server.on("close", () => {
163
+ clearTimeout(timer);
164
+ if (!capturedCode) return reject(new Error("Login was cancelled."));
165
+ if (capturedState !== state) return reject(new Error("State mismatch — possible CSRF, aborted."));
166
+ resolve(capturedCode);
167
+ });
168
+ });
169
+
170
+ // 5. Exchange the code for an access token.
171
+ const tokenRes = await fetch(`${host}/oauth/token`, {
172
+ method: "POST",
173
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
174
+ body: new URLSearchParams({
175
+ grant_type: "authorization_code",
176
+ code,
177
+ redirect_uri: redirectUri,
178
+ client_id: clientId,
179
+ code_verifier: verifier,
180
+ }).toString(),
181
+ });
182
+ if (!tokenRes.ok) {
183
+ const body = await tokenRes.text().catch(() => "");
184
+ throw new Error(`token exchange failed: ${tokenRes.status} ${body}`);
185
+ }
186
+ const tokenBody = (await tokenRes.json()) as { access_token: string };
187
+
188
+ const creds: Credentials = {
189
+ access_token: tokenBody.access_token,
190
+ client_id: clientId,
191
+ host,
192
+ saved_at: Date.now(),
193
+ };
194
+ await saveCreds(creds);
195
+ return creds;
196
+ }
197
+
198
+ // Whoami — hits the new /api/whoami endpoint --------------------------------
199
+
200
+ export interface WhoamiPayload {
201
+ namespace: string;
202
+ email: string | null;
203
+ }
204
+
205
+ export async function whoami(creds: Credentials): Promise<WhoamiPayload> {
206
+ const res = await fetch(`${creds.host}/api/whoami`, {
207
+ headers: { Authorization: `Bearer ${creds.access_token}` },
208
+ });
209
+ if (res.status === 401) throw new NeedsLoginError();
210
+ if (!res.ok) {
211
+ const body = await res.text().catch(() => "");
212
+ throw new Error(`whoami failed: ${res.status} ${body}`);
213
+ }
214
+ return (await res.json()) as WhoamiPayload;
215
+ }