@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
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Sync Apple Calendar events to ~/.cache/fit/basecamp/apple_calendar/ as JSON.
|
|
3
|
-
|
|
4
|
-
Queries the macOS Calendar SQLite database for events in a sliding window
|
|
5
|
-
(past and future) and writes one JSON file per event.
|
|
6
|
-
|
|
7
|
-
Usage: python3 scripts/sync.py [--days N]
|
|
8
|
-
|
|
9
|
-
Options:
|
|
10
|
-
--days N How many days back to sync (default: 30)
|
|
11
|
-
|
|
12
|
-
Requires: macOS with Calendar app configured and Full Disk Access granted.
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
import argparse
|
|
16
|
-
import json
|
|
17
|
-
import os
|
|
18
|
-
import subprocess
|
|
19
|
-
from datetime import datetime, timezone, timedelta
|
|
20
|
-
|
|
21
|
-
EPOCH = datetime(2001, 1, 1, tzinfo=timezone.utc)
|
|
22
|
-
OUTDIR = os.path.expanduser("~/.cache/fit/basecamp/apple_calendar")
|
|
23
|
-
|
|
24
|
-
DB_PATHS = [
|
|
25
|
-
os.path.expanduser(
|
|
26
|
-
"~/Library/Group Containers/group.com.apple.calendar/Calendar.sqlitedb"
|
|
27
|
-
),
|
|
28
|
-
os.path.expanduser("~/Library/Calendars/Calendar.sqlitedb"),
|
|
29
|
-
]
|
|
30
|
-
|
|
31
|
-
STATUS_MAP = {
|
|
32
|
-
0: "unknown",
|
|
33
|
-
1: "pending",
|
|
34
|
-
2: "accepted",
|
|
35
|
-
3: "declined",
|
|
36
|
-
4: "tentative",
|
|
37
|
-
5: "delegated",
|
|
38
|
-
6: "completed",
|
|
39
|
-
7: "in-process",
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
ROLE_MAP = {0: "unknown", 1: "required", 2: "optional", 3: "chair"}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def find_db():
|
|
46
|
-
db = next((p for p in DB_PATHS if os.path.exists(p)), None)
|
|
47
|
-
if not db:
|
|
48
|
-
print("Error: Apple Calendar database not found. Is Calendar configured?")
|
|
49
|
-
exit(1)
|
|
50
|
-
return db
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def query(db, sql):
|
|
54
|
-
result = subprocess.run(
|
|
55
|
-
["sqlite3", "-readonly", "-json", db, sql], capture_output=True, text=True
|
|
56
|
-
)
|
|
57
|
-
if result.returncode != 0:
|
|
58
|
-
if "database is locked" in result.stderr:
|
|
59
|
-
import time
|
|
60
|
-
|
|
61
|
-
time.sleep(2)
|
|
62
|
-
result = subprocess.run(
|
|
63
|
-
["sqlite3", "-readonly", "-json", db, sql],
|
|
64
|
-
capture_output=True,
|
|
65
|
-
text=True,
|
|
66
|
-
)
|
|
67
|
-
if result.returncode != 0:
|
|
68
|
-
print(f"SQLite error: {result.stderr.strip()}")
|
|
69
|
-
return []
|
|
70
|
-
return json.loads(result.stdout) if result.stdout.strip() else []
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def coredata_to_iso(ts, tz_name=None):
|
|
74
|
-
"""Convert Core Data timestamp to ISO 8601."""
|
|
75
|
-
if ts is None:
|
|
76
|
-
return None
|
|
77
|
-
dt = EPOCH + timedelta(seconds=ts)
|
|
78
|
-
if tz_name and tz_name != "_float":
|
|
79
|
-
try:
|
|
80
|
-
from zoneinfo import ZoneInfo
|
|
81
|
-
|
|
82
|
-
dt = dt.astimezone(ZoneInfo(tz_name))
|
|
83
|
-
except Exception:
|
|
84
|
-
pass
|
|
85
|
-
return dt.isoformat()
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def main():
|
|
89
|
-
parser = argparse.ArgumentParser(description="Sync Apple Calendar events.")
|
|
90
|
-
parser.add_argument("--days", type=int, default=30,
|
|
91
|
-
help="How many days back to sync (default: 30)")
|
|
92
|
-
args = parser.parse_args()
|
|
93
|
-
|
|
94
|
-
db = find_db()
|
|
95
|
-
os.makedirs(OUTDIR, exist_ok=True)
|
|
96
|
-
|
|
97
|
-
now = datetime.now(timezone.utc)
|
|
98
|
-
start = now - timedelta(days=args.days)
|
|
99
|
-
end = now + timedelta(days=14)
|
|
100
|
-
START_TS = (start - EPOCH).total_seconds()
|
|
101
|
-
END_TS = (end - EPOCH).total_seconds()
|
|
102
|
-
|
|
103
|
-
# Fetch events with a single query
|
|
104
|
-
events = query(
|
|
105
|
-
db,
|
|
106
|
-
f"""
|
|
107
|
-
SELECT
|
|
108
|
-
ci.ROWID AS id,
|
|
109
|
-
ci.summary,
|
|
110
|
-
ci.start_date,
|
|
111
|
-
ci.end_date,
|
|
112
|
-
ci.start_tz,
|
|
113
|
-
ci.end_tz,
|
|
114
|
-
ci.all_day,
|
|
115
|
-
ci.description,
|
|
116
|
-
ci.has_attendees,
|
|
117
|
-
ci.conference_url,
|
|
118
|
-
loc.title AS location,
|
|
119
|
-
cal.title AS calendar_name,
|
|
120
|
-
org.address AS organizer_email,
|
|
121
|
-
org.display_name AS organizer_name
|
|
122
|
-
FROM CalendarItem ci
|
|
123
|
-
LEFT JOIN Location loc ON loc.ROWID = ci.location_id
|
|
124
|
-
LEFT JOIN Calendar cal ON cal.ROWID = ci.calendar_id
|
|
125
|
-
LEFT JOIN Identity org ON org.ROWID = ci.organizer_id
|
|
126
|
-
WHERE ci.start_date <= {END_TS}
|
|
127
|
-
AND COALESCE(ci.end_date, ci.start_date) >= {START_TS}
|
|
128
|
-
AND ci.summary IS NOT NULL
|
|
129
|
-
AND ci.summary != ''
|
|
130
|
-
ORDER BY ci.start_date ASC
|
|
131
|
-
LIMIT 1000;
|
|
132
|
-
""",
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
# Collect event IDs for batch attendee query
|
|
136
|
-
event_ids = [str(ev["id"]) for ev in events]
|
|
137
|
-
|
|
138
|
-
# Batch-fetch all attendees in one query (avoids N+1)
|
|
139
|
-
attendees_by_event = {}
|
|
140
|
-
if event_ids:
|
|
141
|
-
id_list = ",".join(event_ids)
|
|
142
|
-
attendees_raw = query(
|
|
143
|
-
db,
|
|
144
|
-
f"""
|
|
145
|
-
SELECT
|
|
146
|
-
p.owner_id,
|
|
147
|
-
p.email,
|
|
148
|
-
p.status,
|
|
149
|
-
p.role,
|
|
150
|
-
p.is_self,
|
|
151
|
-
p.entity_type,
|
|
152
|
-
i.display_name
|
|
153
|
-
FROM Participant p
|
|
154
|
-
LEFT JOIN Identity i ON i.ROWID = p.identity_id
|
|
155
|
-
WHERE p.owner_id IN ({id_list})
|
|
156
|
-
AND p.entity_type = 7;
|
|
157
|
-
""",
|
|
158
|
-
)
|
|
159
|
-
for a in attendees_raw:
|
|
160
|
-
oid = a["owner_id"]
|
|
161
|
-
attendees_by_event.setdefault(oid, []).append(a)
|
|
162
|
-
|
|
163
|
-
# Write event JSON files
|
|
164
|
-
written_ids = set()
|
|
165
|
-
for ev in events:
|
|
166
|
-
eid = ev["id"]
|
|
167
|
-
|
|
168
|
-
# Organizer — strip mailto: prefix from Identity.address
|
|
169
|
-
org_email = ev.get("organizer_email") or None
|
|
170
|
-
if org_email and org_email.startswith("mailto:"):
|
|
171
|
-
org_email = org_email[7:]
|
|
172
|
-
|
|
173
|
-
# Attendees
|
|
174
|
-
attendees = []
|
|
175
|
-
for a in attendees_by_event.get(eid, []):
|
|
176
|
-
if not a.get("email"):
|
|
177
|
-
continue
|
|
178
|
-
attendees.append(
|
|
179
|
-
{
|
|
180
|
-
"email": a["email"],
|
|
181
|
-
"name": (a.get("display_name") or "").strip() or None,
|
|
182
|
-
"status": STATUS_MAP.get(a.get("status"), "unknown"),
|
|
183
|
-
"role": ROLE_MAP.get(a.get("role"), "unknown"),
|
|
184
|
-
"self": bool(a.get("is_self")),
|
|
185
|
-
}
|
|
186
|
-
)
|
|
187
|
-
|
|
188
|
-
is_all_day = bool(ev.get("all_day"))
|
|
189
|
-
|
|
190
|
-
event_json = {
|
|
191
|
-
"id": f"apple_cal_{eid}",
|
|
192
|
-
"summary": ev["summary"],
|
|
193
|
-
"start": {
|
|
194
|
-
"dateTime": coredata_to_iso(ev["start_date"], ev.get("start_tz")),
|
|
195
|
-
"timeZone": ev.get("start_tz")
|
|
196
|
-
if ev.get("start_tz") != "_float"
|
|
197
|
-
else None,
|
|
198
|
-
},
|
|
199
|
-
"end": {
|
|
200
|
-
"dateTime": coredata_to_iso(
|
|
201
|
-
ev["end_date"] if ev["end_date"] else ev["start_date"],
|
|
202
|
-
ev.get("end_tz"),
|
|
203
|
-
),
|
|
204
|
-
"timeZone": ev.get("end_tz")
|
|
205
|
-
if ev.get("end_tz") != "_float"
|
|
206
|
-
else None,
|
|
207
|
-
},
|
|
208
|
-
"allDay": is_all_day,
|
|
209
|
-
"location": ev.get("location") or None,
|
|
210
|
-
"description": ev.get("description") or None,
|
|
211
|
-
"conferenceUrl": ev.get("conference_url") or None,
|
|
212
|
-
"calendar": ev.get("calendar_name") or None,
|
|
213
|
-
"organizer": {
|
|
214
|
-
"email": org_email,
|
|
215
|
-
"name": (ev.get("organizer_name") or "").strip() or None,
|
|
216
|
-
}
|
|
217
|
-
if org_email
|
|
218
|
-
else None,
|
|
219
|
-
"attendees": attendees if attendees else None,
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
filepath = os.path.join(OUTDIR, f"{eid}.json")
|
|
223
|
-
with open(filepath, "w") as f:
|
|
224
|
-
json.dump(event_json, f, indent=2)
|
|
225
|
-
written_ids.add(f"{eid}.json")
|
|
226
|
-
|
|
227
|
-
# Clean up events outside the window
|
|
228
|
-
removed = 0
|
|
229
|
-
for fname in os.listdir(OUTDIR):
|
|
230
|
-
if fname.endswith(".json") and fname not in written_ids:
|
|
231
|
-
os.remove(os.path.join(OUTDIR, fname))
|
|
232
|
-
removed += 1
|
|
233
|
-
|
|
234
|
-
print(f"Apple Calendar Sync Complete")
|
|
235
|
-
print(f"Events synced: {len(written_ids)}")
|
|
236
|
-
print(f"Time window: {start.date()} to {end.date()}")
|
|
237
|
-
print(f"Files cleaned up: {removed} (outside window)")
|
|
238
|
-
print(f"Output: {OUTDIR}")
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if __name__ == "__main__":
|
|
242
|
-
main()
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Parse a macOS Mail .emlx or .partial.emlx file and output the plain text body.
|
|
3
|
-
|
|
4
|
-
Usage: python3 scripts/parse-emlx.py <path-to-emlx-file>
|
|
5
|
-
|
|
6
|
-
The .emlx format is: first line = byte count, then RFC822 message, then Apple
|
|
7
|
-
plist. This script extracts and prints the plain text body.
|
|
8
|
-
|
|
9
|
-
If the email has no text/plain part (HTML-only), falls back to stripping HTML
|
|
10
|
-
tags and outputting as plain text.
|
|
11
|
-
|
|
12
|
-
Exit codes:
|
|
13
|
-
0 — success (body printed to stdout)
|
|
14
|
-
1 — file not found or parse error (message on stderr)
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
import email
|
|
18
|
-
import html as html_mod
|
|
19
|
-
import re
|
|
20
|
-
import sys
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def html_to_text(html):
|
|
24
|
-
"""Strip HTML tags and convert to plain text. Uses only stdlib."""
|
|
25
|
-
# Remove style and script blocks
|
|
26
|
-
text = re.sub(
|
|
27
|
-
r"<(style|script)[^>]*>.*?</\1>", "", html, flags=re.DOTALL | re.IGNORECASE
|
|
28
|
-
)
|
|
29
|
-
# Replace br and p tags with newlines
|
|
30
|
-
text = re.sub(r"<br\s*/?\s*>", "\n", text, flags=re.IGNORECASE)
|
|
31
|
-
text = re.sub(r"</p>", "\n", text, flags=re.IGNORECASE)
|
|
32
|
-
# Strip remaining tags
|
|
33
|
-
text = re.sub(r"<[^>]+>", "", text)
|
|
34
|
-
# Decode HTML entities
|
|
35
|
-
text = html_mod.unescape(text)
|
|
36
|
-
# Collapse whitespace
|
|
37
|
-
text = re.sub(r"[ \t]+", " ", text)
|
|
38
|
-
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
39
|
-
return text.strip()
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def extract_body(msg):
|
|
43
|
-
"""Extract plain text body from an email message, with HTML fallback."""
|
|
44
|
-
body = None
|
|
45
|
-
html_body = None
|
|
46
|
-
|
|
47
|
-
if msg.is_multipart():
|
|
48
|
-
for part in msg.walk():
|
|
49
|
-
ct = part.get_content_type()
|
|
50
|
-
if ct == "text/plain" and body is None:
|
|
51
|
-
charset = part.get_content_charset() or "utf-8"
|
|
52
|
-
payload = part.get_payload(decode=True)
|
|
53
|
-
if payload:
|
|
54
|
-
body = payload.decode(charset, errors="replace")
|
|
55
|
-
elif ct == "text/html" and html_body is None:
|
|
56
|
-
charset = part.get_content_charset() or "utf-8"
|
|
57
|
-
payload = part.get_payload(decode=True)
|
|
58
|
-
if payload:
|
|
59
|
-
html_body = payload.decode(charset, errors="replace")
|
|
60
|
-
else:
|
|
61
|
-
ct = msg.get_content_type()
|
|
62
|
-
charset = msg.get_content_charset() or "utf-8"
|
|
63
|
-
payload = msg.get_payload(decode=True)
|
|
64
|
-
if payload:
|
|
65
|
-
text = payload.decode(charset, errors="replace")
|
|
66
|
-
if ct == "text/plain":
|
|
67
|
-
body = text
|
|
68
|
-
elif ct == "text/html":
|
|
69
|
-
html_body = text
|
|
70
|
-
|
|
71
|
-
if body:
|
|
72
|
-
return body
|
|
73
|
-
elif html_body:
|
|
74
|
-
return html_to_text(html_body)
|
|
75
|
-
return None
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def parse_emlx(path):
|
|
79
|
-
try:
|
|
80
|
-
with open(path, "rb") as f:
|
|
81
|
-
byte_count = int(f.readline())
|
|
82
|
-
raw = f.read(byte_count)
|
|
83
|
-
msg = email.message_from_bytes(raw)
|
|
84
|
-
|
|
85
|
-
print(f"From: {msg.get('From', 'Unknown')}")
|
|
86
|
-
print(f"Date: {msg.get('Date', '')}")
|
|
87
|
-
print("---")
|
|
88
|
-
|
|
89
|
-
body = extract_body(msg)
|
|
90
|
-
if body:
|
|
91
|
-
print(body)
|
|
92
|
-
except FileNotFoundError:
|
|
93
|
-
print(f"Error: File not found: {path}", file=sys.stderr)
|
|
94
|
-
sys.exit(1)
|
|
95
|
-
except Exception as e:
|
|
96
|
-
print(f"Error parsing {path}: {e}", file=sys.stderr)
|
|
97
|
-
sys.exit(1)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if __name__ == "__main__":
|
|
101
|
-
if len(sys.argv) != 2:
|
|
102
|
-
print("Usage: python3 scripts/parse-emlx.py <path>", file=sys.stderr)
|
|
103
|
-
sys.exit(1)
|
|
104
|
-
parse_emlx(sys.argv[1])
|