Skill: bryant24hao/macos-calendar-assistant (version 1.0.0)

--- name: macos-calendar-assistant description: Manage macOS Calendar with OpenClaw in IM-first workflows (Telegram/Discord/Feishu/iMessage/Slack), including screenshot-to-schedule extraction, idempotent create/update, move/extend/reschedule, reminders, conflict checks, daily review sync, and duplicate cleanup. Use when users ask to add/edit/move/postpone events, parse schedule screenshots/chat messages, adjust weekly plans into daily execution, or keep calendar and review notes in sync. --- # macos-calendar-assistant Use bundled scripts for reliable Calendar.app operations. ## Workflow 1. Extract title, start/end, timezone, calendar, location, notes, alarm. 2. Check conflicts before writing: - `scripts/list_events.swift <start_iso> <end_iso>` 3. Prefer idempotent writes: - `scripts/upsert_event.py` (create/update/skip) 4. Apply alarm if requested: - `scripts/set_alarm.py --uid <event_uid> --alarm-minutes <n>` 5. For hygiene, run duplicate scan: - `scripts/calendar_clean.py --start <iso> --end <iso>` ## Calendar routing defaults - Workout / Run / Training → `Training` - Work / Meeting / Client → `Work` - Product / Development / Building → `Product` - Personal / Social / Travel → `Life` - If unspecified: prefer writable iCloud/CalDAV calendars over local calendars. > Note: Calendar names vary by user setup. Map the intent to the closest local calendar name before writing. ## Commands ### List calendars ```bash swift scripts/list_calendars.swift ``` ### List events in range ```bash swift scripts/list_events.swift "2026-03-06T00:00:00+08:00" "2026-03-06T23:59:59+08:00" ``` Output includes `uid` for follow-up alarm/edit operations. ### Idempotent create/update (recommended) ```bash python3 scripts/upsert_event.py \ --title "Team sync" \ --start "2026-03-06T19:00:00+08:00" \ --end "2026-03-06T20:00:00+08:00" \ --calendar "Work" \ --notes "Agenda" \ --location "Online" \ --alarm-minutes 15 ``` Result is one of: `CREATED`, `UPDATED`, `SKIPPED`. Use `--dry-run` for preview. ### Legacy direct add (always creates) ```bash python3 scripts/add_event.py --title "..." --start "..." --end "..." ``` ### Set alarm by UID ```bash python3 scripts/set_alarm.py --uid "EVENT_UID" --alarm-minutes 15 ``` ### Move event (legacy utility) ```bash swift scripts/move_event.swift "Team sync" "Work" "2026-03-07T10:00:00+08:00" 60 --search-days 7 # optional precise match: # --original-start "2026-03-06T10:00:00+08:00" ``` Prefer `upsert_event.py` for most rescheduling flows; use `move_event.swift` for direct title-based move when needed. ### Duplicate scan / cleanup ```bash python3 scripts/calendar_clean.py --start "2026-03-01T00:00:00+08:00" --end "2026-03-08T23:59:59+08:00" python3 scripts/calendar_clean.py --start "..." --end "..." --apply --confirm yes --snapshot-out ./delete-plan.json ``` ### Upcoming events (within 2 hours) ```bash python3 scripts/within_2h.py ``` ### Environment + tests ```bash python3 scripts/env_check.py python3 scripts/regression_test.py scripts/smoke_test.sh ``` ### Daily auto-check notifier ```bash scripts/install.sh # run env check + install cron from config.json scripts/uninstall.sh # remove cron ``` ## Extraction & scheduling heuristics (from real usage) 1. **Speaker ownership from chat screenshots** - Treat the user's message bubble as primary intent. - Treat counterpart bubbles as constraints (availability/travel window), not direct auto-create tasks. 2. **Conflict policy** - If user explicitly says "override" (for example, "replace this slot"), allow replacing an existing slot and reschedule the displaced event. - If not explicit, warn and ask for a choice before overwriting. 3. **Time-window intent parsing** - Phrases like "4–6 PM for the other person" should first be interpreted as an availability window. - Convert to a formal event only after user confirmation. 4. **Reschedule priority** - Prefer moving flexible events (workouts/optional blocks) before strategic P0 work blocks. - Do not auto-move P0 items unless user explicitly requests. 5. **Confirmation prompt template** - Use: "I identified X as your intent and Y as counterpart constraints. I will apply Z. Confirm?" - Keep it short; avoid over-confirming when intent is explicit. ## Constraints - macOS only (EventKit + Calendar permission required) - Default timezone comes from `config.json.timezone` (fallback Asia/Shanghai) when user does not specify - Use `--apply` only after reviewing dry-run output

path: README.md
content: # macos-calendar-assistant

> I use this skill to turn weekly planning, daily execution, summary, and schedule adjustment into one continuous loop.

English | [中文](#中文)

---

## English

## Why I built this skill
Before this, I built a client app called **CalendarAI**. It proved that AI-powered calendar CRUD is feasible.

I also studied a range of scheduling products, including **Toki / Calendly / Clockwise / Motion / Amie**.
Each gave me useful ideas across conversational UX, booking links, team scheduling, auto-rescheduling, and product design.

My conclusion was clear:
high-frequency, natural scheduling is not just about "creating events" — it needs to happen inside the communication tools people already use daily.

After heavily using OpenClaw, I became even more certain:
**managing schedules directly in IM conversations, then syncing into system calendar** is more agentic-friendly and more human-friendly than relying on a standalone client.

I built `macos-calendar-assistant` to close this loop:
- how weekly plans become daily blocks,
- how daily execution gets reviewed,
- and how review outcomes sync back into upcoming schedules.

So calendar is not just a record — it becomes an iterative execution system.

## How I use it
My typical workflow:
1. Create a weekly plan.
2. Use OpenClaw + AI to break it into executable time blocks.
3. Write a daily summary at the end of the day.
4. Adjust upcoming schedules directly from review results.

This creates a full loop: **plan → execute → summarize → adjust**.

## How I use it with IM
This skill works with OpenClaw across IM channels (Telegram / Discord / Feishu / iMessage / Slack).
I can say things like "add this event", "extend to 14:00", or "move to Friday night" in chat, without app switching.

If my calendar is synced via iCloud, the same schedule is visible on both **Mac Calendar** and **iPhone Calendar**.
So any event creation/update/adjustment done through OpenClaw is reflected across desktop and mobile, giving me one consistent calendar system.

### Screenshot-to-schedule (my most common pattern)
I often forward screenshots (group notices, posters, chat screenshots, booking pages):
1. OpenClaw extracts time, location, and context.
2. The skill writes/upserts to Calendar idempotently (no duplicate spam).
3. I refine details in chat (time, location, reminders).

## Problems this solves for me
- Calendar pollution from repeated AI writes
- Drift between plan and real execution
- Review insights not reflected into future schedule
- High adjustment cost within the same week

## Core capabilities
- Idempotent write (`CREATED` / `UPDATED` / `SKIPPED`)
- Calendar/event reads for conflict checks
- UID-based alarm updates
- Duplicate detection with safe cleanup (`--apply --confirm yes`)
- Daily automatic checks (cron)

## Visual workflow
```mermaid
flowchart LR
  A[Weekly Plan] --> B[OpenClaw parsing]
  B --> C[Calendar Skill idempotent upsert]
  C --> D[Calendar time blocks]
  D --> E[Daily execution]
  E --> F[Daily summary]
  F --> G[Daily review]
  G --> H[Adjust tomorrow/rest-of-week schedule]
  H --> C
```

```mermaid
sequenceDiagram
  participant U as Me (IM)
  participant IM as Telegram/Discord/Feishu
  participant OC as OpenClaw
  participant SK as Calendar Skill
  participant CAL as macOS Calendar

  U->>IM: Forward screenshot / send text
  IM->>OC: Message + image
  OC->>OC: Extract time/location/context
  OC->>SK: upsert_event(...)
  SK->>CAL: Idempotent create/update
  CAL-->>OC: CREATED/UPDATED/SKIPPED
  OC-->>U: Confirmation in chat
```

```mermaid
flowchart TD
  A[Run duplicate scan] --> B{delete_candidates > 0?}
  B -- No --> C[Done]
  B -- Yes --> D[Generate snapshot delete plan]
  D --> E[Manual review]
  E --> F{Apply deletion?}
  F -- No --> C
  F -- Yes --> G[--apply --confirm yes]
  G --> H[Deletion result + log]
```

## Real demos

### Demo 1 — Add and edit tasks in chat
Create a task from IM, then update duration in a follow-up message. Changes are synced to Calendar instantly.

![Demo 1 - Add and edit task](./assets/demo-add-edit-task.jpg)

### Demo 2 — Daily review template in Calendar
OpenClaw + skill writes both the review time block and checklist into Calendar, making execute → review → adjust repeatable.

![Demo 2 - Daily review](./assets/demo-daily-review.jpg)

### Demo 3 — Weekly calendar outcome view
A full-week view after IM-driven scheduling, rescheduling, and daily review updates via OpenClaw + skill.

![Demo 3 - Weekly view](./assets/demo-weekly-view.jpg)

## Requirements
- macOS
- Python 3.9+
- Swift (Xcode Command Line Tools)
- Calendar permission granted to terminal/host process

## Quick start
```bash
cd scripts
./install.sh
```

## Common commands
```bash
# Environment check
python3 scripts/env_check.py

# Idempotent create/update
python3 scripts/upsert_event.py \
  --title "Deep work: Product positioning" \
  --start "2026-03-06T10:00:00+08:00" \
  --end "2026-03-06T11:30:00+08:00" \
  --calendar "Product" \
  --notes "Aligned with weekly goals" \
  --alarm-minutes 15

# Duplicate scan (read-only)
python3 scripts/calendar_clean.py --start "2026-03-01T00:00:00+08:00" --end "2026-03-08T23:59:59+08:00"

# Duplicate cleanup (double confirmation)
python3 scripts/calendar_clean.py --start "..." --end "..." --apply --confirm yes --snapshot-out ./delete-plan.json
```

## Tests
```bash
scripts/smoke_test.sh
python3 scripts/regression_test.py
```

## Uninstall
```bash
cd scripts
./uninstall.sh
```

---

## 中文

## 我为什么做这个 Skill
这个 Skill 的背景是：我前段时间做过一个叫 **CalendarAI** 的客户端，已经验证了“调用 AI 做日程增删改查”这件事是可行的。

我也系统研究过一批日程与智能排程产品，包括 **Toki / Calendly / Clockwise / Motion / Amie** 等。
它们分别在对话交互、约会链接、团队排程、自动重排与体验设计上给了我不同启发。
这些研究给了我一个很明确的结论：
真正高频、自然、可持续的日程管理，不只是“能建事件”，而是要把管理动作放进用户每天已经在用的沟通场景里。

所以在我开始重度使用 OpenClaw 后，我更确认了一点：
**直接在 IM 对话里管理日程，再同步到系统日历**，比单独做一个客户端更 agentic-friendly，也更 human-friendly。

我最终做这个 `macos-calendar-assistant`，就是为了把这条链路打通：
- 周计划怎么落地到每天
- 每天执行后怎么复盘
- 复盘结果怎么回写到后续日程

让日程不只是记录，而是持续迭代的执行系统。

## 我主要怎么用
我自己的典型工作流：
1. 每周先写周计划
2. 用 OpenClaw + AI 把计划拆成可执行时间块
3. 每天结束写总结
4. 根据复盘结果直接调整后续日程

这样可以形成“计划—执行—总结—修正”的闭环。

## 我把它接到 IM 里怎么用
这个 Skill 可以配合 OpenClaw 直接接入 IM（Telegram / Discord / Feishu / iMessage / Slack 等）。
我可以在聊天里直接说“加个日程”“延长到14:00”“改到周五晚上”，不需要来回切 App。

如果我的日历走的是 iCloud，同一份日程会自动在 **Mac 端 Calendar** 和 **iPhone 端 Calendar** 同步。
这意味着我在 OpenClaw 里完成的新增/修改/调整，会同时反映到电脑端和手机端，真正做到多端一致的日程管理。

### 截图转日程（我最常用）
我会直接转发截图（群通知、活动海报、聊天截图、预约页面等）：
1. OpenClaw 先识别时间/地点/上下文
2. Skill 幂等写入 Calendar（避免重复）
3. 我在聊天里继续微调（时间、地点、提醒）

## 我解决了哪些痛点
- AI 重复写入导致的日历污染
- 计划和真实执行脱节
- 每日复盘无法同步到后续安排
- 一周内频繁调整成本高

## 核心能力
- 幂等写入（`CREATED` / `UPDATED` / `SKIPPED`）
- 日历/事件读取（冲突检查）
- 按 UID 设置提醒
- 重复检测与安全清理（`--apply --confirm yes`）
- 每日自动检查（cron）

## 可视化流程
```mermaid
flowchart LR
  A[周计划] --> B[OpenClaw 解析]
  B --> C[Calendar Skill 幂等写入]
  C --> D[日历时间块]
  D --> E[每日执行]
  E --> F[每日总结]
  F --> G[每日复盘]
  G --> H[调整明天/本周剩余安排]
  H --> C
```

```mermaid
sequenceDiagram
  participant U as 我(IM)
  participant IM as Telegram/Discord/Feishu
  participant OC as OpenClaw
  participant SK as Calendar Skill
  participant CAL as macOS Calendar

  U->>IM: 转发截图/发送文字
  IM->>OC: 消息 + 图片
  OC->>OC: 提取时间地点上下文
  OC->>SK: upsert_event(...)
  SK->>CAL: 幂等创建/更新
  CAL-->>OC: CREATED/UPDATED/SKIPPED
  OC-->>U: 聊天内确认结果
```

```mermaid
flowchart TD
  A[运行重复扫描] --> B{delete_candidates > 0?}
  B -- 否 --> C[结束]
  B -- 是 --> D[生成 snapshot 删除计划]
  D --> E[人工确认]
  E --> F{是否执行删除}
  F -- 否 --> C
  F -- 是 --> G[--apply --confirm yes]
  G --> H[输出删除结果与日志]
```

## 真实示例图

### 示例 1 — 在聊天中添加并编辑任务
先在 IM 中创建任务，再通过后续消息修改时长，变更会即时同步到日历。

![示例1-添加编辑任务](./assets/demo-add-edit-task.jpg)

### 示例 2 — 每日复盘模板写入日历
OpenClaw + skill 会把复盘时间块和复盘清单一起写入 Calendar，让执行→复盘→调整形成可持续闭环。

![示例2-每日复盘](./assets/demo-daily-review.jpg)

### 示例 3 — 一周结果总览
通过 OpenClaw + skill 在 IM 中持续调度、改期与复盘后，最终形成的一周日程全貌。

![示例3-一周视图](./assets/demo-weekly-view.jpg)

## 环境要求
- macOS
- Python 3.9+
- Swift（Xcode Command Line Tools）
- 终端/宿主进程已授予 Calendar 权限

## 快速开始
```bash
cd scripts
./install.sh
```

## 常用命令
```bash
# 环境自检
python3 scripts/env_check.py

# 幂等创建/更新
python3 scripts/upsert_event.py \
  --title "深度工作：产品定位" \
  --start "2026-03-06T10:00:00+08:00" \
  --end "2026-03-06T11:30:00+08:00" \
  --calendar "产品" \
  --notes "与周目标对齐" \
  --alarm-minutes 15

# 重复扫描（仅检查）
python3 scripts/calendar_clean.py --start "2026-03-01T00:00:00+08:00" --end "2026-03-08T23:59:59+08:00"

# 重复清理（双保险）
python3 scripts/calendar_clean.py --start "..." --end "..." --apply --confirm yes --snapshot-out ./delete-plan.json
```

## 测试
```bash
scripts/smoke_test.sh
python3 scripts/regression_test.py
```

## 卸载
```bash
cd scripts
./uninstall.sh
```


path: config.example.json
content: {
  "timezone": "Asia/Shanghai",
  "check_time": "22:20",
  "lookback_days": 1,
  "lookahead_days": 0,
  "output_dir": "~/calendar-assistant/logs",
  "notification": {
    "enabled": true,
    "title": "Calendar Assistant Reminder"
  },
  "dedup": {
    "prefer_calendars": [],
    "deprioritize_calendars": []
  }
}


path: scripts/list_calendars.swift
content: #!/usr/bin/env swift
import EventKit
import Foundation

// Define output structures
struct CalendarInfo: Codable {
    let name: String
    let account: String
    let type: String
    let writable: Bool
    let isCloud: Bool
}

struct Output: Codable {
    let calendars: [CalendarInfo]
}

let store = EKEventStore()

// Request calendar access (macOS 14+ uses requestFullAccessToEvents)
let semaphore = DispatchSemaphore(value: 0)
var accessGranted = false

if #available(macOS 14.0, *) {
    store.requestFullAccessToEvents { granted, error in
        accessGranted = granted
        semaphore.signal()
    }
} else {
    store.requestAccess(to: .event) { granted, error in
        accessGranted = granted
        semaphore.signal()
    }
}
semaphore.wait()

guard accessGranted else {
    print("{\"calendars\": [], \"error\": \"Calendar access denied\"}")
    exit(0)
}

let calendars = store.calendars(for: .event)

var infos: [CalendarInfo] = []

for cal in calendars {
    guard let source = cal.source else { continue }
    
    var typeString = "Unknown"
    var isCloud = false
    
    switch source.sourceType {
    case .local:
        typeString = "Local"
        isCloud = false
    case .exchange:
        typeString = "Exchange"
        isCloud = true
    case .calDAV:
        typeString = "CalDAV (iCloud/Google)"
        isCloud = true
    case .mobileMe:
        typeString = "MobileMe"
        isCloud = true
    case .subscribed:
        typeString = "Subscribed"
        isCloud = true
    case .birthdays:
        typeString = "Birthdays"
        isCloud = false // Treated as local read-only usually
    @unknown default:
        typeString = "Other"
    }
    
    // Check writability
    let isWritable = cal.allowsContentModifications
    
    infos.append(CalendarInfo(
        name: cal.title,
        account: source.title,
        type: typeString,
        writable: isWritable,
        isCloud: isCloud
    ))
}

// Output JSON
let output = Output(calendars: infos)
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
if let data = try? encoder.encode(output), let jsonString = String(data: data, encoding: .utf8) {
    print(jsonString)
}


path: scripts/set_alarm.py
content: #!/usr/bin/env python3
import argparse
import subprocess


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--uid", required=True)
    ap.add_argument("--alarm-minutes", type=int, required=True)
    args = ap.parse_args()

    uid = args.uid.replace('"', '\\"')
    minutes_before = int(args.alarm_minutes)

    APPLESCRIPT = f'''
set eventUid to "{uid}"
set minutesBefore to {minutes_before}

tell application "Calendar"
  set foundEvent to missing value
  set foundCal to missing value

  repeat with c in calendars
    try
      set matches to (every event of c whose uid is eventUid)
      if (count of matches) > 0 then
        set foundEvent to item 1 of matches
        set foundCal to c
        exit repeat
      end if
    end try
  end repeat

  if foundEvent is missing value then error "Event not found: " & eventUid

  try
    repeat with a in (every display alarm of foundEvent)
      delete a
    end repeat
  end try

  make new display alarm at end of display alarms of foundEvent with properties {{trigger interval:(-minutesBefore * minutes)}}
  return "OK alarm set: " & minutesBefore & " min before (calendar=" & (name of foundCal) & ")"
end tell
'''

    p = subprocess.run(["osascript"], input=APPLESCRIPT, capture_output=True, text=True)
    if p.returncode != 0:
        raise SystemExit(p.stderr.strip() or p.stdout.strip() or f"osascript failed: {p.returncode}")
    print(p.stdout.strip())


if __name__ == "__main__":
    main()


path: scripts/uninstall.sh
content: #!/bin/zsh
set -euo pipefail

TMPCRON=$(mktemp /tmp/mca_cron_remove_XXXXXX)
trap 'rm -f "$TMPCRON"' EXIT

( crontab -l 2>/dev/null || true ) | sed '/calendar_clean_notify.sh/d' > "$TMPCRON"
crontab "$TMPCRON"

echo "Removed calendar_clean_notify.sh cron job."


path: scripts/install.sh
content: #!/bin/zsh
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_PATH="$SKILL_DIR/config.json"

if [[ ! -f "$CONFIG_PATH" ]]; then
  cp "$SKILL_DIR/config.example.json" "$CONFIG_PATH"
  echo "Created $CONFIG_PATH (please review it)."
fi

CHECK_TIME=$(/usr/bin/python3 - <<'PY' "$CONFIG_PATH"
import json,sys
cfg=json.load(open(sys.argv[1]))
print(cfg.get('check_time','22:20'))
PY
)

HOUR="${CHECK_TIME%%:*}"
MIN="${CHECK_TIME##*:}"
if [[ -z "$HOUR" || -z "$MIN" ]]; then
  echo "Invalid check_time in config.json, expected HH:MM"
  exit 1
fi

chmod +x "$SCRIPT_DIR/calendar_clean_notify.sh" "$SCRIPT_DIR/calendar_clean.py" "$SCRIPT_DIR/upsert_event.py" "$SCRIPT_DIR/env_check.py" "$SCRIPT_DIR/regression_test.py"

TMPENV=$(mktemp /tmp/mca_env_check_XXXXXX)
trap 'rm -f "$TMPENV" "$TMPCRON"' EXIT

/usr/bin/python3 "$SCRIPT_DIR/env_check.py" >"$TMPENV"

echo "Environment check passed."

TMPCRON=$(mktemp /tmp/mca_cron_XXXXXX)
( crontab -l 2>/dev/null || true ) | sed '/calendar_clean_notify.sh/d' > "$TMPCRON"

echo "$MIN $HOUR * * * CAL_SKILL_CONFIG=$CONFIG_PATH $SCRIPT_DIR/calendar_clean_notify.sh" >> "$TMPCRON"
crontab "$TMPCRON"

echo "Installed cron: $MIN $HOUR * * * calendar_clean_notify.sh"


path: scripts/add_event.py
content: #!/usr/bin/env python3
import argparse
import subprocess
import datetime as dt


def parse_iso(s: str) -> dt.datetime:
    # Python 3.11+ supports fromisoformat with timezone offset
    try:
        d = dt.datetime.fromisoformat(s)
    except Exception as e:
        raise SystemExit(f"Invalid --start/--end ISO datetime: {s} ({e})")
    if d.tzinfo is None:
        raise SystemExit(f"Timezone required in ISO string: {s}")
    return d


def to_applescript_date(d: dt.datetime) -> str:
    # Convert to local time components (Calendar expects local 'date' object)
    local = d.astimezone()
    return (
        f"set _d to (current date)\n"
        f"set year of _d to {local.year}\n"
        f"set month of _d to {local.month}\n"
        f"set day of _d to {local.day}\n"
        f"set hours of _d to {local.hour}\n"
        f"set minutes of _d to {local.minute}\n"
        f"set seconds of _d to {local.second}\n"
    )


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--title", required=True)
    ap.add_argument("--start", required=True)
    ap.add_argument("--end", required=True)
    ap.add_argument("--calendar", default="")
    ap.add_argument("--notes", default="")
    ap.add_argument("--location", default="")
    ap.add_argument("--alarm-minutes", type=int, default=None)
    args = ap.parse_args()

    start = parse_iso(args.start)
    end = parse_iso(args.end)
    if end <= start:
        raise SystemExit("--end must be after --start")

    # Escape quotes for AppleScript string literals
    def esc(s: str) -> str:
        return s.replace("\\", "\\\\").replace('"', '\\"')

    cal_name = esc(args.calendar)
    title = esc(args.title)
    notes = esc(args.notes)
    location = esc(args.location)

    alarm_block = ""
    if args.alarm_minutes is not None:
        alarm_block = (
            "\n"
            "  -- Remove existing display alarms to avoid duplicates\n"
            "  try\n"
            "    repeat with a in (every display alarm of newEvent)\n"
            "      delete a\n"
            "    end repeat\n"
            "  end try\n"
            f"  make new display alarm at end of display alarms of newEvent with properties {{trigger interval:(-{int(args.alarm_minutes)} * minutes)}}\n"
        )

    start_as = to_applescript_date(start) + "set startDate to _d\n"
    end_as = to_applescript_date(end) + "set endDate to _d\n"

    APPLESCRIPT = f'''
set eventTitle to "{title}"
set eventNotes to "{notes}"
set eventLocation to "{location}"
set desiredCalendarName to "{cal_name}"

{start_as}
{end_as}

tell application "Calendar"
  set targetCal to missing value

  if desiredCalendarName is not "" then
    repeat with c in calendars
      try
        if (name of c is desiredCalendarName) and (writable of c is true) then
          set targetCal to c
          exit repeat
        end if
      end try
    end repeat
  end if

  if targetCal is missing value then
    repeat with c in calendars
      try
        if writable of c is true then
          set targetCal to c
          exit repeat
        end if
      end try
    end repeat
  end if

  if targetCal is missing value then set targetCal to calendar 1

  set newEvent to make new event at end of events of targetCal with properties {{summary:eventTitle, start date:startDate, end date:endDate, description:eventNotes, location:eventLocation}}
{alarm_block}
  set calName to name of targetCal
  set eventId to uid of newEvent
end tell

return "OK calendar=" & calName & " uid=" & eventId
'''

    p = subprocess.run(["osascript"], input=APPLESCRIPT, capture_output=True, text=True)
    if p.returncode != 0:
        raise SystemExit(p.stderr.strip() or p.stdout.strip() or f"osascript failed: {p.returncode}")

    print(p.stdout.strip())


if __name__ == "__main__":
    main()


path: scripts/calendar_clean.py
content: #!/usr/bin/env python3
import argparse
import datetime as dt
import json
import subprocess
from pathlib import Path
from collections import defaultdict

from config_utils import load_config, get_zoneinfo


def parse_iso(s: str) -> dt.datetime:
    d = dt.datetime.fromisoformat(s)
    if d.tzinfo is None:
        raise SystemExit(f"Timezone required: {s}")
    return d


def list_events(start_iso: str, end_iso: str):
    list_events_swift = str(Path(__file__).resolve().parent / "list_events.swift")
    cmd = ["swift", list_events_swift, start_iso, end_iso]
    r = subprocess.run(cmd, capture_output=True, text=True, check=True)
    return json.loads(r.stdout).get("events", [])


def norm_title(t: str) -> str:
    return " ".join((t or "").strip().lower().split())


TZ = get_zoneinfo()
CFG = load_config()


def ts_to_iso(ts: int) -> str:
    return dt.datetime.fromtimestamp(ts, TZ).isoformat()


def _swift_escape(s: str) -> str:
    return (s or "").replace("\\", "\\\\").replace('"', '\\"')


def delete_events(targets):
    # targets: list[(calendar,title,start_ts)]
    rows = []
    for c, t, s in targets:
        rows.append(f'.init(title:"{_swift_escape(t)}", calendar:"{_swift_escape(c)}", start:{s})')
    swift_targets = ",\n".join(rows)

    code = f'''import EventKit
import Foundation

struct Target {{ let title:String; let calendar:String; let start:Double }}
let targets:[Target] = [
{swift_targets}
]
let store = EKEventStore()
let sem = DispatchSemaphore(value: 0)
store.requestAccess(to: .event) {{ granted, _ in
  guard granted else {{ print("access_denied"); sem.signal(); return }}
  var removed=0
  for t in targets {{
    guard let cal = store.calendars(for: .event).first(where: {{$0.title == t.calendar}}) else {{ continue }}
    let d = Date(timeIntervalSince1970: t.start)
    let pred = store.predicateForEvents(withStart: d, end: d.addingTimeInterval(60), calendars: [cal])
    let events = store.events(matching: pred).filter{{$0.title == t.title && abs($0.startDate.timeIntervalSince1970 - t.start) < 1}}
    for e in events {{ do {{ try store.remove(e, span: .thisEvent); removed += 1 }} catch {{}} }}
  }}
  print("removed=\\(removed)")
  sem.signal()
}}
sem.wait()
'''
    p = subprocess.run(["swift", "-"], input=code, capture_output=True, text=True)
    if p.returncode != 0:
        raise SystemExit(p.stderr or p.stdout)
    return p.stdout.strip()


def build_findings(events):
    # exact duplicate by same calendar + same start + normalized title
    groups = defaultdict(list)
    for e in events:
        if e.get("isAllDay"):
            continue
        key = (e.get("calendar", ""), int(e.get("start", 0)), norm_title(e.get("title", "")))
        groups[key].append(e)

    to_delete = []
    findings = []

    for (cal, start_ts, _), arr in groups.items():
        if len(arr) <= 1:
            continue
        arr_sorted = sorted(arr, key=lambda x: (len(x.get("notes", "") or ""), len(x.get("title", "") or "")), reverse=True)
        keep = arr_sorted[0]
        drops = arr_sorted[1:]
        findings.append({
            "type": "exact-duplicate",
            "calendar": cal,
            "start": ts_to_iso(start_ts),
            "keep": keep.get("title", ""),
            "drop": [d.get("title", "") for d in drops],
        })
        for d in drops:
            to_delete.append((d.get("calendar", ""), d.get("title", ""), int(d.get("start", 0))))

    # cross-calendar duplicates in same slot + same title
    slot_map = defaultdict(list)
    for e in events:
        if e.get("isAllDay"):
            continue
        slot_map[(int(e.get("start", 0)), int(e.get("end", 0)))].append(e)

    for (s, en), arr in slot_map.items():
        if len(arr) < 2:
            continue
        by_title = defaultdict(list)
        for e in arr:
            by_title[norm_title(e.get("title", ""))].append(e)

        for _, same in by_title.items():
            if len(same) < 2:
                continue
            # Prefer calendars by configurable priority / depriority
            priority = set(CFG.get("dedup", {}).get("prefer_calendars", []))
            depriority = set(CFG.get("dedup", {}).get("deprioritize_calendars", ["交流", "日历"]))

            def sort_key(ev):
                cal = ev.get("calendar", "")
                return (0 if cal in priority else 1, 1 if cal in depriority else 0)

            keep = sorted(same, key=sort_key)[0]
            drops = [x for x in same if x is not keep]
            if not drops:
                continue

            findings.append({
                "type": "cross-calendar-duplicate",
                "start": ts_to_iso(s),
                "end": ts_to_iso(en),
                "title": same[0].get("title", ""),
                "keep_calendar": keep.get("calendar", ""),
                "drop_calendars": [d.get("calendar", "") for d in drops],
            })
            for d in drops:
                k = (d.get("calendar", ""), d.get("title", ""), int(d.get("start", 0)))
                if k not in to_delete:
                    to_delete.append(k)

    return findings, to_delete


def main():
    ap = argparse.ArgumentParser(description="Detect and clean duplicated/overlapped calendar events.")
    ap.add_argument("--start", required=True)
    ap.add_argument("--end", required=True)
    ap.add_argument("--apply", action="store_true", help="Actually delete suggested duplicates")
    ap.add_argument("--confirm", default="", help="Required for deletion: set --confirm yes")
    ap.add_argument("--snapshot-out", default="", help="Write delete plan JSON before apply")
    args = ap.parse_args()

    _ = parse_iso(args.start)
    _ = parse_iso(args.end)

    events = list_events(args.start, args.end)
    findings, to_delete = build_findings(events)

    output = {
        "range": {"start": args.start, "end": args.end},
        "total_events": len(events),
        "findings": findings,
        "delete_candidates": len(to_delete),
    }
    print(json.dumps(output, ensure_ascii=False, indent=2))

    if args.snapshot_out:
        snap = {
            "generated_at": dt.datetime.now(TZ).isoformat(),
            "range": output["range"],
            "delete_targets": [
                {"calendar": c, "title": t, "start": s, "start_iso": ts_to_iso(s)}
                for c, t, s in to_delete
            ],
        }
        Path(args.snapshot_out).write_text(json.dumps(snap, ensure_ascii=False, indent=2))

    if args.apply:
        if args.confirm.lower() != "yes":
            raise SystemExit("Refusing to delete without explicit confirmation. Use: --apply --confirm yes")
        if not to_delete:
            print("removed=0")
            return
        res = delete_events(to_delete)
        print(res)


if __name__ == "__main__":
    main()


path: scripts/regression_test.py
content: #!/usr/bin/env python3
import datetime as dt
import json
import subprocess
import tempfile
import uuid
from pathlib import Path

from config_utils import get_zoneinfo

SCRIPT_DIR = Path(__file__).resolve().parent
TZ = get_zoneinfo()


def run(cmd, check=True):
    p = subprocess.run(cmd, capture_output=True, text=True)
    if check and p.returncode != 0:
        raise RuntimeError(f"cmd failed: {' '.join(cmd)}\n{p.stdout}\n{p.stderr}")
    return p


def iso(d):
    return d.astimezone(TZ).isoformat(timespec="seconds")


def parse_candidates(clean_output: str):
    data = json.loads(clean_output)
    return int(data.get("delete_candidates", 0))


def main():
    now = dt.datetime.now(TZ).replace(minute=0, second=0, microsecond=0) + dt.timedelta(days=3)
    start = now
    end = now + dt.timedelta(minutes=30)
    title = f"[回归测试]{uuid.uuid4().hex[:8]}"
    cal = "产品"

    # 1) create
    p1 = run([
        "python3", str(SCRIPT_DIR / "upsert_event.py"),
        "--title", title,
        "--start", iso(start),
        "--end", iso(end),
        "--calendar", cal,
        "--notes", "regression-create",
        "--alarm-minutes", "5",
    ])
    if not any(x in p1.stdout for x in ["CREATED", "UPDATED", "SKIPPED"]):
        raise RuntimeError("unexpected upsert create output")

    # 2) idempotent
    p2 = run([
        "python3", str(SCRIPT_DIR / "upsert_event.py"),
        "--title", title,
        "--start", iso(start),
        "--end", iso(end),
        "--calendar", cal,
        "--notes", "regression-create",
        "--alarm-minutes", "5",
    ])
    if not any(x in p2.stdout for x in ["SKIPPED", "UPDATED"]):
        raise RuntimeError("expected SKIPPED/UPDATED in idempotent run")

    # 3) update
    p3 = run([
        "python3", str(SCRIPT_DIR / "upsert_event.py"),
        "--title", title,
        "--start", iso(start),
        "--end", iso(end + dt.timedelta(minutes=15)),
        "--calendar", cal,
        "--notes", "regression-updated",
        "--alarm-minutes", "10",
    ])
    if not any(x in p3.stdout for x in ["UPDATED", "SKIPPED"]):
        raise RuntimeError("expected UPDATED/SKIPPED in update run")

    # 4) create a deliberate duplicate via legacy add_event
    run([
        "python3", str(SCRIPT_DIR / "add_event.py"),
        "--title", title,
        "--start", iso(start),
        "--end", iso(end + dt.timedelta(minutes=15)),
        "--calendar", cal,
        "--notes", "regression-duplicate",
    ])

    range_start = iso(start - dt.timedelta(hours=1))
    range_end = iso(end + dt.timedelta(hours=2))
    snapshot_path = Path(tempfile.gettempdir()) / f"mca-regression-delete-plan-{uuid.uuid4().hex[:6]}.json"

    # 5) clean dry-run should find duplicate
    c1 = run([
        "python3", str(SCRIPT_DIR / "calendar_clean.py"),
        "--start", range_start,
        "--end", range_end,
        "--snapshot-out", str(snapshot_path),
    ])
    cand1 = parse_candidates(c1.stdout)
    if cand1 < 1:
        raise RuntimeError("expected duplicate candidates >= 1")

    # 6) apply with confirm
    run([
        "python3", str(SCRIPT_DIR / "calendar_clean.py"),
        "--start", range_start,
        "--end", range_end,
        "--apply", "--confirm", "yes",
    ])

    # 7) verify candidates reduced to 0 for this narrow window
    c2 = run([
        "python3", str(SCRIPT_DIR / "calendar_clean.py"),
        "--start", range_start,
        "--end", range_end,
    ])
    cand2 = parse_candidates(c2.stdout)
    if cand2 != 0:
        raise RuntimeError(f"expected duplicate candidates 0 after cleanup, got {cand2}")

    # 8) cleanup remaining test event
    cleanup = run([
        "python3", str(SCRIPT_DIR / "calendar_clean.py"),
        "--start", range_start,
        "--end", range_end,
        "--snapshot-out", str(snapshot_path),
    ])
    # If no candidates, remove exact by title/start as a fallback.
    if parse_candidates(cleanup.stdout) == 0:
        swift_code = f'''import EventKit\nimport Foundation\nlet store = EKEventStore()\nlet fmt = ISO8601DateFormatter()\nlet sem = DispatchSemaphore(value: 0)\nstore.requestAccess(to: .event) {{ granted, _ in\n  guard granted else {{ sem.signal(); return }}\n  guard let start = fmt.date(from: "{iso(start)}") else {{ sem.signal(); return }}\n  guard let cal = store.calendars(for: .event).first(where: {{$0.title == "{cal}"}}) else {{ sem.signal(); return }}\n  let pred = store.predicateForEvents(withStart: start, end: start.addingTimeInterval(60), calendars: [cal])\n  let events = store.events(matching: pred).filter {{$0.title == "{title}"}}\n  for e in events {{ try? store.remove(e, span: .thisEvent) }}\n  sem.signal()\n}}\nsem.wait()\n'''
        subprocess.run(["swift", "-"], input=swift_code, capture_output=True, text=True)

    print(json.dumps({
        "ok": True,
        "title": title,
        "range": {"start": range_start, "end": range_end},
        "snapshot": str(snapshot_path),
    }, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: scripts/within_2h.py
content: #!/usr/bin/env python3
import json
import subprocess
from datetime import datetime, timedelta
from pathlib import Path


def parse_iso(s: str) -> datetime:
    return datetime.fromisoformat(s.replace("Z", "+00:00"))


def main():
    now = datetime.now().astimezone()
    horizon = now + timedelta(hours=2)
    list_events_swift = str(Path(__file__).resolve().parent / "list_events.swift")

    # Query a wider range to include today+tomorrow context, then filter in Python.
    from_iso = now.isoformat(timespec="seconds")
    to_iso = (now + timedelta(hours=48)).isoformat(timespec="seconds")

    out = subprocess.check_output([
        "swift",
        list_events_swift,
        from_iso,
        to_iso,
    ], text=True)

    events = json.loads(out).get("events", [])
    upcoming = []
    for ev in events:
        if ev.get("isAllDay"):
            continue
        start_ts = int(ev.get("start", 0))
        start = datetime.fromtimestamp(start_ts).astimezone()
        if now < start <= horizon:
            upcoming.append(ev)

    print(json.dumps({
        "now": now.isoformat(),
        "horizon": horizon.isoformat(),
        "count": len(upcoming),
        "events": upcoming,
    }, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: scripts/move_event.swift
content: import EventKit
import Foundation

let store = EKEventStore()
let calendar = Calendar.current
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withInternetDateTime, .withDashSeparatorInDate, .withColonSeparatorInTime, .withTimeZone]

func parseDate(_ iso: String) -> Date? {
    return dateFormatter.date(from: iso)
}

if CommandLine.arguments.count < 5 {
    print("Usage: move_event.swift <Title> <Calendar> <NewStartISO> <DurationMinutes> [--original-start <ISO>] [--search-days <N>]")
    exit(1)
}

let targetTitle = CommandLine.arguments[1]
let targetCalendarName = CommandLine.arguments[2]
let newStartISO = CommandLine.arguments[3]
let durationMinutes = Double(CommandLine.arguments[4])!

var originalStart: Date? = nil
var searchDays = 7

var i = 5
while i < CommandLine.arguments.count {
    let arg = CommandLine.arguments[i]
    if arg == "--original-start", i + 1 < CommandLine.arguments.count {
        originalStart = parseDate(CommandLine.arguments[i + 1])
        i += 2
        continue
    }
    if arg == "--search-days", i + 1 < CommandLine.arguments.count {
        searchDays = max(1, Int(CommandLine.arguments[i + 1]) ?? 7)
        i += 2
        continue
    }
    i += 1
}

guard let newStartDate = parseDate(newStartISO) else {
    print("Error: Invalid date format (use ISO8601)")
    exit(1)
}

let semaphore = DispatchSemaphore(value: 0)

store.requestAccess(to: .event) { granted, _ in
    guard granted else {
        print("Access denied")
        semaphore.signal()
        return
    }

    let calendars = store.calendars(for: .event)
    guard let cal = calendars.first(where: { $0.title == targetCalendarName }) else {
        print("Error: Calendar '\(targetCalendarName)' not found")
        semaphore.signal()
        return
    }

    let startSearch: Date
    let endSearch: Date

    if let o = originalStart {
        let dayStart = calendar.startOfDay(for: o)
        startSearch = calendar.date(byAdding: .day, value: -1, to: dayStart)!
        endSearch = calendar.date(byAdding: .day, value: 2, to: dayStart)!
    } else {
        let now = Date()
        let dayStart = calendar.startOfDay(for: now)
        startSearch = calendar.date(byAdding: .day, value: -searchDays, to: dayStart)!
        endSearch = calendar.date(byAdding: .day, value: searchDays + 1, to: dayStart)!
    }

    let predicate = store.predicateForEvents(withStart: startSearch, end: endSearch, calendars: [cal])
    let events = store.events(matching: predicate)

    let matched: EKEvent?
    if let o = originalStart {
        matched = events.first(where: { $0.title == targetTitle && abs($0.startDate.timeIntervalSince(o)) < 60 })
            ?? events.first(where: { $0.title == targetTitle })
    } else {
        matched = events.first(where: { $0.title == targetTitle })
    }

    if let event = matched {
        event.startDate = newStartDate
        event.endDate = newStartDate.addingTimeInterval(durationMinutes * 60)

        do {
            try store.save(event, span: .thisEvent)
            print("moved: \(event.title ?? targetTitle) -> \(newStartDate)")
        } catch {
            print("Error saving: \(error)")
        }
    } else {
        print("Error: Event '\(targetTitle)' not found in search window")
    }

    semaphore.signal()
}

semaphore.wait()


path: scripts/smoke_test.sh
content: #!/bin/zsh
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"

TMP_CALS=$(mktemp /tmp/mca_calendars_XXXXXX)
TMP_EVTS=$(mktemp /tmp/mca_events_XXXXXX)
TMP_CLEAN=$(mktemp /tmp/mca_clean_XXXXXX)
TMP_UPSERT=$(mktemp /tmp/mca_upsert_XXXXXX)

cleanup() { rm -f "$TMP_CALS" "$TMP_EVTS" "$TMP_CLEAN" "$TMP_UPSERT"; }
trap cleanup EXIT

VALS=(${(s: :)$(python3 - <<'PY' "$SCRIPT_DIR"
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from pathlib import Path
import json,sys
script_dir=Path(sys.argv[1])
cfg={}
cp=script_dir.parent/'config.json'
if cp.exists():
    try: cfg=json.loads(cp.read_text())
    except: cfg={}
tz=ZoneInfo(cfg.get('timezone','Asia/Shanghai'))
now=datetime.now(tz).replace(second=0,microsecond=0)
day=(now+timedelta(days=1)).strftime('%Y-%m-%d')
start_day=f"{day}T00:00:00{now.strftime('%z')[:3]}:{now.strftime('%z')[3:]}"
end_day=f"{day}T23:59:59{now.strftime('%z')[:3]}:{now.strftime('%z')[3:]}"
up_start=(now+timedelta(days=2)).replace(hour=10,minute=0)
up_end=up_start+timedelta(minutes=30)
clean_start=(now-timedelta(days=3)).replace(hour=0,minute=0)
clean_end=(now+timedelta(days=3)).replace(hour=23,minute=59)
fmt=lambda d:d.isoformat(timespec='seconds')
print(start_day, end_day, fmt(up_start), fmt(up_end), fmt(clean_start), fmt(clean_end))
PY
)})

START_DAY=${VALS[1]}
END_DAY=${VALS[2]}
UPSERT_START=${VALS[3]}
UPSERT_END=${VALS[4]}
CLEAN_START=${VALS[5]}
CLEAN_END=${VALS[6]}

echo "[1/4] list_calendars.swift"
swift "$SCRIPT_DIR/list_calendars.swift" >"$TMP_CALS"

echo "[2/4] list_events.swift"
swift "$SCRIPT_DIR/list_events.swift" "$START_DAY" "$END_DAY" >"$TMP_EVTS"

echo "[3/4] upsert_event.py --dry-run"
python3 "$SCRIPT_DIR/upsert_event.py" \
  --title "[测试] mca smoke" \
  --start "$UPSERT_START" \
  --end "$UPSERT_END" \
  --calendar "产品" \
  --notes "smoke" \
  --alarm-minutes 10 \
  --dry-run >"$TMP_UPSERT"

echo "[4/4] calendar_clean.py"
python3 "$SCRIPT_DIR/calendar_clean.py" \
  --start "$CLEAN_START" \
  --end "$CLEAN_END" >"$TMP_CLEAN"

echo "---- smoke outputs ----"
cat "$TMP_UPSERT"
python3 - "$TMP_CALS" "$TMP_EVTS" "$TMP_CLEAN" <<'PY'
import json,sys
for p in sys.argv[1:]:
    try:
        json.load(open(p))
        print('OK JSON', p)
    except Exception as e:
        print('BAD JSON', p, e)
        raise
print('SMOKE_TEST_OK')
PY


path: scripts/list_events.swift
content: #!/usr/bin/env swift
import EventKit
import Foundation

// Define output structure
struct EventInfo: Codable {
    let uid: String
    let title: String
    let start: Double // Timestamp
    let end: Double   // Timestamp
    let isAllDay: Bool
    let calendar: String
    let location: String?
    let notes: String?
}

struct Output: Codable {
    let events: [EventInfo]
}

let store = EKEventStore()

// Args: start ISO8601, end ISO8601
// Example: 2026-03-03T00:00:00+08:00 2026-03-03T23:59:59+08:00
let args = CommandLine.arguments
guard args.count >= 3 else {
    // Return empty list if args missing (or error)
    print("{\"events\": []}")
    exit(0)
}

let isoFormatter = ISO8601DateFormatter()
guard let startDate = isoFormatter.date(from: args[1]),
      let endDate = isoFormatter.date(from: args[2]) else {
    print("{\"error\": \"Invalid ISO format\"}")
    exit(1)
}

// Request calendar access (macOS 14+ uses requestFullAccessToEvents)
let semaphore = DispatchSemaphore(value: 0)
var accessGranted = false

if #available(macOS 14.0, *) {
    store.requestFullAccessToEvents { granted, error in
        accessGranted = granted
        semaphore.signal()
    }
} else {
    store.requestAccess(to: .event) { granted, error in
        accessGranted = granted
        semaphore.signal()
    }
}
semaphore.wait()

guard accessGranted else {
    print("{\"events\": [], \"error\": \"Calendar access denied\"}")
    exit(0)
}

// Fetch events
let calendars = store.calendars(for: .event) // All calendars
let predicate = store.predicateForEvents(withStart: startDate, end: endDate, calendars: nil)
let events = store.events(matching: predicate)

var outputEvents: [EventInfo] = []

for event in events {
    outputEvents.append(EventInfo(
        uid: event.calendarItemIdentifier,
        title: event.title,
        start: event.startDate.timeIntervalSince1970,
        end: event.endDate.timeIntervalSince1970,
        isAllDay: event.isAllDay,
        calendar: event.calendar.title,
        location: event.location,
        notes: event.notes
    ))
}

let output = Output(events: outputEvents)
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
if let data = try? encoder.encode(output), let jsonString = String(data: data, encoding: .utf8) {
    print(jsonString)
}


path: scripts/calendar_clean_notify.sh
content: #!/bin/zsh
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_PATH="${CAL_SKILL_CONFIG:-$SKILL_DIR/config.json}"

# Defaults
LOOKBACK_DAYS=1
LOOKAHEAD_DAYS=0
OUT_DIR="$HOME/clawd/memory"
NOTIFY_ENABLED=true
NOTIFY_TITLE="MemoryXBOT 日程提醒"
TIMEZONE="Asia/Shanghai"

if [[ -f "$CONFIG_PATH" ]]; then
  LOOKBACK_DAYS=$(/usr/bin/python3 - <<'PY' "$CONFIG_PATH"
import json,sys
cfg=json.load(open(sys.argv[1]))
print(int(cfg.get('lookback_days',1)))
PY
)
  LOOKAHEAD_DAYS=$(/usr/bin/python3 - <<'PY' "$CONFIG_PATH"
import json,sys
cfg=json.load(open(sys.argv[1]))
print(int(cfg.get('lookahead_days',0)))
PY
)
  OUT_DIR=$(/usr/bin/python3 - <<'PY' "$CONFIG_PATH"
import json,sys,os
cfg=json.load(open(sys.argv[1]))
print(os.path.expanduser(cfg.get('output_dir','~/clawd/memory')))
PY
)
  NOTIFY_ENABLED=$(/usr/bin/python3 - <<'PY' "$CONFIG_PATH"
import json,sys
cfg=json.load(open(sys.argv[1]))
notify=cfg.get('notification',{}) if isinstance(cfg.get('notification',{}),dict) else {}
print('true' if notify.get('enabled',True) else 'false')
PY
)
  NOTIFY_TITLE=$(/usr/bin/python3 - <<'PY' "$CONFIG_PATH"
import json,sys
cfg=json.load(open(sys.argv[1]))
notify=cfg.get('notification',{}) if isinstance(cfg.get('notification',{}),dict) else {}
print(str(notify.get('title','MemoryXBOT 日程提醒')))
PY
)
  TIMEZONE=$(/usr/bin/python3 - <<'PY' "$CONFIG_PATH"
import json,sys
cfg=json.load(open(sys.argv[1]))
print(str(cfg.get('timezone','Asia/Shanghai')))
PY
)
fi

mkdir -p "$OUT_DIR"
OUT="$OUT_DIR/calendar-clean-last.json"
ALERT="$OUT_DIR/calendar-clean-alert.txt"

START=$(/usr/bin/python3 - <<'PY' "$TIMEZONE" "$LOOKBACK_DAYS"
import sys
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
z=ZoneInfo(sys.argv[1])
lookback=int(sys.argv[2])
d=(datetime.now(z)-timedelta(days=lookback)).strftime('%Y-%m-%d')
print(f"{d}T00:00:00{datetime.now(z).strftime('%z')[:3]}:{datetime.now(z).strftime('%z')[3:]}")
PY
)
END=$(/usr/bin/python3 - <<'PY' "$TIMEZONE" "$LOOKAHEAD_DAYS"
import sys
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
z=ZoneInfo(sys.argv[1])
lookahead=int(sys.argv[2])
now=datetime.now(z)
d=(now+timedelta(days=lookahead)).strftime('%Y-%m-%d')
o=now.strftime('%z')
print(f"{d}T23:59:59{o[:3]}:{o[3:]}")
PY
)

/usr/bin/python3 "$SCRIPT_DIR/calendar_clean.py" --start "$START" --end "$END" > "$OUT" 2>&1 || true

CANDIDATES=$(/usr/bin/python3 - <<'PY' "$OUT"
import json,sys
try:
    data=json.load(open(sys.argv[1]))
    print(int(data.get('delete_candidates',0)))
except Exception:
    print(0)
PY
)

if [[ "$CANDIDATES" -gt 0 ]]; then
  MSG="Calendar发现 ${CANDIDATES} 条重复候选，建议清理"
  echo "$(date '+%Y-%m-%d %H:%M:%S') $MSG" > "$ALERT"
  if [[ "$NOTIFY_ENABLED" == "true" ]]; then
    /usr/bin/osascript -e "display notification \"$MSG\" with title \"$NOTIFY_TITLE\""
  fi
fi


path: scripts/list_calendars.py
content: #!/usr/bin/env python3
"""Deprecated compatibility wrapper.

Use `swift scripts/list_calendars.swift` directly.
"""

import subprocess
import sys
from pathlib import Path


def main():
    script = Path(__file__).resolve().parent / "list_calendars.swift"
    p = subprocess.run(["swift", str(script)], capture_output=True, text=True)
    if p.returncode != 0:
        print(p.stderr or p.stdout, file=sys.stderr)
        sys.exit(1)
    print(p.stdout)


if __name__ == "__main__":
    main()


path: scripts/env_check.py
content: #!/usr/bin/env python3
import json
import shutil
import subprocess
import sys
from pathlib import Path


def run(cmd):
    return subprocess.run(cmd, capture_output=True, text=True)


def main():
    script_dir = Path(__file__).resolve().parent
    result = {
        "python_ok": sys.version_info >= (3, 9),
        "swift_ok": shutil.which("swift") is not None,
        "calendar_access_ok": False,
        "writable_calendars": 0,
        "errors": [],
    }

    if not result["python_ok"]:
        result["errors"].append("Python 3.9+ required")
    if not result["swift_ok"]:
        result["errors"].append("Swift not found (install Xcode Command Line Tools)")

    if result["swift_ok"]:
        p = run(["swift", str(script_dir / "list_calendars.swift")])
        if p.returncode != 0:
            result["errors"].append("Failed to list calendars (check Calendar permission)")
        else:
            try:
                data = json.loads(p.stdout)
                cals = data.get("calendars", [])
                writable = [c for c in cals if c.get("writable")]
                result["writable_calendars"] = len(writable)
                result["calendar_access_ok"] = len(cals) > 0
                if len(writable) == 0:
                    result["errors"].append("No writable calendars found")
            except Exception:
                result["errors"].append("Invalid JSON from list_calendars.swift")

    result["ok"] = result["python_ok"] and result["swift_ok"] and result["calendar_access_ok"] and result["writable_calendars"] > 0
    print(json.dumps(result, ensure_ascii=False, indent=2))
    if not result["ok"]:
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/config_utils.py
content: #!/usr/bin/env python3
from __future__ import annotations

import json
import subprocess
from pathlib import Path
from zoneinfo import ZoneInfo


def skill_root() -> Path:
    return Path(__file__).resolve().parent.parent


def config_path() -> Path:
    return skill_root() / "config.json"


def load_config() -> dict:
    p = config_path()
    if not p.exists():
        return {}
    try:
        return json.loads(p.read_text())
    except Exception:
        return {}


def _system_timezone() -> str | None:
    """Detect macOS system timezone via systemsetup."""
    try:
        out = subprocess.check_output(
            ["/usr/sbin/systemsetup", "-gettimezone"],
            text=True, timeout=5, stderr=subprocess.DEVNULL,
        )
        # Output: "Time Zone: Asia/Shanghai"
        tz = out.strip().split(":", 1)[-1].strip()
        ZoneInfo(tz)  # validate
        return tz
    except Exception:
        return None


def get_timezone_name(default: str = "Asia/Shanghai") -> str:
    cfg = load_config()
    tz = cfg.get("timezone")
    if tz:
        return str(tz)
    # No timezone in config → try system timezone, then fall back to default
    return _system_timezone() or default


def get_zoneinfo(default: str = "Asia/Shanghai") -> ZoneInfo:
    try:
        return ZoneInfo(get_timezone_name(default))
    except Exception:
        return ZoneInfo(default)


path: scripts/upsert_event.py
content: #!/usr/bin/env python3
import argparse
import datetime as dt
import subprocess
import sys


def parse_iso(s: str) -> dt.datetime:
    try:
        d = dt.datetime.fromisoformat(s)
    except Exception as e:
        raise SystemExit(f"Invalid ISO datetime: {s} ({e})")
    if d.tzinfo is None:
        raise SystemExit(f"Timezone required: {s}")
    return d


def to_applescript_date(var_name: str, d: dt.datetime) -> str:
    local = d.astimezone()
    return (
        f"set {var_name} to (current date)\n"
        f"set year of {var_name} to {local.year}\n"
        f"set month of {var_name} to {local.month}\n"
        f"set day of {var_name} to {local.day}\n"
        f"set hours of {var_name} to {local.hour}\n"
        f"set minutes of {var_name} to {local.minute}\n"
        f"set seconds of {var_name} to {local.second}\n"
    )


def esc(s: str) -> str:
    return s.replace("\\", "\\\\").replace('"', '\\"')


def run_osascript(script: str) -> str:
    p = subprocess.run(["osascript"], input=script, capture_output=True, text=True)
    if p.returncode != 0:
        raise SystemExit(p.stderr.strip() or p.stdout.strip() or f"osascript failed: {p.returncode}")
    return (p.stdout or "").strip()


def main():
    ap = argparse.ArgumentParser(description="Idempotent calendar upsert (create/update/skip).")
    ap.add_argument("--title", required=True)
    ap.add_argument("--start", required=True)
    ap.add_argument("--end", required=True)
    ap.add_argument("--calendar", default="")
    ap.add_argument("--notes", default="")
    ap.add_argument("--location", default="")
    ap.add_argument("--alarm-minutes", type=int, default=None)
    ap.add_argument("--dry-run", action="store_true")
    args = ap.parse_args()

    start = parse_iso(args.start)
    end = parse_iso(args.end)
    if end <= start:
        raise SystemExit("--end must be after --start")

    cal_name = esc(args.calendar)
    title = esc(args.title.strip())
    notes = esc(args.notes)
    location = esc(args.location)

    alarm_block_create = ""
    alarm_block_update = ""
    if args.alarm_minutes is not None:
        mins = int(args.alarm_minutes)
        alarm_block_create = (
            "\n"
            "  try\n"
            "    repeat with a in (every display alarm of newEvent)\n"
            "      delete a\n"
            "    end repeat\n"
            "  end try\n"
            f"  make new display alarm at end of display alarms of newEvent with properties {{trigger interval:(-{mins} * minutes)}}\n"
        )
        alarm_block_update = (
            "\n"
            "      try\n"
            "        repeat with a in (every display alarm of foundEvent)\n"
            "          delete a\n"
            "        end repeat\n"
            "      end try\n"
            f"      make new display alarm at end of display alarms of foundEvent with properties {{trigger interval:(-{mins} * minutes)}}\n"
        )

    start_as = to_applescript_date("startDate", start)
    end_as = to_applescript_date("endDate", end)

    dry = "true" if args.dry_run else "false"

    script = f'''
set eventTitle to "{title}"
set eventNotes to "{notes}"
set eventLocation to "{location}"
set desiredCalendarName to "{cal_name}"
set isDryRun to {dry}

{start_as}
{end_as}

tell application "Calendar"
  set targetCal to missing value

  if desiredCalendarName is not "" then
    repeat with c in calendars
      try
        if (name of c is desiredCalendarName) and (writable of c is true) then
          set targetCal to c
          exit repeat
        end if
      end try
    end repeat
  end if

  if targetCal is missing value then
    repeat with c in calendars
      try
        if writable of c is true then
          set targetCal to c
          exit repeat
        end if
      end try
    end repeat
  end if

  if targetCal is missing value then set targetCal to calendar 1

  -- Idempotency key: same calendar + same startDate + same summary
  set foundEvent to missing value
  set candidates to (every event of targetCal whose start date = startDate)
  repeat with e in candidates
    try
      if (summary of e) is eventTitle then
        set foundEvent to e
        exit repeat
      end if
    end try
  end repeat

  set calName to name of targetCal

  if foundEvent is missing value then
    if isDryRun then
      return "DRYRUN action=create calendar=" & calName
    end if

    set newEvent to make new event at end of events of targetCal with properties {{summary:eventTitle, start date:startDate, end date:endDate, description:eventNotes, location:eventLocation}}
{alarm_block_create}
    return "CREATED calendar=" & calName & " uid=" & (uid of newEvent)
  else
    set needUpdate to false

    try
      if (end date of foundEvent) is not endDate then set needUpdate to true
    end try
    try
      if (description of foundEvent) is not eventNotes then set needUpdate to true
    end try
    try
      if (location of foundEvent) is not eventLocation then set needUpdate to true
    end try

    if needUpdate is false then
      return "SKIPPED calendar=" & calName & " uid=" & (uid of foundEvent)
    end if

    if isDryRun then
      return "DRYRUN action=update calendar=" & calName & " uid=" & (uid of foundEvent)
    end if

    set end date of foundEvent to endDate
    set description of foundEvent to eventNotes
    set location of foundEvent to eventLocation
{alarm_block_update}
    return "UPDATED calendar=" & calName & " uid=" & (uid of foundEvent)
  end if
end tell
'''

    out = run_osascript(script)
    print(out)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)