@forwardimpact/basecamp 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 +201 -0
- package/README.md +229 -0
- package/build.js +124 -0
- package/config/scheduler.json +28 -0
- package/package.json +37 -0
- package/scheduler.js +552 -0
- package/scripts/build-pkg.sh +117 -0
- package/scripts/compile.sh +26 -0
- package/scripts/install.sh +108 -0
- package/scripts/pkg-resources/conclusion.html +62 -0
- package/scripts/pkg-resources/welcome.html +64 -0
- package/scripts/postinstall +46 -0
- package/scripts/uninstall.sh +56 -0
- package/template/.claude/settings.json +40 -0
- package/template/.claude/skills/create-presentations/SKILL.md +75 -0
- package/template/.claude/skills/create-presentations/references/slide.css +35 -0
- package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.js +32 -0
- package/template/.claude/skills/doc-collab/SKILL.md +112 -0
- package/template/.claude/skills/draft-emails/SKILL.md +191 -0
- package/template/.claude/skills/draft-emails/scripts/scan-emails.sh +33 -0
- package/template/.claude/skills/extract-entities/SKILL.md +466 -0
- package/template/.claude/skills/extract-entities/references/TEMPLATES.md +131 -0
- package/template/.claude/skills/extract-entities/scripts/state.py +100 -0
- package/template/.claude/skills/meeting-prep/SKILL.md +135 -0
- package/template/.claude/skills/organize-files/SKILL.md +146 -0
- package/template/.claude/skills/organize-files/scripts/organize-by-type.sh +42 -0
- package/template/.claude/skills/organize-files/scripts/summarize.sh +21 -0
- package/template/.claude/skills/sync-apple-calendar/SKILL.md +101 -0
- package/template/.claude/skills/sync-apple-calendar/references/SCHEMA.md +80 -0
- package/template/.claude/skills/sync-apple-calendar/scripts/sync.py +233 -0
- package/template/.claude/skills/sync-apple-mail/SKILL.md +131 -0
- package/template/.claude/skills/sync-apple-mail/references/SCHEMA.md +88 -0
- package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.py +104 -0
- package/template/.claude/skills/sync-apple-mail/scripts/sync.py +348 -0
- package/template/CLAUDE.md +152 -0
- package/template/USER.md +5 -0
package/scheduler.js
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Basecamp Scheduler — Runs scheduled tasks across multiple knowledge bases.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// node scheduler.js Run due tasks once and exit
|
|
7
|
+
// node scheduler.js --daemon Run continuously (poll every 60s)
|
|
8
|
+
// node scheduler.js --run <task> Run a specific task immediately
|
|
9
|
+
// node scheduler.js --init <path> Initialize a new knowledge base
|
|
10
|
+
// node scheduler.js --install-launchd Install macOS LaunchAgent
|
|
11
|
+
// node scheduler.js --uninstall-launchd Remove macOS LaunchAgent
|
|
12
|
+
// node scheduler.js --validate Validate agents and skills exist
|
|
13
|
+
// node scheduler.js --status Show task status
|
|
14
|
+
// node scheduler.js --help Show this help
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
readFileSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readdirSync,
|
|
22
|
+
} from "node:fs";
|
|
23
|
+
import { execSync } from "node:child_process";
|
|
24
|
+
import { join, dirname, resolve } from "node:path";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
|
|
28
|
+
const HOME = homedir();
|
|
29
|
+
const BASECAMP_HOME = join(HOME, ".fit", "basecamp");
|
|
30
|
+
const CONFIG_PATH = join(BASECAMP_HOME, "scheduler.json");
|
|
31
|
+
const STATE_PATH = join(BASECAMP_HOME, "state.json");
|
|
32
|
+
const LOG_DIR = join(BASECAMP_HOME, "logs");
|
|
33
|
+
const PLIST_NAME = "com.fit-basecamp.scheduler";
|
|
34
|
+
const PLIST_PATH = join(HOME, "Library", "LaunchAgents", `${PLIST_NAME}.plist`);
|
|
35
|
+
const __dirname =
|
|
36
|
+
import.meta.dirname || dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const KB_TEMPLATE_DIR = join(__dirname, "template");
|
|
38
|
+
const IS_COMPILED =
|
|
39
|
+
typeof Deno !== "undefined" &&
|
|
40
|
+
Deno.execPath &&
|
|
41
|
+
!Deno.execPath().endsWith("deno");
|
|
42
|
+
|
|
43
|
+
// --- Helpers ----------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
function ensureDir(dir) {
|
|
46
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readJSON(path, fallback) {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
52
|
+
} catch {
|
|
53
|
+
return fallback;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function writeJSON(path, data) {
|
|
58
|
+
ensureDir(dirname(path));
|
|
59
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function expandPath(p) {
|
|
63
|
+
return p.startsWith("~/") ? join(HOME, p.slice(2)) : resolve(p);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function log(msg) {
|
|
67
|
+
const ts = new Date().toISOString();
|
|
68
|
+
const line = `[${ts}] ${msg}`;
|
|
69
|
+
console.log(line);
|
|
70
|
+
try {
|
|
71
|
+
ensureDir(LOG_DIR);
|
|
72
|
+
writeFileSync(
|
|
73
|
+
join(LOG_DIR, `scheduler-${ts.slice(0, 10)}.log`),
|
|
74
|
+
line + "\n",
|
|
75
|
+
{ flag: "a" },
|
|
76
|
+
);
|
|
77
|
+
} catch {
|
|
78
|
+
/* best effort */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function findClaude() {
|
|
83
|
+
for (const c of [
|
|
84
|
+
"claude",
|
|
85
|
+
"/usr/local/bin/claude",
|
|
86
|
+
join(HOME, ".claude", "bin", "claude"),
|
|
87
|
+
join(HOME, ".local", "bin", "claude"),
|
|
88
|
+
]) {
|
|
89
|
+
try {
|
|
90
|
+
execSync(`which "${c}" 2>/dev/null || command -v "${c}" 2>/dev/null`, {
|
|
91
|
+
encoding: "utf8",
|
|
92
|
+
});
|
|
93
|
+
return c;
|
|
94
|
+
} catch {}
|
|
95
|
+
if (existsSync(c)) return c;
|
|
96
|
+
}
|
|
97
|
+
return "claude";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function loadConfig() {
|
|
101
|
+
return readJSON(CONFIG_PATH, { tasks: {} });
|
|
102
|
+
}
|
|
103
|
+
function loadState() {
|
|
104
|
+
const raw = readJSON(STATE_PATH, null);
|
|
105
|
+
if (!raw || typeof raw !== "object" || !raw.tasks) {
|
|
106
|
+
const state = { tasks: {} };
|
|
107
|
+
saveState(state);
|
|
108
|
+
return state;
|
|
109
|
+
}
|
|
110
|
+
return raw;
|
|
111
|
+
}
|
|
112
|
+
function saveState(state) {
|
|
113
|
+
writeJSON(STATE_PATH, state);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Cron matching ----------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
function matchField(field, value) {
|
|
119
|
+
if (field === "*") return true;
|
|
120
|
+
if (field.startsWith("*/")) return value % parseInt(field.slice(2)) === 0;
|
|
121
|
+
return field.split(",").some((part) => {
|
|
122
|
+
if (part.includes("-")) {
|
|
123
|
+
const [lo, hi] = part.split("-").map(Number);
|
|
124
|
+
return value >= lo && value <= hi;
|
|
125
|
+
}
|
|
126
|
+
return parseInt(part) === value;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function cronMatches(expr, d) {
|
|
131
|
+
const [min, hour, dom, month, dow] = expr.trim().split(/\s+/);
|
|
132
|
+
return (
|
|
133
|
+
matchField(min, d.getMinutes()) &&
|
|
134
|
+
matchField(hour, d.getHours()) &&
|
|
135
|
+
matchField(dom, d.getDate()) &&
|
|
136
|
+
matchField(month, d.getMonth() + 1) &&
|
|
137
|
+
matchField(dow, d.getDay())
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- Scheduling logic -------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
function floorToMinute(d) {
|
|
144
|
+
return new Date(
|
|
145
|
+
d.getFullYear(),
|
|
146
|
+
d.getMonth(),
|
|
147
|
+
d.getDate(),
|
|
148
|
+
d.getHours(),
|
|
149
|
+
d.getMinutes(),
|
|
150
|
+
).getTime();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function shouldRun(task, taskState, now) {
|
|
154
|
+
if (task.enabled === false) 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
|
+
try {
|
|
201
|
+
const args = ["--print"];
|
|
202
|
+
if (task.agent) args.push("--agent", task.agent);
|
|
203
|
+
args.push("-p", prompt);
|
|
204
|
+
|
|
205
|
+
const result = execSync(
|
|
206
|
+
`${claude} ${args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ")}`,
|
|
207
|
+
{
|
|
208
|
+
cwd: kbPath,
|
|
209
|
+
encoding: "utf8",
|
|
210
|
+
timeout: 30 * 60_000,
|
|
211
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
log(`Task ${taskName} completed. Output: ${result.slice(0, 200)}...`);
|
|
216
|
+
Object.assign(ts, {
|
|
217
|
+
status: "finished",
|
|
218
|
+
lastRunAt: new Date().toISOString(),
|
|
219
|
+
lastError: null,
|
|
220
|
+
runCount: (ts.runCount || 0) + 1,
|
|
221
|
+
});
|
|
222
|
+
} catch (err) {
|
|
223
|
+
const errMsg = err.stderr || err.stdout || err.message || String(err);
|
|
224
|
+
log(`Task ${taskName} failed: ${errMsg.slice(0, 300)}`);
|
|
225
|
+
Object.assign(ts, {
|
|
226
|
+
status: "failed",
|
|
227
|
+
lastRunAt: new Date().toISOString(),
|
|
228
|
+
lastError: errMsg.slice(0, 500),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
saveState(state);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function runDueTasks() {
|
|
235
|
+
const config = loadConfig(),
|
|
236
|
+
state = loadState(),
|
|
237
|
+
now = new Date();
|
|
238
|
+
let ranAny = false;
|
|
239
|
+
for (const [name, task] of Object.entries(config.tasks)) {
|
|
240
|
+
if (shouldRun(task, state.tasks[name] || {}, now)) {
|
|
241
|
+
runTask(name, task, config, state);
|
|
242
|
+
ranAny = true;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (!ranAny) log("No tasks due.");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// --- Daemon -----------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
function daemon() {
|
|
251
|
+
log("Scheduler daemon started. Polling every 60 seconds.");
|
|
252
|
+
log(`Config: ${CONFIG_PATH} State: ${STATE_PATH}`);
|
|
253
|
+
runDueTasks();
|
|
254
|
+
setInterval(() => {
|
|
255
|
+
try {
|
|
256
|
+
runDueTasks();
|
|
257
|
+
} catch (err) {
|
|
258
|
+
log(`Error: ${err.message}`);
|
|
259
|
+
}
|
|
260
|
+
}, 60_000);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// --- Init knowledge base ----------------------------------------------------
|
|
264
|
+
|
|
265
|
+
function copyDirRecursive(src, dest) {
|
|
266
|
+
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
267
|
+
const s = join(src, entry.name),
|
|
268
|
+
d = join(dest, entry.name);
|
|
269
|
+
if (entry.isDirectory()) {
|
|
270
|
+
ensureDir(d);
|
|
271
|
+
copyDirRecursive(s, d);
|
|
272
|
+
} else if (!existsSync(d)) {
|
|
273
|
+
writeFileSync(d, readFileSync(s));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function initKB(targetPath) {
|
|
279
|
+
const dest = expandPath(targetPath);
|
|
280
|
+
if (existsSync(join(dest, "CLAUDE.md"))) {
|
|
281
|
+
console.error(`Knowledge base already exists at ${dest}`);
|
|
282
|
+
process.exit(1);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
ensureDir(dest);
|
|
286
|
+
for (const d of [
|
|
287
|
+
"knowledge/People",
|
|
288
|
+
"knowledge/Organizations",
|
|
289
|
+
"knowledge/Projects",
|
|
290
|
+
"knowledge/Topics",
|
|
291
|
+
".claude/skills",
|
|
292
|
+
])
|
|
293
|
+
ensureDir(join(dest, d));
|
|
294
|
+
|
|
295
|
+
if (existsSync(KB_TEMPLATE_DIR)) copyDirRecursive(KB_TEMPLATE_DIR, dest);
|
|
296
|
+
|
|
297
|
+
console.log(
|
|
298
|
+
`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`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// --- LaunchAgent ------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
function installLaunchd() {
|
|
305
|
+
const execPath =
|
|
306
|
+
typeof Deno !== "undefined" ? Deno.execPath() : process.execPath;
|
|
307
|
+
const isCompiled = IS_COMPILED || !execPath.includes("node");
|
|
308
|
+
const progArgs = isCompiled
|
|
309
|
+
? ` <string>${execPath}</string>\n <string>--daemon</string>`
|
|
310
|
+
: ` <string>${execPath}</string>\n <string>${join(__dirname, "scheduler.js")}</string>\n <string>--daemon</string>`;
|
|
311
|
+
|
|
312
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
313
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
314
|
+
<plist version="1.0">
|
|
315
|
+
<dict>
|
|
316
|
+
<key>Label</key>
|
|
317
|
+
<string>${PLIST_NAME}</string>
|
|
318
|
+
<key>ProgramArguments</key>
|
|
319
|
+
<array>
|
|
320
|
+
${progArgs}
|
|
321
|
+
</array>
|
|
322
|
+
<key>RunAtLoad</key>
|
|
323
|
+
<true/>
|
|
324
|
+
<key>KeepAlive</key>
|
|
325
|
+
<true/>
|
|
326
|
+
<key>StandardOutPath</key>
|
|
327
|
+
<string>${join(LOG_DIR, "launchd-stdout.log")}</string>
|
|
328
|
+
<key>StandardErrorPath</key>
|
|
329
|
+
<string>${join(LOG_DIR, "launchd-stderr.log")}</string>
|
|
330
|
+
<key>WorkingDirectory</key>
|
|
331
|
+
<string>${BASECAMP_HOME}</string>
|
|
332
|
+
<key>EnvironmentVariables</key>
|
|
333
|
+
<dict>
|
|
334
|
+
<key>PATH</key>
|
|
335
|
+
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${join(HOME, ".local", "bin")}</string>
|
|
336
|
+
</dict>
|
|
337
|
+
</dict>
|
|
338
|
+
</plist>`;
|
|
339
|
+
|
|
340
|
+
ensureDir(dirname(PLIST_PATH));
|
|
341
|
+
ensureDir(LOG_DIR);
|
|
342
|
+
writeFileSync(PLIST_PATH, plist);
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`, {
|
|
346
|
+
stdio: "ignore",
|
|
347
|
+
});
|
|
348
|
+
} catch {}
|
|
349
|
+
execSync(`launchctl load "${PLIST_PATH}"`);
|
|
350
|
+
console.log(
|
|
351
|
+
`LaunchAgent installed and loaded.\n Plist: ${PLIST_PATH}\n Logs: ${LOG_DIR}/\n Config: ${CONFIG_PATH}`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function uninstallLaunchd() {
|
|
356
|
+
try {
|
|
357
|
+
execSync(`launchctl unload "${PLIST_PATH}" 2>/dev/null`);
|
|
358
|
+
} catch {}
|
|
359
|
+
try {
|
|
360
|
+
execSync(`rm -f "${PLIST_PATH}"`);
|
|
361
|
+
} catch {}
|
|
362
|
+
console.log("LaunchAgent uninstalled.");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// --- Status -----------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
function showStatus() {
|
|
368
|
+
const config = loadConfig(),
|
|
369
|
+
state = loadState();
|
|
370
|
+
console.log("\nBasecamp Scheduler\n==================\n");
|
|
371
|
+
|
|
372
|
+
const tasks = Object.entries(config.tasks || {});
|
|
373
|
+
if (tasks.length === 0) {
|
|
374
|
+
console.log(
|
|
375
|
+
`Tasks: (none configured)\n\nEdit ${CONFIG_PATH} to add tasks.`,
|
|
376
|
+
);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
console.log("Tasks:");
|
|
381
|
+
for (const [name, task] of tasks) {
|
|
382
|
+
const s = state.tasks[name] || {};
|
|
383
|
+
const kbPath = task.kb ? expandPath(task.kb) : null;
|
|
384
|
+
const kbStatus = kbPath ? (existsSync(kbPath) ? "" : " (not found)") : "";
|
|
385
|
+
const lines = [
|
|
386
|
+
` ${task.enabled !== false ? "+" : "-"} ${name}`,
|
|
387
|
+
` KB: ${task.kb || "(none)"}${kbStatus} Schedule: ${JSON.stringify(task.schedule)}`,
|
|
388
|
+
` Status: ${s.status || "never-run"} Last run: ${s.lastRunAt ? new Date(s.lastRunAt).toLocaleString() : "never"} Runs: ${s.runCount || 0}`,
|
|
389
|
+
];
|
|
390
|
+
if (task.agent) lines.push(` Agent: ${task.agent}`);
|
|
391
|
+
if (task.skill) lines.push(` Skill: ${task.skill}`);
|
|
392
|
+
if (s.lastError) lines.push(` Error: ${s.lastError.slice(0, 80)}`);
|
|
393
|
+
console.log(lines.join("\n"));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
try {
|
|
397
|
+
execSync(`launchctl list 2>/dev/null | grep ${PLIST_NAME}`, {
|
|
398
|
+
encoding: "utf8",
|
|
399
|
+
});
|
|
400
|
+
console.log("\nLaunchAgent: loaded");
|
|
401
|
+
} catch {
|
|
402
|
+
console.log("\nLaunchAgent: not loaded (run --install-launchd to start)");
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// --- Validate ---------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
function findInLocalOrGlobal(kbPath, subPath) {
|
|
409
|
+
const local = join(kbPath, ".claude", subPath);
|
|
410
|
+
const global = join(HOME, ".claude", subPath);
|
|
411
|
+
if (existsSync(local)) return local;
|
|
412
|
+
if (existsSync(global)) return global;
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function validate() {
|
|
417
|
+
const config = loadConfig();
|
|
418
|
+
const tasks = Object.entries(config.tasks || {});
|
|
419
|
+
if (tasks.length === 0) {
|
|
420
|
+
console.log("No tasks configured. Nothing to validate.");
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
console.log("\nValidating tasks...\n");
|
|
425
|
+
let errors = 0;
|
|
426
|
+
|
|
427
|
+
for (const [name, task] of tasks) {
|
|
428
|
+
if (!task.kb) {
|
|
429
|
+
console.log(` [FAIL] ${name}: no "kb" path specified`);
|
|
430
|
+
errors++;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const kbPath = expandPath(task.kb);
|
|
434
|
+
if (!existsSync(kbPath)) {
|
|
435
|
+
console.log(` [FAIL] ${name}: path does not exist: ${kbPath}`);
|
|
436
|
+
errors++;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
for (const [kind, sub] of [
|
|
441
|
+
["agent", task.agent],
|
|
442
|
+
["skill", task.skill],
|
|
443
|
+
]) {
|
|
444
|
+
if (!sub) continue;
|
|
445
|
+
const relPath =
|
|
446
|
+
kind === "agent"
|
|
447
|
+
? join("agents", sub.endsWith(".md") ? sub : sub + ".md")
|
|
448
|
+
: join("skills", sub, "SKILL.md");
|
|
449
|
+
const found = findInLocalOrGlobal(kbPath, relPath);
|
|
450
|
+
if (found) {
|
|
451
|
+
console.log(` [OK] ${name}: ${kind} "${sub}" found at ${found}`);
|
|
452
|
+
} else {
|
|
453
|
+
console.log(
|
|
454
|
+
` [FAIL] ${name}: ${kind} "${sub}" not found in ${join(kbPath, ".claude", relPath)} or ${join(HOME, ".claude", relPath)}`,
|
|
455
|
+
);
|
|
456
|
+
errors++;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (!task.agent && !task.skill)
|
|
461
|
+
console.log(` [OK] ${name}: no agent or skill to validate`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
console.log(
|
|
465
|
+
errors > 0
|
|
466
|
+
? `\nValidation failed: ${errors} error(s) found.`
|
|
467
|
+
: "\nAll tasks validated successfully.",
|
|
468
|
+
);
|
|
469
|
+
if (errors > 0) process.exit(1);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// --- Help -------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
function showHelp() {
|
|
475
|
+
const bin = "fit-basecamp";
|
|
476
|
+
console.log(`
|
|
477
|
+
Basecamp Scheduler — Run scheduled tasks across multiple knowledge bases.
|
|
478
|
+
|
|
479
|
+
Usage:
|
|
480
|
+
${bin} Run due tasks once and exit
|
|
481
|
+
${bin} --daemon Run continuously (poll every 60s)
|
|
482
|
+
${bin} --run <task> Run a specific task immediately
|
|
483
|
+
${bin} --init <path> Initialize a new knowledge base
|
|
484
|
+
${bin} --install-launchd Install macOS LaunchAgent for auto-start
|
|
485
|
+
${bin} --uninstall-launchd Remove macOS LaunchAgent
|
|
486
|
+
${bin} --validate Validate agents and skills exist
|
|
487
|
+
${bin} --status Show task status
|
|
488
|
+
${bin} --help Show this help
|
|
489
|
+
|
|
490
|
+
Config: ~/.fit/basecamp/scheduler.json
|
|
491
|
+
State: ~/.fit/basecamp/state.json
|
|
492
|
+
Logs: ~/.fit/basecamp/logs/
|
|
493
|
+
|
|
494
|
+
Config format:
|
|
495
|
+
{
|
|
496
|
+
"tasks": {
|
|
497
|
+
"sync-mail": {
|
|
498
|
+
"kb": "~/Documents/Personal",
|
|
499
|
+
"schedule": { "type": "interval", "minutes": 5 },
|
|
500
|
+
"prompt": "Sync Apple Mail.", "skill": "sync-apple-mail",
|
|
501
|
+
"agent": null, "enabled": true
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
Schedule types:
|
|
507
|
+
interval: { "type": "interval", "minutes": 5 }
|
|
508
|
+
cron: { "type": "cron", "expression": "0 8 * * *" }
|
|
509
|
+
once: { "type": "once", "runAt": "2025-02-12T10:00:00Z" }
|
|
510
|
+
`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// --- CLI entry point --------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
const args = process.argv.slice(2);
|
|
516
|
+
const command = args[0];
|
|
517
|
+
ensureDir(BASECAMP_HOME);
|
|
518
|
+
|
|
519
|
+
const commands = {
|
|
520
|
+
"--help": showHelp,
|
|
521
|
+
"-h": showHelp,
|
|
522
|
+
"--daemon": daemon,
|
|
523
|
+
"--install-launchd": installLaunchd,
|
|
524
|
+
"--uninstall-launchd": uninstallLaunchd,
|
|
525
|
+
"--validate": validate,
|
|
526
|
+
"--status": showStatus,
|
|
527
|
+
"--init": () => {
|
|
528
|
+
if (!args[1]) {
|
|
529
|
+
console.error("Usage: node scheduler.js --init <path>");
|
|
530
|
+
process.exit(1);
|
|
531
|
+
}
|
|
532
|
+
initKB(args[1]);
|
|
533
|
+
},
|
|
534
|
+
"--run": () => {
|
|
535
|
+
if (!args[1]) {
|
|
536
|
+
console.error("Usage: node scheduler.js --run <task-name>");
|
|
537
|
+
process.exit(1);
|
|
538
|
+
}
|
|
539
|
+
const config = loadConfig(),
|
|
540
|
+
state = loadState(),
|
|
541
|
+
task = config.tasks[args[1]];
|
|
542
|
+
if (!task) {
|
|
543
|
+
console.error(
|
|
544
|
+
`Task "${args[1]}" not found. Available: ${Object.keys(config.tasks).join(", ") || "(none)"}`,
|
|
545
|
+
);
|
|
546
|
+
process.exit(1);
|
|
547
|
+
}
|
|
548
|
+
runTask(args[1], task, config, state);
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
(commands[command] || runDueTasks)();
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
# Build a macOS installer package (.pkg) for Basecamp.
|
|
5
|
+
#
|
|
6
|
+
# Uses pkgbuild (component) + productbuild (distribution) to create a .pkg
|
|
7
|
+
# that installs the binary to /usr/local/bin/ and runs a postinstall script
|
|
8
|
+
# to set up the LaunchAgent, config, and default knowledge base.
|
|
9
|
+
#
|
|
10
|
+
# Usage: build-pkg.sh <dist_dir> <app_name> <version> <target>
|
|
11
|
+
# e.g. build-pkg.sh dist basecamp 1.0.0 aarch64-apple-darwin
|
|
12
|
+
|
|
13
|
+
DIST_DIR="${1:?Usage: build-pkg.sh <dist_dir> <app_name> <version> <target>}"
|
|
14
|
+
APP_NAME="${2:?Usage: build-pkg.sh <dist_dir> <app_name> <version> <target>}"
|
|
15
|
+
VERSION="${3:?Usage: build-pkg.sh <dist_dir> <app_name> <version> <target>}"
|
|
16
|
+
TARGET="${4:?Usage: build-pkg.sh <dist_dir> <app_name> <version> <target>}"
|
|
17
|
+
|
|
18
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
19
|
+
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
20
|
+
BINARY_PATH="$DIST_DIR/$APP_NAME-$TARGET"
|
|
21
|
+
IDENTIFIER="com.fit-basecamp.scheduler"
|
|
22
|
+
|
|
23
|
+
if [ ! -f "$BINARY_PATH" ]; then
|
|
24
|
+
echo "Error: binary not found at $BINARY_PATH"
|
|
25
|
+
echo "Run compile.sh first."
|
|
26
|
+
exit 1
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
# Determine short arch name
|
|
30
|
+
case "$TARGET" in
|
|
31
|
+
*aarch64*) ARCH_SHORT="arm64" ;;
|
|
32
|
+
*) ARCH_SHORT="x86_64" ;;
|
|
33
|
+
esac
|
|
34
|
+
|
|
35
|
+
PKG_NAME="$APP_NAME-$VERSION-$ARCH_SHORT.pkg"
|
|
36
|
+
PKG_PATH="$DIST_DIR/$PKG_NAME"
|
|
37
|
+
PAYLOAD_DIR="$DIST_DIR/pkg-payload-$ARCH_SHORT"
|
|
38
|
+
SCRIPTS_DIR="$DIST_DIR/pkg-scripts-$ARCH_SHORT"
|
|
39
|
+
RESOURCES_DIR="$DIST_DIR/pkg-resources-$ARCH_SHORT"
|
|
40
|
+
COMPONENT_PKG="$DIST_DIR/pkg-component-$ARCH_SHORT.pkg"
|
|
41
|
+
|
|
42
|
+
echo ""
|
|
43
|
+
echo "Building pkg: $PKG_NAME..."
|
|
44
|
+
|
|
45
|
+
# --- Clean previous artifacts ------------------------------------------------
|
|
46
|
+
|
|
47
|
+
rm -rf "$PAYLOAD_DIR" "$SCRIPTS_DIR" "$RESOURCES_DIR" "$COMPONENT_PKG"
|
|
48
|
+
rm -f "$PKG_PATH"
|
|
49
|
+
|
|
50
|
+
# --- Create payload (files to install) ---------------------------------------
|
|
51
|
+
|
|
52
|
+
mkdir -p "$PAYLOAD_DIR/usr/local/bin"
|
|
53
|
+
mkdir -p "$PAYLOAD_DIR/usr/local/share/fit-basecamp/config"
|
|
54
|
+
|
|
55
|
+
cp "$BINARY_PATH" "$PAYLOAD_DIR/usr/local/bin/$APP_NAME"
|
|
56
|
+
chmod +x "$PAYLOAD_DIR/usr/local/bin/$APP_NAME"
|
|
57
|
+
|
|
58
|
+
cp "$PROJECT_DIR/config/scheduler.json" "$PAYLOAD_DIR/usr/local/share/fit-basecamp/config/scheduler.json"
|
|
59
|
+
cp "$SCRIPT_DIR/uninstall.sh" "$PAYLOAD_DIR/usr/local/share/fit-basecamp/uninstall.sh"
|
|
60
|
+
chmod +x "$PAYLOAD_DIR/usr/local/share/fit-basecamp/uninstall.sh"
|
|
61
|
+
|
|
62
|
+
# --- Create scripts directory ------------------------------------------------
|
|
63
|
+
|
|
64
|
+
mkdir -p "$SCRIPTS_DIR"
|
|
65
|
+
cp "$SCRIPT_DIR/postinstall" "$SCRIPTS_DIR/postinstall"
|
|
66
|
+
chmod +x "$SCRIPTS_DIR/postinstall"
|
|
67
|
+
|
|
68
|
+
# --- Build component package -------------------------------------------------
|
|
69
|
+
|
|
70
|
+
pkgbuild \
|
|
71
|
+
--root "$PAYLOAD_DIR" \
|
|
72
|
+
--scripts "$SCRIPTS_DIR" \
|
|
73
|
+
--identifier "$IDENTIFIER" \
|
|
74
|
+
--version "$VERSION" \
|
|
75
|
+
--install-location "/" \
|
|
76
|
+
"$COMPONENT_PKG"
|
|
77
|
+
|
|
78
|
+
# --- Create distribution resources -------------------------------------------
|
|
79
|
+
|
|
80
|
+
mkdir -p "$RESOURCES_DIR"
|
|
81
|
+
cp "$SCRIPT_DIR/pkg-resources/welcome.html" "$RESOURCES_DIR/welcome.html"
|
|
82
|
+
cp "$SCRIPT_DIR/pkg-resources/conclusion.html" "$RESOURCES_DIR/conclusion.html"
|
|
83
|
+
|
|
84
|
+
# --- Create distribution.xml ------------------------------------------------
|
|
85
|
+
|
|
86
|
+
DIST_XML="$DIST_DIR/distribution-$ARCH_SHORT.xml"
|
|
87
|
+
cat > "$DIST_XML" <<EOF
|
|
88
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
89
|
+
<installer-gui-script minSpecVersion="2">
|
|
90
|
+
<title>Basecamp ${VERSION}</title>
|
|
91
|
+
<welcome file="welcome.html" mime-type="text/html" />
|
|
92
|
+
<conclusion file="conclusion.html" mime-type="text/html" />
|
|
93
|
+
<options customize="never" require-scripts="false" hostArchitectures="$ARCH_SHORT" />
|
|
94
|
+
<domains enable_localSystem="true" />
|
|
95
|
+
<pkg-ref id="$IDENTIFIER" version="$VERSION">pkg-component-$ARCH_SHORT.pkg</pkg-ref>
|
|
96
|
+
<choices-outline>
|
|
97
|
+
<line choice="$IDENTIFIER" />
|
|
98
|
+
</choices-outline>
|
|
99
|
+
<choice id="$IDENTIFIER" visible="false">
|
|
100
|
+
<pkg-ref id="$IDENTIFIER" />
|
|
101
|
+
</choice>
|
|
102
|
+
</installer-gui-script>
|
|
103
|
+
EOF
|
|
104
|
+
|
|
105
|
+
# --- Build distribution package ----------------------------------------------
|
|
106
|
+
|
|
107
|
+
productbuild \
|
|
108
|
+
--distribution "$DIST_XML" \
|
|
109
|
+
--resources "$RESOURCES_DIR" \
|
|
110
|
+
--package-path "$DIST_DIR" \
|
|
111
|
+
"$PKG_PATH"
|
|
112
|
+
|
|
113
|
+
# --- Clean up staging --------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
rm -rf "$PAYLOAD_DIR" "$SCRIPTS_DIR" "$RESOURCES_DIR" "$COMPONENT_PKG" "$DIST_XML"
|
|
116
|
+
|
|
117
|
+
echo " -> $PKG_PATH"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
# Compile Basecamp into a standalone Deno binary.
|
|
5
|
+
#
|
|
6
|
+
# Usage: compile.sh <dist_dir> <app_name> <target>
|
|
7
|
+
# e.g. compile.sh dist basecamp aarch64-apple-darwin
|
|
8
|
+
|
|
9
|
+
DIST_DIR="${1:?Usage: compile.sh <dist_dir> <app_name> <target>}"
|
|
10
|
+
APP_NAME="${2:?Usage: compile.sh <dist_dir> <app_name> <target>}"
|
|
11
|
+
TARGET="${3:?Usage: compile.sh <dist_dir> <app_name> <target>}"
|
|
12
|
+
|
|
13
|
+
OUTPUT="$DIST_DIR/$APP_NAME-$TARGET"
|
|
14
|
+
|
|
15
|
+
echo ""
|
|
16
|
+
echo "Compiling $APP_NAME for $TARGET..."
|
|
17
|
+
mkdir -p "$DIST_DIR"
|
|
18
|
+
|
|
19
|
+
deno compile \
|
|
20
|
+
--allow-all \
|
|
21
|
+
--target "$TARGET" \
|
|
22
|
+
--output "$OUTPUT" \
|
|
23
|
+
--include template/ \
|
|
24
|
+
scheduler.js
|
|
25
|
+
|
|
26
|
+
echo " -> $OUTPUT"
|