@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.
- package/README.md +32 -0
- package/bin/emdee.js +468 -3
- package/package.json +2 -1
- package/skills/emdee-conventions.md +106 -0
- package/skills/emdee-describe-image.md +98 -0
- package/skills/emdee-onboarder.md +121 -0
- package/skills/emdee-summariser.md +98 -0
- package/src/cli/auth-commands.ts +72 -0
- package/src/cli/auth.ts +215 -0
- package/src/cli/read-commands.ts +245 -15
- package/src/cli/remote-client.ts +74 -0
- package/src/cli/skills-install.ts +54 -0
- package/src/cli/write-commands.ts +349 -0
- package/src/lib/mcp/tools/add_association.ts +2 -14
- package/src/lib/mcp/tools/create_child.ts +0 -2
|
@@ -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
|
+
});
|
package/src/cli/auth.ts
ADDED
|
@@ -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
|
+
}
|