@forwardimpact/basecamp 0.3.0 → 2.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/basecamp.js DELETED
@@ -1,660 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // Basecamp — CLI and scheduler for personal knowledge bases.
4
- //
5
- // Usage:
6
- // node basecamp.js Run due tasks once and exit
7
- // node basecamp.js --daemon Run continuously (poll every 60s)
8
- // node basecamp.js --run <task> Run a specific task immediately
9
- // node basecamp.js --init <path> Initialize a new knowledge base
10
- // node basecamp.js --validate Validate agents and skills exist
11
- // node basecamp.js --status Show task status
12
- // node basecamp.js --help Show this help
13
-
14
- import {
15
- readFileSync,
16
- writeFileSync,
17
- existsSync,
18
- mkdirSync,
19
- readdirSync,
20
- unlinkSync,
21
- chmodSync,
22
- } from "node:fs";
23
- import { execSync } from "node:child_process";
24
- import { spawn } from "node:child_process";
25
- import { join, dirname, resolve } from "node:path";
26
- import { homedir } from "node:os";
27
- import { fileURLToPath } from "node:url";
28
- import { createServer } from "node:net";
29
-
30
- const HOME = homedir();
31
- const BASECAMP_HOME = join(HOME, ".fit", "basecamp");
32
- const CONFIG_PATH = join(BASECAMP_HOME, "scheduler.json");
33
- const STATE_PATH = join(BASECAMP_HOME, "state.json");
34
- const LOG_DIR = join(BASECAMP_HOME, "logs");
35
- const __dirname =
36
- import.meta.dirname || dirname(fileURLToPath(import.meta.url));
37
- const KB_TEMPLATE_DIR = join(__dirname, "template");
38
- const SOCKET_PATH = join(BASECAMP_HOME, "basecamp.sock");
39
-
40
- let daemonStartedAt = null;
41
-
42
- // --- Helpers ----------------------------------------------------------------
43
-
44
- function ensureDir(dir) {
45
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
46
- }
47
-
48
- function readJSON(path, fallback) {
49
- try {
50
- return JSON.parse(readFileSync(path, "utf8"));
51
- } catch {
52
- return fallback;
53
- }
54
- }
55
-
56
- function writeJSON(path, data) {
57
- ensureDir(dirname(path));
58
- writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
59
- }
60
-
61
- function expandPath(p) {
62
- return p.startsWith("~/") ? join(HOME, p.slice(2)) : resolve(p);
63
- }
64
-
65
- function log(msg) {
66
- const ts = new Date().toISOString();
67
- const line = `[${ts}] ${msg}`;
68
- console.log(line);
69
- try {
70
- ensureDir(LOG_DIR);
71
- writeFileSync(
72
- join(LOG_DIR, `scheduler-${ts.slice(0, 10)}.log`),
73
- line + "\n",
74
- { flag: "a" },
75
- );
76
- } catch {
77
- /* best effort */
78
- }
79
- }
80
-
81
- function findClaude() {
82
- for (const c of [
83
- "claude",
84
- "/usr/local/bin/claude",
85
- join(HOME, ".claude", "bin", "claude"),
86
- join(HOME, ".local", "bin", "claude"),
87
- ]) {
88
- try {
89
- execSync(`which "${c}" 2>/dev/null || command -v "${c}" 2>/dev/null`, {
90
- encoding: "utf8",
91
- });
92
- return c;
93
- } catch {}
94
- if (existsSync(c)) return c;
95
- }
96
- return "claude";
97
- }
98
-
99
- function loadConfig() {
100
- return readJSON(CONFIG_PATH, { tasks: {} });
101
- }
102
- function loadState() {
103
- const raw = readJSON(STATE_PATH, null);
104
- if (!raw || typeof raw !== "object" || !raw.tasks) {
105
- const state = { tasks: {} };
106
- saveState(state);
107
- return state;
108
- }
109
- return raw;
110
- }
111
- function saveState(state) {
112
- writeJSON(STATE_PATH, state);
113
- }
114
-
115
- // --- Cron matching ----------------------------------------------------------
116
-
117
- function matchField(field, value) {
118
- if (field === "*") return true;
119
- if (field.startsWith("*/")) return value % parseInt(field.slice(2)) === 0;
120
- return field.split(",").some((part) => {
121
- if (part.includes("-")) {
122
- const [lo, hi] = part.split("-").map(Number);
123
- return value >= lo && value <= hi;
124
- }
125
- return parseInt(part) === value;
126
- });
127
- }
128
-
129
- function cronMatches(expr, d) {
130
- const [min, hour, dom, month, dow] = expr.trim().split(/\s+/);
131
- return (
132
- matchField(min, d.getMinutes()) &&
133
- matchField(hour, d.getHours()) &&
134
- matchField(dom, d.getDate()) &&
135
- matchField(month, d.getMonth() + 1) &&
136
- matchField(dow, d.getDay())
137
- );
138
- }
139
-
140
- // --- Scheduling logic -------------------------------------------------------
141
-
142
- function floorToMinute(d) {
143
- return new Date(
144
- d.getFullYear(),
145
- d.getMonth(),
146
- d.getDate(),
147
- d.getHours(),
148
- d.getMinutes(),
149
- ).getTime();
150
- }
151
-
152
- function shouldRun(task, taskState, now) {
153
- if (task.enabled === false) return false;
154
- if (taskState.status === "running") return false;
155
- const { schedule } = task;
156
- if (!schedule) return false;
157
- const lastRun = taskState.lastRunAt ? new Date(taskState.lastRunAt) : null;
158
-
159
- if (schedule.type === "cron") {
160
- if (lastRun && floorToMinute(lastRun) === floorToMinute(now)) return false;
161
- return cronMatches(schedule.expression, now);
162
- }
163
- if (schedule.type === "interval") {
164
- const ms = (schedule.minutes || 5) * 60_000;
165
- return !lastRun || now.getTime() - lastRun.getTime() >= ms;
166
- }
167
- if (schedule.type === "once") {
168
- return !taskState.lastRunAt && now >= new Date(schedule.runAt);
169
- }
170
- return false;
171
- }
172
-
173
- // --- Task execution ---------------------------------------------------------
174
-
175
- function runTask(taskName, task, _config, state) {
176
- if (!task.kb) {
177
- log(`Task ${taskName}: no "kb" specified, skipping.`);
178
- return;
179
- }
180
- const kbPath = expandPath(task.kb);
181
- if (!existsSync(kbPath)) {
182
- log(`Task ${taskName}: path "${kbPath}" does not exist, skipping.`);
183
- return;
184
- }
185
-
186
- const claude = findClaude();
187
- const prompt = task.skill
188
- ? `Use the skill "${task.skill}" — ${task.prompt || `Run the ${taskName} task.`}`
189
- : task.prompt || `Run the ${taskName} task.`;
190
-
191
- log(
192
- `Running task: ${taskName} (kb: ${task.kb}${task.agent ? `, agent: ${task.agent}` : ""}${task.skill ? `, skill: ${task.skill}` : ""})`,
193
- );
194
-
195
- const ts = (state.tasks[taskName] ||= {});
196
- ts.status = "running";
197
- ts.startedAt = new Date().toISOString();
198
- saveState(state);
199
-
200
- const spawnArgs = ["--print"];
201
- if (task.agent) spawnArgs.push("--agent", task.agent);
202
- spawnArgs.push("-p", prompt);
203
-
204
- return new Promise((resolve) => {
205
- const child = spawn(claude, spawnArgs, {
206
- cwd: kbPath,
207
- stdio: ["pipe", "pipe", "pipe"],
208
- timeout: 30 * 60_000,
209
- });
210
-
211
- let stdout = "";
212
- let stderr = "";
213
- child.stdout.on("data", (d) => (stdout += d));
214
- child.stderr.on("data", (d) => (stderr += d));
215
-
216
- child.on("close", (code) => {
217
- if (code === 0) {
218
- log(`Task ${taskName} completed. Output: ${stdout.slice(0, 200)}...`);
219
- Object.assign(ts, {
220
- status: "finished",
221
- startedAt: null,
222
- lastRunAt: new Date().toISOString(),
223
- lastError: null,
224
- runCount: (ts.runCount || 0) + 1,
225
- });
226
- } else {
227
- const errMsg = stderr || stdout || `Exit code ${code}`;
228
- log(`Task ${taskName} failed: ${errMsg.slice(0, 300)}`);
229
- Object.assign(ts, {
230
- status: "failed",
231
- startedAt: null,
232
- lastRunAt: new Date().toISOString(),
233
- lastError: errMsg.slice(0, 500),
234
- });
235
- }
236
- saveState(state);
237
- resolve();
238
- });
239
-
240
- child.on("error", (err) => {
241
- log(`Task ${taskName} failed: ${err.message}`);
242
- Object.assign(ts, {
243
- status: "failed",
244
- startedAt: null,
245
- lastRunAt: new Date().toISOString(),
246
- lastError: err.message.slice(0, 500),
247
- });
248
- saveState(state);
249
- resolve();
250
- });
251
- });
252
- }
253
-
254
- async function runDueTasks() {
255
- const config = loadConfig(),
256
- state = loadState(),
257
- now = new Date();
258
- let ranAny = false;
259
- for (const [name, task] of Object.entries(config.tasks)) {
260
- if (shouldRun(task, state.tasks[name] || {}, now)) {
261
- await runTask(name, task, config, state);
262
- ranAny = true;
263
- }
264
- }
265
- if (!ranAny) log("No tasks due.");
266
- }
267
-
268
- // --- Next-run computation ---------------------------------------------------
269
-
270
- /** @param {object} task @param {object} taskState @param {Date} now */
271
- function computeNextRunAt(task, taskState, now) {
272
- if (task.enabled === false) return null;
273
- const { schedule } = task;
274
- if (!schedule) return null;
275
-
276
- if (schedule.type === "interval") {
277
- const ms = (schedule.minutes || 5) * 60_000;
278
- const lastRun = taskState.lastRunAt ? new Date(taskState.lastRunAt) : null;
279
- if (!lastRun) return now.toISOString();
280
- return new Date(lastRun.getTime() + ms).toISOString();
281
- }
282
-
283
- if (schedule.type === "cron") {
284
- const limit = 24 * 60;
285
- const start = new Date(floorToMinute(now) + 60_000);
286
- for (let i = 0; i < limit; i++) {
287
- const candidate = new Date(start.getTime() + i * 60_000);
288
- if (cronMatches(schedule.expression, candidate)) {
289
- return candidate.toISOString();
290
- }
291
- }
292
- return null;
293
- }
294
-
295
- if (schedule.type === "once") {
296
- if (taskState.lastRunAt) return null;
297
- return schedule.runAt;
298
- }
299
-
300
- return null;
301
- }
302
-
303
- // --- Socket server ----------------------------------------------------------
304
-
305
- /** @param {import('node:net').Socket} socket @param {object} data */
306
- function send(socket, data) {
307
- try {
308
- socket.write(JSON.stringify(data) + "\n");
309
- } catch {}
310
- }
311
-
312
- function handleStatusRequest(socket) {
313
- const config = loadConfig();
314
- const state = loadState();
315
- const now = new Date();
316
- const tasks = {};
317
-
318
- for (const [name, task] of Object.entries(config.tasks)) {
319
- const ts = state.tasks[name] || {};
320
- tasks[name] = {
321
- enabled: task.enabled !== false,
322
- status: ts.status || "never-run",
323
- lastRunAt: ts.lastRunAt || null,
324
- nextRunAt: computeNextRunAt(task, ts, now),
325
- runCount: ts.runCount || 0,
326
- lastError: ts.lastError || null,
327
- };
328
- if (ts.startedAt) tasks[name].startedAt = ts.startedAt;
329
- }
330
-
331
- send(socket, {
332
- type: "status",
333
- uptime: daemonStartedAt
334
- ? Math.floor((Date.now() - daemonStartedAt) / 1000)
335
- : 0,
336
- tasks,
337
- });
338
- }
339
-
340
- function handleRestartRequest(socket) {
341
- send(socket, { type: "ack", command: "restart" });
342
- setTimeout(() => process.exit(0), 100);
343
- }
344
-
345
- function handleRunRequest(socket, taskName) {
346
- if (!taskName) {
347
- send(socket, { type: "error", message: "Missing task name" });
348
- return;
349
- }
350
- const config = loadConfig();
351
- const task = config.tasks[taskName];
352
- if (!task) {
353
- send(socket, { type: "error", message: `Task not found: ${taskName}` });
354
- return;
355
- }
356
- send(socket, { type: "ack", command: "run", task: taskName });
357
- const state = loadState();
358
- runTask(taskName, task, config, state).catch((err) => {
359
- console.error(`[socket] runTask error for ${taskName}:`, err.message);
360
- });
361
- }
362
-
363
- function handleMessage(socket, line) {
364
- let request;
365
- try {
366
- request = JSON.parse(line);
367
- } catch {
368
- send(socket, { type: "error", message: "Invalid JSON" });
369
- return;
370
- }
371
-
372
- const handlers = {
373
- status: () => handleStatusRequest(socket),
374
- restart: () => handleRestartRequest(socket),
375
- run: () => handleRunRequest(socket, request.task),
376
- };
377
-
378
- const handler = handlers[request.type];
379
- if (handler) {
380
- handler();
381
- } else {
382
- send(socket, {
383
- type: "error",
384
- message: `Unknown request type: ${request.type}`,
385
- });
386
- }
387
- }
388
-
389
- function startSocketServer() {
390
- try {
391
- unlinkSync(SOCKET_PATH);
392
- } catch {}
393
-
394
- const server = createServer((socket) => {
395
- let buffer = "";
396
- socket.on("data", (data) => {
397
- buffer += data.toString();
398
- let idx;
399
- while ((idx = buffer.indexOf("\n")) !== -1) {
400
- const line = buffer.slice(0, idx).trim();
401
- buffer = buffer.slice(idx + 1);
402
- if (line) handleMessage(socket, line);
403
- }
404
- });
405
- socket.on("error", () => {});
406
- });
407
-
408
- server.listen(SOCKET_PATH, () => {
409
- chmodSync(SOCKET_PATH, 0o600);
410
- log(`Socket server listening on ${SOCKET_PATH}`);
411
- });
412
-
413
- server.on("error", (err) => {
414
- log(`Socket server error: ${err.message}`);
415
- });
416
-
417
- const cleanup = () => {
418
- server.close();
419
- try {
420
- unlinkSync(SOCKET_PATH);
421
- } catch {}
422
- process.exit(0);
423
- };
424
- process.on("SIGTERM", cleanup);
425
- process.on("SIGINT", cleanup);
426
-
427
- return server;
428
- }
429
-
430
- // --- Daemon -----------------------------------------------------------------
431
-
432
- function daemon() {
433
- daemonStartedAt = Date.now();
434
- log("Scheduler daemon started. Polling every 60 seconds.");
435
- log(`Config: ${CONFIG_PATH} State: ${STATE_PATH}`);
436
- startSocketServer();
437
- runDueTasks();
438
- setInterval(async () => {
439
- try {
440
- await runDueTasks();
441
- } catch (err) {
442
- log(`Error: ${err.message}`);
443
- }
444
- }, 60_000);
445
- }
446
-
447
- // --- Init knowledge base ----------------------------------------------------
448
-
449
- function copyDirRecursive(src, dest) {
450
- for (const entry of readdirSync(src, { withFileTypes: true })) {
451
- const s = join(src, entry.name),
452
- d = join(dest, entry.name);
453
- if (entry.isDirectory()) {
454
- ensureDir(d);
455
- copyDirRecursive(s, d);
456
- } else if (!existsSync(d)) {
457
- writeFileSync(d, readFileSync(s));
458
- }
459
- }
460
- }
461
-
462
- function initKB(targetPath) {
463
- const dest = expandPath(targetPath);
464
- if (existsSync(join(dest, "CLAUDE.md"))) {
465
- console.error(`Knowledge base already exists at ${dest}`);
466
- process.exit(1);
467
- }
468
-
469
- ensureDir(dest);
470
- for (const d of [
471
- "knowledge/People",
472
- "knowledge/Organizations",
473
- "knowledge/Projects",
474
- "knowledge/Topics",
475
- ".claude/skills",
476
- ])
477
- ensureDir(join(dest, d));
478
-
479
- if (existsSync(KB_TEMPLATE_DIR)) copyDirRecursive(KB_TEMPLATE_DIR, dest);
480
-
481
- console.log(
482
- `Knowledge base initialized at ${dest}\n\nNext steps:\n 1. Edit ${dest}/USER.md with your name, email, and domain\n 2. cd ${dest} && claude`,
483
- );
484
- }
485
-
486
- // --- Status -----------------------------------------------------------------
487
-
488
- function showStatus() {
489
- const config = loadConfig(),
490
- state = loadState();
491
- console.log("\nBasecamp Scheduler\n==================\n");
492
-
493
- const tasks = Object.entries(config.tasks || {});
494
- if (tasks.length === 0) {
495
- console.log(
496
- `Tasks: (none configured)\n\nEdit ${CONFIG_PATH} to add tasks.`,
497
- );
498
- return;
499
- }
500
-
501
- console.log("Tasks:");
502
- for (const [name, task] of tasks) {
503
- const s = state.tasks[name] || {};
504
- const kbPath = task.kb ? expandPath(task.kb) : null;
505
- const kbStatus = kbPath ? (existsSync(kbPath) ? "" : " (not found)") : "";
506
- const lines = [
507
- ` ${task.enabled !== false ? "+" : "-"} ${name}`,
508
- ` KB: ${task.kb || "(none)"}${kbStatus} Schedule: ${JSON.stringify(task.schedule)}`,
509
- ` Status: ${s.status || "never-run"} Last run: ${s.lastRunAt ? new Date(s.lastRunAt).toLocaleString() : "never"} Runs: ${s.runCount || 0}`,
510
- ];
511
- if (task.agent) lines.push(` Agent: ${task.agent}`);
512
- if (task.skill) lines.push(` Skill: ${task.skill}`);
513
- if (s.lastError) lines.push(` Error: ${s.lastError.slice(0, 80)}`);
514
- console.log(lines.join("\n"));
515
- }
516
- }
517
-
518
- // --- Validate ---------------------------------------------------------------
519
-
520
- function findInLocalOrGlobal(kbPath, subPath) {
521
- const local = join(kbPath, ".claude", subPath);
522
- const global = join(HOME, ".claude", subPath);
523
- if (existsSync(local)) return local;
524
- if (existsSync(global)) return global;
525
- return null;
526
- }
527
-
528
- function validate() {
529
- const config = loadConfig();
530
- const tasks = Object.entries(config.tasks || {});
531
- if (tasks.length === 0) {
532
- console.log("No tasks configured. Nothing to validate.");
533
- return;
534
- }
535
-
536
- console.log("\nValidating tasks...\n");
537
- let errors = 0;
538
-
539
- for (const [name, task] of tasks) {
540
- if (!task.kb) {
541
- console.log(` [FAIL] ${name}: no "kb" path specified`);
542
- errors++;
543
- continue;
544
- }
545
- const kbPath = expandPath(task.kb);
546
- if (!existsSync(kbPath)) {
547
- console.log(` [FAIL] ${name}: path does not exist: ${kbPath}`);
548
- errors++;
549
- continue;
550
- }
551
-
552
- for (const [kind, sub] of [
553
- ["agent", task.agent],
554
- ["skill", task.skill],
555
- ]) {
556
- if (!sub) continue;
557
- const relPath =
558
- kind === "agent"
559
- ? join("agents", sub.endsWith(".md") ? sub : sub + ".md")
560
- : join("skills", sub, "SKILL.md");
561
- const found = findInLocalOrGlobal(kbPath, relPath);
562
- if (found) {
563
- console.log(` [OK] ${name}: ${kind} "${sub}" found at ${found}`);
564
- } else {
565
- console.log(
566
- ` [FAIL] ${name}: ${kind} "${sub}" not found in ${join(kbPath, ".claude", relPath)} or ${join(HOME, ".claude", relPath)}`,
567
- );
568
- errors++;
569
- }
570
- }
571
-
572
- if (!task.agent && !task.skill)
573
- console.log(` [OK] ${name}: no agent or skill to validate`);
574
- }
575
-
576
- console.log(
577
- errors > 0
578
- ? `\nValidation failed: ${errors} error(s) found.`
579
- : "\nAll tasks validated successfully.",
580
- );
581
- if (errors > 0) process.exit(1);
582
- }
583
-
584
- // --- Help -------------------------------------------------------------------
585
-
586
- function showHelp() {
587
- const bin = "fit-basecamp";
588
- console.log(`
589
- Basecamp Scheduler — Run scheduled tasks across multiple knowledge bases.
590
-
591
- Usage:
592
- ${bin} Run due tasks once and exit
593
- ${bin} --daemon Run continuously (poll every 60s)
594
- ${bin} --run <task> Run a specific task immediately
595
- ${bin} --init <path> Initialize a new knowledge base
596
- ${bin} --validate Validate agents and skills exist
597
- ${bin} --status Show task status
598
- ${bin} --help Show this help
599
-
600
- Config: ~/.fit/basecamp/scheduler.json
601
- State: ~/.fit/basecamp/state.json
602
- Logs: ~/.fit/basecamp/logs/
603
-
604
- Config format:
605
- {
606
- "tasks": {
607
- "sync-mail": {
608
- "kb": "~/Documents/Personal",
609
- "schedule": { "type": "interval", "minutes": 5 },
610
- "prompt": "Sync Apple Mail.", "skill": "sync-apple-mail",
611
- "agent": null, "enabled": true
612
- }
613
- }
614
- }
615
-
616
- Schedule types:
617
- interval: { "type": "interval", "minutes": 5 }
618
- cron: { "type": "cron", "expression": "0 8 * * *" }
619
- once: { "type": "once", "runAt": "2025-02-12T10:00:00Z" }
620
- `);
621
- }
622
-
623
- // --- CLI entry point --------------------------------------------------------
624
-
625
- const args = process.argv.slice(2);
626
- const command = args[0];
627
- ensureDir(BASECAMP_HOME);
628
-
629
- const commands = {
630
- "--help": showHelp,
631
- "-h": showHelp,
632
- "--daemon": daemon,
633
- "--validate": validate,
634
- "--status": showStatus,
635
- "--init": () => {
636
- if (!args[1]) {
637
- console.error("Usage: node basecamp.js --init <path>");
638
- process.exit(1);
639
- }
640
- initKB(args[1]);
641
- },
642
- "--run": async () => {
643
- if (!args[1]) {
644
- console.error("Usage: node basecamp.js --run <task-name>");
645
- process.exit(1);
646
- }
647
- const config = loadConfig(),
648
- state = loadState(),
649
- task = config.tasks[args[1]];
650
- if (!task) {
651
- console.error(
652
- `Task "${args[1]}" not found. Available: ${Object.keys(config.tasks).join(", ") || "(none)"}`,
653
- );
654
- process.exit(1);
655
- }
656
- await runTask(args[1], task, config, state);
657
- },
658
- };
659
-
660
- await (commands[command] || runDueTasks)();