@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.
- package/README.md +32 -0
- package/bin/emdee.js +475 -4
- 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
- package/src/mcp/server.ts +2 -1
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
|
+
}
|
package/src/cli/read-commands.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
});
|