@aisystemresources/emdee 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,349 @@
1
+ // SPRINT-091 chunk 2: write-side CLI verbs.
2
+ //
3
+ // Dispatcher that fronts the 8 most-common write tools as CLI commands.
4
+ // Every verb accepts --remote (routes through cloud) and --json (returns
5
+ // the raw MCP envelope for machine consumption). Local mode calls the tool
6
+ // function directly with the same ToolContext shape MCP uses — same code,
7
+ // same guards, same errors.
8
+ //
9
+ // One dispatcher rather than one file per verb keeps the wire-up compact.
10
+ // bin/emdee.js just shells `write-commands.ts <verb> <args>` for each.
11
+
12
+ import path from "node:path";
13
+ import { parseArgs, type ParseArgsConfig } from "node:util";
14
+ import type { ToolContext } from "../lib/mcp/tools/types";
15
+ import { patchSection } from "../lib/mcp/tools/patch_section";
16
+ import { appendSection } from "../lib/mcp/tools/append_section";
17
+ import { appendDoc } from "../lib/mcp/tools/append_doc";
18
+ import { patchPreamble } from "../lib/mcp/tools/patch_preamble";
19
+ import { createChild } from "../lib/mcp/tools/create_child";
20
+ import { addAssociation } from "../lib/mcp/tools/add_association";
21
+ import { moveDoc } from "../lib/mcp/tools/move_doc";
22
+ import { renameDoc } from "../lib/mcp/tools/rename_doc";
23
+ import { writeDoc } from "../lib/mcp/tools/write_doc";
24
+ import { writeDocPreview } from "../lib/mcp/tools/write_doc_preview";
25
+ import { trashDoc } from "../lib/mcp/tools/trash_doc";
26
+ import { restoreDoc } from "../lib/mcp/tools/restore_doc";
27
+ import { deleteDoc } from "../lib/mcp/tools/delete_doc";
28
+ import { callTool, unwrapText } from "./remote-client";
29
+ import { NeedsLoginError } from "./auth";
30
+
31
+ const docsDir = path.resolve(process.env.EMDEE_DOCS ?? path.join(process.cwd(), "docs"));
32
+
33
+ type ToolFn = (ctx: ToolContext, args: Record<string, unknown>) => Promise<unknown>;
34
+
35
+ interface VerbSpec {
36
+ toolName: string;
37
+ toolFn: ToolFn;
38
+ parse: ParseArgsConfig["options"];
39
+ buildArgs: (values: Record<string, string | boolean | undefined>) => Record<string, unknown>;
40
+ }
41
+
42
+ // Shared flags. Every write verb accepts these.
43
+ const COMMON = {
44
+ remote: { type: "boolean" },
45
+ json: { type: "boolean" },
46
+ } as const;
47
+
48
+ function asString(v: unknown): string {
49
+ return typeof v === "string" ? v : "";
50
+ }
51
+
52
+ function optionalString(v: unknown): string | undefined {
53
+ return typeof v === "string" && v.trim() ? v : undefined;
54
+ }
55
+
56
+ const VERBS: Record<string, VerbSpec> = {
57
+ "patch-section": {
58
+ toolName: "patch_section",
59
+ toolFn: patchSection as unknown as ToolFn,
60
+ parse: {
61
+ ...COMMON,
62
+ path: { type: "string" },
63
+ "section-id": { type: "string" },
64
+ heading: { type: "string" },
65
+ body: { type: "string" },
66
+ "expected-hash": { type: "string" },
67
+ "gate-on": { type: "string", multiple: true },
68
+ },
69
+ buildArgs: (v) => {
70
+ const args: Record<string, unknown> = {
71
+ path: asString(v.path),
72
+ body: asString(v.body),
73
+ expected_content_hash: asString(v["expected-hash"]),
74
+ };
75
+ if (v["section-id"]) args.section_id = v["section-id"];
76
+ if (v.heading) args.heading = v.heading;
77
+ if (Array.isArray(v["gate-on"])) args.gate_on_warnings = v["gate-on"];
78
+ return args;
79
+ },
80
+ },
81
+ "append-section": {
82
+ toolName: "append_section",
83
+ toolFn: appendSection as unknown as ToolFn,
84
+ parse: {
85
+ ...COMMON,
86
+ path: { type: "string" },
87
+ "section-id": { type: "string" },
88
+ heading: { type: "string" },
89
+ body: { type: "string" },
90
+ "create-if-missing": { type: "boolean" },
91
+ "gate-on": { type: "string", multiple: true },
92
+ },
93
+ buildArgs: (v) => {
94
+ const args: Record<string, unknown> = {
95
+ path: asString(v.path),
96
+ body: asString(v.body),
97
+ };
98
+ if (v["section-id"]) args.section_id = v["section-id"];
99
+ if (v.heading) args.heading = v.heading;
100
+ if (v["create-if-missing"]) args.create_if_missing = true;
101
+ if (Array.isArray(v["gate-on"])) args.gate_on_warnings = v["gate-on"];
102
+ return args;
103
+ },
104
+ },
105
+ "append-doc": {
106
+ toolName: "append_doc",
107
+ toolFn: appendDoc as unknown as ToolFn,
108
+ parse: {
109
+ ...COMMON,
110
+ path: { type: "string" },
111
+ body: { type: "string" },
112
+ "gate-on": { type: "string", multiple: true },
113
+ },
114
+ buildArgs: (v) => {
115
+ const args: Record<string, unknown> = { path: asString(v.path), body: asString(v.body) };
116
+ if (Array.isArray(v["gate-on"])) args.gate_on_warnings = v["gate-on"];
117
+ return args;
118
+ },
119
+ },
120
+ "patch-preamble": {
121
+ toolName: "patch_preamble",
122
+ toolFn: patchPreamble as unknown as ToolFn,
123
+ parse: {
124
+ ...COMMON,
125
+ path: { type: "string" },
126
+ body: { type: "string" },
127
+ "expected-hash": { type: "string" },
128
+ "gate-on": { type: "string", multiple: true },
129
+ },
130
+ buildArgs: (v) => {
131
+ const args: Record<string, unknown> = {
132
+ path: asString(v.path),
133
+ body: asString(v.body),
134
+ expected_content_hash: asString(v["expected-hash"]),
135
+ };
136
+ if (Array.isArray(v["gate-on"])) args.gate_on_warnings = v["gate-on"];
137
+ return args;
138
+ },
139
+ },
140
+ "create-child": {
141
+ toolName: "create_child",
142
+ toolFn: createChild as unknown as ToolFn,
143
+ parse: {
144
+ ...COMMON,
145
+ "parent-path": { type: "string" },
146
+ title: { type: "string" },
147
+ body: { type: "string" },
148
+ summary: { type: "string" },
149
+ "child-path": { type: "string" },
150
+ "gate-on": { type: "string", multiple: true },
151
+ },
152
+ buildArgs: (v) => {
153
+ const args: Record<string, unknown> = {
154
+ parent_path: asString(v["parent-path"]),
155
+ title: asString(v.title),
156
+ };
157
+ const body = optionalString(v.body);
158
+ if (body) args.body = body;
159
+ const summary = optionalString(v.summary);
160
+ if (summary) args.summary = summary;
161
+ const childPath = optionalString(v["child-path"]);
162
+ if (childPath) args.child_path = childPath;
163
+ if (Array.isArray(v["gate-on"])) args.gate_on_warnings = v["gate-on"];
164
+ return args;
165
+ },
166
+ },
167
+ "add-association": {
168
+ toolName: "add_association",
169
+ toolFn: addAssociation as unknown as ToolFn,
170
+ parse: {
171
+ ...COMMON,
172
+ "a-path": { type: "string" },
173
+ "b-path": { type: "string" },
174
+ label: { type: "string" },
175
+ "gate-on": { type: "string", multiple: true },
176
+ },
177
+ buildArgs: (v) => {
178
+ const args: Record<string, unknown> = {
179
+ a_path: asString(v["a-path"]),
180
+ b_path: asString(v["b-path"]),
181
+ };
182
+ const label = optionalString(v.label);
183
+ if (label) args.label = label;
184
+ if (Array.isArray(v["gate-on"])) args.gate_on_warnings = v["gate-on"];
185
+ return args;
186
+ },
187
+ },
188
+ "move-doc": {
189
+ toolName: "move_doc",
190
+ toolFn: moveDoc as unknown as ToolFn,
191
+ parse: {
192
+ ...COMMON,
193
+ path: { type: "string" },
194
+ "new-parent-path": { type: "string" },
195
+ "old-parent-path": { type: "string" },
196
+ position: { type: "string" },
197
+ "gate-on": { type: "string", multiple: true },
198
+ },
199
+ buildArgs: (v) => {
200
+ const args: Record<string, unknown> = {
201
+ path: asString(v.path),
202
+ new_parent_path: asString(v["new-parent-path"]),
203
+ };
204
+ const oldParent = optionalString(v["old-parent-path"]);
205
+ if (oldParent) args.old_parent_path = oldParent;
206
+ const pos = optionalString(v.position);
207
+ if (pos) args.position = Number(pos);
208
+ if (Array.isArray(v["gate-on"])) args.gate_on_warnings = v["gate-on"];
209
+ return args;
210
+ },
211
+ },
212
+ "rename-doc": {
213
+ toolName: "rename_doc",
214
+ toolFn: renameDoc as unknown as ToolFn,
215
+ parse: {
216
+ ...COMMON,
217
+ "old-path": { type: "string" },
218
+ "new-title": { type: "string" },
219
+ "new-path": { type: "string" },
220
+ },
221
+ buildArgs: (v) => {
222
+ const args: Record<string, unknown> = {
223
+ old_path: asString(v["old-path"]),
224
+ new_title: asString(v["new-title"]),
225
+ };
226
+ const newPath = optionalString(v["new-path"]);
227
+ if (newPath) args.new_path = newPath;
228
+ return args;
229
+ },
230
+ },
231
+ "write-doc": {
232
+ toolName: "write_doc",
233
+ toolFn: writeDoc as unknown as ToolFn,
234
+ parse: {
235
+ ...COMMON,
236
+ path: { type: "string" },
237
+ content: { type: "string" },
238
+ "gate-on": { type: "string", multiple: true },
239
+ },
240
+ buildArgs: (v) => {
241
+ const args: Record<string, unknown> = {
242
+ path: asString(v.path),
243
+ content: asString(v.content),
244
+ };
245
+ if (Array.isArray(v["gate-on"])) args.gate_on_warnings = v["gate-on"];
246
+ return args;
247
+ },
248
+ },
249
+ "write-doc-preview": {
250
+ toolName: "write_doc_preview",
251
+ toolFn: writeDocPreview as unknown as ToolFn,
252
+ parse: {
253
+ ...COMMON,
254
+ path: { type: "string" },
255
+ content: { type: "string" },
256
+ },
257
+ buildArgs: (v) => ({
258
+ path: asString(v.path),
259
+ content: asString(v.content),
260
+ }),
261
+ },
262
+ "trash-doc": {
263
+ toolName: "trash_doc",
264
+ toolFn: trashDoc as unknown as ToolFn,
265
+ parse: {
266
+ ...COMMON,
267
+ path: { type: "string" },
268
+ "original-parent-path": { type: "string" },
269
+ },
270
+ buildArgs: (v) => {
271
+ const args: Record<string, unknown> = { path: asString(v.path) };
272
+ const op = optionalString(v["original-parent-path"]);
273
+ if (op) args.original_parent_path = op;
274
+ return args;
275
+ },
276
+ },
277
+ "restore-doc": {
278
+ toolName: "restore_doc",
279
+ toolFn: restoreDoc as unknown as ToolFn,
280
+ parse: { ...COMMON, path: { type: "string" } },
281
+ buildArgs: (v) => ({ path: asString(v.path) }),
282
+ },
283
+ "delete-doc": {
284
+ toolName: "delete_doc",
285
+ toolFn: deleteDoc as unknown as ToolFn,
286
+ parse: { ...COMMON, path: { type: "string" } },
287
+ buildArgs: (v) => ({ path: asString(v.path) }),
288
+ },
289
+ };
290
+
291
+ function formatOutput(result: unknown, wantJson: boolean): string {
292
+ // MCP tools return { content: [{type: "text", text: "..."}] }. The text
293
+ // is JSON. For human-friendly output we pull the parsed JSON out and
294
+ // pretty-print. For --json we return the parsed JSON as-is.
295
+ let payload: unknown = result;
296
+ const withContent = result as { content?: Array<{ type: string; text?: string }> };
297
+ if (withContent.content?.[0]?.type === "text" && typeof withContent.content[0].text === "string") {
298
+ try {
299
+ payload = JSON.parse(withContent.content[0].text);
300
+ } catch {
301
+ payload = withContent.content[0].text;
302
+ }
303
+ }
304
+ if (wantJson) return JSON.stringify(payload, null, 2);
305
+ if (typeof payload === "string") return payload;
306
+ return JSON.stringify(payload, null, 2);
307
+ }
308
+
309
+ async function runVerb(verbName: string, argv: string[]): Promise<void> {
310
+ const spec = VERBS[verbName];
311
+ if (!spec) {
312
+ process.stderr.write(`unknown write verb: ${verbName}\navailable: ${Object.keys(VERBS).join(", ")}\n`);
313
+ process.exit(1);
314
+ }
315
+ const { values: rawValues } = parseArgs({ args: argv, options: spec.parse, strict: true });
316
+ const values = rawValues as unknown as Record<string, string | boolean | undefined>;
317
+ const args = spec.buildArgs(values);
318
+ const wantJson = Boolean(values.json);
319
+ const remote = Boolean(values.remote);
320
+
321
+ const result = remote
322
+ ? await callTool(spec.toolName, args).then((r) => r as unknown)
323
+ : await spec.toolFn({ mode: "local", docsDir }, args);
324
+
325
+ const output = remote
326
+ ? formatOutput({ content: [{ type: "text", text: unwrapText(result as { content?: Array<{ type: string; text?: string }> }) }] }, wantJson)
327
+ : formatOutput(result, wantJson);
328
+
329
+ process.stdout.write(output + "\n");
330
+ }
331
+
332
+ const [, , verb, ...rest] = process.argv;
333
+
334
+ async function main(): Promise<void> {
335
+ if (!verb) {
336
+ process.stderr.write(`usage: emdee <verb> [options]\nverbs: ${Object.keys(VERBS).join(", ")}\n`);
337
+ process.exit(1);
338
+ }
339
+ await runVerb(verb, rest);
340
+ }
341
+
342
+ main().catch((err) => {
343
+ if (err instanceof NeedsLoginError) {
344
+ process.stderr.write(`${err.message}\n`);
345
+ process.exit(1);
346
+ }
347
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
348
+ process.exit(1);
349
+ });
@@ -3,8 +3,8 @@ import { createHash } from "node:crypto";
3
3
  import { validatePath, readVaultFile, writeVaultFile, loadVaultIndex } from "./vault";
4
4
  import { lintDocContent } from "./lint";
5
5
  import { buildLintVaultContext } from "./lint_doc";
6
- import { evaluateLintGate, type LintFix } from "./lint_gate";
7
- import type { LintWarning, LintVaultContext } from "./lint";
6
+ import { evaluateLintGate } from "./lint_gate";
7
+ import type { LintVaultContext } from "./lint";
8
8
  import { resolveWikiLink } from "../../../core/resolveLink";
9
9
  import type { ToolContext } from "./types";
10
10
  import { validateArgs } from "./validate_args";
@@ -113,16 +113,6 @@ function patchAssociatedWith(content: string, otherTitle: string, label?: string
113
113
  };
114
114
  }
115
115
 
116
- function fixesFromWarnings(warnings: LintWarning[], codes: string[]): LintFix[] {
117
- const codeSet = new Set(codes);
118
- return warnings.filter((w) => codeSet.has(w.code)).map((w) => ({
119
- code: w.code,
120
- line: w.line ?? null,
121
- fix_suggestion: w.suggestion,
122
- original_message: w.message,
123
- }));
124
- }
125
-
126
116
  function json(value: unknown) {
127
117
  return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
128
118
  }
@@ -282,11 +272,9 @@ export async function addAssociation(ctx: ToolContext, args: Record<string, unkn
282
272
  }
283
273
  }
284
274
 
285
- let bWritten = bPatch.alreadyPresent;
286
275
  if (!bPatch.alreadyPresent) {
287
276
  try {
288
277
  await writeVaultFile(ctx, bPath, bPatch.newContent);
289
- bWritten = true;
290
278
  } catch (err) {
291
279
  return json({
292
280
  error: "partial_write",
@@ -291,11 +291,9 @@ export async function createChild(ctx: ToolContext, args: Record<string, unknown
291
291
  }
292
292
  }
293
293
 
294
- let parentWritten = alreadyPresent;
295
294
  if (!alreadyPresent) {
296
295
  try {
297
296
  await writeVaultFile(ctx, parentPath, newParentContent);
298
- parentWritten = true;
299
297
  } catch (err) {
300
298
  return json({
301
299
  error: "partial_write",