@thothica/docs-cli 1.0.0 → 1.1.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 +6 -1
- package/bin/thd.mjs +113 -1
- package/lib/core.mjs +10 -2
- package/lib/mcp.mjs +22 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -43,15 +43,20 @@ thd whoami Show the signed-in account
|
|
|
43
43
|
|
|
44
44
|
thd doc ls [-q QUERY] [--shared] [--json] List / search documents
|
|
45
45
|
thd doc new "Title" [--md FILE|-] Create (markdown body, or --template ID)
|
|
46
|
+
thd doc upload FILE.docx [--folder id|name] Upload an existing .docx (optional --title)
|
|
46
47
|
thd doc cat ID [--text] Print content (markdown by default)
|
|
47
48
|
thd doc get ID [-o FILE] Download the real .docx
|
|
48
|
-
thd doc set ID --md FILE|-
|
|
49
|
+
thd doc set ID --md FILE|- [--force] Replace content from markdown
|
|
50
|
+
thd doc mv ID (--folder id|name | --root) Move a document into a folder
|
|
49
51
|
thd doc rm ID [--permanent] Trash (or delete) a document
|
|
50
52
|
|
|
53
|
+
thd folder ls List your folders
|
|
54
|
+
thd folder new "Name" [--parent id|name] Create a folder
|
|
51
55
|
thd comment ls ID List comments inside the document
|
|
52
56
|
thd comment add ID "text" [--anchor "..."] Add a comment (optional --reply-to ID)
|
|
53
57
|
|
|
54
58
|
thd templates List document templates
|
|
59
|
+
thd mcp-config [--client NAME] Print connector config for MCP clients
|
|
55
60
|
thd open [ID] Open the app (or a document) in the browser
|
|
56
61
|
thd mcp Run the MCP server (for AI agents) over stdio
|
|
57
62
|
```
|
package/bin/thd.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// edit, and comment on documents from the terminal or an AI agent. Ships an MCP
|
|
4
4
|
// server (`thd mcp`). Zero dependencies; Node >= 18.
|
|
5
5
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { basename } from "node:path";
|
|
6
7
|
import {
|
|
7
8
|
DEFAULT_ORIGIN,
|
|
8
9
|
CLI_VERSION,
|
|
@@ -42,6 +43,7 @@ const BOOLEANS = new Set([
|
|
|
42
43
|
"markdown",
|
|
43
44
|
"text",
|
|
44
45
|
"force",
|
|
46
|
+
"root",
|
|
45
47
|
"no-open",
|
|
46
48
|
"help",
|
|
47
49
|
"version",
|
|
@@ -120,6 +122,22 @@ function readInput(spec) {
|
|
|
120
122
|
return readFileSync(spec, "utf8");
|
|
121
123
|
}
|
|
122
124
|
|
|
125
|
+
const DOCX_MIME =
|
|
126
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
127
|
+
|
|
128
|
+
// Resolve a --folder value that may be a folder id or a folder name.
|
|
129
|
+
async function resolveFolder(origin, token, value) {
|
|
130
|
+
const res = await call(origin, token, "GET", "/folders");
|
|
131
|
+
const folders = res.folders || [];
|
|
132
|
+
const byId = folders.find((f) => f.id === value);
|
|
133
|
+
if (byId) return byId.id;
|
|
134
|
+
const byName = folders.find(
|
|
135
|
+
(f) => f.name.toLowerCase() === String(value).toLowerCase()
|
|
136
|
+
);
|
|
137
|
+
if (byName) return byName.id;
|
|
138
|
+
die(`folder not found: "${value}". See 'thd folder ls', or 'thd folder new "${value}"'.`);
|
|
139
|
+
}
|
|
140
|
+
|
|
123
141
|
// ---------------------------------------------------------------------------
|
|
124
142
|
// Commands
|
|
125
143
|
// ---------------------------------------------------------------------------
|
|
@@ -237,6 +255,35 @@ async function cmdDoc(flags, sub, rest) {
|
|
|
237
255
|
out(green("✓ Saved ") + dim(`version ${res.version}`));
|
|
238
256
|
return;
|
|
239
257
|
}
|
|
258
|
+
case "upload": {
|
|
259
|
+
const file = rest[0];
|
|
260
|
+
if (!file) die("usage: thd doc upload FILE.docx [--folder id|name] [--title T]");
|
|
261
|
+
const buf = readFileSync(file);
|
|
262
|
+
const fd = new FormData();
|
|
263
|
+
fd.set("file", new Blob([buf], { type: DOCX_MIME }), basename(file));
|
|
264
|
+
if (flags.title) fd.set("title", flags.title);
|
|
265
|
+
if (flags.folder) fd.set("folderId", await resolveFolder(origin, token, flags.folder));
|
|
266
|
+
const doc = await call(origin, token, "POST", "/documents", { body: fd });
|
|
267
|
+
if (flags.json) return out(JSON.stringify(doc, null, 2));
|
|
268
|
+
out(green("✓ Uploaded ") + bold(doc.title));
|
|
269
|
+
out(` id: ${doc.id}`);
|
|
270
|
+
out(` url: ${cyan(`${origin}/doc/${doc.id}`)}`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
case "mv":
|
|
274
|
+
case "move": {
|
|
275
|
+
const id = rest[0];
|
|
276
|
+
if (!id) die("usage: thd doc mv ID (--folder id|name | --root)");
|
|
277
|
+
let folderId;
|
|
278
|
+
if (flags.root) folderId = null;
|
|
279
|
+
else if (flags.folder) folderId = await resolveFolder(origin, token, flags.folder);
|
|
280
|
+
else die("specify a destination: --folder id|name, or --root");
|
|
281
|
+
await call(origin, token, "PATCH", `/documents/${encodeURIComponent(id)}`, {
|
|
282
|
+
body: { folderId },
|
|
283
|
+
});
|
|
284
|
+
out(green("✓ Moved ") + dim(folderId ? `to folder ${folderId}` : "to root"));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
240
287
|
case "rm":
|
|
241
288
|
case "delete": {
|
|
242
289
|
const id = rest[0];
|
|
@@ -292,6 +339,61 @@ async function cmdTemplates(flags) {
|
|
|
292
339
|
}
|
|
293
340
|
}
|
|
294
341
|
|
|
342
|
+
async function cmdFolder(flags, sub, rest) {
|
|
343
|
+
const { origin, token } = requireToken(flags);
|
|
344
|
+
if (!sub || sub === "ls" || sub === "list") {
|
|
345
|
+
const res = await call(origin, token, "GET", "/folders");
|
|
346
|
+
if (flags.json) return out(JSON.stringify(res.folders, null, 2));
|
|
347
|
+
const folders = res.folders || [];
|
|
348
|
+
if (!folders.length) return out(dim("No folders."));
|
|
349
|
+
for (const f of folders)
|
|
350
|
+
out(`${bold(f.id)} ${f.name}${f.parentId ? dim(` (in ${f.parentId})`) : ""}`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (sub === "new" || sub === "create") {
|
|
354
|
+
const name = rest[0];
|
|
355
|
+
if (!name) die('usage: thd folder new "Name" [--parent id|name]');
|
|
356
|
+
const body = { name };
|
|
357
|
+
if (flags.parent) body.parentId = await resolveFolder(origin, token, flags.parent);
|
|
358
|
+
const f = await call(origin, token, "POST", "/folders", { body });
|
|
359
|
+
out(green("✓ Created folder ") + bold(f.name) + dim(` (${f.id})`));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
die('usage: thd folder ls | new "Name"');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function cmdMcpConfig(flags) {
|
|
366
|
+
const local = { command: "thd", args: ["mcp"] };
|
|
367
|
+
const blocks = {
|
|
368
|
+
"claude-code": "claude mcp add thothica-docs -- thd mcp",
|
|
369
|
+
"claude-desktop": JSON.stringify({ mcpServers: { "thothica-docs": local } }, null, 2),
|
|
370
|
+
cursor: JSON.stringify({ mcpServers: { "thothica-docs": local } }, null, 2),
|
|
371
|
+
codex: '[mcp_servers.thothica-docs]\ncommand = "thd"\nargs = ["mcp"]',
|
|
372
|
+
opencode: JSON.stringify(
|
|
373
|
+
{ mcp: { "thothica-docs": { type: "local", command: ["thd", "mcp"], enabled: true } } },
|
|
374
|
+
null,
|
|
375
|
+
2
|
|
376
|
+
),
|
|
377
|
+
};
|
|
378
|
+
const client = (flags.client || "").toLowerCase().replace(/\s+/g, "-");
|
|
379
|
+
if (client && blocks[client]) return out(blocks[client]);
|
|
380
|
+
const show = (label, hint, block) => {
|
|
381
|
+
out(bold(label) + (hint ? dim(" " + hint) : ""));
|
|
382
|
+
out(block);
|
|
383
|
+
out("");
|
|
384
|
+
};
|
|
385
|
+
show("Claude Code", "", blocks["claude-code"]);
|
|
386
|
+
show(
|
|
387
|
+
"Claude Desktop",
|
|
388
|
+
"~/Library/Application Support/Claude/claude_desktop_config.json (macOS)",
|
|
389
|
+
blocks["claude-desktop"]
|
|
390
|
+
);
|
|
391
|
+
show("Cursor", ".cursor/mcp.json", blocks.cursor);
|
|
392
|
+
show("Codex", "~/.codex/config.toml", blocks.codex);
|
|
393
|
+
show("opencode", "opencode.json", blocks.opencode);
|
|
394
|
+
out(dim("Run `thd login` once so the server has a credential. For CI, set THOTHICA_DOCS_TOKEN."));
|
|
395
|
+
}
|
|
396
|
+
|
|
295
397
|
function cmdOpen(flags, rest) {
|
|
296
398
|
const origin = resolveOrigin(flags.origin);
|
|
297
399
|
const url = rest[0] ? `${origin}/doc/${rest[0]}` : origin;
|
|
@@ -310,17 +412,22 @@ ${bold("Access")}
|
|
|
310
412
|
${bold("Documents")}
|
|
311
413
|
thd doc ls [-q QUERY] [--shared] [--json] List / search documents
|
|
312
414
|
thd doc new "Title" [--md FILE|-] Create (markdown body, or --template ID)
|
|
415
|
+
thd doc upload FILE.docx [--folder id|name] Upload an existing .docx (optional --title)
|
|
313
416
|
thd doc cat ID [--text] Print content (markdown by default)
|
|
314
417
|
thd doc get ID [-o FILE] Download the real .docx
|
|
315
418
|
thd doc set ID --md FILE|- [--force] Replace content from markdown
|
|
419
|
+
thd doc mv ID (--folder id|name | --root) Move a document into a folder
|
|
316
420
|
thd doc rm ID [--permanent] Trash (or delete) a document
|
|
317
421
|
|
|
318
|
-
${bold("
|
|
422
|
+
${bold("Folders & comments")}
|
|
423
|
+
thd folder ls List your folders
|
|
424
|
+
thd folder new "Name" [--parent id|name] Create a folder
|
|
319
425
|
thd comment ls ID List comments inside the document
|
|
320
426
|
thd comment add ID "text" [--anchor "..."] Add a comment (optional --reply-to ID)
|
|
321
427
|
|
|
322
428
|
${bold("More")}
|
|
323
429
|
thd templates List document templates
|
|
430
|
+
thd mcp-config [--client NAME] Print connector config (Claude Desktop, Cursor, ...)
|
|
324
431
|
thd open [ID] Open the app (or a document) in the browser
|
|
325
432
|
thd mcp Run the MCP server (for AI agents) over stdio
|
|
326
433
|
|
|
@@ -357,8 +464,13 @@ async function main() {
|
|
|
357
464
|
case "comment":
|
|
358
465
|
case "comments":
|
|
359
466
|
return await cmdComment(flags, sub, rest);
|
|
467
|
+
case "folder":
|
|
468
|
+
case "folders":
|
|
469
|
+
return await cmdFolder(flags, sub, rest);
|
|
360
470
|
case "templates":
|
|
361
471
|
return await cmdTemplates(flags);
|
|
472
|
+
case "mcp-config":
|
|
473
|
+
return cmdMcpConfig(flags);
|
|
362
474
|
case "open":
|
|
363
475
|
return cmdOpen(flags, parsed._.slice(1));
|
|
364
476
|
case "mcp":
|
package/lib/core.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { join } from "node:path";
|
|
|
17
17
|
import { randomBytes, scryptSync, createCipheriv, createDecipheriv } from "node:crypto";
|
|
18
18
|
|
|
19
19
|
export const DEFAULT_ORIGIN = "https://docs.thothica.com";
|
|
20
|
-
export const CLI_VERSION = "1.
|
|
20
|
+
export const CLI_VERSION = "1.1.0";
|
|
21
21
|
const SERVICE = "thothica-docs";
|
|
22
22
|
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
@@ -334,10 +334,18 @@ export async function api(origin, token, method, path, opts = {}) {
|
|
|
334
334
|
const headers = { ...(opts.headers || {}) };
|
|
335
335
|
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
336
336
|
let body = opts.body;
|
|
337
|
-
|
|
337
|
+
const isForm = typeof FormData !== "undefined" && body instanceof FormData;
|
|
338
|
+
if (
|
|
339
|
+
body != null &&
|
|
340
|
+
typeof body === "object" &&
|
|
341
|
+
!isForm &&
|
|
342
|
+
!(body instanceof Uint8Array) &&
|
|
343
|
+
!Buffer.isBuffer(body)
|
|
344
|
+
) {
|
|
338
345
|
headers["Content-Type"] = headers["Content-Type"] || "application/json";
|
|
339
346
|
body = JSON.stringify(body);
|
|
340
347
|
}
|
|
348
|
+
// FormData sets its own multipart Content-Type (with boundary); leave it alone.
|
|
341
349
|
const res = await fetch(url, { method, headers, body });
|
|
342
350
|
if (opts.raw) return res;
|
|
343
351
|
const text = await res.text();
|
package/lib/mcp.mjs
CHANGED
|
@@ -245,6 +245,28 @@ const TOOLS = [
|
|
|
245
245
|
);
|
|
246
246
|
},
|
|
247
247
|
},
|
|
248
|
+
{
|
|
249
|
+
name: "move_document",
|
|
250
|
+
description:
|
|
251
|
+
"Move a document into a folder, or to the root. Pass folderId (from list_folders/create_folder), or set toRoot:true.",
|
|
252
|
+
inputSchema: {
|
|
253
|
+
type: "object",
|
|
254
|
+
required: ["id"],
|
|
255
|
+
properties: {
|
|
256
|
+
id: { type: "string" },
|
|
257
|
+
folderId: { type: "string" },
|
|
258
|
+
toRoot: { type: "boolean" },
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
async handler(args, ctx) {
|
|
262
|
+
const folderId = args.toRoot ? null : args.folderId;
|
|
263
|
+
if (folderId === undefined) return fail("Provide folderId, or set toRoot:true.");
|
|
264
|
+
const doc = await ctx.call("PATCH", `/documents/${encodeURIComponent(args.id)}`, {
|
|
265
|
+
folderId,
|
|
266
|
+
});
|
|
267
|
+
return ok(`Moved "${doc.title}" ${folderId ? `into folder ${folderId}` : "to root"}.`);
|
|
268
|
+
},
|
|
269
|
+
},
|
|
248
270
|
{
|
|
249
271
|
name: "list_folders",
|
|
250
272
|
description: "List the user's folders.",
|
package/package.json
CHANGED