@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.
@@ -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
+ });