@aisystemresources/emdee 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.
@@ -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
+ }
@@ -1,17 +1,240 @@
1
+ // SPRINT-091 chunks 1+3: CLI read verbs.
2
+ //
3
+ // Two flavours of reads:
4
+ // - Bytes-only: `list`, `drift-batch` — legacy verbs, print raw text
5
+ // - Structured: `get-doc`, `get-summary`, `get-neighbors`, `get-context`,
6
+ // `search`, `read-doc-section`, `list-summary-drift` — table-driven
7
+ // dispatcher entries. Each takes --remote (routes cloud), --format
8
+ // (text|json, default text where available), and prints unwrapped payload.
9
+ //
10
+ // Structured reads share the dispatcher with writes' shape so the surface
11
+ // stays consistent, but their `formatOutput` collapses the MCP envelope by
12
+ // default (users want the actual body, not JSON metadata).
13
+
1
14
  import path from "node:path";
2
- import { parseArgs } from "node:util";
15
+ import { parseArgs, type ParseArgsConfig } from "node:util";
3
16
  import { buildIndex } from "../core/indexer";
17
+ import { callTool, unwrapText } from "./remote-client";
18
+ import { NeedsLoginError } from "./auth";
19
+ import type { ToolContext } from "../lib/mcp/tools/types";
20
+ import { getDoc } from "../lib/mcp/tools/get_doc";
21
+ import { getSummary } from "../lib/mcp/tools/get_summary";
22
+ import { getNeighbors } from "../lib/mcp/tools/get_neighbors";
23
+ import { getContext } from "../lib/mcp/tools/get_context";
24
+ import { search } from "../lib/mcp/tools/search";
25
+ import { readDocSection } from "../lib/mcp/tools/read_doc_section";
26
+ import { listDocs } from "../lib/mcp/tools/list_docs";
27
+ import { listSummaryDrift } from "../lib/mcp/tools/list_summary_drift";
4
28
 
5
29
  const docsDir = path.resolve(process.env.EMDEE_DOCS ?? path.join(process.cwd(), "docs"));
6
30
 
31
+ type ToolFn = (ctx: ToolContext, args: Record<string, unknown>) => Promise<unknown>;
32
+
33
+ interface ReadVerb {
34
+ toolName: string;
35
+ toolFn: ToolFn;
36
+ parse: ParseArgsConfig["options"];
37
+ buildArgs: (values: Record<string, string | boolean | undefined>) => Record<string, unknown>;
38
+ }
39
+
40
+ const COMMON = {
41
+ remote: { type: "boolean" },
42
+ format: { type: "string" },
43
+ json: { type: "boolean" },
44
+ } as const;
45
+
46
+ function asString(v: unknown): string {
47
+ return typeof v === "string" ? v : "";
48
+ }
49
+ function optionalString(v: unknown): string | undefined {
50
+ return typeof v === "string" && v.length > 0 ? v : undefined;
51
+ }
52
+
53
+ const READ_VERBS: Record<string, ReadVerb> = {
54
+ "get-doc": {
55
+ toolName: "get_doc",
56
+ toolFn: getDoc as unknown as ToolFn,
57
+ parse: {
58
+ ...COMMON,
59
+ path: { type: "string" },
60
+ full: { type: "boolean" },
61
+ "expected-hash": { type: "string" },
62
+ },
63
+ buildArgs: (v) => {
64
+ const args: Record<string, unknown> = { path: asString(v.path) };
65
+ if (v.full) args.full = true;
66
+ if (v.format === "text") args.format = "text";
67
+ const expected = optionalString(v["expected-hash"]);
68
+ if (expected) args.expected_content_hash = expected;
69
+ return args;
70
+ },
71
+ },
72
+ "get-summary": {
73
+ toolName: "get_summary",
74
+ toolFn: getSummary as unknown as ToolFn,
75
+ parse: { ...COMMON, path: { type: "string" } },
76
+ buildArgs: (v) => {
77
+ const args: Record<string, unknown> = { path: asString(v.path) };
78
+ if (v.format === "text") args.format = "text";
79
+ return args;
80
+ },
81
+ },
82
+ "get-neighbors": {
83
+ toolName: "get_neighbors",
84
+ toolFn: getNeighbors as unknown as ToolFn,
85
+ parse: { ...COMMON, path: { type: "string" } },
86
+ buildArgs: (v) => ({ path: asString(v.path) }),
87
+ },
88
+ "get-context": {
89
+ toolName: "get_context",
90
+ toolFn: getContext as unknown as ToolFn,
91
+ parse: {
92
+ ...COMMON,
93
+ path: { type: "string" },
94
+ hops: { type: "string" },
95
+ "budget-tokens": { type: "string" },
96
+ "include-full": { type: "boolean" },
97
+ "include-associates": { type: "boolean" },
98
+ "expected-hash": { type: "string" },
99
+ },
100
+ buildArgs: (v) => {
101
+ const args: Record<string, unknown> = { path: asString(v.path) };
102
+ const hops = optionalString(v.hops);
103
+ if (hops) args.hops = Number(hops);
104
+ const budget = optionalString(v["budget-tokens"]);
105
+ if (budget) args.budget_tokens = Number(budget);
106
+ if (v["include-full"] === true) args.include_full = true;
107
+ if (v["include-associates"] === true) args.include_associates = true;
108
+ const expected = optionalString(v["expected-hash"]);
109
+ if (expected) args.expected_content_hash = expected;
110
+ return args;
111
+ },
112
+ },
113
+ search: {
114
+ toolName: "search",
115
+ toolFn: search as unknown as ToolFn,
116
+ parse: { ...COMMON, query: { type: "string" }, limit: { type: "string" } },
117
+ buildArgs: (v) => {
118
+ const args: Record<string, unknown> = { query: asString(v.query) };
119
+ const limit = optionalString(v.limit);
120
+ if (limit) args.limit = Number(limit);
121
+ return args;
122
+ },
123
+ },
124
+ "read-doc-section": {
125
+ toolName: "read_doc_section",
126
+ toolFn: readDocSection as unknown as ToolFn,
127
+ parse: {
128
+ ...COMMON,
129
+ path: { type: "string" },
130
+ "section-id": { type: "string" },
131
+ heading: { type: "string" },
132
+ "expected-hash": { type: "string" },
133
+ },
134
+ buildArgs: (v) => {
135
+ const args: Record<string, unknown> = { path: asString(v.path) };
136
+ const sid = optionalString(v["section-id"]);
137
+ if (sid) args.section_id = sid;
138
+ const heading = optionalString(v.heading);
139
+ if (heading) args.heading = heading;
140
+ const expected = optionalString(v["expected-hash"]);
141
+ if (expected) args.expected_content_hash = expected;
142
+ return args;
143
+ },
144
+ },
145
+ "list-docs": {
146
+ toolName: "list_docs",
147
+ toolFn: listDocs as unknown as ToolFn,
148
+ parse: { ...COMMON, prefix: { type: "string" } },
149
+ buildArgs: (v) => {
150
+ const args: Record<string, unknown> = {};
151
+ if (v.format === "text") args.format = "text";
152
+ return args;
153
+ },
154
+ },
155
+ "list-summary-drift": {
156
+ toolName: "list_summary_drift",
157
+ toolFn: listSummaryDrift as unknown as ToolFn,
158
+ parse: {
159
+ ...COMMON,
160
+ prefix: { type: "string" },
161
+ limit: { type: "string" },
162
+ offset: { type: "string" },
163
+ },
164
+ buildArgs: (v) => {
165
+ const args: Record<string, unknown> = {};
166
+ const prefix = optionalString(v.prefix);
167
+ if (prefix) args.prefix = prefix;
168
+ const limit = optionalString(v.limit);
169
+ if (limit) args.limit = Number(limit);
170
+ const offset = optionalString(v.offset);
171
+ if (offset) args.offset = Number(offset);
172
+ if (v.format === "text") args.format = "text";
173
+ return args;
174
+ },
175
+ },
176
+ };
177
+
178
+ function formatReadOutput(result: unknown, wantJson: boolean): string {
179
+ // Structured reads: default to unwrapped MCP text (already-parsed JSON or
180
+ // raw text); --json gives the parsed JSON representation.
181
+ const withContent = result as { content?: Array<{ type: string; text?: string }> };
182
+ const text = withContent.content?.[0]?.text;
183
+ if (typeof text !== "string") return JSON.stringify(result, null, 2);
184
+ if (wantJson) {
185
+ try {
186
+ return JSON.stringify(JSON.parse(text), null, 2);
187
+ } catch {
188
+ return text;
189
+ }
190
+ }
191
+ return text;
192
+ }
193
+
194
+ async function runStructuredRead(verbName: string, argv: string[]): Promise<void> {
195
+ const spec = READ_VERBS[verbName];
196
+ if (!spec) throw new Error(`unknown read verb: ${verbName}`);
197
+ const { values: raw } = parseArgs({ args: argv, options: spec.parse, strict: true });
198
+ const values = raw as unknown as Record<string, string | boolean | undefined>;
199
+ const args = spec.buildArgs(values);
200
+ const remote = Boolean(values.remote);
201
+ const wantJson = Boolean(values.json);
202
+
203
+ const result = remote
204
+ ? await callTool(spec.toolName, args)
205
+ : await spec.toolFn({ mode: "local", docsDir }, args);
206
+
207
+ const output = formatReadOutput(result, wantJson);
208
+ process.stdout.write(output + (output.endsWith("\n") ? "" : "\n"));
209
+ }
210
+
211
+ // -------- legacy simple verbs (list, drift-batch) ---------
212
+
7
213
  async function cmdList(argv: string[]): Promise<void> {
8
214
  const { values } = parseArgs({
9
215
  args: argv,
10
- options: { prefix: { type: "string" } },
216
+ options: {
217
+ prefix: { type: "string" },
218
+ remote: { type: "boolean" },
219
+ },
11
220
  strict: true,
12
221
  });
13
- const idx = await buildIndex(docsDir);
14
222
  const prefix = values.prefix ?? "";
223
+ if (values.remote) {
224
+ const args: Record<string, unknown> = { format: "text" };
225
+ if (prefix) args.prefix = prefix;
226
+ const result = await callTool("list_docs", args);
227
+ const text = unwrapText(result);
228
+ if (prefix) {
229
+ for (const line of text.split("\n")) {
230
+ if (line.startsWith(prefix)) process.stdout.write(line + "\n");
231
+ }
232
+ } else {
233
+ process.stdout.write(text + (text.endsWith("\n") ? "" : "\n"));
234
+ }
235
+ return;
236
+ }
237
+ const idx = await buildIndex(docsDir);
15
238
  for (const d of idx.docs) {
16
239
  if (!prefix || d.path.startsWith(prefix)) process.stdout.write(d.path + "\n");
17
240
  }
@@ -24,11 +247,20 @@ async function cmdDriftBatch(argv: string[]): Promise<void> {
24
247
  limit: { type: "string", default: "10" },
25
248
  offset: { type: "string", default: "0" },
26
249
  prefix: { type: "string" },
250
+ remote: { type: "boolean" },
27
251
  },
28
252
  strict: true,
29
253
  });
30
254
  const limit = Math.max(1, Number(values.limit) | 0);
31
255
  const offset = Math.max(0, Number(values.offset) | 0);
256
+ if (values.remote) {
257
+ const args: Record<string, unknown> = { limit, offset };
258
+ if (values.prefix) args.prefix = values.prefix;
259
+ const result = await callTool("list_summary_drift", args);
260
+ const text = unwrapText(result);
261
+ process.stdout.write(text + (text.endsWith("\n") ? "" : "\n"));
262
+ return;
263
+ }
32
264
  const idx = await buildIndex(docsDir);
33
265
  const filtered = idx.docs
34
266
  .filter((d) => !values.prefix || d.path.startsWith(values.prefix))
@@ -44,21 +276,19 @@ async function cmdDriftBatch(argv: string[]): Promise<void> {
44
276
  const [, , sub, ...rest] = process.argv;
45
277
 
46
278
  async function main(): Promise<void> {
47
- switch (sub) {
48
- case "list":
49
- await cmdList(rest);
50
- return;
51
- case "drift-batch":
52
- await cmdDriftBatch(rest);
53
- return;
54
- default:
55
- process.stderr.write(`unknown subcommand: ${sub ?? "(none)"}\n`);
56
- process.stderr.write(`usage: emdee <list|drift-batch> [--prefix P] [--limit N] [--offset K]\n`);
57
- process.exit(1);
58
- }
279
+ if (sub === "list") return cmdList(rest);
280
+ if (sub === "drift-batch") return cmdDriftBatch(rest);
281
+ if (sub && READ_VERBS[sub]) return runStructuredRead(sub, rest);
282
+ process.stderr.write(`unknown read subcommand: ${sub ?? "(none)"}\n`);
283
+ process.stderr.write(`verbs: list, drift-batch, ${Object.keys(READ_VERBS).join(", ")}\n`);
284
+ process.exit(1);
59
285
  }
60
286
 
61
287
  main().catch((err) => {
288
+ if (err instanceof NeedsLoginError) {
289
+ process.stderr.write(`${err.message}\n`);
290
+ process.exit(1);
291
+ }
62
292
  process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
63
293
  process.exit(1);
64
294
  });
@@ -0,0 +1,74 @@
1
+ // SPRINT-091: thin wrapper around POST /api/mcp for CLI --remote commands.
2
+ //
3
+ // Every call routes through the same JSON-RPC surface claude.ai uses, so we
4
+ // inherit auth + rate limits + logging for free. The MCP SDK's tool response
5
+ // envelope (`content: [{type: "text", text: "..."}]`) unwraps here — CLI
6
+ // verbs receive the already-parsed JSON so they can print it plain.
7
+
8
+ import { loadCreds, NeedsLoginError, type Credentials } from "./auth";
9
+ import { randomUUID } from "node:crypto";
10
+
11
+ export interface RemoteResult {
12
+ content?: Array<{ type: string; text?: string }>;
13
+ isError?: boolean;
14
+ [k: string]: unknown;
15
+ }
16
+
17
+ interface JsonRpcResponse {
18
+ jsonrpc: "2.0";
19
+ id: string;
20
+ result?: RemoteResult;
21
+ error?: { code: number; message: string; data?: unknown };
22
+ }
23
+
24
+ /**
25
+ * Invoke an MCP tool over HTTP against the user's authenticated vault.
26
+ * Throws NeedsLoginError if no creds or token is rejected.
27
+ */
28
+ export async function callTool(
29
+ name: string,
30
+ args: Record<string, unknown>,
31
+ credsOverride?: Credentials,
32
+ ): Promise<RemoteResult> {
33
+ const creds = credsOverride ?? (await loadCreds());
34
+ if (!creds) throw new NeedsLoginError();
35
+
36
+ const res = await fetch(`${creds.host}/api/mcp`, {
37
+ method: "POST",
38
+ headers: {
39
+ "Content-Type": "application/json",
40
+ "Accept": "application/json, text/event-stream",
41
+ Authorization: `Bearer ${creds.access_token}`,
42
+ },
43
+ body: JSON.stringify({
44
+ jsonrpc: "2.0",
45
+ id: randomUUID(),
46
+ method: "tools/call",
47
+ params: { name, arguments: args },
48
+ }),
49
+ });
50
+
51
+ if (res.status === 401) throw new NeedsLoginError("Access token was rejected. Run `emdee login` again.");
52
+ if (!res.ok) {
53
+ const body = await res.text().catch(() => "");
54
+ throw new Error(`remote call failed: ${res.status} ${body}`);
55
+ }
56
+
57
+ const body = (await res.json()) as JsonRpcResponse;
58
+ if (body.error) throw new Error(`remote tool error: ${body.error.message}`);
59
+ if (!body.result) throw new Error("remote call returned no result");
60
+ return body.result;
61
+ }
62
+
63
+ /**
64
+ * Convenience: unwrap the MCP text envelope back to a string. Tools that
65
+ * return JSON encode it inside `content[0].text`; tools that return plain
66
+ * text put it there directly. Callers decide whether to JSON.parse.
67
+ */
68
+ export function unwrapText(result: RemoteResult): string {
69
+ const first = result.content?.[0];
70
+ if (!first || first.type !== "text" || typeof first.text !== "string") {
71
+ return JSON.stringify(result);
72
+ }
73
+ return first.text;
74
+ }
@@ -0,0 +1,54 @@
1
+ // SPRINT-094: install the EMDEE skills bundle into a Claude Code skills dir.
2
+ //
3
+ // Copies every .md file from the installed package's skills/ folder into
4
+ // the target directory (default ~/.claude/skills/). Idempotent — overwrites
5
+ // existing files. Users re-run after upgrading the package to get the
6
+ // latest skill content.
7
+
8
+ import { readdir, mkdir, copyFile } from "node:fs/promises";
9
+ import { parseArgs } from "node:util";
10
+ import path from "node:path";
11
+ import os from "node:os";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const DEFAULT_TARGET = path.join(os.homedir(), ".claude", "skills");
15
+ const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
16
+ const SKILLS_SRC = path.join(PKG_ROOT, "skills");
17
+
18
+ async function main(): Promise<void> {
19
+ const { values } = parseArgs({
20
+ args: process.argv.slice(2),
21
+ options: {
22
+ dir: { type: "string" },
23
+ },
24
+ strict: true,
25
+ });
26
+
27
+ const target = values.dir ? path.resolve(values.dir) : DEFAULT_TARGET;
28
+ await mkdir(target, { recursive: true });
29
+
30
+ let files: string[];
31
+ try {
32
+ files = (await readdir(SKILLS_SRC)).filter((f) => f.endsWith(".md"));
33
+ } catch {
34
+ process.stderr.write(`emdee skills install: skills/ folder not found at ${SKILLS_SRC}. Reinstall the package.\n`);
35
+ process.exit(1);
36
+ }
37
+
38
+ if (files.length === 0) {
39
+ process.stderr.write(`emdee skills install: no .md files in ${SKILLS_SRC}.\n`);
40
+ process.exit(1);
41
+ }
42
+
43
+ for (const f of files) {
44
+ await copyFile(path.join(SKILLS_SRC, f), path.join(target, f));
45
+ process.stdout.write(` ${f}\n`);
46
+ }
47
+ process.stdout.write(`\nInstalled ${files.length} skills to ${target}.\n`);
48
+ process.stdout.write("Restart Claude Code (or reload skills) to pick them up.\n");
49
+ }
50
+
51
+ main().catch((err) => {
52
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
53
+ process.exit(1);
54
+ });