integral-mind 0.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/dist/cli.js ADDED
@@ -0,0 +1,945 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * im — the Integral Mind CLI. Thin HTTP client: no database, no parsers.
4
+ *
5
+ * Exit codes:
6
+ * 0 success
7
+ * 1 runtime failure (network unreachable, server 5xx)
8
+ * 2 usage error (unknown command, bad flags, missing config)
9
+ * 3 authentication/authorization failure (401/403, bad or unscoped token)
10
+ * 4 not found (404)
11
+ * 5 validation rejected the input (400/422)
12
+ */
13
+ import { realpathSync } from "node:fs";
14
+ import { readFile } from "node:fs/promises";
15
+ import { basename } from "node:path";
16
+ import { createInterface } from "node:readline";
17
+ import { fileURLToPath } from "node:url";
18
+ import { parseArgs } from "node:util";
19
+ import { checkConfigPermissions, configPath, readConfigFile, resolveConfig, writeConfigFile, } from "./config.js";
20
+ import { redactSecrets, summarizeFindings } from "./redact.js";
21
+ import { ApiError, ImClient } from "./client.js";
22
+ import { CANONICAL_DOC, DROP_IN_BLOCK } from "./docs.js";
23
+ import { defaultSessionEndDeps, installClaudeHook, runClaudeSessionEnd, uninstallClaudeHook, } from "./hooks.js";
24
+ export { EXIT } from "./exit.js";
25
+ import { EXIT } from "./exit.js";
26
+ import { runSyncCommand } from "./sync/cli.js";
27
+ const USAGE = `im — Integral Mind CLI
28
+
29
+ Usage:
30
+ im config set <url|token> <value> Store connection settings (chmod 600)
31
+ im config show Show effective config and its sources
32
+ im push session [options] Push a session envelope (redacted by default)
33
+ im push text <content> [options] Capture plain text
34
+ im push file <path> [options] Capture a file's contents as text
35
+ im push url <url> [options] Capture a web page
36
+ im push youtube <url> [options] Capture a YouTube video
37
+ im search <query> [options] Hybrid search (filters: --type --after --before --code --sessions)
38
+ im sync <connector> [options] Delta-sync a machine-local source (mempalace, obsidian, repo)
39
+ im sync connect|status|disconnect Manage sync connections
40
+ im recent [options] List recently ingested sources
41
+ im stats Corpus statistics
42
+ im projects list|create <name> Manage projects (admin token)
43
+ im sources list|reparse|delete Manage sources (admin token)
44
+ im tokens list|generate|revoke Manage MCP tokens (admin token)
45
+ im docs [--drop-in] Print the canonical manual (or the AGENTS.md block)\n im hooks install|uninstall claude Auto-push Claude Code sessions at session end
46
+
47
+ push session options:
48
+ --file <path> Read the envelope JSON from a file
49
+ --stdin Read the envelope JSON from stdin
50
+ --text <t> Build a text-fallback envelope instead of JSON
51
+ --agent <name> Agent name for --text envelopes (default: cli)
52
+ --title <t> Session title
53
+ --project <uuid> Target project (default: your first project)
54
+ --no-redact Skip secret redaction (NOT recommended)
55
+
56
+ Common options:
57
+ --project <uuid> Target project --limit <n> Result count
58
+ --title <t> Source title --json Raw JSON output
59
+
60
+ Environment:
61
+ INTEGRAL_MIND_URL, INTEGRAL_MIND_TOKEN override the config file.
62
+
63
+ Exit codes: 0 ok · 1 runtime · 2 usage · 3 auth · 4 not found · 5 validation`;
64
+ const EXIT_CODES_HELP = "Exit codes: 0 ok · 1 runtime · 2 usage · 3 auth · 4 not found · 5 validation";
65
+ // Agent-grade per-command help: every entry has copy-paste examples and
66
+ // exit-code semantics. Content mirrors the canonical manual (`im docs`).
67
+ const COMMAND_HELP = {
68
+ config: `im config — store connection settings (file mode 0600)
69
+
70
+ Usage:
71
+ im config set url <https://instance>
72
+ im config set token <imk_...>
73
+ im config show
74
+
75
+ Examples:
76
+ im config set url https://span-vault.com
77
+ im config set token imk_abc123
78
+ INTEGRAL_MIND_URL and INTEGRAL_MIND_TOKEN env vars override the file.
79
+
80
+ ${EXIT_CODES_HELP}`,
81
+ push: `im push — store sources in your knowledge base
82
+
83
+ Usage:
84
+ im push session (--file <envelope.json> | --stdin | --text <t>) [--agent a] [--title t] [--project uuid] [--no-redact]
85
+ im push text <content> [--title t] [--project uuid]
86
+ im push file <path> [--title t] [--project uuid]
87
+ im push url <url> [--title t] [--project uuid]
88
+ im push youtube <url> [--title t] [--project uuid]
89
+
90
+ Examples:
91
+ im push session --file transcript.json
92
+ cat transcript.json | im push session --stdin
93
+ im push text "advisory locks coordinate blob GC" --title "GC note"
94
+ im push file ./notes/design.md
95
+
96
+ Session pushes are redacted by default (typed [REDACTED:type]
97
+ placeholders; summary on stderr); --no-redact bypasses. Pushes are
98
+ idempotent by content hash. Run "im docs" for the envelope format.
99
+
100
+ ${EXIT_CODES_HELP}`,
101
+ search: `im search — hybrid search returning cited evidence chunks (no LLM)
102
+
103
+ Usage:
104
+ im search <query> [--limit n] [--project uuid] [--type t]... [--after date] [--before date] [--code] [--sessions] [--json]
105
+
106
+ Examples:
107
+ im search "failover postmortem"
108
+ im search "advisory locks" --limit 3 --json
109
+ im search "retry budget" --type session --type text # only these source types
110
+ im search "interlock fix" --after 2026-06-01 # ingested on/after (inclusive)
111
+ im search "interlock fix" --before 2026-07-01 # ingested before (exclusive)
112
+ im search "quorum fence" --sessions # sessions + summaries only
113
+ im search "token verification" --code # synced repos only; cites path:lines
114
+
115
+ Filters combine. --sessions calls the dedicated session_search primitive
116
+ (rows carry session type + push date); --code calls code_search. Both
117
+ accept --after/--before but not --type, and are mutually exclusive.
118
+
119
+ ${EXIT_CODES_HELP}`,
120
+ recent: `im recent — list newest sources
121
+
122
+ Usage:
123
+ im recent [--limit n] [--project uuid] [--json]
124
+
125
+ Example:
126
+ im recent --limit 10
127
+
128
+ ${EXIT_CODES_HELP}`,
129
+ stats: `im stats — corpus statistics
130
+
131
+ Usage:
132
+ im stats [--json]
133
+
134
+ Example:
135
+ im stats --json
136
+
137
+ ${EXIT_CODES_HELP}`,
138
+ sync: `im sync — delta-sync machine-local sources into your knowledge base
139
+
140
+ Usage:
141
+ im sync mempalace [--palace <label>] [--full]
142
+ im sync obsidian [--label <name>] [--full]
143
+ im sync repo [--label <name>] [--full]
144
+ im sync connect mempalace --label <name> --path <palace-dir> [--layout project_per_palace|project_per_wing]
145
+ im sync connect obsidian --vault <vault-dir> [--label <name>] [--project <uuid>]
146
+ im sync connect repo --path <repo-dir> [--label <name>] [--project <uuid>]
147
+ im sync status
148
+ im sync disconnect <mempalace|obsidian|repo> <label>
149
+
150
+ Examples:
151
+ im sync connect mempalace --label personal --path ~/.mempalace/palace
152
+ im sync mempalace # delta sync: only changed items push
153
+ im sync connect obsidian --vault ~/vaults/Brain
154
+ im sync obsidian # edited notes re-sync; renames tracked
155
+ im sync connect repo --path ~/projects/my-app
156
+ im sync repo # after a commit, only changed files re-index
157
+ im sync status
158
+
159
+ Per-connector delta state lives under ~/.config/integral-mind/sync/; a
160
+ repeat run with no local changes pushes nothing. connect reuses an
161
+ existing server-side registration with the same label, so reconnecting
162
+ after a reinstall never duplicates sources.
163
+
164
+ ${EXIT_CODES_HELP}`,
165
+ hooks: `im hooks — Claude Code session auto-push
166
+
167
+ Usage:
168
+ im hooks install claude Wire the SessionEnd hook into ~/.claude/settings.json
169
+ im hooks uninstall claude Remove it (other hooks untouched)
170
+
171
+ Examples:
172
+ im hooks install claude
173
+ im hooks uninstall claude
174
+
175
+ Install is idempotent. At session end the transcript is reshaped into
176
+ the session envelope, redacted, and pushed; failures never block the
177
+ Claude session (see ~/.local/state/integral-mind/hooks.log).
178
+
179
+ ${EXIT_CODES_HELP}`,
180
+ projects: `im projects — manage projects (requires an admin-scoped token)
181
+
182
+ Usage:
183
+ im projects list [--json]
184
+ im projects create <name> [--json]
185
+
186
+ Examples:
187
+ im projects list
188
+ im projects create "Planescape Campaign"
189
+
190
+ ${EXIT_CODES_HELP}`,
191
+ sources: `im sources — manage sources (requires an admin-scoped token)
192
+
193
+ Usage:
194
+ im sources list [--project uuid] [--limit n] [--json]
195
+ im sources reparse <source-id>
196
+ im sources delete <source-id> [--force]
197
+
198
+ Examples:
199
+ im sources list --limit 20
200
+ im sources reparse 3fa85f64-5717-4562-b3fc-2c963f66afa6
201
+ im sources delete 3fa85f64-5717-4562-b3fc-2c963f66afa6 # prompts
202
+ im sources delete 3fa85f64-5717-4562-b3fc-2c963f66afa6 --force # scripted
203
+
204
+ delete permanently removes the source, its chunks, and its summaries;
205
+ without --force it asks you to type "yes" on stdin first.
206
+
207
+ ${EXIT_CODES_HELP}`,
208
+ tokens: `im tokens — manage MCP tokens (requires an admin-scoped token)
209
+
210
+ Usage:
211
+ im tokens list [--json]
212
+ im tokens generate --name <name> --scopes <capture,search[,admin]> [--json]
213
+ im tokens revoke <token-id> [--force]
214
+
215
+ Examples:
216
+ im tokens list
217
+ im tokens generate --name ci-agent --scopes capture,search
218
+ im tokens revoke 3fa85f64-5717-4562-b3fc-2c963f66afa6 --force
219
+
220
+ generate prints the plaintext token ONCE — store it immediately; only a
221
+ hash is kept server-side. revoke is immediate and permanent; without
222
+ --force it asks you to type "yes" on stdin first.
223
+
224
+ ${EXIT_CODES_HELP}`,
225
+ docs: `im docs — print the canonical manual
226
+
227
+ Usage:
228
+ im docs Full manual (setup, commands, envelope, exit codes)
229
+ im docs --drop-in ~10-line block for AGENTS.md / CLAUDE.md
230
+
231
+ Example:
232
+ im docs --drop-in >> AGENTS.md
233
+
234
+ ${EXIT_CODES_HELP}`,
235
+ };
236
+ function maybeCommandHelp(command, args, stdio) {
237
+ // Only tokens before a `--` terminator count as help requests, so literal
238
+ // "-h"/"--help" payloads stay storable: `im push text -- -h`.
239
+ const terminator = args.indexOf("--");
240
+ const scanned = terminator === -1 ? args : args.slice(0, terminator);
241
+ if (!scanned.includes("--help") && !scanned.includes("-h"))
242
+ return false;
243
+ stdio.out(COMMAND_HELP[command] ?? USAGE);
244
+ return true;
245
+ }
246
+ export async function main(argv, env = process.env, stdio = { out: console.log, err: console.error }, readStdin = defaultReadStdin, readLine = defaultReadLine) {
247
+ const [command, ...rest] = argv;
248
+ try {
249
+ switch (command) {
250
+ case undefined:
251
+ case "help":
252
+ case "--help":
253
+ case "-h":
254
+ stdio.out(USAGE);
255
+ return command === undefined ? EXIT.USAGE : EXIT.OK;
256
+ case "config":
257
+ return await runConfig(rest, env, stdio);
258
+ case "push":
259
+ return await runPush(rest, env, stdio, readStdin);
260
+ case "search":
261
+ return await runSearch(rest, env, stdio);
262
+ case "sync": {
263
+ if (maybeCommandHelp("sync", rest, stdio))
264
+ return EXIT.OK;
265
+ return await runSyncCommand(rest, env, stdio);
266
+ }
267
+ case "recent":
268
+ return await runRecent(rest, env, stdio);
269
+ case "stats":
270
+ return await runStats(rest, env, stdio);
271
+ case "projects":
272
+ return await runProjects(rest, env, stdio);
273
+ case "sources":
274
+ return await runSources(rest, env, stdio, readLine);
275
+ case "tokens":
276
+ return await runTokens(rest, env, stdio, readLine);
277
+ case "hooks":
278
+ return await runHooks(rest, env, stdio, readStdin);
279
+ case "docs": {
280
+ if (maybeCommandHelp("docs", rest, stdio))
281
+ return EXIT.OK;
282
+ let dropIn = false;
283
+ try {
284
+ const parsed = parseArgs({ args: rest, options: { "drop-in": { type: "boolean" } } });
285
+ dropIn = parsed.values["drop-in"] === true;
286
+ }
287
+ catch {
288
+ stdio.err("Usage: im docs [--drop-in]");
289
+ return EXIT.USAGE;
290
+ }
291
+ stdio.out(dropIn ? DROP_IN_BLOCK : CANONICAL_DOC);
292
+ return EXIT.OK;
293
+ }
294
+ default:
295
+ stdio.err(`Unknown command: ${command}\n`);
296
+ stdio.out(USAGE);
297
+ return EXIT.USAGE;
298
+ }
299
+ }
300
+ catch (err) {
301
+ return reportError(err, stdio);
302
+ }
303
+ }
304
+ async function client(env, stdio) {
305
+ const resolved = await resolveConfig(env);
306
+ if ("error" in resolved) {
307
+ stdio.err(resolved.error);
308
+ return EXIT.USAGE;
309
+ }
310
+ const warning = await checkConfigPermissions(env);
311
+ if (warning)
312
+ stdio.err(warning);
313
+ return new ImClient(resolved);
314
+ }
315
+ async function runConfig(args, env, stdio) {
316
+ if (maybeCommandHelp("config", args, stdio))
317
+ return EXIT.OK;
318
+ const [sub, key, value] = args;
319
+ if (sub === "show") {
320
+ const file = await readConfigFile(env);
321
+ const url = env.INTEGRAL_MIND_URL ?? file.url;
322
+ const token = env.INTEGRAL_MIND_TOKEN ?? file.token;
323
+ stdio.out(`config file: ${configPath(env)}`);
324
+ stdio.out(`url: ${url ?? "(unset)"}${env.INTEGRAL_MIND_URL ? " (from env)" : ""}`);
325
+ stdio.out(`token: ${token ? `…${token.slice(-4)}` : "(unset)"}${env.INTEGRAL_MIND_TOKEN ? " (from env)" : ""}`);
326
+ return EXIT.OK;
327
+ }
328
+ if (sub === "set" && (key === "url" || key === "token") && value) {
329
+ const path = await writeConfigFile({ [key]: value }, env);
330
+ stdio.out(`Saved ${key} to ${path}`);
331
+ return EXIT.OK;
332
+ }
333
+ stdio.err("Usage: im config set <url|token> <value> | im config show");
334
+ return EXIT.USAGE;
335
+ }
336
+ async function runPush(args, env, stdio, readStdin) {
337
+ if (maybeCommandHelp("push", args, stdio))
338
+ return EXIT.OK;
339
+ const [kind, ...rest] = args;
340
+ switch (kind) {
341
+ case "session":
342
+ return runPushSession(rest, env, stdio, readStdin);
343
+ case "text":
344
+ case "url":
345
+ case "youtube":
346
+ case "file":
347
+ return runPushCapture(kind, rest, env, stdio);
348
+ default:
349
+ stdio.err("Usage: im push <session|text|file|url|youtube> …");
350
+ return EXIT.USAGE;
351
+ }
352
+ }
353
+ async function runPushSession(args, env, stdio, readStdin) {
354
+ const { values } = parseArgs({
355
+ args,
356
+ options: {
357
+ file: { type: "string" },
358
+ stdin: { type: "boolean" },
359
+ text: { type: "string" },
360
+ agent: { type: "string" },
361
+ title: { type: "string" },
362
+ project: { type: "string" },
363
+ "no-redact": { type: "boolean" },
364
+ json: { type: "boolean" },
365
+ },
366
+ });
367
+ let envelope;
368
+ if (values.text) {
369
+ envelope = {
370
+ agent: values.agent ?? "cli",
371
+ ...(values.title ? { title: values.title } : {}),
372
+ text: values.text,
373
+ };
374
+ }
375
+ else {
376
+ const raw = values.file
377
+ ? await readFile(values.file, "utf8")
378
+ : values.stdin
379
+ ? await readStdin()
380
+ : null;
381
+ if (raw === null) {
382
+ stdio.err("push session needs --file <envelope.json>, --stdin, or --text <content>");
383
+ return EXIT.USAGE;
384
+ }
385
+ try {
386
+ envelope = JSON.parse(raw);
387
+ }
388
+ catch {
389
+ stdio.err("Envelope is not valid JSON");
390
+ return EXIT.VALIDATION;
391
+ }
392
+ if (values.title)
393
+ envelope.title = values.title;
394
+ }
395
+ if (!values["no-redact"]) {
396
+ const findings = redactEnvelopeInPlace(envelope);
397
+ stdio.err(summarizeFindings(findings));
398
+ }
399
+ else {
400
+ stdio.err("redaction: SKIPPED (--no-redact)");
401
+ }
402
+ if (values.project)
403
+ envelope.project_id = values.project;
404
+ const im = await client(env, stdio);
405
+ if (typeof im === "number")
406
+ return im;
407
+ const result = await im.pushSession(envelope);
408
+ if (values.json) {
409
+ stdio.out(JSON.stringify(result, null, 2));
410
+ }
411
+ else {
412
+ stdio.out(`${result.deduplicated ? "Already known" : "Pushed"} session ${String(result.source_id)} (status: ${String(result.status)})`);
413
+ }
414
+ return EXIT.OK;
415
+ }
416
+ export function redactEnvelopeInPlace(envelope) {
417
+ const totals = new Map();
418
+ const scrub = (value) => {
419
+ const { text, findings } = redactSecrets(value);
420
+ for (const f of findings)
421
+ totals.set(f.type, (totals.get(f.type) ?? 0) + f.count);
422
+ return text;
423
+ };
424
+ if (typeof envelope.text === "string")
425
+ envelope.text = scrub(envelope.text);
426
+ if (typeof envelope.title === "string")
427
+ envelope.title = scrub(envelope.title);
428
+ if (Array.isArray(envelope.messages)) {
429
+ for (const message of envelope.messages) {
430
+ if (typeof message === "object" &&
431
+ message !== null &&
432
+ typeof message.content === "string") {
433
+ message.content = scrub(message.content);
434
+ }
435
+ }
436
+ }
437
+ return [...totals.entries()]
438
+ .map(([type, count]) => ({ type, count }))
439
+ .sort((a, b) => a.type.localeCompare(b.type));
440
+ }
441
+ async function runPushCapture(kind, args, env, stdio) {
442
+ const { values, positionals } = parseArgs({
443
+ args,
444
+ allowPositionals: true,
445
+ options: {
446
+ title: { type: "string" },
447
+ project: { type: "string" },
448
+ json: { type: "boolean" },
449
+ },
450
+ });
451
+ const [subject] = positionals;
452
+ if (!subject) {
453
+ stdio.err(`Usage: im push ${kind} <${kind === "file" ? "path" : kind}> [--title] [--project]`);
454
+ return EXIT.USAGE;
455
+ }
456
+ let type = kind === "file" ? "text" : kind;
457
+ let content = subject;
458
+ let title = values.title;
459
+ if (kind === "file") {
460
+ // Thin client: file contents are captured as a text source; the
461
+ // dedicated multipart file type stays a webapp feature for now.
462
+ content = await readFile(subject, "utf8");
463
+ title ??= basename(subject);
464
+ }
465
+ const im = await client(env, stdio);
466
+ if (typeof im === "number")
467
+ return im;
468
+ const result = await im.callTool("capture", {
469
+ content,
470
+ type,
471
+ ...(title ? { title } : {}),
472
+ ...(values.project ? { project_id: values.project } : {}),
473
+ });
474
+ if (values.json) {
475
+ stdio.out(JSON.stringify(result, null, 2));
476
+ }
477
+ else {
478
+ stdio.out(`Captured ${String(result.type)} source ${String(result.source_id)} (status: ${String(result.status)})`);
479
+ }
480
+ return EXIT.OK;
481
+ }
482
+ async function runSearch(args, env, stdio) {
483
+ if (maybeCommandHelp("search", args, stdio))
484
+ return EXIT.OK;
485
+ const { values, positionals } = parseArgs({
486
+ args,
487
+ allowPositionals: true,
488
+ options: {
489
+ limit: { type: "string" },
490
+ project: { type: "string" },
491
+ type: { type: "string", multiple: true },
492
+ after: { type: "string" },
493
+ before: { type: "string" },
494
+ code: { type: "boolean" },
495
+ sessions: { type: "boolean" },
496
+ json: { type: "boolean" },
497
+ },
498
+ });
499
+ const query = positionals.join(" ").trim();
500
+ if (!query) {
501
+ stdio.err("Usage: im search <query> [--limit n] [--project uuid] [--type t]... [--after date] [--before date] [--code] [--sessions]");
502
+ return EXIT.USAGE;
503
+ }
504
+ if (values.code && values.sessions) {
505
+ stdio.err("--code and --sessions are mutually exclusive");
506
+ return EXIT.USAGE;
507
+ }
508
+ if ((values.code || values.sessions) && values.type?.length) {
509
+ stdio.err(`--type cannot be combined with ${values.code ? "--code" : "--sessions"}`);
510
+ return EXIT.USAGE;
511
+ }
512
+ const im = await client(env, stdio);
513
+ if (typeof im === "number")
514
+ return im;
515
+ if (values.code)
516
+ return runCodeSearch(im, query, values, stdio);
517
+ if (values.sessions)
518
+ return runSessionSearch(im, query, values, stdio);
519
+ const result = (await im.callTool("search", {
520
+ query,
521
+ ...(values.limit ? { limit: Number(values.limit) } : {}),
522
+ ...(values.project ? { project_id: values.project } : {}),
523
+ ...(values.type?.length ? { source_types: values.type } : {}),
524
+ ...(values.after ? { after: values.after } : {}),
525
+ ...(values.before ? { before: values.before } : {}),
526
+ }));
527
+ const rows = result.results ?? [];
528
+ if (values.json) {
529
+ stdio.out(JSON.stringify(rows, null, 2));
530
+ return EXIT.OK;
531
+ }
532
+ if (rows.length === 0) {
533
+ stdio.out(`No results for "${query}".`);
534
+ return EXIT.OK;
535
+ }
536
+ for (const [i, row] of rows.entries()) {
537
+ stdio.out(`${i + 1}. [${row.source_title ?? row.source_id.slice(0, 8)} · chunk ${row.idx} · score ${row.score.toFixed(3)} · chars ${row.char_start}–${row.char_end}]`);
538
+ stdio.out(` ${row.text.slice(0, 300).replaceAll("\n", "\n ")}`);
539
+ }
540
+ return EXIT.OK;
541
+ }
542
+ async function runCodeSearch(im, query, values, stdio) {
543
+ const result = (await im.callTool("code_search", {
544
+ query,
545
+ ...(values.limit ? { limit: Number(values.limit) } : {}),
546
+ ...(values.project ? { project_id: values.project } : {}),
547
+ ...(values.after ? { after: values.after } : {}),
548
+ ...(values.before ? { before: values.before } : {}),
549
+ }));
550
+ const rows = result.results ?? [];
551
+ if (values.json) {
552
+ stdio.out(JSON.stringify(rows, null, 2));
553
+ return EXIT.OK;
554
+ }
555
+ if (rows.length === 0) {
556
+ stdio.out(`No code results for "${query}". Index a repo with \`im sync repo\`.`);
557
+ return EXIT.OK;
558
+ }
559
+ for (const [i, row] of rows.entries()) {
560
+ const location = `${row.path ?? row.source_title ?? row.source_id.slice(0, 8)}:${row.line_start ?? "?"}-${row.line_end ?? "?"}`;
561
+ stdio.out(`${i + 1}. [${location} · score ${row.score.toFixed(3)}]`);
562
+ stdio.out(` ${row.text.slice(0, 300).replaceAll("\n", "\n ")}`);
563
+ }
564
+ return EXIT.OK;
565
+ }
566
+ async function runSessionSearch(im, query, values, stdio) {
567
+ const result = (await im.callTool("session_search", {
568
+ query,
569
+ ...(values.limit ? { limit: Number(values.limit) } : {}),
570
+ ...(values.project ? { project_id: values.project } : {}),
571
+ ...(values.after ? { after: values.after } : {}),
572
+ ...(values.before ? { before: values.before } : {}),
573
+ }));
574
+ const rows = result.results ?? [];
575
+ if (values.json) {
576
+ stdio.out(JSON.stringify(rows, null, 2));
577
+ return EXIT.OK;
578
+ }
579
+ if (rows.length === 0) {
580
+ stdio.out(`No session results for "${query}". Push one with \`im push session\`.`);
581
+ return EXIT.OK;
582
+ }
583
+ for (const [i, row] of rows.entries()) {
584
+ stdio.out(`${i + 1}. [${row.source_title ?? row.source_id.slice(0, 8)} · ${row.source_type} · ${row.ingested_at.slice(0, 10)} · score ${row.score.toFixed(3)}]`);
585
+ stdio.out(` ${row.text.slice(0, 300).replaceAll("\n", "\n ")}`);
586
+ }
587
+ return EXIT.OK;
588
+ }
589
+ async function runRecent(args, env, stdio) {
590
+ if (maybeCommandHelp("recent", args, stdio))
591
+ return EXIT.OK;
592
+ const { values } = parseArgs({
593
+ args,
594
+ options: {
595
+ limit: { type: "string" },
596
+ project: { type: "string" },
597
+ json: { type: "boolean" },
598
+ },
599
+ });
600
+ const im = await client(env, stdio);
601
+ if (typeof im === "number")
602
+ return im;
603
+ const result = (await im.callTool("browse_recent", {
604
+ ...(values.limit ? { limit: Number(values.limit) } : {}),
605
+ ...(values.project ? { project_id: values.project } : {}),
606
+ }));
607
+ const sources = result.sources ?? [];
608
+ if (values.json) {
609
+ stdio.out(JSON.stringify(sources, null, 2));
610
+ return EXIT.OK;
611
+ }
612
+ if (sources.length === 0) {
613
+ stdio.out("No sources yet. Push one with `im push`.");
614
+ return EXIT.OK;
615
+ }
616
+ for (const s of sources) {
617
+ stdio.out(`• ${s.title ?? "(untitled)"} — ${s.type}, ${s.char_count.toLocaleString()} chars, ${s.parse_status}, ${s.ingested_at}`);
618
+ }
619
+ return EXIT.OK;
620
+ }
621
+ async function runStats(args, env, stdio) {
622
+ if (maybeCommandHelp("stats", args, stdio))
623
+ return EXIT.OK;
624
+ const { values } = parseArgs({ args, options: { json: { type: "boolean" } } });
625
+ const im = await client(env, stdio);
626
+ if (typeof im === "number")
627
+ return im;
628
+ const result = await im.callTool("stats", {});
629
+ if (values.json) {
630
+ stdio.out(JSON.stringify(result, null, 2));
631
+ return EXIT.OK;
632
+ }
633
+ stdio.out(`${String(result.source_count)} sources · ${String(result.chunk_count)} chunks · ${String(result.project_count)} projects`);
634
+ stdio.out(`Total AI cost: $${(Number(result.total_cost_cents ?? 0) / 100).toFixed(4)} · last ingested: ${String(result.last_ingested_at ?? "(never)")}`);
635
+ return EXIT.OK;
636
+ }
637
+ /**
638
+ * Confirmation gate for destructive admin commands: --force skips it;
639
+ * otherwise the user must type "yes" on stdin. Reads a single line (not
640
+ * until EOF), so an interactive prompt returns as soon as Enter is hit.
641
+ * Returns true to proceed.
642
+ */
643
+ async function confirmDestructive(prompt, force, stdio, readLine) {
644
+ if (force)
645
+ return true;
646
+ stdio.err(`${prompt}\nType "yes" to confirm (or re-run with --force):`);
647
+ const answer = (await readLine()).trim().toLowerCase();
648
+ if (answer === "yes")
649
+ return true;
650
+ stdio.err("Aborted — nothing was changed.");
651
+ return false;
652
+ }
653
+ async function runProjects(args, env, stdio) {
654
+ if (maybeCommandHelp("projects", args, stdio))
655
+ return EXIT.OK;
656
+ const [sub, ...rest] = args;
657
+ if (sub === "list") {
658
+ const { values } = parseArgs({ args: rest, options: { json: { type: "boolean" } } });
659
+ const im = await client(env, stdio);
660
+ if (typeof im === "number")
661
+ return im;
662
+ const result = await im.request("GET", "/api/admin/projects");
663
+ if (values.json) {
664
+ stdio.out(JSON.stringify(result.projects, null, 2));
665
+ return EXIT.OK;
666
+ }
667
+ if (result.projects.length === 0) {
668
+ stdio.out("No projects yet. Create one with `im projects create <name>`.");
669
+ return EXIT.OK;
670
+ }
671
+ for (const p of result.projects) {
672
+ stdio.out(`• ${p.name} — ${p.id} · ${p.source_count} sources${p.archived ? " · archived" : ""}${p.tags.length ? ` · tags: ${p.tags.join(", ")}` : ""}`);
673
+ }
674
+ return EXIT.OK;
675
+ }
676
+ if (sub === "create") {
677
+ const { values, positionals } = parseArgs({
678
+ args: rest,
679
+ allowPositionals: true,
680
+ options: { json: { type: "boolean" } },
681
+ });
682
+ const name = positionals.join(" ").trim();
683
+ if (!name) {
684
+ stdio.err("Usage: im projects create <name>");
685
+ return EXIT.USAGE;
686
+ }
687
+ const im = await client(env, stdio);
688
+ if (typeof im === "number")
689
+ return im;
690
+ const result = await im.request("POST", "/api/admin/projects", { name });
691
+ if (values.json)
692
+ stdio.out(JSON.stringify(result.project, null, 2));
693
+ else
694
+ stdio.out(`Created project "${result.project.name}" (${result.project.id})`);
695
+ return EXIT.OK;
696
+ }
697
+ stdio.err("Usage: im projects <list|create> …");
698
+ return EXIT.USAGE;
699
+ }
700
+ async function runSources(args, env, stdio, readLine) {
701
+ if (maybeCommandHelp("sources", args, stdio))
702
+ return EXIT.OK;
703
+ const [sub, ...rest] = args;
704
+ if (sub === "list") {
705
+ const { values } = parseArgs({
706
+ args: rest,
707
+ options: {
708
+ project: { type: "string" },
709
+ limit: { type: "string" },
710
+ json: { type: "boolean" },
711
+ },
712
+ });
713
+ const im = await client(env, stdio);
714
+ if (typeof im === "number")
715
+ return im;
716
+ const query = new URLSearchParams();
717
+ if (values.project)
718
+ query.set("project_id", values.project);
719
+ if (values.limit)
720
+ query.set("limit", values.limit);
721
+ const qs = query.size > 0 ? `?${query.toString()}` : "";
722
+ const result = await im.request("GET", `/api/admin/sources${qs}`);
723
+ if (values.json) {
724
+ stdio.out(JSON.stringify(result.sources, null, 2));
725
+ return EXIT.OK;
726
+ }
727
+ if (result.sources.length === 0) {
728
+ stdio.out("No sources found.");
729
+ return EXIT.OK;
730
+ }
731
+ for (const s of result.sources) {
732
+ stdio.out(`• ${s.title ?? "(untitled)"} — ${s.id} · ${s.type} · ${s.parse_status} · ${s.char_count.toLocaleString()} chars · ${s.ingested_at}`);
733
+ }
734
+ return EXIT.OK;
735
+ }
736
+ if (sub === "reparse") {
737
+ const { values, positionals } = parseArgs({
738
+ args: rest,
739
+ allowPositionals: true,
740
+ options: { json: { type: "boolean" } },
741
+ });
742
+ const [id] = positionals;
743
+ if (!id) {
744
+ stdio.err("Usage: im sources reparse <source-id>");
745
+ return EXIT.USAGE;
746
+ }
747
+ const im = await client(env, stdio);
748
+ if (typeof im === "number")
749
+ return im;
750
+ const result = await im.request("POST", `/api/admin/sources/${encodeURIComponent(id)}/reparse`);
751
+ if (result.warning)
752
+ stdio.err(`Warning: ${result.warning}`);
753
+ if (values.json)
754
+ stdio.out(JSON.stringify(result.source, null, 2));
755
+ else
756
+ stdio.out(`Re-parsing ${result.source.title ?? "(untitled)"} (${result.source.id}) — status: ${result.source.parse_status}`);
757
+ return EXIT.OK;
758
+ }
759
+ if (sub === "delete") {
760
+ const { values, positionals } = parseArgs({
761
+ args: rest,
762
+ allowPositionals: true,
763
+ options: { force: { type: "boolean" }, json: { type: "boolean" } },
764
+ });
765
+ const [id] = positionals;
766
+ if (!id) {
767
+ stdio.err("Usage: im sources delete <source-id> [--force]");
768
+ return EXIT.USAGE;
769
+ }
770
+ const proceed = await confirmDestructive(`This permanently deletes source ${id}, its chunks, and its summaries.`, values.force === true, stdio, readLine);
771
+ if (!proceed)
772
+ return EXIT.USAGE;
773
+ const im = await client(env, stdio);
774
+ if (typeof im === "number")
775
+ return im;
776
+ const result = await im.request("DELETE", `/api/admin/sources/${encodeURIComponent(id)}`);
777
+ if (values.json)
778
+ stdio.out(JSON.stringify(result.deleted, null, 2));
779
+ else
780
+ stdio.out(`Deleted ${result.deleted.title ?? "(untitled)"} (${result.deleted.id})`);
781
+ return EXIT.OK;
782
+ }
783
+ stdio.err("Usage: im sources <list|reparse|delete> …");
784
+ return EXIT.USAGE;
785
+ }
786
+ async function runTokens(args, env, stdio, readLine) {
787
+ if (maybeCommandHelp("tokens", args, stdio))
788
+ return EXIT.OK;
789
+ const [sub, ...rest] = args;
790
+ if (sub === "list") {
791
+ const { values } = parseArgs({ args: rest, options: { json: { type: "boolean" } } });
792
+ const im = await client(env, stdio);
793
+ if (typeof im === "number")
794
+ return im;
795
+ const result = await im.request("GET", "/api/admin/tokens");
796
+ if (values.json) {
797
+ stdio.out(JSON.stringify(result.tokens, null, 2));
798
+ return EXIT.OK;
799
+ }
800
+ if (result.tokens.length === 0) {
801
+ stdio.out("No tokens. Generate one with `im tokens generate`.");
802
+ return EXIT.OK;
803
+ }
804
+ for (const t of result.tokens) {
805
+ stdio.out(`• ${t.name} — …${t.last_four} · ${t.id} · scopes: ${t.scopes.join(",")}${t.revoked_at ? " · REVOKED" : t.last_used_at ? ` · last used ${t.last_used_at}` : " · never used"}`);
806
+ }
807
+ return EXIT.OK;
808
+ }
809
+ if (sub === "generate") {
810
+ const { values } = parseArgs({
811
+ args: rest,
812
+ options: {
813
+ name: { type: "string" },
814
+ scopes: { type: "string" },
815
+ json: { type: "boolean" },
816
+ },
817
+ });
818
+ const scopes = (values.scopes ?? "")
819
+ .split(",")
820
+ .map((s) => s.trim())
821
+ .filter(Boolean);
822
+ if (!values.name || scopes.length === 0) {
823
+ stdio.err("Usage: im tokens generate --name <name> --scopes <capture,search[,admin]>");
824
+ return EXIT.USAGE;
825
+ }
826
+ const im = await client(env, stdio);
827
+ if (typeof im === "number")
828
+ return im;
829
+ const result = await im.request("POST", "/api/admin/tokens", { name: values.name, scopes });
830
+ if (values.json) {
831
+ stdio.out(JSON.stringify(result.token, null, 2));
832
+ return EXIT.OK;
833
+ }
834
+ stdio.out(result.token.plaintext);
835
+ stdio.err(`Generated token "${result.token.name}" (${result.token.id}, scopes: ${result.token.scopes.join(",")}).\n` +
836
+ "The plaintext above is shown ONCE — store it now; only a hash is kept.");
837
+ return EXIT.OK;
838
+ }
839
+ if (sub === "revoke") {
840
+ const { values, positionals } = parseArgs({
841
+ args: rest,
842
+ allowPositionals: true,
843
+ options: { force: { type: "boolean" }, json: { type: "boolean" } },
844
+ });
845
+ const [id] = positionals;
846
+ if (!id) {
847
+ stdio.err("Usage: im tokens revoke <token-id> [--force]");
848
+ return EXIT.USAGE;
849
+ }
850
+ const proceed = await confirmDestructive(`This immediately and permanently revokes token ${id}; anything using it loses access.`, values.force === true, stdio, readLine);
851
+ if (!proceed)
852
+ return EXIT.USAGE;
853
+ const im = await client(env, stdio);
854
+ if (typeof im === "number")
855
+ return im;
856
+ const result = await im.request("DELETE", `/api/admin/tokens/${encodeURIComponent(id)}`);
857
+ if (values.json)
858
+ stdio.out(JSON.stringify(result.revoked, null, 2));
859
+ else
860
+ stdio.out(`Revoked token ${result.revoked.id}`);
861
+ return EXIT.OK;
862
+ }
863
+ stdio.err("Usage: im tokens <list|generate|revoke> …");
864
+ return EXIT.USAGE;
865
+ }
866
+ async function runHooks(args, env, stdio, readStdin) {
867
+ if (maybeCommandHelp("hooks", args, stdio))
868
+ return EXIT.OK;
869
+ const [action, target] = args;
870
+ if (action === "claude-session-end") {
871
+ // Invoked by Claude Code, never interactively. Always exits 0.
872
+ return await runClaudeSessionEnd(await readStdin(), defaultSessionEndDeps(env));
873
+ }
874
+ if (action === "install" && target === "claude") {
875
+ const outcome = await installClaudeHook(env);
876
+ stdio.out(outcome === "installed"
877
+ ? "Installed the Claude Code SessionEnd hook. Sessions push automatically from now on."
878
+ : "Claude Code SessionEnd hook already installed — nothing to do.");
879
+ return EXIT.OK;
880
+ }
881
+ if (action === "uninstall" && target === "claude") {
882
+ const outcome = await uninstallClaudeHook(env);
883
+ stdio.out(outcome === "uninstalled"
884
+ ? "Removed the Claude Code SessionEnd hook."
885
+ : "No Claude Code SessionEnd hook found — nothing to do.");
886
+ return EXIT.OK;
887
+ }
888
+ stdio.err("Usage: im hooks <install|uninstall> claude");
889
+ return EXIT.USAGE;
890
+ }
891
+ function reportError(err, stdio) {
892
+ if (err instanceof ApiError) {
893
+ stdio.err(`Error (${err.code}): ${err.message}`);
894
+ if (err.status === 401 || err.status === 403)
895
+ return EXIT.AUTH;
896
+ if (err.status === 404)
897
+ return EXIT.NOT_FOUND;
898
+ if (err.status === 400 || err.status === 409 || err.status === 422)
899
+ return EXIT.VALIDATION;
900
+ return EXIT.RUNTIME;
901
+ }
902
+ stdio.err(`Error: ${err instanceof Error ? err.message : String(err)}`);
903
+ return EXIT.RUNTIME;
904
+ }
905
+ async function defaultReadStdin() {
906
+ const chunks = [];
907
+ for await (const chunk of process.stdin)
908
+ chunks.push(chunk);
909
+ return Buffer.concat(chunks).toString("utf8");
910
+ }
911
+ /**
912
+ * Read a single line from stdin — used by confirmation prompts, where
913
+ * waiting for EOF (defaultReadStdin) would hang an interactive terminal
914
+ * after the user presses Enter. Works for piped input too (`echo yes |`).
915
+ */
916
+ async function defaultReadLine() {
917
+ const rl = createInterface({ input: process.stdin });
918
+ try {
919
+ for await (const line of rl)
920
+ return line;
921
+ return "";
922
+ }
923
+ finally {
924
+ rl.close();
925
+ }
926
+ }
927
+ // Run main() only when this module is the executed entrypoint — including
928
+ // npm bin shims, where argv[1] is a symlink (im or integral-mind) that
929
+ // realpaths to this file. Importing the module (tests) never triggers it.
930
+ const isDirectRun = (() => {
931
+ if (!process.argv[1])
932
+ return false;
933
+ try {
934
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
935
+ }
936
+ catch {
937
+ return false;
938
+ }
939
+ })();
940
+ if (isDirectRun) {
941
+ main(process.argv.slice(2)).then((code) => {
942
+ process.exitCode = code;
943
+ });
944
+ }
945
+ //# sourceMappingURL=cli.js.map