@thothica/docs-cli 1.0.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 ADDED
@@ -0,0 +1,90 @@
1
+ # @thothica/docs-cli (`thd`)
2
+
3
+ Command-line tool and MCP server for [Thothica Docs](https://docs.thothica.com).
4
+ Log in without pasting a key, then read, create, edit, and comment on native
5
+ `.docx` documents from the terminal or an AI agent.
6
+
7
+ Zero runtime dependencies. Node >= 18.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install -g @thothica/docs-cli
13
+ # or from the repo: npm install -g ./cli
14
+ # or run without installing: npx @thothica/docs-cli <command>
15
+ ```
16
+
17
+ ## Log in
18
+
19
+ ```bash
20
+ thd login
21
+ ```
22
+
23
+ This starts a device-authorization flow: it opens the browser, the user signs in
24
+ and approves, and a key is minted and stored in the operating system keychain.
25
+ No key is ever pasted or printed.
26
+
27
+ Where the key is stored:
28
+
29
+ - macOS: the login keychain (via `security`)
30
+ - Linux: the Secret Service / libsecret (via `secret-tool`), if present
31
+ - Windows: DPAPI, encrypted per user
32
+ - Fallback (no keychain): a machine-bound AES-256-GCM encrypted file at
33
+ `~/.config/thothica-docs/`, mode `0600`. Never plaintext.
34
+
35
+ For CI or headless use, set `THOTHICA_DOCS_TOKEN` instead of logging in.
36
+
37
+ ## Commands
38
+
39
+ ```
40
+ thd login [--client-name NAME] [--no-open] Log in via the browser
41
+ thd logout Remove the stored credential
42
+ thd whoami Show the signed-in account
43
+
44
+ thd doc ls [-q QUERY] [--shared] [--json] List / search documents
45
+ thd doc new "Title" [--md FILE|-] Create (markdown body, or --template ID)
46
+ thd doc cat ID [--text] Print content (markdown by default)
47
+ thd doc get ID [-o FILE] Download the real .docx
48
+ thd doc set ID --md FILE|- Replace content from markdown
49
+ thd doc rm ID [--permanent] Trash (or delete) a document
50
+
51
+ thd comment ls ID List comments inside the document
52
+ thd comment add ID "text" [--anchor "..."] Add a comment (optional --reply-to ID)
53
+
54
+ thd templates List document templates
55
+ thd open [ID] Open the app (or a document) in the browser
56
+ thd mcp Run the MCP server (for AI agents) over stdio
57
+ ```
58
+
59
+ Global flags: `--origin URL` (target another deployment), `--json` (machine
60
+ output). Environment: `THOTHICA_DOCS_TOKEN`, `THOTHICA_DOCS_ORIGIN`.
61
+
62
+ ## MCP server
63
+
64
+ `thd mcp` speaks the Model Context Protocol over stdio, exposing documents as
65
+ tools for AI agents. See [AGENTS.md](../AGENTS.md) for per-client configuration
66
+ (Claude Code, Cursor, Codex, opencode). The server reads the credential stored by
67
+ `thd login`, or `THOTHICA_DOCS_TOKEN`.
68
+
69
+ ## Examples
70
+
71
+ ```bash
72
+ # create a document from a markdown file
73
+ thd doc new "Q3 plan" --md plan.md
74
+
75
+ # pipe markdown in
76
+ echo "# Notes\n\n- one\n- two" | thd doc new "Notes" --md -
77
+
78
+ # read it back as markdown
79
+ thd doc cat DOC_ID
80
+
81
+ # comment on a phrase
82
+ thd comment add DOC_ID "Please expand this." --anchor "next quarter"
83
+
84
+ # download the .docx
85
+ thd doc get DOC_ID -o q3-plan.docx
86
+ ```
87
+
88
+ ## License
89
+
90
+ AGPL-3.0.
package/bin/thd.mjs ADDED
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env node
2
+ // Thothica Docs CLI (`thd`). Log in without pasting a key, then read, create,
3
+ // edit, and comment on documents from the terminal or an AI agent. Ships an MCP
4
+ // server (`thd mcp`). Zero dependencies; Node >= 18.
5
+ import { readFileSync, writeFileSync } from "node:fs";
6
+ import {
7
+ DEFAULT_ORIGIN,
8
+ CLI_VERSION,
9
+ resolveOrigin,
10
+ loadToken,
11
+ saveToken,
12
+ deleteToken,
13
+ describeStore,
14
+ deviceLogin,
15
+ tryOpenBrowser,
16
+ api,
17
+ ApiError,
18
+ } from "../lib/core.mjs";
19
+ import { runMcpServer } from "../lib/mcp.mjs";
20
+
21
+ // ---- tiny output helpers ----
22
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
23
+ const c = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
24
+ const bold = (s) => c("1", s);
25
+ const dim = (s) => c("2", s);
26
+ const green = (s) => c("32", s);
27
+ const red = (s) => c("31", s);
28
+ const gold = (s) => c("33", s);
29
+ const cyan = (s) => c("36", s);
30
+ const out = (s = "") => process.stdout.write(s + "\n");
31
+ const eout = (s = "") => process.stderr.write(s + "\n");
32
+ function die(msg) {
33
+ eout(red("error: ") + msg);
34
+ process.exit(1);
35
+ }
36
+
37
+ // ---- arg parsing ----
38
+ const BOOLEANS = new Set([
39
+ "json",
40
+ "shared",
41
+ "permanent",
42
+ "markdown",
43
+ "text",
44
+ "force",
45
+ "no-open",
46
+ "help",
47
+ "version",
48
+ "quiet",
49
+ ]);
50
+ const ALIASES = { q: "query", o: "out", h: "help", v: "version", m: "md" };
51
+
52
+ // A token is a flag value unless it is another option. A bare "-" (stdin) is a
53
+ // value, and a lone negative number ("-5") is a value; "--x"/"-x" are options.
54
+ function isValue(tok) {
55
+ if (tok === "-") return true;
56
+ if (!tok.startsWith("-")) return true;
57
+ return /^-\d/.test(tok);
58
+ }
59
+
60
+ function parseArgs(argv) {
61
+ const res = { _: [], flags: {} };
62
+ for (let i = 0; i < argv.length; i++) {
63
+ let a = argv[i];
64
+ if (a.startsWith("--")) {
65
+ let key = a.slice(2);
66
+ let val;
67
+ const eq = key.indexOf("=");
68
+ if (eq >= 0) {
69
+ val = key.slice(eq + 1);
70
+ key = key.slice(0, eq);
71
+ }
72
+ if (BOOLEANS.has(key)) {
73
+ res.flags[key] = true;
74
+ } else if (val !== undefined) {
75
+ res.flags[key] = val;
76
+ } else if (i + 1 < argv.length && isValue(argv[i + 1])) {
77
+ res.flags[key] = argv[++i];
78
+ } else {
79
+ res.flags[key] = true;
80
+ }
81
+ } else if (a.startsWith("-") && a.length > 1) {
82
+ const short = a.slice(1);
83
+ const key = ALIASES[short] || short;
84
+ if (BOOLEANS.has(key)) res.flags[key] = true;
85
+ else if (i + 1 < argv.length && isValue(argv[i + 1])) res.flags[key] = argv[++i];
86
+ else res.flags[key] = true;
87
+ } else {
88
+ res._.push(a);
89
+ }
90
+ }
91
+ return res;
92
+ }
93
+
94
+ function client(flags) {
95
+ const origin = resolveOrigin(flags.origin);
96
+ const token = loadToken(origin);
97
+ return { origin, token };
98
+ }
99
+ function requireToken(flags) {
100
+ const { origin, token } = client(flags);
101
+ if (!token)
102
+ die(`not logged in. Run ${bold("thd login")} first, or set THOTHICA_DOCS_TOKEN.`);
103
+ return { origin, token };
104
+ }
105
+ async function call(origin, token, method, path, opts) {
106
+ try {
107
+ return await api(origin, token, method, path, opts);
108
+ } catch (e) {
109
+ if (e instanceof ApiError) {
110
+ if (e.status === 401) die("unauthorized. Your key may be revoked; run `thd login` again.");
111
+ const extra = e.body?.hint || e.body?.detail;
112
+ die(`request failed: ${e.code || e.status}${extra ? `\n ${extra}` : ""}`);
113
+ }
114
+ die(String(e?.message || e));
115
+ }
116
+ }
117
+ function readInput(spec) {
118
+ // spec is a filename, or "-" for stdin
119
+ if (spec === "-" || spec === "/dev/stdin") return readFileSync(0, "utf8");
120
+ return readFileSync(spec, "utf8");
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Commands
125
+ // ---------------------------------------------------------------------------
126
+
127
+ async function cmdLogin(flags) {
128
+ const origin = resolveOrigin(flags.origin);
129
+ const clientName = flags["client-name"] || "Thothica Docs CLI";
130
+ eout(dim(`Signing in to ${origin} ...`));
131
+ const key = await deviceLogin(origin, clientName, ({ userCode, url }) => {
132
+ out("");
133
+ out(` ${bold("To authorize, open:")} ${cyan(url)}`);
134
+ out(` ${bold("Your code:")} ${gold(userCode)}`);
135
+ out("");
136
+ out(dim(" Sign in (if needed) and approve. Waiting..."));
137
+ if (!flags["no-open"]) tryOpenBrowser(url);
138
+ }).catch((e) => die(e.message));
139
+ const store = saveToken(origin, key);
140
+ // confirm the key works
141
+ const me = await call(origin, key, "GET", "/me");
142
+ out("");
143
+ out(green("✓ Logged in") + ` as ${bold(me.name || me.email)} <${me.email}>`);
144
+ out(dim(` Key stored in: ${store}`));
145
+ out(dim(` Origin: ${origin}`));
146
+ }
147
+
148
+ function cmdLogout(flags) {
149
+ const origin = resolveOrigin(flags.origin);
150
+ deleteToken(origin);
151
+ out(green("✓ Logged out.") + dim(" Local credential removed."));
152
+ out(dim(" (The key stays valid on the server until revoked in Settings → API keys.)"));
153
+ }
154
+
155
+ async function cmdWhoami(flags) {
156
+ const { origin, token } = requireToken(flags);
157
+ const me = await call(origin, token, "GET", "/me");
158
+ if (flags.json) return out(JSON.stringify(me, null, 2));
159
+ out(`${bold(me.name || me.email)} <${me.email}>`);
160
+ out(dim(` auth: ${me.via}`));
161
+ out(dim(` docs: ${me.documents}`));
162
+ out(dim(` storage: ${(me.storageBytes / 1024).toFixed(1)} KB`));
163
+ out(dim(` origin: ${origin}`));
164
+ out(dim(` key from: ${describeStore(origin)}`));
165
+ }
166
+
167
+ async function cmdDoc(flags, sub, rest) {
168
+ const { origin, token } = requireToken(flags);
169
+ switch (sub) {
170
+ case "ls":
171
+ case "list": {
172
+ const p = new URLSearchParams();
173
+ if (flags.query) p.set("q", flags.query);
174
+ if (flags.folder) p.set("folder", flags.folder);
175
+ if (flags.tag) p.set("tag", flags.tag);
176
+ if (flags.shared) p.set("shared", "1");
177
+ p.set("limit", String(flags.limit || 50));
178
+ const res = await call(origin, token, "GET", `/documents?${p.toString()}`);
179
+ if (flags.json) return out(JSON.stringify(res.documents, null, 2));
180
+ const docs = res.documents || [];
181
+ if (!docs.length) return out(dim("No documents."));
182
+ for (const d of docs) {
183
+ out(`${bold(d.id)} ${d.title}`);
184
+ out(dim(` v${d.version} · ${d.role} · ${new Date(d.updatedAt).toISOString().slice(0, 10)}`));
185
+ }
186
+ return;
187
+ }
188
+ case "new":
189
+ case "create": {
190
+ const title = rest[0];
191
+ if (!title) die("usage: thd doc new \"Title\" [--md file|-] [--template id] [--folder id]");
192
+ const body = { title };
193
+ if (flags.md) body.markdown = readInput(flags.md);
194
+ if (flags.template) body.template = flags.template;
195
+ if (flags.folder) body.folderId = flags.folder;
196
+ const doc = await call(origin, token, "POST", "/documents", { body });
197
+ if (flags.json) return out(JSON.stringify(doc, null, 2));
198
+ out(green("✓ Created ") + bold(doc.title));
199
+ out(` id: ${doc.id}`);
200
+ out(` url: ${cyan(`${origin}/doc/${doc.id}`)}`);
201
+ return;
202
+ }
203
+ case "cat":
204
+ case "read": {
205
+ const id = rest[0];
206
+ if (!id) die("usage: thd doc cat ID [--markdown|--text]");
207
+ const fmt = flags.text ? "text" : "markdown";
208
+ const res = await call(origin, token, "GET", `/documents/${encodeURIComponent(id)}/text?format=${fmt}`);
209
+ return out(res.markdown ?? res.text ?? "");
210
+ }
211
+ case "get":
212
+ case "download": {
213
+ const id = rest[0];
214
+ if (!id) die("usage: thd doc get ID [-o file.docx]");
215
+ const meta = await call(origin, token, "GET", `/documents/${encodeURIComponent(id)}`);
216
+ const res = await api(origin, token, "GET", `/documents/${encodeURIComponent(id)}/content`, { raw: true });
217
+ if (!res.ok) die(`download failed (${res.status}).`);
218
+ const buf = Buffer.from(await res.arrayBuffer());
219
+ const file = flags.out || `${meta.title.replace(/[\\/:*?"<>|]/g, "_")}.docx`;
220
+ writeFileSync(file, buf);
221
+ out(green("✓ Saved ") + `${file} ${dim(`(${buf.length} bytes)`)}`);
222
+ return;
223
+ }
224
+ case "set":
225
+ case "update": {
226
+ const id = rest[0];
227
+ if (!id || !flags.md) die("usage: thd doc set ID --md file|- [--force]");
228
+ const md = readInput(flags.md);
229
+ const q = flags.force ? "?force=1" : "";
230
+ const res = await call(
231
+ origin,
232
+ token,
233
+ "PUT",
234
+ `/documents/${encodeURIComponent(id)}/content${q}`,
235
+ { body: md, headers: { "Content-Type": "text/markdown" } }
236
+ );
237
+ out(green("✓ Saved ") + dim(`version ${res.version}`));
238
+ return;
239
+ }
240
+ case "rm":
241
+ case "delete": {
242
+ const id = rest[0];
243
+ if (!id) die("usage: thd doc rm ID [--permanent]");
244
+ const q = flags.permanent ? "?permanent=1" : "";
245
+ await call(origin, token, "DELETE", `/documents/${encodeURIComponent(id)}${q}`);
246
+ out(green(flags.permanent ? "✓ Deleted permanently." : "✓ Moved to trash."));
247
+ return;
248
+ }
249
+ default:
250
+ die(`unknown doc subcommand: ${sub || "(none)"}. Try: ls, new, cat, get, set, rm`);
251
+ }
252
+ }
253
+
254
+ async function cmdComment(flags, sub, rest) {
255
+ const { origin, token } = requireToken(flags);
256
+ const id = rest[0];
257
+ if (sub === "ls" || sub === "list") {
258
+ if (!id) die("usage: thd comment ls ID");
259
+ const res = await call(origin, token, "GET", `/documents/${encodeURIComponent(id)}/comments`);
260
+ if (flags.json) return out(JSON.stringify(res.comments, null, 2));
261
+ const comments = res.comments || [];
262
+ if (!comments.length) return out(dim("No comments."));
263
+ for (const cm of comments) {
264
+ const meta = [cm.parentId ? `reply→${cm.parentId}` : null, cm.done ? "resolved" : null]
265
+ .filter(Boolean)
266
+ .join(", ");
267
+ out(`${bold(`[${cm.id}]`)} ${cm.author}${meta ? dim(` (${meta})`) : ""}`);
268
+ out(` ${cm.text}`);
269
+ }
270
+ return;
271
+ }
272
+ if (sub === "add") {
273
+ const text = rest[1];
274
+ if (!id || !text) die('usage: thd comment add ID "text" [--anchor "text"] [--reply-to ID]');
275
+ const body = { text };
276
+ if (flags.anchor) body.anchor = flags.anchor;
277
+ if (flags["reply-to"]) body.replyTo = flags["reply-to"];
278
+ const res = await call(origin, token, "POST", `/documents/${encodeURIComponent(id)}/comments`, { body });
279
+ out(green("✓ Comment added ") + dim(`(id ${res.comment?.id ?? "?"}, version ${res.version})`));
280
+ return;
281
+ }
282
+ die("usage: thd comment ls|add ...");
283
+ }
284
+
285
+ async function cmdTemplates(flags) {
286
+ const { origin, token } = requireToken(flags);
287
+ const res = await call(origin, token, "GET", "/templates");
288
+ if (flags.json) return out(JSON.stringify(res.templates, null, 2));
289
+ for (const t of res.templates || []) {
290
+ out(`${bold(t.id.padEnd(14))} ${t.name}`);
291
+ out(dim(` ${t.description}`));
292
+ }
293
+ }
294
+
295
+ function cmdOpen(flags, rest) {
296
+ const origin = resolveOrigin(flags.origin);
297
+ const url = rest[0] ? `${origin}/doc/${rest[0]}` : origin;
298
+ tryOpenBrowser(url);
299
+ out(dim(`Opening ${url}`));
300
+ }
301
+
302
+ function helpText() {
303
+ return `${bold("thd")} ${dim("· Thothica Docs CLI v" + CLI_VERSION)}
304
+
305
+ ${bold("Access")}
306
+ thd login [--client-name NAME] [--no-open] Log in via the browser (no key to paste)
307
+ thd logout Remove the stored credential
308
+ thd whoami Show the signed-in account
309
+
310
+ ${bold("Documents")}
311
+ thd doc ls [-q QUERY] [--shared] [--json] List / search documents
312
+ thd doc new "Title" [--md FILE|-] Create (markdown body, or --template ID)
313
+ thd doc cat ID [--text] Print content (markdown by default)
314
+ thd doc get ID [-o FILE] Download the real .docx
315
+ thd doc set ID --md FILE|- [--force] Replace content from markdown
316
+ thd doc rm ID [--permanent] Trash (or delete) a document
317
+
318
+ ${bold("Comments")}
319
+ thd comment ls ID List comments inside the document
320
+ thd comment add ID "text" [--anchor "..."] Add a comment (optional --reply-to ID)
321
+
322
+ ${bold("More")}
323
+ thd templates List document templates
324
+ thd open [ID] Open the app (or a document) in the browser
325
+ thd mcp Run the MCP server (for AI agents) over stdio
326
+
327
+ ${bold("Global")}
328
+ --origin URL Target a different deployment (default ${DEFAULT_ORIGIN})
329
+ --json Machine-readable output where supported
330
+ Env: THOTHICA_DOCS_TOKEN (key override, for CI), THOTHICA_DOCS_ORIGIN
331
+
332
+ Docs: ${DEFAULT_ORIGIN}/api · ${DEFAULT_ORIGIN}/llms.txt`;
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+
337
+ async function main() {
338
+ const argv = process.argv.slice(2);
339
+ const parsed = parseArgs(argv);
340
+ const [cmd, sub, ...rest] = parsed._;
341
+ const flags = parsed.flags;
342
+
343
+ if (flags.version || cmd === "version") return out(CLI_VERSION);
344
+ if (!cmd || cmd === "help" || flags.help) return out(helpText());
345
+
346
+ try {
347
+ switch (cmd) {
348
+ case "login":
349
+ return await cmdLogin(flags);
350
+ case "logout":
351
+ return cmdLogout(flags);
352
+ case "whoami":
353
+ return await cmdWhoami(flags);
354
+ case "doc":
355
+ case "docs":
356
+ return await cmdDoc(flags, sub, rest);
357
+ case "comment":
358
+ case "comments":
359
+ return await cmdComment(flags, sub, rest);
360
+ case "templates":
361
+ return await cmdTemplates(flags);
362
+ case "open":
363
+ return cmdOpen(flags, parsed._.slice(1));
364
+ case "mcp":
365
+ return await runMcpServer({ origin: flags.origin });
366
+ default:
367
+ die(`unknown command: ${cmd}. Run ${bold("thd help")}.`);
368
+ }
369
+ } catch (e) {
370
+ die(String(e?.message || e));
371
+ }
372
+ }
373
+
374
+ main();
package/lib/core.mjs ADDED
@@ -0,0 +1,403 @@
1
+ // Core logic for the Thothica Docs CLI: configuration, secure credential
2
+ // storage, the HTTP client, and the device-login flow. Zero runtime deps;
3
+ // Node >= 18 (global fetch + WebCrypto). Secrets go to the OS keychain when
4
+ // available, otherwise a machine-bound AES-256-GCM encrypted file (never
5
+ // plaintext).
6
+ import { spawnSync } from "node:child_process";
7
+ import {
8
+ mkdirSync,
9
+ readFileSync,
10
+ writeFileSync,
11
+ existsSync,
12
+ chmodSync,
13
+ rmSync,
14
+ } from "node:fs";
15
+ import { homedir, hostname, userInfo, platform } from "node:os";
16
+ import { join } from "node:path";
17
+ import { randomBytes, scryptSync, createCipheriv, createDecipheriv } from "node:crypto";
18
+
19
+ export const DEFAULT_ORIGIN = "https://docs.thothica.com";
20
+ export const CLI_VERSION = "1.0.0";
21
+ const SERVICE = "thothica-docs";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Config
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export function configDir() {
28
+ if (process.env.THOTHICA_DOCS_HOME) return process.env.THOTHICA_DOCS_HOME;
29
+ if (platform() === "win32") {
30
+ return join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), SERVICE);
31
+ }
32
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
33
+ return join(base, SERVICE);
34
+ }
35
+
36
+ function ensureDir() {
37
+ const dir = configDir();
38
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
39
+ return dir;
40
+ }
41
+
42
+ function statePath() {
43
+ return join(configDir(), "state.json");
44
+ }
45
+
46
+ function readState() {
47
+ try {
48
+ return JSON.parse(readFileSync(statePath(), "utf8"));
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ function writeState(state) {
55
+ ensureDir();
56
+ writeFileSync(statePath(), JSON.stringify(state, null, 2), { mode: 0o600 });
57
+ try {
58
+ chmodSync(statePath(), 0o600);
59
+ } catch {}
60
+ }
61
+
62
+ export function resolveOrigin(flagOrigin) {
63
+ const raw =
64
+ flagOrigin ||
65
+ process.env.THOTHICA_DOCS_ORIGIN ||
66
+ readState().origin ||
67
+ DEFAULT_ORIGIN;
68
+ return raw.replace(/\/+$/, "");
69
+ }
70
+
71
+ function accountFor(origin) {
72
+ try {
73
+ return new URL(origin).host;
74
+ } catch {
75
+ return origin;
76
+ }
77
+ }
78
+
79
+ function safeAccount(account) {
80
+ return account.replace(/[^a-zA-Z0-9._-]/g, "_") || "default";
81
+ }
82
+
83
+ // Some environments (containers, CI, locked-down machines) have no usable
84
+ // keychain, or the user prefers a file. THOTHICA_DOCS_STORE=file forces the
85
+ // machine-bound encrypted-file backend.
86
+ function forceFile() {
87
+ return process.env.THOTHICA_DOCS_STORE === "file";
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Credential storage: keychain backends + encrypted-file fallback
92
+ // ---------------------------------------------------------------------------
93
+
94
+ function run(cmd, args, input) {
95
+ try {
96
+ const res = spawnSync(cmd, args, {
97
+ input: input ?? undefined,
98
+ encoding: "utf8",
99
+ });
100
+ return { code: res.status ?? 1, stdout: res.stdout || "", stderr: res.stderr || "", err: res.error };
101
+ } catch (e) {
102
+ return { code: 1, stdout: "", stderr: String(e), err: e };
103
+ }
104
+ }
105
+
106
+ // -- macOS keychain --
107
+ function macSet(account, secret) {
108
+ const r = run("security", [
109
+ "add-generic-password",
110
+ "-U",
111
+ "-a", account,
112
+ "-s", SERVICE,
113
+ "-D", "application password",
114
+ "-j", "Thothica Docs API key",
115
+ "-w", secret,
116
+ ]);
117
+ return r.code === 0;
118
+ }
119
+ function macGet(account) {
120
+ const r = run("security", ["find-generic-password", "-a", account, "-s", SERVICE, "-w"]);
121
+ return r.code === 0 ? r.stdout.replace(/\n$/, "") : null;
122
+ }
123
+ function macDelete(account) {
124
+ run("security", ["delete-generic-password", "-a", account, "-s", SERVICE]);
125
+ }
126
+
127
+ // -- Linux libsecret (secret-tool) -- reads the secret from stdin, not argv --
128
+ function hasSecretTool() {
129
+ return run("secret-tool", ["--version"]).code === 0 || run("which", ["secret-tool"]).code === 0;
130
+ }
131
+ function linuxSet(account, secret) {
132
+ if (!hasSecretTool()) return false;
133
+ const r = run(
134
+ "secret-tool",
135
+ ["store", "--label=Thothica Docs", "service", SERVICE, "account", account],
136
+ secret
137
+ );
138
+ return r.code === 0;
139
+ }
140
+ function linuxGet(account) {
141
+ if (!hasSecretTool()) return null;
142
+ const r = run("secret-tool", ["lookup", "service", SERVICE, "account", account]);
143
+ return r.code === 0 && r.stdout ? r.stdout.replace(/\n$/, "") : null;
144
+ }
145
+ function linuxDelete(account) {
146
+ if (hasSecretTool()) run("secret-tool", ["clear", "service", SERVICE, "account", account]);
147
+ }
148
+
149
+ // -- Windows DPAPI (per-user) via PowerShell, stored to a per-account file --
150
+ function winFile(account) {
151
+ return join(configDir(), `credential.${safeAccount(account)}.dpapi`);
152
+ }
153
+ function winSet(account, secret) {
154
+ ensureDir();
155
+ const path = winFile(account);
156
+ const script = `$s = [Console]::In.ReadToEnd(); $sec = ConvertTo-SecureString $s -AsPlainText -Force; ConvertFrom-SecureString $sec | Set-Content -NoNewline -Path '${path.replace(/'/g, "''")}'`;
157
+ const r = run("powershell", ["-NoProfile", "-Command", script], secret);
158
+ if (r.code === 0) {
159
+ try {
160
+ chmodSync(path, 0o600);
161
+ } catch {}
162
+ return true;
163
+ }
164
+ return false;
165
+ }
166
+ function winGet(account) {
167
+ const path = winFile(account);
168
+ if (!existsSync(path)) return null;
169
+ const script = `$enc = Get-Content -Raw -Path '${path.replace(/'/g, "''")}'; $sec = ConvertTo-SecureString $enc; [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))`;
170
+ const r = run("powershell", ["-NoProfile", "-Command", script]);
171
+ return r.code === 0 && r.stdout ? r.stdout.replace(/\r?\n$/, "") : null;
172
+ }
173
+ function winDelete(account) {
174
+ const path = winFile(account);
175
+ if (existsSync(path)) rmSync(path, { force: true });
176
+ }
177
+
178
+ // -- Encrypted-file fallback: machine-bound AES-256-GCM, 0600 --
179
+ function keyMaterialPath() {
180
+ return join(configDir(), "machine.key");
181
+ }
182
+ function encPath(account) {
183
+ return join(configDir(), `credentials.${safeAccount(account)}.enc`);
184
+ }
185
+ function machineKeyMaterial() {
186
+ ensureDir();
187
+ const p = keyMaterialPath();
188
+ if (!existsSync(p)) {
189
+ writeFileSync(p, randomBytes(32), { mode: 0o600 });
190
+ try {
191
+ chmodSync(p, 0o600);
192
+ } catch {}
193
+ }
194
+ return readFileSync(p);
195
+ }
196
+ function deriveKey(salt) {
197
+ const base = Buffer.concat([
198
+ Buffer.from(`${hostname()}::${userInfo().username}::${SERVICE}`),
199
+ machineKeyMaterial(),
200
+ ]);
201
+ return scryptSync(base, salt, 32);
202
+ }
203
+ function fileSet(account, secret) {
204
+ ensureDir();
205
+ const salt = randomBytes(16);
206
+ const iv = randomBytes(12);
207
+ const key = deriveKey(salt);
208
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
209
+ const ct = Buffer.concat([cipher.update(Buffer.from(secret, "utf8")), cipher.final()]);
210
+ const tag = cipher.getAuthTag();
211
+ const payload = {
212
+ v: 1,
213
+ salt: salt.toString("base64"),
214
+ iv: iv.toString("base64"),
215
+ tag: tag.toString("base64"),
216
+ ct: ct.toString("base64"),
217
+ };
218
+ const path = encPath(account);
219
+ writeFileSync(path, JSON.stringify(payload), { mode: 0o600 });
220
+ try {
221
+ chmodSync(path, 0o600);
222
+ } catch {}
223
+ return true;
224
+ }
225
+ function fileGet(account) {
226
+ const path = encPath(account);
227
+ if (!existsSync(path)) return null;
228
+ try {
229
+ const p = JSON.parse(readFileSync(path, "utf8"));
230
+ const key = deriveKey(Buffer.from(p.salt, "base64"));
231
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(p.iv, "base64"));
232
+ decipher.setAuthTag(Buffer.from(p.tag, "base64"));
233
+ const pt = Buffer.concat([
234
+ decipher.update(Buffer.from(p.ct, "base64")),
235
+ decipher.final(),
236
+ ]);
237
+ return pt.toString("utf8");
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+ function fileDelete(account) {
243
+ const path = encPath(account);
244
+ if (existsSync(path)) rmSync(path, { force: true });
245
+ }
246
+
247
+ function backends() {
248
+ const os = platform();
249
+ if (os === "darwin") return { name: "macOS keychain", set: macSet, get: macGet, del: macDelete };
250
+ if (os === "win32") return { name: "Windows DPAPI", set: winSet, get: winGet, del: winDelete };
251
+ if (os === "linux" && hasSecretTool())
252
+ return { name: "libsecret keychain", set: linuxSet, get: linuxGet, del: linuxDelete };
253
+ return null;
254
+ }
255
+
256
+ const FILE_BACKEND = {
257
+ name: "machine-bound encrypted file (AES-256-GCM, 0600)",
258
+ set: fileSet,
259
+ get: fileGet,
260
+ del: fileDelete,
261
+ };
262
+
263
+ /** Store a token securely. Returns the human name of the store used. */
264
+ export function saveToken(origin, token) {
265
+ const account = accountFor(origin);
266
+ const primary = forceFile() ? null : backends();
267
+ let usedBackend = "file";
268
+ let storeName = FILE_BACKEND.name;
269
+ if (primary && primary.set(account, token)) {
270
+ usedBackend = "keychain";
271
+ storeName = primary.name;
272
+ } else {
273
+ FILE_BACKEND.set(account, token);
274
+ }
275
+ const state = readState();
276
+ state.origin = origin;
277
+ state.accounts = state.accounts || {};
278
+ state.accounts[account] = { backend: usedBackend, updatedAt: Date.now() };
279
+ writeState(state);
280
+ return storeName;
281
+ }
282
+
283
+ /** Resolve the active token: env override first, then stored credential. */
284
+ export function loadToken(origin) {
285
+ if (process.env.THOTHICA_DOCS_TOKEN) return process.env.THOTHICA_DOCS_TOKEN.trim();
286
+ const account = accountFor(origin);
287
+ if (forceFile()) return FILE_BACKEND.get(account);
288
+ const state = readState();
289
+ const pref = state.accounts?.[account]?.backend;
290
+ const primary = backends();
291
+ if (pref === "file") return FILE_BACKEND.get(account);
292
+ if (primary) {
293
+ const v = primary.get(account);
294
+ if (v) return v;
295
+ }
296
+ return FILE_BACKEND.get(account);
297
+ }
298
+
299
+ export function deleteToken(origin) {
300
+ const account = accountFor(origin);
301
+ const primary = backends();
302
+ if (primary) primary.del(account);
303
+ FILE_BACKEND.del(account);
304
+ const state = readState();
305
+ if (state.accounts) delete state.accounts[account];
306
+ writeState(state);
307
+ }
308
+
309
+ /** Where the credential for this origin lives (for `whoami`). */
310
+ export function describeStore(origin) {
311
+ if (process.env.THOTHICA_DOCS_TOKEN) return "THOTHICA_DOCS_TOKEN environment variable";
312
+ const account = accountFor(origin);
313
+ const pref = readState().accounts?.[account]?.backend;
314
+ if (pref === "keychain") return backends()?.name || "OS keychain";
315
+ if (pref === "file") return FILE_BACKEND.name;
316
+ return "not stored";
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // HTTP client
321
+ // ---------------------------------------------------------------------------
322
+
323
+ export class ApiError extends Error {
324
+ constructor(status, code, body) {
325
+ super(`${status} ${code || "error"}`);
326
+ this.status = status;
327
+ this.code = code;
328
+ this.body = body;
329
+ }
330
+ }
331
+
332
+ export async function api(origin, token, method, path, opts = {}) {
333
+ const url = `${origin.replace(/\/+$/, "")}/api/v1${path}`;
334
+ const headers = { ...(opts.headers || {}) };
335
+ if (token) headers["Authorization"] = `Bearer ${token}`;
336
+ let body = opts.body;
337
+ if (body != null && typeof body === "object" && !(body instanceof Uint8Array) && !Buffer.isBuffer(body)) {
338
+ headers["Content-Type"] = headers["Content-Type"] || "application/json";
339
+ body = JSON.stringify(body);
340
+ }
341
+ const res = await fetch(url, { method, headers, body });
342
+ if (opts.raw) return res;
343
+ const text = await res.text();
344
+ let json = null;
345
+ try {
346
+ json = text ? JSON.parse(text) : null;
347
+ } catch {
348
+ json = { raw: text };
349
+ }
350
+ if (!res.ok) throw new ApiError(res.status, json?.error, json);
351
+ return json;
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Device login flow
356
+ // ---------------------------------------------------------------------------
357
+
358
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
359
+
360
+ export function tryOpenBrowser(url) {
361
+ const os = platform();
362
+ const cmd = os === "darwin" ? "open" : os === "win32" ? "cmd" : "xdg-open";
363
+ const args = os === "win32" ? ["/c", "start", "", url] : [url];
364
+ try {
365
+ spawnSync(cmd, args, { stdio: "ignore" });
366
+ } catch {}
367
+ }
368
+
369
+ /**
370
+ * Run the device-authorization flow. `onPrompt({ userCode, url })` is called
371
+ * once with what to show the user. Returns the minted API key.
372
+ */
373
+ export async function deviceLogin(origin, clientName, onPrompt) {
374
+ const startRes = await fetch(`${origin}/api/v1/auth/device/start`, {
375
+ method: "POST",
376
+ headers: { "Content-Type": "application/json" },
377
+ body: JSON.stringify({ clientName }),
378
+ });
379
+ if (!startRes.ok) throw new Error(`Could not start login (${startRes.status}).`);
380
+ const start = await startRes.json();
381
+ if (onPrompt) onPrompt({ userCode: start.user_code, url: start.verification_uri_complete });
382
+ const deadline = Date.now() + (start.expires_in || 600) * 1000;
383
+ const interval = Math.max(2, start.interval || 5) * 1000;
384
+ while (Date.now() < deadline) {
385
+ await sleep(interval);
386
+ let j;
387
+ try {
388
+ const res = await fetch(`${origin}/api/v1/auth/device/token`, {
389
+ method: "POST",
390
+ headers: { "Content-Type": "application/json" },
391
+ body: JSON.stringify({ device_code: start.device_code }),
392
+ });
393
+ j = await res.json();
394
+ } catch {
395
+ continue; // transient; keep polling
396
+ }
397
+ if (j.status === "approved" && j.key) return j.key;
398
+ if (j.status === "denied") throw new Error("Authorization was denied in the browser.");
399
+ if (j.status === "expired") throw new Error("The code expired. Run login again.");
400
+ // pending -> keep polling
401
+ }
402
+ throw new Error("Timed out waiting for approval.");
403
+ }
package/lib/mcp.mjs ADDED
@@ -0,0 +1,446 @@
1
+ // A zero-dependency MCP (Model Context Protocol) server over stdio. Exposes
2
+ // Thothica Docs as first-class tools so an AI agent (Claude Code, Codex,
3
+ // opencode, Cursor, ...) can read, create, edit, and comment on documents.
4
+ // Newline-delimited JSON-RPC 2.0; nothing but protocol messages go to stdout.
5
+ import { api, loadToken, resolveOrigin, ApiError, CLI_VERSION } from "./core.mjs";
6
+
7
+ const PROTOCOL_VERSION = "2024-11-05";
8
+
9
+ function log(...args) {
10
+ // Logs must never touch stdout (reserved for protocol messages).
11
+ process.stderr.write(`[thothica-docs mcp] ${args.join(" ")}\n`);
12
+ }
13
+
14
+ function ok(text) {
15
+ return { content: [{ type: "text", text }] };
16
+ }
17
+ function fail(text) {
18
+ return { content: [{ type: "text", text }], isError: true };
19
+ }
20
+
21
+ function pretty(obj) {
22
+ return JSON.stringify(obj, null, 2);
23
+ }
24
+
25
+ // A short, model-friendly view of a document record.
26
+ function docLine(d) {
27
+ const tags = d.tags?.length ? ` [${d.tags.map((t) => t.name).join(", ")}]` : "";
28
+ return `${d.id} "${d.title}" (v${d.version}, ${d.role})${tags}`;
29
+ }
30
+
31
+ const TOOLS = [
32
+ {
33
+ name: "whoami",
34
+ description:
35
+ "Return the authenticated Thothica Docs account (email, name, document count, storage used). Use this first to confirm access.",
36
+ inputSchema: { type: "object", properties: {} },
37
+ async handler(_args, ctx) {
38
+ const me = await ctx.call("GET", "/me");
39
+ return ok(
40
+ `Signed in as ${me.name || me.email} <${me.email}> (auth: ${me.via}).\n` +
41
+ `Documents: ${me.documents}. Storage: ${(me.storageBytes / 1024).toFixed(1)} KB.`
42
+ );
43
+ },
44
+ },
45
+ {
46
+ name: "list_documents",
47
+ description:
48
+ "List or search the user's documents. Optional filters: query (title search), folder id, tag id, shared (docs shared with the user), limit.",
49
+ inputSchema: {
50
+ type: "object",
51
+ properties: {
52
+ query: { type: "string", description: "title search" },
53
+ folder: { type: "string", description: "folder id, or 'root'" },
54
+ tag: { type: "string", description: "tag id" },
55
+ shared: { type: "boolean", description: "only documents shared with me" },
56
+ limit: { type: "integer", description: "max results (default 50)" },
57
+ },
58
+ },
59
+ async handler(args, ctx) {
60
+ const p = new URLSearchParams();
61
+ if (args.query) p.set("q", args.query);
62
+ if (args.folder) p.set("folder", args.folder);
63
+ if (args.tag) p.set("tag", args.tag);
64
+ if (args.shared) p.set("shared", "1");
65
+ p.set("limit", String(args.limit || 50));
66
+ const res = await ctx.call("GET", `/documents?${p.toString()}`);
67
+ const docs = res.documents || [];
68
+ if (!docs.length) return ok("No documents found.");
69
+ return ok(`${docs.length} document(s):\n` + docs.map(docLine).join("\n"));
70
+ },
71
+ },
72
+ {
73
+ name: "search_documents",
74
+ description: "Search the user's documents by title. Shorthand for list_documents with a query.",
75
+ inputSchema: {
76
+ type: "object",
77
+ required: ["query"],
78
+ properties: { query: { type: "string" }, limit: { type: "integer" } },
79
+ },
80
+ async handler(args, ctx) {
81
+ const p = new URLSearchParams({ q: args.query, limit: String(args.limit || 50) });
82
+ const res = await ctx.call("GET", `/documents?${p.toString()}`);
83
+ const docs = res.documents || [];
84
+ if (!docs.length) return ok(`No documents match "${args.query}".`);
85
+ return ok(`${docs.length} match(es):\n` + docs.map(docLine).join("\n"));
86
+ },
87
+ },
88
+ {
89
+ name: "read_document",
90
+ description:
91
+ "Read a document's content as markdown (default) or plain text, so you can understand or edit it without parsing .docx.",
92
+ inputSchema: {
93
+ type: "object",
94
+ required: ["id"],
95
+ properties: {
96
+ id: { type: "string" },
97
+ format: { type: "string", enum: ["markdown", "text"], description: "default markdown" },
98
+ },
99
+ },
100
+ async handler(args, ctx) {
101
+ const fmt = args.format === "text" ? "text" : "markdown";
102
+ const res = await ctx.call("GET", `/documents/${encodeURIComponent(args.id)}/text?format=${fmt}`);
103
+ const content = res.markdown ?? res.text ?? "";
104
+ return ok(`# ${res.title} (v${res.version})\n\n${content || "(empty document)"}`);
105
+ },
106
+ },
107
+ {
108
+ name: "create_document",
109
+ description:
110
+ "Create a new document. Provide markdown for the body (headings, lists, bold/italic, quotes are rendered to real .docx), or a template id, or neither for a blank document.",
111
+ inputSchema: {
112
+ type: "object",
113
+ required: ["title"],
114
+ properties: {
115
+ title: { type: "string" },
116
+ markdown: { type: "string", description: "document body in markdown" },
117
+ template: { type: "string", description: "template id from list_templates" },
118
+ folderId: { type: "string" },
119
+ },
120
+ },
121
+ async handler(args, ctx) {
122
+ const body = { title: args.title };
123
+ if (args.markdown != null) body.markdown = args.markdown;
124
+ if (args.template) body.template = args.template;
125
+ if (args.folderId) body.folderId = args.folderId;
126
+ const doc = await ctx.call("POST", "/documents", body);
127
+ return ok(
128
+ `Created "${doc.title}".\nid: ${doc.id}\nversion: ${doc.version}\nurl: ${ctx.origin}/doc/${doc.id}`
129
+ );
130
+ },
131
+ },
132
+ {
133
+ name: "update_document",
134
+ description:
135
+ "Replace a document's content with new markdown (creates a new version). This rebuilds the whole document, so include the full intended content. It does NOT preserve existing comments or tracked changes: if the document has any, the call is refused unless you pass force:true.",
136
+ inputSchema: {
137
+ type: "object",
138
+ required: ["id", "markdown"],
139
+ properties: {
140
+ id: { type: "string" },
141
+ markdown: { type: "string" },
142
+ force: {
143
+ type: "boolean",
144
+ description: "proceed even though existing comments/tracked changes will be discarded",
145
+ },
146
+ },
147
+ },
148
+ async handler(args, ctx) {
149
+ const q = args.force ? "?force=1" : "";
150
+ const res = await ctx.call(
151
+ "PUT",
152
+ `/documents/${encodeURIComponent(args.id)}/content${q}`,
153
+ args.markdown,
154
+ { headers: { "Content-Type": "text/markdown" } }
155
+ );
156
+ return ok(`Saved. New version: ${res.version} (${res.size} bytes).`);
157
+ },
158
+ },
159
+ {
160
+ name: "rename_document",
161
+ description: "Rename a document (editor role).",
162
+ inputSchema: {
163
+ type: "object",
164
+ required: ["id", "title"],
165
+ properties: { id: { type: "string" }, title: { type: "string" } },
166
+ },
167
+ async handler(args, ctx) {
168
+ const doc = await ctx.call("PATCH", `/documents/${encodeURIComponent(args.id)}`, { title: args.title });
169
+ return ok(`Renamed to "${doc.title}".`);
170
+ },
171
+ },
172
+ {
173
+ name: "get_document_info",
174
+ description: "Get a document's metadata (owner, role, size, version, folder, tags, timestamps).",
175
+ inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
176
+ async handler(args, ctx) {
177
+ const doc = await ctx.call("GET", `/documents/${encodeURIComponent(args.id)}`);
178
+ return ok(pretty(doc));
179
+ },
180
+ },
181
+ {
182
+ name: "list_comments",
183
+ description: "List comments living inside the document (threaded, with resolved state).",
184
+ inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
185
+ async handler(args, ctx) {
186
+ const res = await ctx.call("GET", `/documents/${encodeURIComponent(args.id)}/comments`);
187
+ const comments = res.comments || [];
188
+ if (!comments.length) return ok("No comments.");
189
+ return ok(
190
+ comments
191
+ .map(
192
+ (c) =>
193
+ `[${c.id}]${c.parentId ? ` (reply to ${c.parentId})` : ""}${c.done ? " (resolved)" : ""} ${c.author}: ${c.text}`
194
+ )
195
+ .join("\n")
196
+ );
197
+ },
198
+ },
199
+ {
200
+ name: "add_comment",
201
+ description:
202
+ "Add a comment to a document. Anchor it to some existing text (recommended) or omit anchor to attach to the last paragraph. Set replyTo to a comment id for a threaded reply. Requires commenter role or higher.",
203
+ inputSchema: {
204
+ type: "object",
205
+ required: ["id", "text"],
206
+ properties: {
207
+ id: { type: "string" },
208
+ text: { type: "string" },
209
+ anchor: { type: "string", description: "existing text to attach the comment to" },
210
+ replyTo: { type: "string", description: "parent comment id" },
211
+ },
212
+ },
213
+ async handler(args, ctx) {
214
+ const body = { text: args.text };
215
+ if (args.anchor) body.anchor = args.anchor;
216
+ if (args.replyTo) body.replyTo = args.replyTo;
217
+ const res = await ctx.call("POST", `/documents/${encodeURIComponent(args.id)}/comments`, body);
218
+ return ok(`Comment added (id ${res.comment?.id ?? "?"}). Document is now version ${res.version}.`);
219
+ },
220
+ },
221
+ {
222
+ name: "list_suggestions",
223
+ description: "List tracked-change suggestions (insertions, deletions, formatting) inside the document.",
224
+ inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
225
+ async handler(args, ctx) {
226
+ const res = await ctx.call("GET", `/documents/${encodeURIComponent(args.id)}/suggestions`);
227
+ const s = res.suggestions || [];
228
+ if (!s.length) return ok("No suggestions.");
229
+ return ok(s.map((x) => `${x.type} by ${x.author}: ${x.text}`).join("\n"));
230
+ },
231
+ },
232
+ {
233
+ name: "list_versions",
234
+ description: "List a document's version history.",
235
+ inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
236
+ async handler(args, ctx) {
237
+ const res = await ctx.call("GET", `/documents/${encodeURIComponent(args.id)}/versions`);
238
+ const v = res.versions || [];
239
+ return ok(
240
+ v
241
+ .map(
242
+ (x) => `v${x.version} ${x.size} bytes ${x.authorName || "?"}${x.label ? ` (${x.label})` : ""}`
243
+ )
244
+ .join("\n") || "No versions."
245
+ );
246
+ },
247
+ },
248
+ {
249
+ name: "list_folders",
250
+ description: "List the user's folders.",
251
+ inputSchema: { type: "object", properties: {} },
252
+ async handler(_args, ctx) {
253
+ const res = await ctx.call("GET", "/folders");
254
+ const f = res.folders || [];
255
+ return ok(f.map((x) => `${x.id} "${x.name}"${x.parentId ? ` (in ${x.parentId})` : ""}`).join("\n") || "No folders.");
256
+ },
257
+ },
258
+ {
259
+ name: "create_folder",
260
+ description: "Create a folder.",
261
+ inputSchema: {
262
+ type: "object",
263
+ required: ["name"],
264
+ properties: { name: { type: "string" }, parentId: { type: "string" } },
265
+ },
266
+ async handler(args, ctx) {
267
+ const f = await ctx.call("POST", "/folders", { name: args.name, parentId: args.parentId || null });
268
+ return ok(`Created folder "${f.name}" (id ${f.id}).`);
269
+ },
270
+ },
271
+ {
272
+ name: "list_templates",
273
+ description: "List document templates usable with create_document (template id).",
274
+ inputSchema: { type: "object", properties: {} },
275
+ async handler(_args, ctx) {
276
+ const res = await ctx.call("GET", "/templates");
277
+ return ok((res.templates || []).map((t) => `${t.id} "${t.name}" ${t.description}`).join("\n"));
278
+ },
279
+ },
280
+ {
281
+ name: "share_document",
282
+ description: "Share a document with a person by email and role (editor, commenter, or viewer). Owner only.",
283
+ inputSchema: {
284
+ type: "object",
285
+ required: ["id", "email", "role"],
286
+ properties: {
287
+ id: { type: "string" },
288
+ email: { type: "string" },
289
+ role: { type: "string", enum: ["editor", "commenter", "viewer"] },
290
+ },
291
+ },
292
+ async handler(args, ctx) {
293
+ await ctx.call("POST", `/documents/${encodeURIComponent(args.id)}/permissions`, {
294
+ email: args.email,
295
+ role: args.role,
296
+ });
297
+ return ok(`Shared with ${args.email} as ${args.role}.`);
298
+ },
299
+ },
300
+ {
301
+ name: "set_document_fonts",
302
+ description:
303
+ "Set the document-wide heading and/or body font (any Google font family name). Rewrites the Word styles; creates a new version.",
304
+ inputSchema: {
305
+ type: "object",
306
+ required: ["id"],
307
+ properties: {
308
+ id: { type: "string" },
309
+ headingFont: { type: "string" },
310
+ bodyFont: { type: "string" },
311
+ },
312
+ },
313
+ async handler(args, ctx) {
314
+ const body = {};
315
+ if (args.headingFont) body.headingFont = args.headingFont;
316
+ if (args.bodyFont) body.bodyFont = args.bodyFont;
317
+ const res = await ctx.call("POST", `/documents/${encodeURIComponent(args.id)}/theme`, body);
318
+ return ok(`Fonts updated. New version: ${res.version}.`);
319
+ },
320
+ },
321
+ {
322
+ name: "document_url",
323
+ description: "Get the browser URL where a document can be opened and edited by a human.",
324
+ inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string" } } },
325
+ async handler(args, ctx) {
326
+ return ok(`${ctx.origin}/doc/${args.id}`);
327
+ },
328
+ },
329
+ ];
330
+
331
+ const TOOL_BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
332
+
333
+ export async function runMcpServer(opts = {}) {
334
+ const origin = resolveOrigin(opts.origin);
335
+ const token = loadToken(origin);
336
+ if (!token) {
337
+ log("No credential found. Tools will report an auth error. Run `thd login` or set THOTHICA_DOCS_TOKEN.");
338
+ }
339
+
340
+ const ctx = {
341
+ origin,
342
+ async call(method, path, body, extra = {}) {
343
+ if (!token) {
344
+ throw new Error(
345
+ "Not authenticated with Thothica Docs. Run `thd login` in a terminal, or set THOTHICA_DOCS_TOKEN."
346
+ );
347
+ }
348
+ return api(origin, token, method, path, { body, ...extra });
349
+ },
350
+ };
351
+
352
+ function send(msg) {
353
+ process.stdout.write(JSON.stringify(msg) + "\n");
354
+ }
355
+ function respond(id, result) {
356
+ send({ jsonrpc: "2.0", id, result });
357
+ }
358
+ function respondError(id, code, message) {
359
+ send({ jsonrpc: "2.0", id, error: { code, message } });
360
+ }
361
+
362
+ async function handle(msg) {
363
+ const { id, method, params } = msg;
364
+ const isRequest = id !== undefined && id !== null;
365
+ if (method === "initialize") {
366
+ respond(id, {
367
+ protocolVersion: params?.protocolVersion || PROTOCOL_VERSION,
368
+ capabilities: { tools: { listChanged: false } },
369
+ serverInfo: { name: "thothica-docs", version: CLI_VERSION },
370
+ });
371
+ return;
372
+ }
373
+ if (method === "notifications/initialized" || method === "initialized") return; // notification
374
+ if (method === "ping") {
375
+ if (isRequest) respond(id, {});
376
+ return;
377
+ }
378
+ if (method === "tools/list") {
379
+ respond(id, {
380
+ tools: TOOLS.map((t) => ({
381
+ name: t.name,
382
+ description: t.description,
383
+ inputSchema: t.inputSchema,
384
+ })),
385
+ });
386
+ return;
387
+ }
388
+ if (method === "tools/call") {
389
+ const tool = TOOL_BY_NAME.get(params?.name);
390
+ if (!tool) {
391
+ respond(id, fail(`Unknown tool: ${params?.name}`));
392
+ return;
393
+ }
394
+ try {
395
+ const result = await tool.handler(params.arguments || {}, ctx);
396
+ respond(id, result);
397
+ } catch (e) {
398
+ const extra = e instanceof ApiError ? e.body?.hint || e.body?.detail : null;
399
+ const msg =
400
+ e instanceof ApiError
401
+ ? `API error ${e.status}: ${e.code || "error"}${extra ? ` (${extra})` : ""}`
402
+ : String(e?.message || e);
403
+ respond(id, fail(msg));
404
+ }
405
+ return;
406
+ }
407
+ if (isRequest) respondError(id, -32601, `Method not found: ${method}`);
408
+ }
409
+
410
+ // Read newline-delimited JSON-RPC from stdin. Track in-flight handlers so a
411
+ // closed stdin (e.g. a batch pipe) does not drop pending async responses.
412
+ let buffer = "";
413
+ let pending = 0;
414
+ let ended = false;
415
+ const maybeExit = () => {
416
+ if (ended && pending === 0) process.exit(0);
417
+ };
418
+ process.stdin.setEncoding("utf8");
419
+ process.stdin.on("data", (chunk) => {
420
+ buffer += chunk;
421
+ let nl;
422
+ while ((nl = buffer.indexOf("\n")) >= 0) {
423
+ const line = buffer.slice(0, nl).trim();
424
+ buffer = buffer.slice(nl + 1);
425
+ if (!line) continue;
426
+ let msg;
427
+ try {
428
+ msg = JSON.parse(line);
429
+ } catch {
430
+ continue; // ignore non-JSON noise
431
+ }
432
+ pending++;
433
+ handle(msg)
434
+ .catch((e) => log("handler error:", String(e)))
435
+ .finally(() => {
436
+ pending--;
437
+ maybeExit();
438
+ });
439
+ }
440
+ });
441
+ process.stdin.on("end", () => {
442
+ ended = true;
443
+ maybeExit();
444
+ });
445
+ log(`ready (origin ${origin}${token ? "" : ", not authenticated"})`);
446
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@thothica/docs-cli",
3
+ "version": "1.0.0",
4
+ "description": "Command-line tool and MCP server for Thothica Docs: let any AI agent read, create, edit, and comment on native .docx documents.",
5
+ "type": "module",
6
+ "bin": {
7
+ "thd": "bin/thd.mjs",
8
+ "thothica-docs": "bin/thd.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "lib",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "keywords": [
19
+ "thothica",
20
+ "docs",
21
+ "docx",
22
+ "mcp",
23
+ "ai-agent",
24
+ "cli",
25
+ "word"
26
+ ],
27
+ "license": "AGPL-3.0",
28
+ "homepage": "https://docs.thothica.com",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/adoistic/thothica-docs.git",
32
+ "directory": "cli"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/adoistic/thothica-docs/issues"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "dependencies": {}
41
+ }