@forwardimpact/basecamp 2.0.0 → 2.3.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/config/scheduler.json +5 -0
- package/package.json +1 -1
- package/src/basecamp.js +288 -57
- package/template/.claude/agents/chief-of-staff.md +6 -2
- package/template/.claude/agents/concierge.md +2 -3
- package/template/.claude/agents/librarian.md +4 -6
- package/template/.claude/agents/recruiter.md +269 -0
- package/template/.claude/settings.json +0 -4
- package/template/.claude/skills/analyze-cv/SKILL.md +269 -0
- package/template/.claude/skills/create-presentations/SKILL.md +2 -2
- package/template/.claude/skills/create-presentations/references/slide.css +1 -1
- package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.mjs +47 -0
- package/template/.claude/skills/draft-emails/SKILL.md +85 -123
- package/template/.claude/skills/draft-emails/scripts/scan-emails.mjs +66 -0
- package/template/.claude/skills/draft-emails/scripts/send-email.mjs +118 -0
- package/template/.claude/skills/extract-entities/SKILL.md +2 -2
- package/template/.claude/skills/extract-entities/scripts/state.mjs +130 -0
- package/template/.claude/skills/manage-tasks/SKILL.md +242 -0
- package/template/.claude/skills/organize-files/SKILL.md +3 -3
- package/template/.claude/skills/organize-files/scripts/organize-by-type.mjs +105 -0
- package/template/.claude/skills/organize-files/scripts/summarize.mjs +84 -0
- package/template/.claude/skills/process-hyprnote/SKILL.md +2 -2
- package/template/.claude/skills/right-to-be-forgotten/SKILL.md +333 -0
- package/template/.claude/skills/send-chat/SKILL.md +170 -0
- package/template/.claude/skills/sync-apple-calendar/SKILL.md +5 -5
- package/template/.claude/skills/sync-apple-calendar/scripts/sync.mjs +325 -0
- package/template/.claude/skills/sync-apple-mail/SKILL.md +6 -6
- package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +374 -0
- package/template/.claude/skills/sync-apple-mail/scripts/sync.mjs +629 -0
- package/template/.claude/skills/track-candidates/SKILL.md +376 -0
- package/template/.claude/skills/upstream-skill/SKILL.md +207 -0
- package/template/.claude/skills/weekly-update/SKILL.md +250 -0
- package/template/CLAUDE.md +68 -40
- package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.js +0 -32
- package/template/.claude/skills/draft-emails/scripts/scan-emails.sh +0 -34
- package/template/.claude/skills/extract-entities/scripts/state.py +0 -100
- package/template/.claude/skills/organize-files/scripts/organize-by-type.sh +0 -42
- package/template/.claude/skills/organize-files/scripts/summarize.sh +0 -21
- package/template/.claude/skills/sync-apple-calendar/scripts/sync.py +0 -242
- package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.py +0 -104
- package/template/.claude/skills/sync-apple-mail/scripts/sync.py +0 -455
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Sync Apple Calendar events to ~/.cache/fit/basecamp/apple_calendar/ as JSON.
|
|
4
|
+
*
|
|
5
|
+
* Queries the macOS Calendar SQLite database (via node:sqlite) for events in a
|
|
6
|
+
* sliding window — N days in the past through 14 days in the future. Writes one
|
|
7
|
+
* JSON file per event and removes files for events that fall outside the window.
|
|
8
|
+
* Attendee details (name, email, status, role) are batch-fetched and included.
|
|
9
|
+
*
|
|
10
|
+
* Requires macOS with Calendar app configured and Full Disk Access granted.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
if (process.argv.includes("-h") || process.argv.includes("--help")) {
|
|
14
|
+
console.log(`sync-apple-calendar — sync calendar events to JSON
|
|
15
|
+
|
|
16
|
+
Usage: node scripts/sync.mjs [--days N] [-h|--help]
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--days N Days back to sync (default: 30)
|
|
20
|
+
-h, --help Show this help message and exit
|
|
21
|
+
|
|
22
|
+
Requires macOS with Calendar configured and Full Disk Access granted.`);
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
import { DatabaseSync } from "node:sqlite";
|
|
27
|
+
import {
|
|
28
|
+
existsSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
readdirSync,
|
|
31
|
+
unlinkSync,
|
|
32
|
+
writeFileSync,
|
|
33
|
+
} from "node:fs";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { homedir } from "node:os";
|
|
36
|
+
|
|
37
|
+
const HOME = homedir();
|
|
38
|
+
const OUTDIR = join(HOME, ".cache/fit/basecamp/apple_calendar");
|
|
39
|
+
|
|
40
|
+
/** Core Data epoch: 2001-01-01T00:00:00Z */
|
|
41
|
+
const EPOCH_MS = Date.UTC(2001, 0, 1);
|
|
42
|
+
|
|
43
|
+
const DB_PATHS = [
|
|
44
|
+
join(
|
|
45
|
+
HOME,
|
|
46
|
+
"Library/Group Containers/group.com.apple.calendar/Calendar.sqlitedb",
|
|
47
|
+
),
|
|
48
|
+
join(HOME, "Library/Calendars/Calendar.sqlitedb"),
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const STATUS_MAP = {
|
|
52
|
+
0: "unknown",
|
|
53
|
+
1: "pending",
|
|
54
|
+
2: "accepted",
|
|
55
|
+
3: "declined",
|
|
56
|
+
4: "tentative",
|
|
57
|
+
5: "delegated",
|
|
58
|
+
6: "completed",
|
|
59
|
+
7: "in-process",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const ROLE_MAP = {
|
|
63
|
+
0: "unknown",
|
|
64
|
+
1: "required",
|
|
65
|
+
2: "optional",
|
|
66
|
+
3: "chair",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Find the Apple Calendar database.
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
function findDb() {
|
|
74
|
+
const db = DB_PATHS.find((p) => existsSync(p));
|
|
75
|
+
if (!db) {
|
|
76
|
+
console.error(
|
|
77
|
+
"Error: Apple Calendar database not found. Is Calendar configured?",
|
|
78
|
+
);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
return db;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Open the database in read-only mode with retry on lock.
|
|
86
|
+
* @param {string} dbPath
|
|
87
|
+
* @returns {import("node:sqlite").DatabaseSync}
|
|
88
|
+
*/
|
|
89
|
+
function openDb(dbPath) {
|
|
90
|
+
try {
|
|
91
|
+
return new DatabaseSync(dbPath, { readOnly: true });
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (err.message.includes("locked")) {
|
|
94
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
|
|
95
|
+
return new DatabaseSync(dbPath, { readOnly: true });
|
|
96
|
+
}
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Execute a read-only query and return results.
|
|
103
|
+
* @param {import("node:sqlite").DatabaseSync} db
|
|
104
|
+
* @param {string} sql
|
|
105
|
+
* @returns {Array<Record<string, any>>}
|
|
106
|
+
*/
|
|
107
|
+
function query(db, sql) {
|
|
108
|
+
try {
|
|
109
|
+
return db.prepare(sql).all();
|
|
110
|
+
} catch (err) {
|
|
111
|
+
console.error(`SQLite error: ${err.message}`);
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Convert Core Data timestamp to ISO 8601.
|
|
118
|
+
* @param {number | null} ts - Seconds since 2001-01-01
|
|
119
|
+
* @param {string | null} tzName
|
|
120
|
+
* @returns {string | null}
|
|
121
|
+
*/
|
|
122
|
+
function coredataToIso(ts, tzName) {
|
|
123
|
+
if (ts == null) return null;
|
|
124
|
+
const ms = EPOCH_MS + ts * 1000;
|
|
125
|
+
const dt = new Date(ms);
|
|
126
|
+
|
|
127
|
+
if (tzName && tzName !== "_float") {
|
|
128
|
+
try {
|
|
129
|
+
return (
|
|
130
|
+
dt.toLocaleString("sv-SE", { timeZone: tzName }).replace(" ", "T") +
|
|
131
|
+
getUtcOffset(dt, tzName)
|
|
132
|
+
);
|
|
133
|
+
} catch {
|
|
134
|
+
// Fall through to UTC
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return dt.toISOString();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get UTC offset string for a timezone at a given instant.
|
|
142
|
+
* @param {Date} dt
|
|
143
|
+
* @param {string} tzName
|
|
144
|
+
* @returns {string} e.g. "+02:00" or "-05:00"
|
|
145
|
+
*/
|
|
146
|
+
function getUtcOffset(dt, tzName) {
|
|
147
|
+
try {
|
|
148
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
149
|
+
timeZone: tzName,
|
|
150
|
+
timeZoneName: "longOffset",
|
|
151
|
+
}).formatToParts(dt);
|
|
152
|
+
const tzPart = parts.find((p) => p.type === "timeZoneName");
|
|
153
|
+
if (tzPart) {
|
|
154
|
+
// "GMT+2" → "+02:00", "GMT-5:30" → "-05:30", "GMT" → "+00:00"
|
|
155
|
+
const match = tzPart.value.match(/GMT([+-])(\d{1,2})(?::(\d{2}))?/);
|
|
156
|
+
if (match) {
|
|
157
|
+
const sign = match[1];
|
|
158
|
+
const hours = match[2].padStart(2, "0");
|
|
159
|
+
const mins = (match[3] ?? "00").padStart(2, "0");
|
|
160
|
+
return `${sign}${hours}:${mins}`;
|
|
161
|
+
}
|
|
162
|
+
return "+00:00"; // GMT with no offset
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// Fallback
|
|
166
|
+
}
|
|
167
|
+
return "Z";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- Main ---
|
|
171
|
+
|
|
172
|
+
function main() {
|
|
173
|
+
let daysBack = 30;
|
|
174
|
+
const daysIdx = process.argv.indexOf("--days");
|
|
175
|
+
if (daysIdx !== -1 && process.argv[daysIdx + 1]) {
|
|
176
|
+
daysBack = parseInt(process.argv[daysIdx + 1], 10);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const dbPath = findDb();
|
|
180
|
+
mkdirSync(OUTDIR, { recursive: true });
|
|
181
|
+
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
const start = new Date(now - daysBack * 86400000);
|
|
184
|
+
const end = new Date(now + 14 * 86400000);
|
|
185
|
+
const startTs = (start.getTime() - EPOCH_MS) / 1000;
|
|
186
|
+
const endTs = (end.getTime() - EPOCH_MS) / 1000;
|
|
187
|
+
|
|
188
|
+
const db = openDb(dbPath);
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
// Fetch events with a single query
|
|
192
|
+
const events = query(
|
|
193
|
+
db,
|
|
194
|
+
`
|
|
195
|
+
SELECT
|
|
196
|
+
ci.ROWID AS id,
|
|
197
|
+
ci.summary,
|
|
198
|
+
ci.start_date,
|
|
199
|
+
ci.end_date,
|
|
200
|
+
ci.start_tz,
|
|
201
|
+
ci.end_tz,
|
|
202
|
+
ci.all_day,
|
|
203
|
+
ci.description,
|
|
204
|
+
ci.has_attendees,
|
|
205
|
+
ci.conference_url,
|
|
206
|
+
loc.title AS location,
|
|
207
|
+
cal.title AS calendar_name,
|
|
208
|
+
org.address AS organizer_email,
|
|
209
|
+
org.display_name AS organizer_name
|
|
210
|
+
FROM CalendarItem ci
|
|
211
|
+
LEFT JOIN Location loc ON loc.ROWID = ci.location_id
|
|
212
|
+
LEFT JOIN Calendar cal ON cal.ROWID = ci.calendar_id
|
|
213
|
+
LEFT JOIN Identity org ON org.ROWID = ci.organizer_id
|
|
214
|
+
WHERE ci.start_date <= ${endTs}
|
|
215
|
+
AND COALESCE(ci.end_date, ci.start_date) >= ${startTs}
|
|
216
|
+
AND ci.summary IS NOT NULL
|
|
217
|
+
AND ci.summary != ''
|
|
218
|
+
ORDER BY ci.start_date ASC
|
|
219
|
+
LIMIT 1000;
|
|
220
|
+
`,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// Collect event IDs for batch attendee query
|
|
224
|
+
const eventIds = events.map((ev) => String(ev.id));
|
|
225
|
+
|
|
226
|
+
// Batch-fetch all attendees in one query
|
|
227
|
+
const attendeesByEvent = {};
|
|
228
|
+
if (eventIds.length > 0) {
|
|
229
|
+
const idList = eventIds.join(",");
|
|
230
|
+
const attendeesRaw = query(
|
|
231
|
+
db,
|
|
232
|
+
`
|
|
233
|
+
SELECT
|
|
234
|
+
p.owner_id,
|
|
235
|
+
p.email,
|
|
236
|
+
p.status,
|
|
237
|
+
p.role,
|
|
238
|
+
p.is_self,
|
|
239
|
+
p.entity_type,
|
|
240
|
+
i.display_name
|
|
241
|
+
FROM Participant p
|
|
242
|
+
LEFT JOIN Identity i ON i.ROWID = p.identity_id
|
|
243
|
+
WHERE p.owner_id IN (${idList})
|
|
244
|
+
AND p.entity_type = 7;
|
|
245
|
+
`,
|
|
246
|
+
);
|
|
247
|
+
for (const a of attendeesRaw) {
|
|
248
|
+
attendeesByEvent[a.owner_id] ??= [];
|
|
249
|
+
attendeesByEvent[a.owner_id].push(a);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Write event JSON files
|
|
254
|
+
const writtenIds = new Set();
|
|
255
|
+
for (const ev of events) {
|
|
256
|
+
const eid = ev.id;
|
|
257
|
+
|
|
258
|
+
// Organizer — strip mailto: prefix
|
|
259
|
+
let orgEmail = ev.organizer_email ?? null;
|
|
260
|
+
if (orgEmail?.startsWith("mailto:")) orgEmail = orgEmail.slice(7);
|
|
261
|
+
|
|
262
|
+
// Attendees
|
|
263
|
+
const attendees = [];
|
|
264
|
+
for (const a of attendeesByEvent[eid] ?? []) {
|
|
265
|
+
if (!a.email) continue;
|
|
266
|
+
attendees.push({
|
|
267
|
+
email: a.email,
|
|
268
|
+
name: (a.display_name ?? "").trim() || null,
|
|
269
|
+
status: STATUS_MAP[a.status] ?? "unknown",
|
|
270
|
+
role: ROLE_MAP[a.role] ?? "unknown",
|
|
271
|
+
self: Boolean(a.is_self),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const isAllDay = Boolean(ev.all_day);
|
|
276
|
+
|
|
277
|
+
const eventJson = {
|
|
278
|
+
id: `apple_cal_${eid}`,
|
|
279
|
+
summary: ev.summary,
|
|
280
|
+
start: {
|
|
281
|
+
dateTime: coredataToIso(ev.start_date, ev.start_tz),
|
|
282
|
+
timeZone: ev.start_tz !== "_float" ? ev.start_tz : null,
|
|
283
|
+
},
|
|
284
|
+
end: {
|
|
285
|
+
dateTime: coredataToIso(ev.end_date ?? ev.start_date, ev.end_tz),
|
|
286
|
+
timeZone: ev.end_tz !== "_float" ? ev.end_tz : null,
|
|
287
|
+
},
|
|
288
|
+
allDay: isAllDay,
|
|
289
|
+
location: ev.location || null,
|
|
290
|
+
description: ev.description || null,
|
|
291
|
+
conferenceUrl: ev.conference_url || null,
|
|
292
|
+
calendar: ev.calendar_name || null,
|
|
293
|
+
organizer: orgEmail
|
|
294
|
+
? { email: orgEmail, name: (ev.organizer_name ?? "").trim() || null }
|
|
295
|
+
: null,
|
|
296
|
+
attendees: attendees.length > 0 ? attendees : null,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const filename = `${eid}.json`;
|
|
300
|
+
writeFileSync(join(OUTDIR, filename), JSON.stringify(eventJson, null, 2));
|
|
301
|
+
writtenIds.add(filename);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Clean up events outside the window
|
|
305
|
+
let removed = 0;
|
|
306
|
+
for (const fname of readdirSync(OUTDIR)) {
|
|
307
|
+
if (fname.endsWith(".json") && !writtenIds.has(fname)) {
|
|
308
|
+
unlinkSync(join(OUTDIR, fname));
|
|
309
|
+
removed++;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
console.log("Apple Calendar Sync Complete");
|
|
314
|
+
console.log(`Events synced: ${writtenIds.size}`);
|
|
315
|
+
console.log(
|
|
316
|
+
`Time window: ${start.toISOString().slice(0, 10)} to ${end.toISOString().slice(0, 10)}`,
|
|
317
|
+
);
|
|
318
|
+
console.log(`Files cleaned up: ${removed} (outside window)`);
|
|
319
|
+
console.log(`Output: ${OUTDIR}`);
|
|
320
|
+
} finally {
|
|
321
|
+
db.close();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
main();
|
|
@@ -41,10 +41,10 @@ their email.
|
|
|
41
41
|
|
|
42
42
|
## Implementation
|
|
43
43
|
|
|
44
|
-
Run the sync as a single
|
|
45
|
-
handles all data transformation in one pass:
|
|
44
|
+
Run the sync as a single Node.js script with embedded SQLite. This avoids N+1
|
|
45
|
+
process invocations and handles all data transformation in one pass:
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
node scripts/sync.mjs [--days N]
|
|
48
48
|
|
|
49
49
|
- `--days N` — how many days back to look on first sync (default: 30)
|
|
50
50
|
|
|
@@ -61,7 +61,7 @@ The script:
|
|
|
61
61
|
7. Updates sync state timestamp
|
|
62
62
|
8. Reports summary (threads processed, files written)
|
|
63
63
|
|
|
64
|
-
The script
|
|
64
|
+
The script imports `scripts/parse-emlx.mjs` to extract plain text bodies from
|
|
65
65
|
`.emlx` / `.partial.emlx` files (handles HTML-only emails by stripping tags).
|
|
66
66
|
|
|
67
67
|
## Database Schema
|
|
@@ -128,7 +128,7 @@ Rules:
|
|
|
128
128
|
- `.emlx` / `.partial.emlx` not found → fall back to database summary field
|
|
129
129
|
- `.emlx` parse error → fall back to database summary field
|
|
130
130
|
- HTML-only email → strip tags and use as plain text body (handled by
|
|
131
|
-
parse-emlx.
|
|
131
|
+
parse-emlx.mjs)
|
|
132
132
|
- `find` timeout → skip that message's body, use summary; attachment index empty
|
|
133
133
|
- Attachment file not found on disk → listed as `*(not available)*` in markdown
|
|
134
134
|
- Attachment copy fails (permissions, disk full) → listed as `*(not available)*`
|
|
@@ -137,7 +137,7 @@ Rules:
|
|
|
137
137
|
|
|
138
138
|
## Constraints
|
|
139
139
|
|
|
140
|
-
- Open database read-only (
|
|
140
|
+
- Open database read-only (`readOnly: true`)
|
|
141
141
|
- Only sync Inbox and Sent folders
|
|
142
142
|
- Limit to 500 threads per run
|
|
143
143
|
- Incremental: only threads with new messages since last sync
|