@neethan/joa 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.
@@ -0,0 +1,702 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { parseArgs } from "node:util";
6
+ import yaml from "js-yaml";
7
+ import { ConfigError, DatabaseError, JournalWriteError, ValidationError, bootstrap, importEntries, loadConfig, log, query, rebuildIndex, resolveJournalsPath, serializeEntry, status, } from "../core/index.js";
8
+ import { bold, colorizeCompactLine, cyan, dim, green, red, yellow } from "./output.js";
9
+ // ---------------------------------------------------------------------------
10
+ // Parse args
11
+ // ---------------------------------------------------------------------------
12
+ const { values, positionals } = parseArgs({
13
+ args: process.argv.slice(2),
14
+ options: {
15
+ // Global
16
+ help: { type: "boolean", short: "h" },
17
+ version: { type: "boolean", short: "v" },
18
+ // Log / Query shared
19
+ category: { type: "string", short: "c" },
20
+ tag: { type: "string", short: "t", multiple: true },
21
+ format: { type: "string", short: "f" },
22
+ // Log specific
23
+ thread: { type: "string" },
24
+ detail: { type: "string", short: "d" },
25
+ resource: { type: "string", short: "r", multiple: true },
26
+ // Query specific
27
+ preset: { type: "string", short: "p" },
28
+ search: { type: "string", short: "s" },
29
+ since: { type: "string" },
30
+ until: { type: "string" },
31
+ limit: { type: "string", short: "n" },
32
+ session: { type: "string" },
33
+ agent: { type: "string" },
34
+ device: { type: "string" },
35
+ },
36
+ allowPositionals: true,
37
+ });
38
+ const command = positionals[0] ?? "";
39
+ const args = positionals.slice(1);
40
+ // ---------------------------------------------------------------------------
41
+ // Version / Help
42
+ // ---------------------------------------------------------------------------
43
+ function showVersion() {
44
+ try {
45
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
46
+ console.log(`joa ${pkg.version}`);
47
+ }
48
+ catch {
49
+ console.log("joa (unknown version)");
50
+ }
51
+ }
52
+ function showHelp(cmd) {
53
+ switch (cmd) {
54
+ case "log":
55
+ console.log(`Usage: joa log <summary> [options]
56
+
57
+ Log a journal entry.
58
+
59
+ Options:
60
+ -c, --category <cat> Entry category (default: "observation")
61
+ -t, --tag <tag> Tags (repeat for multiple)
62
+ --thread <id|new> Thread ID or "new" to start a thread
63
+ -d, --detail <json> JSON detail object
64
+ -r, --resource <path> Resource paths/URLs (repeat for multiple)`);
65
+ return;
66
+ case "query":
67
+ console.log(`Usage: joa query [options]
68
+
69
+ Query journal entries.
70
+
71
+ Options:
72
+ -p, --preset <name> Preset: catchup, threads, timeline, decisions, changes
73
+ -s, --search <term> Full-text search
74
+ -c, --category <cat> Category filter
75
+ -t, --tag <tag> Tag filter (repeat for multiple)
76
+ --thread <id> Thread ID filter
77
+ --session <id> Session ID filter
78
+ --agent <name> Agent filter
79
+ --device <name> Device filter
80
+ --since <time> Time filter (1d, 7d, 2w, 1m, or ISO date)
81
+ --until <time> Time upper bound
82
+ -n, --limit <num> Max entries (default: 50)
83
+ -f, --format <fmt> Output format: compact, md, json (default: compact)`);
84
+ return;
85
+ case "export":
86
+ console.log(`Usage: joa export [options] > backup.jsonl
87
+
88
+ Export entries as JSONL to stdout.
89
+
90
+ Options:
91
+ --since <time> Time filter
92
+ --until <time> Time upper bound
93
+ -c, --category <cat> Category filter
94
+ -t, --tag <tag> Tag filter`);
95
+ return;
96
+ case "import":
97
+ console.log(`Usage: joa import <file.jsonl>
98
+ cat entries.jsonl | joa import -
99
+
100
+ Import entries from a JSONL file or stdin.`);
101
+ return;
102
+ case "config":
103
+ console.log(`Usage: joa config get <key>
104
+ joa config set <key> <value>
105
+
106
+ Supported keys: device, agent, defaults.device, defaults.agent,
107
+ defaults.tags, db.path, journals.path`);
108
+ return;
109
+ case "setup":
110
+ console.log(`Usage: joa setup
111
+
112
+ Interactive setup to configure joa for your agent platforms.
113
+
114
+ Universal (always included):
115
+ Claude Code, Cursor, Gemini CLI, Codex, Amp, OpenCode
116
+
117
+ Additional (selectable):
118
+ GitHub Copilot, Pi`);
119
+ return;
120
+ default:
121
+ console.log(`${bold("joa")} — Journal of Agents
122
+
123
+ ${bold("Usage:")} joa <command> [options]
124
+
125
+ ${bold("Commands:")}
126
+ log <summary> Log a journal entry
127
+ query Query entries with filters
128
+ catchup Recent entries (last 7 days)
129
+ threads Active threads summary
130
+ timeline Chronological entries
131
+ decisions Decision entries
132
+ search <term> Full-text search
133
+ status Journal health and stats
134
+ rebuild Rebuild SQLite index from JSONL
135
+ export Export entries as JSONL
136
+ import <file> Import entries from JSONL
137
+ setup Configure joa for agent platforms
138
+ config get|set View or update configuration
139
+ mcp [--agent <n>] Start MCP stdio server
140
+
141
+ ${bold("Flags:")}
142
+ -h, --help Show help (or command-specific help)
143
+ -v, --version Show version
144
+
145
+ Run ${cyan("joa <command> --help")} for command-specific usage.`);
146
+ }
147
+ }
148
+ if (values.version) {
149
+ showVersion();
150
+ process.exit(0);
151
+ }
152
+ if (!command || values.help) {
153
+ showHelp(command || undefined);
154
+ process.exit(values.help ? 0 : 1);
155
+ }
156
+ // ---------------------------------------------------------------------------
157
+ // Command handlers
158
+ // ---------------------------------------------------------------------------
159
+ async function cmdLog(cmdArgs, vals) {
160
+ const summary = cmdArgs[0];
161
+ if (!summary) {
162
+ console.error(red("Usage: joa log <summary> [options]"));
163
+ process.exit(1);
164
+ }
165
+ let detail;
166
+ if (vals.detail) {
167
+ try {
168
+ detail = JSON.parse(vals.detail);
169
+ }
170
+ catch {
171
+ console.error(red("Invalid JSON in --detail"));
172
+ process.exit(1);
173
+ }
174
+ }
175
+ const { logCtx } = await bootstrap();
176
+ const result = await log({
177
+ summary,
178
+ category: vals.category ?? "observation",
179
+ thread_id: vals.thread,
180
+ tags: vals.tag,
181
+ detail,
182
+ resources: vals.resource,
183
+ }, logCtx);
184
+ console.log(green("Logged: ") +
185
+ result.entry_id +
186
+ (result.thread_id ? dim(` (thread: ${result.thread_id})`) : ""));
187
+ if (result.warning) {
188
+ console.error(yellow(`Warning: ${result.warning}`));
189
+ }
190
+ }
191
+ async function cmdQuery(vals) {
192
+ // Validate preset
193
+ const validPresets = ["catchup", "threads", "timeline", "decisions", "changes"];
194
+ if (vals.preset && !validPresets.includes(vals.preset)) {
195
+ console.error(red(`Invalid preset: ${vals.preset}. Valid presets: ${validPresets.join(", ")}`));
196
+ process.exit(1);
197
+ }
198
+ // Validate limit
199
+ let limit;
200
+ if (vals.limit) {
201
+ limit = Number.parseInt(vals.limit, 10);
202
+ if (Number.isNaN(limit) || limit <= 0) {
203
+ console.error(red("--limit must be a positive integer"));
204
+ process.exit(1);
205
+ }
206
+ }
207
+ // Normalize empty search
208
+ const search = vals.search?.trim() || undefined;
209
+ const { config, readCtx } = await bootstrap();
210
+ const result = query({
211
+ preset: vals.preset,
212
+ search,
213
+ category: vals.category,
214
+ tags: vals.tag,
215
+ thread_id: vals.thread,
216
+ session_id: vals.session,
217
+ agent: vals.agent,
218
+ device: vals.device,
219
+ since: vals.since,
220
+ until: vals.until,
221
+ limit,
222
+ format: vals.format ?? "compact",
223
+ }, readCtx, config);
224
+ // Colorize compact output for terminal
225
+ if (result.format === "compact" && result.rendered !== "No entries found.") {
226
+ const lines = result.rendered.split("\n").map(colorizeCompactLine);
227
+ console.log(lines.join("\n"));
228
+ }
229
+ else {
230
+ console.log(result.rendered);
231
+ }
232
+ if (result.entries.length > 0 && result.total > result.entries.length) {
233
+ console.error(dim(`Showing ${result.entries.length} of ${result.total} entries`));
234
+ }
235
+ }
236
+ async function cmdStatus() {
237
+ const { config, readCtx, sid } = await bootstrap();
238
+ const s = status(readCtx, config, sid);
239
+ const categories = Object.entries(s.entries_by_category)
240
+ .map(([cat, count]) => `${cat} (${count})`)
241
+ .join(", ");
242
+ const dbSize = s.db_size_bytes > 0 ? `${(s.db_size_bytes / 1024 / 1024).toFixed(1)} MB` : "in-memory";
243
+ console.log(`${bold("joa status")}
244
+ ${dim("Entries:")} ${s.total_entries.toLocaleString()}
245
+ ${dim("Categories:")} ${categories || "none"}
246
+ ${dim("Oldest:")} ${s.oldest_entry ?? "\u2014"}
247
+ ${dim("Newest:")} ${s.newest_entry ?? "\u2014"}
248
+ ${dim("Session:")} ${s.current_session_id}
249
+ ${dim("DB:")} ${s.db_path} (${dbSize}, ${s.db_healthy ? green("healthy") : red("unhealthy")})
250
+ ${dim("Journals:")} ${s.journals_dir} (${s.journal_files} files)`);
251
+ }
252
+ async function cmdRebuild() {
253
+ const { config, db } = await bootstrap();
254
+ const journalsDir = resolveJournalsPath(config);
255
+ console.log("Rebuilding index from JSONL files...");
256
+ await rebuildIndex(db, journalsDir);
257
+ const count = db.countEntries({});
258
+ console.log(green(`Done. Indexed ${count} entries.`));
259
+ }
260
+ async function cmdExport(vals) {
261
+ const { config, readCtx } = await bootstrap();
262
+ const result = query({
263
+ category: vals.category,
264
+ tags: vals.tag,
265
+ since: vals.since,
266
+ until: vals.until,
267
+ limit: vals.limit ? Number.parseInt(vals.limit, 10) : 10000,
268
+ format: "json",
269
+ }, readCtx, config);
270
+ for (const entry of result.entries) {
271
+ const row = serializeEntry(entry);
272
+ process.stdout.write(`${JSON.stringify(row)}\n`);
273
+ }
274
+ console.error(dim(`Exported ${result.entries.length} entries`));
275
+ }
276
+ async function cmdImport(cmdArgs) {
277
+ const file = cmdArgs[0];
278
+ if (!file) {
279
+ console.error(red("Usage: joa import <file.jsonl> or joa import -"));
280
+ process.exit(1);
281
+ }
282
+ const MAX_STDIN_BYTES = 100 * 1024 * 1024; // 100 MB
283
+ let content;
284
+ if (file === "-") {
285
+ const chunks = [];
286
+ let totalBytes = 0;
287
+ for await (const chunk of process.stdin) {
288
+ totalBytes += chunk.length;
289
+ if (totalBytes > MAX_STDIN_BYTES) {
290
+ console.error(red(`Stdin exceeds ${MAX_STDIN_BYTES / 1024 / 1024} MB limit`));
291
+ process.exit(1);
292
+ }
293
+ chunks.push(chunk);
294
+ }
295
+ content = Buffer.concat(chunks).toString("utf8");
296
+ }
297
+ else {
298
+ try {
299
+ content = readFileSync(file, "utf8");
300
+ }
301
+ catch {
302
+ console.error(red(`Cannot read file: ${file}`));
303
+ process.exit(1);
304
+ }
305
+ }
306
+ const lines = content.split("\n").filter((l) => l.trim().length > 0);
307
+ if (lines.length === 0) {
308
+ console.log("No entries to import");
309
+ return;
310
+ }
311
+ const { logCtx } = await bootstrap();
312
+ const result = await importEntries(lines, logCtx.db, logCtx.journalsDir);
313
+ console.log(green(`Imported ${result.imported} entries`) +
314
+ (result.skipped > 0 ? dim(` (${result.skipped} skipped as duplicates)`) : "") +
315
+ (result.malformed > 0 ? yellow(` (${result.malformed} malformed)`) : ""));
316
+ }
317
+ function writeJsonMcpServers(configPath, serverEntry, rootKey = "mcpServers") {
318
+ const dir = dirname(configPath);
319
+ if (!existsSync(dir))
320
+ mkdirSync(dir, { recursive: true });
321
+ let existing = {};
322
+ if (existsSync(configPath)) {
323
+ try {
324
+ existing = JSON.parse(readFileSync(configPath, "utf8"));
325
+ }
326
+ catch {
327
+ console.warn(yellow(`Warning: ${configPath} contains invalid JSON and will be overwritten`));
328
+ }
329
+ }
330
+ const merged = {
331
+ ...existing,
332
+ [rootKey]: {
333
+ ...existing[rootKey],
334
+ ...serverEntry,
335
+ },
336
+ };
337
+ writeFileSync(configPath, JSON.stringify(merged, null, 2));
338
+ }
339
+ function standardWriter(configPath, agentName) {
340
+ writeJsonMcpServers(configPath, {
341
+ joa: { command: "joa", args: ["mcp", "--agent", agentName] },
342
+ });
343
+ }
344
+ const AGENTS = {
345
+ // --- Universal ---
346
+ "claude-code": {
347
+ label: "Claude Code",
348
+ tier: "universal",
349
+ globalPath: (home) => join(home, ".claude.json"),
350
+ localPath: (cwd) => join(cwd, ".mcp.json"),
351
+ writeConfig: standardWriter,
352
+ },
353
+ cursor: {
354
+ label: "Cursor",
355
+ tier: "universal",
356
+ globalPath: (home) => join(home, ".cursor", "mcp.json"),
357
+ localPath: (cwd) => join(cwd, ".cursor", "mcp.json"),
358
+ writeConfig: standardWriter,
359
+ },
360
+ "gemini-cli": {
361
+ label: "Gemini CLI",
362
+ tier: "universal",
363
+ globalPath: (home) => join(home, ".gemini", "settings.json"),
364
+ localPath: (cwd) => join(cwd, ".gemini", "settings.json"),
365
+ writeConfig: standardWriter,
366
+ },
367
+ codex: {
368
+ label: "Codex",
369
+ tier: "universal",
370
+ globalPath: (home) => join(home, ".codex", "config.toml"),
371
+ localPath: (cwd) => join(cwd, ".codex", "config.toml"),
372
+ writeConfig: (configPath, agentName) => {
373
+ const dir = dirname(configPath);
374
+ if (!existsSync(dir))
375
+ mkdirSync(dir, { recursive: true });
376
+ // Codex uses TOML. Read existing, append/replace the [mcp_servers.joa] section.
377
+ let existing = "";
378
+ if (existsSync(configPath)) {
379
+ try {
380
+ existing = readFileSync(configPath, "utf8");
381
+ }
382
+ catch {
383
+ console.warn(yellow(`Warning: ${configPath} is unreadable and will be overwritten`));
384
+ }
385
+ }
386
+ // Remove existing [mcp_servers.joa] block if present
387
+ const cleaned = existing.replace(/\[mcp_servers\.joa\][^\[]*(?=\[|$)/s, "").trimEnd();
388
+ const block = `\n\n[mcp_servers.joa]\ncommand = "joa"\nargs = ["mcp", "--agent", "${agentName}"]\n`;
389
+ writeFileSync(configPath, cleaned + block);
390
+ },
391
+ },
392
+ amp: {
393
+ label: "Amp",
394
+ tier: "universal",
395
+ globalPath: (home) => join(home, ".config", "amp", "settings.json"),
396
+ localPath: (cwd) => join(cwd, ".amp", "settings.json"),
397
+ writeConfig: (configPath, agentName) => {
398
+ writeJsonMcpServers(configPath, { joa: { command: "joa", args: ["mcp", "--agent", agentName] } }, "amp.mcpServers");
399
+ },
400
+ },
401
+ opencode: {
402
+ label: "OpenCode",
403
+ tier: "universal",
404
+ globalPath: (home) => join(home, ".config", "opencode", "opencode.json"),
405
+ localPath: (cwd) => join(cwd, "opencode.json"),
406
+ writeConfig: (configPath, agentName) => {
407
+ const dir = dirname(configPath);
408
+ if (!existsSync(dir))
409
+ mkdirSync(dir, { recursive: true });
410
+ let existing = {};
411
+ if (existsSync(configPath)) {
412
+ try {
413
+ existing = JSON.parse(readFileSync(configPath, "utf8"));
414
+ }
415
+ catch {
416
+ console.warn(yellow(`Warning: ${configPath} contains invalid JSON and will be overwritten`));
417
+ }
418
+ }
419
+ const mcp = existing.mcp ?? {};
420
+ const merged = {
421
+ ...existing,
422
+ mcp: {
423
+ ...mcp,
424
+ joa: {
425
+ type: "local",
426
+ command: ["joa", "mcp", "--agent", agentName],
427
+ },
428
+ },
429
+ };
430
+ writeFileSync(configPath, JSON.stringify(merged, null, 2));
431
+ },
432
+ },
433
+ // --- Additional ---
434
+ "github-copilot": {
435
+ label: "GitHub Copilot",
436
+ tier: "additional",
437
+ globalPath: (_home) => {
438
+ if (process.platform === "darwin")
439
+ return join(homedir(), "Library", "Application Support", "Code", "User", "mcp.json");
440
+ if (process.platform === "win32")
441
+ return join(process.env.APPDATA ?? homedir(), "Code", "User", "mcp.json");
442
+ return join(homedir(), ".config", "Code", "User", "mcp.json");
443
+ },
444
+ localPath: (cwd) => join(cwd, ".vscode", "mcp.json"),
445
+ writeConfig: (configPath, agentName) => {
446
+ writeJsonMcpServers(configPath, {
447
+ joa: {
448
+ type: "stdio",
449
+ command: "joa",
450
+ args: ["mcp", "--agent", agentName],
451
+ },
452
+ }, "servers");
453
+ },
454
+ },
455
+ pi: {
456
+ label: "Pi",
457
+ tier: "additional",
458
+ globalPath: (home) => join(home, ".pi", "mcp.json"),
459
+ localPath: (cwd) => join(cwd, ".pi", "mcp.json"),
460
+ writeConfig: standardWriter,
461
+ },
462
+ };
463
+ const UNIVERSAL_AGENTS = Object.entries(AGENTS)
464
+ .filter(([, def]) => def.tier === "universal")
465
+ .map(([id]) => id);
466
+ const ADDITIONAL_AGENTS = Object.entries(AGENTS)
467
+ .filter(([, def]) => def.tier === "additional")
468
+ .map(([id, def]) => ({ value: id, label: def.label }));
469
+ // ---------------------------------------------------------------------------
470
+ // joa setup
471
+ // ---------------------------------------------------------------------------
472
+ async function cmdSetup() {
473
+ const { intro, outro, note, multiselect, select, confirm, isCancel, cancel } = await import("@clack/prompts");
474
+ intro(bold("joa setup"));
475
+ const universalLabels = UNIVERSAL_AGENTS.map((id) => ` \u2022 ${AGENTS[id]?.label}`).join("\n");
476
+ note(universalLabels, "Universal agents (always included)");
477
+ const additional = await multiselect({
478
+ message: "Select additional agents (Enter to skip)",
479
+ options: ADDITIONAL_AGENTS,
480
+ required: false,
481
+ });
482
+ if (isCancel(additional)) {
483
+ cancel("Setup cancelled.");
484
+ process.exit(0);
485
+ }
486
+ const allAgents = [...UNIVERSAL_AGENTS, ...additional];
487
+ const scope = await select({
488
+ message: "Installation scope?",
489
+ options: [
490
+ {
491
+ value: "global",
492
+ label: "Global",
493
+ hint: "user-level config files",
494
+ },
495
+ { value: "local", label: "Local", hint: "project-level config files" },
496
+ ],
497
+ });
498
+ if (isCancel(scope)) {
499
+ cancel("Setup cancelled.");
500
+ process.exit(0);
501
+ }
502
+ const agentList = allAgents.map((id) => AGENTS[id]?.label ?? id).join(", ");
503
+ const proceed = await confirm({
504
+ message: `Configure joa for ${agentList} (${scope})?`,
505
+ });
506
+ if (isCancel(proceed) || !proceed) {
507
+ cancel("Setup cancelled.");
508
+ process.exit(0);
509
+ }
510
+ // Ensure ~/.joa directory structure
511
+ const joaDir = join(homedir(), ".joa");
512
+ const journalsDir = join(joaDir, "journals");
513
+ if (!existsSync(joaDir))
514
+ mkdirSync(joaDir, { recursive: true });
515
+ if (!existsSync(journalsDir))
516
+ mkdirSync(journalsDir, { recursive: true });
517
+ const home = homedir();
518
+ const cwd = process.cwd();
519
+ for (const agentId of allAgents) {
520
+ const def = AGENTS[agentId];
521
+ if (!def)
522
+ continue;
523
+ const configPath = scope === "local" ? def.localPath(cwd) : def.globalPath(home);
524
+ def.writeConfig(configPath, agentId);
525
+ console.log(green(` \u2713 ${def.label}`) + dim(` \u2192 ${configPath}`));
526
+ }
527
+ outro(green("Done! joa is ready."));
528
+ }
529
+ function cmdConfigGet(key) {
530
+ if (!key) {
531
+ console.error(red("Usage: joa config get <key>"));
532
+ process.exit(1);
533
+ }
534
+ const config = loadConfig();
535
+ // Resolve aliases
536
+ const resolvedKey = key === "device" ? "defaults.device" : key === "agent" ? "defaults.agent" : key;
537
+ const parts = resolvedKey.split(".");
538
+ let value = config;
539
+ for (const part of parts) {
540
+ if (value === null || value === undefined || typeof value !== "object") {
541
+ console.error(red(`Unknown config key: ${key}`));
542
+ process.exit(1);
543
+ }
544
+ value = value[part];
545
+ }
546
+ if (value === undefined) {
547
+ console.error(red(`Unknown config key: ${key}`));
548
+ process.exit(1);
549
+ }
550
+ console.log(typeof value === "string" ? value : JSON.stringify(value, null, 2));
551
+ }
552
+ async function cmdConfigSet(key, val) {
553
+ if (!key || val === undefined) {
554
+ console.error(red("Usage: joa config set <key> <value>"));
555
+ process.exit(1);
556
+ }
557
+ const joaDir = join(homedir(), ".joa");
558
+ const configPath = join(joaDir, "config.yaml");
559
+ if (!existsSync(joaDir))
560
+ mkdirSync(joaDir, { recursive: true });
561
+ let config = {};
562
+ if (existsSync(configPath)) {
563
+ try {
564
+ const raw = yaml.load(readFileSync(configPath, "utf8"));
565
+ if (raw && typeof raw === "object")
566
+ config = raw;
567
+ }
568
+ catch {
569
+ // Malformed config — start fresh
570
+ }
571
+ }
572
+ // Resolve aliases
573
+ const resolvedKey = key === "device" ? "defaults.device" : key === "agent" ? "defaults.agent" : key;
574
+ const validTopKeys = ["defaults", "db", "journals", "presets"];
575
+ const topKey = resolvedKey.split(".")[0];
576
+ if (topKey && !validTopKeys.includes(topKey)) {
577
+ console.error(red(`Unknown config key: ${key}. Valid keys: ${validTopKeys.join(", ")}`));
578
+ process.exit(1);
579
+ }
580
+ // Parse value — detect JSON
581
+ let parsed = val;
582
+ if (val.startsWith("[") ||
583
+ val.startsWith("{") ||
584
+ val === "true" ||
585
+ val === "false" ||
586
+ val === "null") {
587
+ try {
588
+ parsed = JSON.parse(val);
589
+ }
590
+ catch {
591
+ // Keep as string
592
+ }
593
+ }
594
+ // Set nested key
595
+ const parts = resolvedKey.split(".");
596
+ let obj = config;
597
+ for (let i = 0; i < parts.length - 1; i++) {
598
+ const part = parts[i];
599
+ if (part === undefined)
600
+ break;
601
+ if (typeof obj[part] !== "object" || obj[part] === null) {
602
+ obj[part] = {};
603
+ }
604
+ obj = obj[part];
605
+ }
606
+ const lastKey = parts[parts.length - 1];
607
+ if (lastKey !== undefined)
608
+ obj[lastKey] = parsed;
609
+ writeFileSync(configPath, yaml.dump(config));
610
+ console.log(green(`Set ${key} = ${typeof parsed === "string" ? parsed : JSON.stringify(parsed)}`));
611
+ }
612
+ // ---------------------------------------------------------------------------
613
+ // Dispatch
614
+ // ---------------------------------------------------------------------------
615
+ const v = values;
616
+ try {
617
+ switch (command) {
618
+ case "log":
619
+ await cmdLog(args, v);
620
+ break;
621
+ case "query":
622
+ await cmdQuery(v);
623
+ break;
624
+ case "catchup":
625
+ await cmdQuery({ ...v, preset: "catchup" });
626
+ break;
627
+ case "threads":
628
+ await cmdQuery({ ...v, preset: "threads" });
629
+ break;
630
+ case "timeline":
631
+ await cmdQuery({ ...v, preset: "timeline" });
632
+ break;
633
+ case "decisions":
634
+ await cmdQuery({ ...v, preset: "decisions" });
635
+ break;
636
+ case "search":
637
+ await cmdQuery({ ...v, search: args.join(" ") });
638
+ break;
639
+ case "status":
640
+ await cmdStatus();
641
+ break;
642
+ case "rebuild":
643
+ await cmdRebuild();
644
+ break;
645
+ case "export":
646
+ await cmdExport(v);
647
+ break;
648
+ case "import":
649
+ await cmdImport(args);
650
+ break;
651
+ case "setup":
652
+ await cmdSetup();
653
+ break;
654
+ case "config": {
655
+ switch (args[0]) {
656
+ case "get":
657
+ cmdConfigGet(args[1]);
658
+ break;
659
+ case "set":
660
+ await cmdConfigSet(args[1], args[2]);
661
+ break;
662
+ default:
663
+ showHelp("config");
664
+ process.exit(1);
665
+ }
666
+ break;
667
+ }
668
+ case "mcp":
669
+ if (values.agent)
670
+ process.env.JOA_MCP_AGENT = values.agent;
671
+ await import("../mcp/server.js");
672
+ break;
673
+ default:
674
+ console.error(red(`Unknown command: ${command}`));
675
+ showHelp();
676
+ process.exit(1);
677
+ }
678
+ }
679
+ catch (err) {
680
+ if (err instanceof ValidationError) {
681
+ console.error(red(err.message));
682
+ process.exit(1);
683
+ }
684
+ if (err instanceof DatabaseError) {
685
+ console.error(red(`Database error: ${err.message}`));
686
+ console.error(dim("Try running: joa rebuild"));
687
+ process.exit(2);
688
+ }
689
+ if (err instanceof JournalWriteError) {
690
+ console.error(red(`Write error: ${err.message}`));
691
+ console.error(dim("Check disk space and file permissions for your journals directory."));
692
+ process.exit(3);
693
+ }
694
+ if (err instanceof ConfigError) {
695
+ console.error(red(`Config error: ${err.message}`));
696
+ console.error(dim("Check your config file: ~/.joa/config.yaml or .joa.yaml"));
697
+ process.exit(4);
698
+ }
699
+ const message = err instanceof Error ? err.message : String(err);
700
+ console.error(red(`Error: ${message}`));
701
+ process.exit(1);
702
+ }