@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.
Files changed (41) hide show
  1. package/config/scheduler.json +5 -0
  2. package/package.json +1 -1
  3. package/src/basecamp.js +288 -57
  4. package/template/.claude/agents/chief-of-staff.md +6 -2
  5. package/template/.claude/agents/concierge.md +2 -3
  6. package/template/.claude/agents/librarian.md +4 -6
  7. package/template/.claude/agents/recruiter.md +269 -0
  8. package/template/.claude/settings.json +0 -4
  9. package/template/.claude/skills/analyze-cv/SKILL.md +269 -0
  10. package/template/.claude/skills/create-presentations/SKILL.md +2 -2
  11. package/template/.claude/skills/create-presentations/references/slide.css +1 -1
  12. package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.mjs +47 -0
  13. package/template/.claude/skills/draft-emails/SKILL.md +85 -123
  14. package/template/.claude/skills/draft-emails/scripts/scan-emails.mjs +66 -0
  15. package/template/.claude/skills/draft-emails/scripts/send-email.mjs +118 -0
  16. package/template/.claude/skills/extract-entities/SKILL.md +2 -2
  17. package/template/.claude/skills/extract-entities/scripts/state.mjs +130 -0
  18. package/template/.claude/skills/manage-tasks/SKILL.md +242 -0
  19. package/template/.claude/skills/organize-files/SKILL.md +3 -3
  20. package/template/.claude/skills/organize-files/scripts/organize-by-type.mjs +105 -0
  21. package/template/.claude/skills/organize-files/scripts/summarize.mjs +84 -0
  22. package/template/.claude/skills/process-hyprnote/SKILL.md +2 -2
  23. package/template/.claude/skills/right-to-be-forgotten/SKILL.md +333 -0
  24. package/template/.claude/skills/send-chat/SKILL.md +170 -0
  25. package/template/.claude/skills/sync-apple-calendar/SKILL.md +5 -5
  26. package/template/.claude/skills/sync-apple-calendar/scripts/sync.mjs +325 -0
  27. package/template/.claude/skills/sync-apple-mail/SKILL.md +6 -6
  28. package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +374 -0
  29. package/template/.claude/skills/sync-apple-mail/scripts/sync.mjs +629 -0
  30. package/template/.claude/skills/track-candidates/SKILL.md +376 -0
  31. package/template/.claude/skills/upstream-skill/SKILL.md +207 -0
  32. package/template/.claude/skills/weekly-update/SKILL.md +250 -0
  33. package/template/CLAUDE.md +68 -40
  34. package/template/.claude/skills/create-presentations/scripts/convert-to-pdf.js +0 -32
  35. package/template/.claude/skills/draft-emails/scripts/scan-emails.sh +0 -34
  36. package/template/.claude/skills/extract-entities/scripts/state.py +0 -100
  37. package/template/.claude/skills/organize-files/scripts/organize-by-type.sh +0 -42
  38. package/template/.claude/skills/organize-files/scripts/summarize.sh +0 -21
  39. package/template/.claude/skills/sync-apple-calendar/scripts/sync.py +0 -242
  40. package/template/.claude/skills/sync-apple-mail/scripts/parse-emlx.py +0 -104
  41. 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])