@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.
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/dist/cli/main.js +702 -0
- package/dist/cli/output.js +22 -0
- package/dist/core/bootstrap.js +39 -0
- package/dist/core/config.js +131 -0
- package/dist/core/context.js +1 -0
- package/dist/core/db.js +254 -0
- package/dist/core/entry.js +84 -0
- package/dist/core/errors.js +24 -0
- package/dist/core/formatters.js +54 -0
- package/dist/core/ids.js +26 -0
- package/dist/core/import.js +34 -0
- package/dist/core/index.js +19 -0
- package/dist/core/journal.js +65 -0
- package/dist/core/log.js +49 -0
- package/dist/core/query.js +114 -0
- package/dist/core/status.js +30 -0
- package/dist/core/sync.js +94 -0
- package/dist/core/time.js +50 -0
- package/dist/mcp/server.js +149 -0
- package/package.json +59 -0
package/dist/cli/main.js
ADDED
|
@@ -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
|
+
}
|