skill-cron
Use when the user wants to register, inspect, manually run, or remove scheduled Claude skills with Telegram push notifications. Presents a menu, discovers schedulable skills with headless-prompt frontmatter, converts natural-language schedules into cron entries with conflict conf
Install
npx skills add https://github.com/KerberosClaw/kc_ai_skills/tree/main/skill-cron
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install kerberosclaw-kc-ai-skills@llmmart
git clone https://github.com/KerberosClaw/kc_ai_skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole kerberosclaw/kc_ai_skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
skill-cron
You are a scheduled-skill operations manager. You translate the user's scheduling intent into explicit cron-managed jobs, verify notification plumbing, and keep every automated command inspectable.
統一管理需要定時執行 + Telegram 推播的 skill。
不適用
- 不替沒有
headless-prompt的 skill 硬排程;先要求補 frontmatter。 - 不把模糊或互相衝突的時間描述自行猜成 cron。
- 不存取或輸出 Telegram token 內容;只驗證設定是否存在與可用。
Trigger
/skill-cron
不帶參數時顯示主選單。也可帶子命令快速操作(如 /skill-cron list)。
主選單
收到 /skill-cron 時,顯示以下選單(使用 AskUserQuestion 詢問):
┌─ skill-cron 排程管理器 ─────────────┐
│ │
│ 1. 列出排程與狀態 │
│ 2. 新增排程 │
│ 3. 移除/啟停排程 │
│ 4. Telegram 設定 │
│ 5. 手動執行一次 │
│ │
└──────────────────────────────────────┘
詢問:「輸入編號 [1-5]」
使用者只能輸入 1-5。輸入其他內容時重新顯示選單。
選項 1:列出排程與狀態
執行:
python3 ${CLAUDE_SKILL_DIR}/scripts/cron_manager.py list
將輸出整理成表格呈現。
選項 2:新增排程
Step 2-1:選擇 skill
掃描 ~/.claude/skills/ 下所有包含 headless-prompt 的 SKILL.md,列出可排程的 skill:
可排程的 skills:
a. morning-brief — 每日晨間新聞摘要推播
b. xxx — ...
沒有找到?skill 需要在 SKILL.md frontmatter 加入 headless-prompt 欄位。
詢問:「選擇 skill [a/b/...]」
如果只有一個,直接選定並確認。
Step 2-2:排程時間(自然語言 → cron)
詢問:「排程時間(用自然語言描述,如『平日 9:00 18:00,假日不執行』)」
你(Claude)負責將自然語言轉換為 cron 表達式。 轉換規則:
時間詞彙對應
| 自然語言 | 對應 |
|---|---|
| 平日/工作日 | 週一~五(1-5) |
| 假日/週末 | 週六日(0,6) |
| 每天 | 所有天(*) |
| 週一/Monday | 1 |
| 週二~週日 | 2~0 |
| 不執行 | 該天不產生 cron entry |
衝突偵測(重要)
當使用者的描述中出現重疊時,必須詢問而非自行決定。
衝突範例:
使用者:平日 9:00, 18:00 假日不執行 週一 9:30
⚠ 偵測到衝突:
「平日 9:00」已涵蓋週一,但又指定「週一 9:30」
週一要怎麼處理?
a. 9:00, 18:00(跟其他平日一樣,忽略 9:30)
b. 9:30, 18:00(週一用 9:30 取代 9:00)
c. 9:00, 9:30, 18:00(三個都要)
衝突判定規則:
- 「平日」和具體「週X」重疊 → 衝突
- 「每天」和「假日不執行」→ 衝突
- 「週六 10:00」和「假日不執行」→ 衝突
- 同一天同一時間重複出現 → 去重,不算衝突
解析結果確認
轉換完成後,顯示解析結果表格讓使用者確認:
解析結果:
┌──────────────────────────────────────┐
│ 週一 09:30, 18:00 │
│ 週二~五 09:00, 18:00 │
│ 週六~日 不執行 │
├──────────────────────────────────────┤
│ 共 9 次/週 │
│ cron entries: │
│ 30 9 * * 1 │
│ 0 9 * * 2-5 │
│ 0 18 * * 1-5 │
└──────────────────────────────────────┘
確認? [Y/重新輸入]
使用者確認後才進入下一步。
Step 2-3:標籤
詢問:「給這組排程一個標籤(如『盤中追蹤』『每日報告』)」
Step 2-4:寫入
對每一條 cron entry 呼叫:
python3 ${CLAUDE_SKILL_DIR}/scripts/cron_manager.py add <skill> "<cron_expr>" "<label>"
如果一組自然語言產生多條 cron entries,每條各自建立一個 job。label 必須各自唯一——job id = <skill>-<label>(由 cron_manager 產生),同 skill 同 label 會被判為重複而拒建。
選項 3:移除/啟停排程
先跑 list 顯示現有 jobs,然後:
要做什麼?
a. 移除排程
b. 啟用排程
c. 停用排程
d. 返回主選單
選擇後,讓使用者指定 job ID。
對應指令:
python3 ${CLAUDE_SKILL_DIR}/scripts/cron_manager.py remove <job_id>
python3 ${CLAUDE_SKILL_DIR}/scripts/cron_manager.py enable <job_id>
python3 ${CLAUDE_SKILL_DIR}/scripts/cron_manager.py disable <job_id>
選項 4:Telegram 設定
顯示目前狀態後:
Telegram 狀態:未設定 / 已設定(token: 123...ABC → channel: -100xxx)
a. 設定 Bot Token + Channel ID
b. 發送測試訊息
c. 移除設定
d. 返回主選單
4-a:設定
依序詢問:
- 「貼上 Bot Token(從 Telegram @BotFather 取得):」
- 「貼上 Channel ID(頻道或群組 ID,通常以 -100 開頭):」
拿到後:
python3 ${CLAUDE_SKILL_DIR}/scripts/cron_manager.py telegram-set <bot_token> <channel_id>
儲存後詢問:「要發送測試訊息嗎? [Y/n]」
4-b:測試
python3 ${CLAUDE_SKILL_DIR}/scripts/cron_manager.py telegram-test
4-c:移除
確認後:
python3 ${CLAUDE_SKILL_DIR}/scripts/cron_manager.py telegram-remove
選項 5:手動執行一次
先跑 list 顯示現有 jobs,讓使用者選擇要執行哪一個。
python3 ${CLAUDE_SKILL_DIR}/scripts/cron_manager.py run <job_id>
顯示執行結果。如有 Telegram 設定,會自動推送。
設定檔
位置:~/.claude/configs/skill-cron.json
{
"telegram": {
"bot_token": "123:ABC...",
"channel_id": "-100..."
},
"jobs": [
{
"id": "morning-brief-晨間",
"skill": "morning-brief",
"cron": "30 9 * * 1",
"label": "晨間",
"enabled": true
}
]
}
Skill 整合規範
要讓一個 skill 支援 skill-cron 排程,需在其 SKILL.md frontmatter 中加入 headless-prompt:
---
name: morning-brief
headless-prompt: "Run python3 ~/.claude/skills/morning-brief/scripts/fetch_news.py --top 5, then summarize..."
---
規則:
- 必須使用絕對路徑(
~可以) - 不能使用
/skill語法(-p模式不支援) - 要包含完整的指令描述(Claude 需要知道要做什麼)
日誌
排程執行的日誌存放在:~/.claude/logs/skill-cron/
每個 job 保留最近 50 筆 log,自動清理舊的。
Anti-patterns
- ❌ 模糊時間自己猜成 cron — 描述有重疊(「平日 9:00」+「週一 9:30」)必須問,不自行拍板哪個贏
- ❌ 替沒有
headless-prompt的 skill 硬排程 — 先要求該 skill 補 frontmatter,-p模式跑不了沒 headless prompt 的 skill - ❌ 輸出 / log 出 Telegram token — 只驗證設定存在與可用,token 內容不回顯、不寫進對話
- ❌ 破壞性操作不確認 — 移除 / 覆蓋排程直接執行;每個都要先確認
- ❌ headless-prompt 用
/skill語法 —-p模式不支援 slash command,會被 silently drop;一律寫完整絕對路徑指令
互動規則
- 所有輸入都用選項或固定格式 — 不接受開放式自然語言(排程時間除外)
- 排程時間是唯一例外 — 允許自然語言,但必須解析後確認才寫入
- 偵測到衝突必須問 — 不能自行決定衝突的解法
- 每個破壞性操作(移除、覆蓋)都要確認 — 不能直接執行
- 不認識的輸入重新顯示選單 — 不要嘗試理解使用者在說什麼
注意事項
- 排程使用 macOS launchd(LaunchAgent),不使用 crontab(crontab 缺乏 OAuth 所需的 user session)
- plist 檔案由 cron_manager.py sync 自動管理,存在
~/Library/LaunchAgents/com.skill-cron.*.plist - Telegram bot token 存在本地 config 中,不會被 git 追蹤
claude -p需要有效的 Claude 訂閱- 排程執行時 Claude 會使用與互動模式相同的模型
Files (kc_ai_skills)
-
docs
-
DESIGN.md 6.2 KB
# skill-cron — 因為 `claude -p "/your-skill"` 會卡住 > **English summary:** Design doc for skill-cron, a launchd-based scheduler for Claude Code skills. Born from discovering that `claude -p "/skill"` hangs silently — skills only work in interactive mode. Solution: a `headless-prompt` field in SKILL.md frontmatter + a runner script. Uses macOS launchd (not crontab) because `claude -p` needs the user's login session for OAuth — crontab runs in a bare daemon context without it. Includes Telegram push via urllib and auto-rotating logs. ## 這東西為什麼存在 Claude Code 的 skill 在互動模式下很好用 — 打 `/your-skill` 就跑。但如果你想用排程定時跑呢? ```bash # 你以為可以這樣 claude -p "/your-skill" # 實際上會無限卡住,沒有任何輸出 # 我們在凌晨一點 debug 了 40 分鐘才搞懂為什麼 ``` 官方文件寫了,但埋在一段不起眼的地方: > "User-invoked skills like `/commit` are only available in interactive mode. In `-p` mode, describe the task you want to accomplish instead." 翻譯:**`claude -p` 不支援 `/skill` 語法。** 它不會報錯,它只是卡住。靜靜地。直到你 `Ctrl+C`。 所以你得用直接描述的方式: ```bash # 這個能跑 claude -p "Run python3 ~/.claude/skills/morning-brief/scripts/fetch_news.py --top 5, then summarize..." ``` 但你不可能把這種一百字的 prompt 塞進排程設定。於是 skill-cron 誕生了。 --- ## 設計思路 ### 職責分離 ``` Skill(如 /morning-brief) = 純核心功能:爬蟲 + 分析 + 輸出 = 只管做事,不管什麼時候做 skill-cron(管理器) = 什麼時候做:launchd 排程 = 做完通知誰:Telegram 推播 = 怎麼在 -p 模式跑 skill:headless-prompt 橋接 ``` 一個 skill 不需要知道自己會被排程。一個排程系統不需要知道 skill 在做什麼。中間靠一個叫 `headless-prompt` 的欄位連接。 ### headless-prompt:給 `-p` 模式用的翻譯層 在 SKILL.md 的 frontmatter 加一個欄位: ```yaml --- name: morning-brief headless-prompt: "Run python3 ~/.claude/skills/morning-brief/scripts/fetch_news.py --top 5, then summarize..." --- ``` 規則是從踩坑中學來的: - **必須用絕對路徑** — `${CLAUDE_SKILL_DIR}` 在 `-p` 模式不展開。我們試過。 - **不能用 `/skill` 語法** — 前面說了,會卡住。 - **要寫完整指令** — `-p` 模式下 Claude 看不到 SKILL.md 的內容,它只有你給的 prompt。 ### 執行鏈 ``` launchd(macOS 排程) → cron_runner.sh(我們的 wrapper) → 設好環境變數(PATH, USER, SHELL) → claude -p "{prompt}" --allowedTools "Bash,Read,Glob,Grep" → 拿到分析結果 → 如有 Telegram config → urllib 推送 → 寫 log,清理舊 log ``` `--allowedTools` 指定允許的工具,這樣 `-p` 模式下不會卡在權限確認。 --- ## 為什麼用 launchd 不用 crontab 我們花了一整個早上才搞懂這個。 **`claude -p` 需要 OAuth token,而 crontab 的 daemon 環境拿不到。** | 環境 | 有 login session | claude -p | |------|-----------------|-----------| | Terminal(手動) | ✅ | ✅ 正常 | | launchd LaunchAgent | ✅ 跑在使用者 session | ✅ 正常 | | crontab | ❌ 獨立 daemon context | ❌ `Not logged in` | crontab 跑的進程完全沒有 GUI session — keychain 打不開、OAuth token 拿不到。`claude -p` 啟動後連上 API 的第一步就失敗,甚至不會輸出 error message(直接卡住或靜默退出)。 macOS 的 LaunchAgent(放在 `~/Library/LaunchAgents/`)跑在使用者的 login session 裡,可以存取 keychain,所以 `claude -p` 能正常認證。 ### cron_manager.py 的 sync 機制 `cron_manager.py` 把 config 裡的 job 轉成 launchd plist: 1. 把 cron 表達式解析成 `StartCalendarInterval` dict 陣列 2. 生成 plist 到 `~/Library/LaunchAgents/com.skill-cron.{job_id}.plist` 3. `launchctl load` 載入排程 因為 launchd 的 `StartCalendarInterval` 不支援 cron 的 range 語法(如 `9-12`),manager 會展開成個別的 dict entry。 --- ## cron_runner.sh 環境設定 雖然改用 launchd,runner script 還是要設好環境: ### PATH launchd 的 PATH 也很精簡。你的 `claude` 裝在 `~/.local/bin`。 ```bash export PATH="$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:$PATH" ``` ### USER / SHELL claude CLI 需要這兩個來找到它的設定。 ```bash export USER="${USER:-$(whoami)}" export SHELL="${SHELL:-/bin/bash}" ``` ### Telegram 推送的引號地獄 原本用 `curl` + inline Python 構造 JSON payload,在 shell 的 heredoc 裡嵌 Python,Python 裡面有 shell 變數。三層引號互相打架。 最後放棄 curl,改用純 Python `urllib.request` 一行解決。 --- ## 設定檔放哪 `~/.claude/configs/skill-cron.json` 考慮過 `~/.config/skill-cron/`(XDG 標準),但這個 skill 就是 Claude Code 生態系的一部分,放 `~/.claude/` 下更自然。而且這個檔案存了 Telegram bot token,不能被 git 追蹤 — `~/.claude/` 本來就不在任何 repo 裡。 ## LaunchAgent 管理 skill-cron 在 `~/Library/LaunchAgents/` 下管理以 `com.skill-cron.` 為前綴的 plist 檔案。`sync` 時會先 unload + 刪除所有舊的,再重新建立 + load。 ## 日誌 `~/.claude/logs/skill-cron/{job_id}-{timestamp}.log` 每個 job 留最近 50 筆。debug 的時候第一件事就是 `cat` 最新的 log。 ## 支援 skill-cron 的 skill | Skill | 做什麼 | 建議排程 | |-------|--------|---------| | (範例)morning-brief | 晨間新聞摘要 | 每天 `07:00` | 想讓你的 skill 也支援排程?在 SKILL.md frontmatter 加 `headless-prompt` 就好。一行。 ## 限制 1. **macOS only** — 用的是 launchd LaunchAgent,Linux 需要改用 systemd timer 或其他方案 2. **Mac 要登入** — LaunchAgent 只在使用者登入時執行。關機、登出就不會跑 3. **Claude Max 訂閱** — `claude -p` 需要有效的 OAuth 登入狀態。token 過期了要重新 `claude login` 4. **Telegram 4096 字元** — 超長報告會被截斷。但說真的,你不需要在手機上看一萬字的分析報告 5. **`--allowedTools` 白名單** — 排程執行時沒有人可以按「允許」,所以用 `--allowedTools` 預先指定允許的工具
-
-
scripts
-
cron_manager.py 15.1 KB
#!/usr/bin/env python3 """ skill-cron manager — manage scheduled skill jobs + Telegram push Config: ~/.claude/configs/skill-cron.json Scheduler: macOS launchd (LaunchAgents) — crontab lacks the user session context required for claude -p to authenticate via OAuth. Usage: python3 cron_manager.py list python3 cron_manager.py add <skill> <cron_expr> <label> python3 cron_manager.py remove <job_id> python3 cron_manager.py enable <job_id> python3 cron_manager.py disable <job_id> python3 cron_manager.py telegram-set <bot_token> <channel_id> python3 cron_manager.py telegram-test python3 cron_manager.py telegram-remove python3 cron_manager.py run <job_id> # manual trigger python3 cron_manager.py sync # sync config → launchd """ import json import os import plistlib import subprocess import sys import re import urllib.request import urllib.parse from pathlib import Path from datetime import datetime CONFIG_DIR = Path.home() / ".claude" / "configs" CONFIG_FILE = CONFIG_DIR / "skill-cron.json" SKILL_DIR = Path.home() / ".claude" / "skills" RUNNER_SCRIPT = Path(__file__).parent / "cron_runner.sh" LAUNCHD_DIR = Path.home() / "Library" / "LaunchAgents" LAUNCHD_PREFIX = "com.skill-cron." # ── Config ────────────────────────────────────────────────── def load_config() -> dict: if not CONFIG_FILE.exists(): return {"telegram": {}, "jobs": []} with open(CONFIG_FILE) as f: return json.load(f) def save_config(config: dict) -> None: CONFIG_DIR.mkdir(parents=True, exist_ok=True) with open(CONFIG_FILE, "w") as f: json.dump(config, f, indent=2, ensure_ascii=False) print(f"[config] saved: {CONFIG_FILE}") # ── Skill resolution ─────────────────────────────────────── def find_skill(name: str) -> Path | None: """Find a skill directory by name.""" skill_path = SKILL_DIR / name if skill_path.exists() and (skill_path / "SKILL.md").exists(): return skill_path return None def read_headless_prompt(skill_name: str) -> str | None: """Read headless-prompt from SKILL.md frontmatter, or return None.""" skill_path = find_skill(skill_name) if not skill_path: return None skill_md = skill_path / "SKILL.md" content = skill_md.read_text() # Parse YAML frontmatter if not content.startswith("---"): return None end = content.find("---", 3) if end == -1: return None frontmatter = content[3:end] # Look for headless-prompt field match = re.search(r'headless-prompt:\s*["\'](.+?)["\']', frontmatter) if match: return match.group(1) # Multi-line headless-prompt with | match = re.search(r'headless-prompt:\s*\|\s*\n((?:\s+.+\n?)+)', frontmatter) if match: lines = match.group(1).split("\n") return "\n".join(line.strip() for line in lines if line.strip()) return None # ── Job management ───────────────────────────────────────── def make_job_id(skill: str, label: str) -> str: """Generate a job ID from skill name and label.""" clean_label = re.sub(r'[^a-zA-Z0-9\u4e00-\u9fff]', '-', label).strip('-') return f"{skill}-{clean_label}" if clean_label else skill def cmd_add(args: list[str]) -> None: if len(args) < 3: print("Usage: add <skill> <cron_expr> <label>") print('Example: add morning-brief "0 7 * * 1-5" 晨間') sys.exit(1) skill = args[0] cron_expr = args[1] label = args[2] # Validate skill exists if not find_skill(skill): print(f"[error] skill not found: {skill}") print(f"[error] looked in: {SKILL_DIR / skill}") sys.exit(1) # Check for headless prompt prompt = read_headless_prompt(skill) if not prompt: print(f"[error] skill '{skill}' has no headless-prompt in SKILL.md frontmatter") print(f"[hint] add this to {SKILL_DIR / skill / 'SKILL.md'} frontmatter:") print(f' headless-prompt: "Run python3 ... and analyze the output"') sys.exit(1) config = load_config() job_id = make_job_id(skill, label) # Check duplicate if any(j["id"] == job_id for j in config["jobs"]): print(f"[error] job already exists: {job_id}") sys.exit(1) job = { "id": job_id, "skill": skill, "cron": cron_expr, "label": label, "enabled": True, "created": datetime.now().isoformat(), } config["jobs"].append(job) save_config(config) sync_launchd(config) print(f"[added] {job_id}: {cron_expr} ({label})") def cmd_remove(args: list[str]) -> None: if not args: print("Usage: remove <job_id>") sys.exit(1) job_id = args[0] config = load_config() before = len(config["jobs"]) config["jobs"] = [j for j in config["jobs"] if j["id"] != job_id] if len(config["jobs"]) == before: print(f"[error] job not found: {job_id}") sys.exit(1) save_config(config) sync_launchd(config) print(f"[removed] {job_id}") def cmd_enable_disable(args: list[str], enabled: bool) -> None: if not args: print(f"Usage: {'enable' if enabled else 'disable'} <job_id>") sys.exit(1) job_id = args[0] config = load_config() for job in config["jobs"]: if job["id"] == job_id: job["enabled"] = enabled save_config(config) sync_launchd(config) print(f"[{'enabled' if enabled else 'disabled'}] {job_id}") return print(f"[error] job not found: {job_id}") sys.exit(1) def cmd_list(_args: list[str]) -> None: config = load_config() # Telegram status tg = config.get("telegram", {}) if tg.get("bot_token") and tg.get("channel_id"): masked_token = tg["bot_token"][:8] + "..." + tg["bot_token"][-4:] print(f"Telegram: {masked_token} → {tg['channel_id']}") else: print("Telegram: not configured") print() jobs = config.get("jobs", []) if not jobs: print("No scheduled jobs.") return print(f"{'ID':<25} {'Schedule':<25} {'Label':<10} {'Status':<10}") print("-" * 70) for j in jobs: status = "enabled" if j.get("enabled", True) else "disabled" print(f"{j['id']:<25} {j['cron']:<25} {j['label']:<10} {status:<10}") # ── Telegram ─────────────────────────────────────────────── def cmd_telegram_set(args: list[str]) -> None: if len(args) < 2: print("Usage: telegram-set <bot_token> <channel_id>") sys.exit(1) config = load_config() config["telegram"] = {"bot_token": args[0], "channel_id": args[1]} save_config(config) print("[telegram] credentials saved") def cmd_telegram_test(_args: list[str]) -> None: config = load_config() tg = config.get("telegram", {}) if not tg.get("bot_token") or not tg.get("channel_id"): print("[error] telegram not configured. Run: telegram-set <token> <channel_id>") sys.exit(1) msg = f"skill-cron test message\n{datetime.now().isoformat()}" success = send_telegram(tg["bot_token"], tg["channel_id"], msg) if success: print("[telegram] test message sent successfully") else: print("[telegram] failed to send test message") sys.exit(1) def cmd_telegram_remove(_args: list[str]) -> None: config = load_config() config["telegram"] = {} save_config(config) print("[telegram] credentials removed") def send_telegram(bot_token: str, channel_id: str, text: str) -> bool: """Send a message via Telegram Bot API. Returns True on success.""" url = f"https://api.telegram.org/bot{bot_token}/sendMessage" data = json.dumps({ "chat_id": channel_id, "text": text, "parse_mode": "HTML", "disable_web_page_preview": True, }).encode() req = urllib.request.Request( url, data=data, headers={"Content-Type": "application/json"} ) try: with urllib.request.urlopen(req, timeout=10) as resp: return resp.status == 200 except Exception as e: print(f"[telegram] error: {e}", file=sys.stderr) return False # ── launchd sync ────────────────────────────────────────── def parse_cron_to_calendar_intervals(cron_expr: str) -> list[dict]: """Convert a cron expression to launchd StartCalendarInterval dicts. Supports standard 5-field cron: minute hour dom month dow Handles comma-separated values and ranges (e.g. 1-5, 9-12). """ parts = cron_expr.strip().split() if len(parts) != 5: return [] def expand_field(field: str) -> list[int] | None: """Expand a cron field to a list of ints, or None for '*'.""" if field == "*": return None values = set() for token in field.split(","): if "-" in token: lo, hi = token.split("-", 1) values.update(range(int(lo), int(hi) + 1)) else: values.add(int(token)) return sorted(values) minutes = expand_field(parts[0]) hours = expand_field(parts[1]) # dom and month are rarely used in skill-cron, skip for now weekdays = expand_field(parts[4]) # Build cartesian product of all specified values minute_list = minutes if minutes else [None] hour_list = hours if hours else [None] weekday_list = weekdays if weekdays else [None] intervals = [] for m in minute_list: for h in hour_list: for w in weekday_list: entry = {} if m is not None: entry["Minute"] = m if h is not None: entry["Hour"] = h if w is not None: entry["Weekday"] = w intervals.append(entry) return intervals def plist_path_for_job(job_id: str) -> Path: return LAUNCHD_DIR / f"{LAUNCHD_PREFIX}{job_id}.plist" def build_plist(job: dict, prompt: str) -> dict: """Build a launchd plist dict for a job.""" job_id = job["id"] runner = str(RUNNER_SCRIPT) intervals = parse_cron_to_calendar_intervals(job["cron"]) plist = { "Label": f"{LAUNCHD_PREFIX}{job_id}", "ProgramArguments": [runner, prompt, job_id], "StandardErrorPath": str( Path.home() / ".claude" / "logs" / "skill-cron" / f"launchd-{job_id}-stderr.log" ), } if len(intervals) == 1: plist["StartCalendarInterval"] = intervals[0] elif intervals: plist["StartCalendarInterval"] = intervals return plist def sync_launchd(config: dict) -> None: """Sync enabled jobs to launchd LaunchAgents.""" LAUNCHD_DIR.mkdir(parents=True, exist_ok=True) # Unload and remove all existing skill-cron plists for plist_file in LAUNCHD_DIR.glob(f"{LAUNCHD_PREFIX}*.plist"): subprocess.run( ["launchctl", "unload", str(plist_file)], capture_output=True, ) plist_file.unlink() # Create and load plists for enabled jobs enabled_jobs = [j for j in config.get("jobs", []) if j.get("enabled", True)] loaded = 0 for job in enabled_jobs: prompt = read_headless_prompt(job["skill"]) if not prompt: continue plist = build_plist(job, prompt) plist_file = plist_path_for_job(job["id"]) with open(plist_file, "wb") as f: plistlib.dump(plist, f) result = subprocess.run( ["launchctl", "load", str(plist_file)], capture_output=True, text=True, ) if result.returncode == 0: loaded += 1 else: print(f"[launchd] error loading {job['id']}: {result.stderr}") print(f"[launchd] synced ({loaded} jobs)") def cmd_sync(_args: list[str]) -> None: config = load_config() sync_launchd(config) # ── Manual run ───────────────────────────────────────────── def cmd_run(args: list[str]) -> None: if not args: print("Usage: run <job_id>") sys.exit(1) job_id = args[0] config = load_config() job = next((j for j in config["jobs"] if j["id"] == job_id), None) if not job: print(f"[error] job not found: {job_id}") sys.exit(1) prompt = read_headless_prompt(job["skill"]) if not prompt: print(f"[error] no headless-prompt for skill: {job['skill']}") sys.exit(1) print(f"[run] executing {job_id}...") result = subprocess.run( ["claude", "-p", prompt, "--allowedTools", "Bash,Read,Glob,Grep"], capture_output=True, text=True, timeout=300, ) output = result.stdout.strip() if output: print(output) # Push to Telegram if configured tg = config.get("telegram", {}) if tg.get("bot_token") and tg.get("channel_id"): # Truncate to Telegram's 4096 char limit tg_text = f"<b>[{job['label']}]</b>\n\n{output}" if len(tg_text) > 4096: tg_text = tg_text[:4090] + "\n..." if send_telegram(tg["bot_token"], tg["channel_id"], tg_text): print("\n[telegram] sent") else: print("\n[telegram] send failed") else: print("[run] no output") if result.stderr: print(f"[stderr] {result.stderr[:500]}") # ── Main ─────────────────────────────────────────────────── COMMANDS = { "list": cmd_list, "add": cmd_add, "remove": cmd_remove, "enable": lambda a: cmd_enable_disable(a, True), "disable": lambda a: cmd_enable_disable(a, False), "telegram-set": cmd_telegram_set, "telegram-test": cmd_telegram_test, "telegram-remove": cmd_telegram_remove, "run": cmd_run, "sync": cmd_sync, } if __name__ == "__main__": if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: print("skill-cron — scheduled skill runner + Telegram push") print() print("Commands:") print(" list Show all jobs + Telegram status") print(" add <skill> <cron> <label> Register a scheduled job") print(" remove <job_id> Remove a job") print(" enable <job_id> Enable a job") print(" disable <job_id> Disable a job") print(" telegram-set <token> <channel> Set Telegram credentials") print(" telegram-test Send test message") print(" telegram-remove Remove Telegram credentials") print(" run <job_id> Manually trigger a job") print(" sync Sync config → launchd") sys.exit(0) cmd = sys.argv[1] COMMANDS[cmd](sys.argv[2:]) -
cron_runner.sh 2.6 KB
#!/bin/bash # cron_runner.sh — invoked by launchd to run a skill in headless mode # # Usage: cron_runner.sh <prompt> <job_id> # # 1. Runs claude -p with the given prompt # 2. If Telegram is configured, pushes the output # 3. Logs to ~/.claude/logs/skill-cron/ set -euo pipefail # Ensure PATH includes claude binary location export PATH="$HOME/.local/bin:/usr/local/bin:/opt/homebrew/bin:$PATH" export USER="${USER:-$(whoami)}" export SHELL="${SHELL:-/bin/bash}" PROMPT="$1" JOB_ID="${2:-unknown}" CONFIG_FILE="$HOME/.claude/configs/skill-cron.json" LOG_DIR="$HOME/.claude/logs/skill-cron" TIMESTAMP=$(date +%Y%m%d-%H%M%S) LOG_FILE="${LOG_DIR}/${JOB_ID}-${TIMESTAMP}.log" mkdir -p "$LOG_DIR" echo "[${TIMESTAMP}] Running job: ${JOB_ID}" | tee "$LOG_FILE" # Run claude in headless mode OUTPUT=$(claude -p "$PROMPT" --allowedTools "Bash,Read,Glob,Grep" 2>>"$LOG_FILE") || { echo "[error] claude -p failed" | tee -a "$LOG_FILE" exit 1 } echo "$OUTPUT" >> "$LOG_FILE" if [ -z "$OUTPUT" ]; then echo "[warn] no output from claude" | tee -a "$LOG_FILE" exit 0 fi # Push to Telegram if configured if [ -f "$CONFIG_FILE" ]; then BOT_TOKEN=$(python3 -c "import json; c=json.load(open('$CONFIG_FILE')); print(c.get('telegram',{}).get('bot_token',''))" 2>/dev/null) CHANNEL_ID=$(python3 -c "import json; c=json.load(open('$CONFIG_FILE')); print(c.get('telegram',{}).get('channel_id',''))" 2>/dev/null) if [ -n "$BOT_TOKEN" ] && [ -n "$CHANNEL_ID" ]; then # Split long messages into chunks at paragraph boundaries (Telegram 4096 limit) echo "$OUTPUT" | python3 - "$BOT_TOKEN" "$CHANNEL_ID" <<'PYEOF' import json, sys, urllib.request, time text = sys.stdin.read().strip() bot_token, channel_id = sys.argv[1], sys.argv[2] limit = 4000 chunks = [] while len(text) > limit: cut = text.rfind("\n\n", 0, limit) if cut == -1: cut = text.rfind("\n", 0, limit) if cut == -1: cut = limit chunks.append(text[:cut]) text = text[cut:].lstrip("\n") if text: chunks.append(text) for i, chunk in enumerate(chunks): data = json.dumps({"chat_id": channel_id, "text": chunk, "disable_web_page_preview": True}).encode() req = urllib.request.Request( f"https://api.telegram.org/bot{bot_token}/sendMessage", data=data, headers={"Content-Type": "application/json"}) urllib.request.urlopen(req, timeout=10) if i < len(chunks) - 1: time.sleep(0.5) PYEOF echo "[telegram] sent" | tee -a "$LOG_FILE" fi fi # Cleanup old logs (keep last 50) ls -t "$LOG_DIR"/${JOB_ID}-*.log 2>/dev/null | tail -n +51 | xargs rm -f 2>/dev/null echo "[done] ${JOB_ID}" | tee -a "$LOG_FILE"
-
-
SKILL.md 9.1 KB
--- name: skill-cron description: "Use when the user wants to register, inspect, manually run, or remove scheduled Claude skills with Telegram push notifications. Presents a menu, discovers schedulable skills with headless-prompt frontmatter, converts natural-language schedules into cron entries with conflict confirmation, writes managed config, and verifies notification delivery. NOT for one-off task execution without scheduling or for running skills that lack a headless prompt." version: 0.3.0 status: mvp triggers: - "/skill-cron" - "排程" - "定時執行" - "crontab" - "telegram 通知" --- # skill-cron You are a scheduled-skill operations manager. You translate the user's scheduling intent into explicit cron-managed jobs, verify notification plumbing, and keep every automated command inspectable. 統一管理需要定時執行 + Telegram 推播的 skill。 ## 不適用 - 不替沒有 `headless-prompt` 的 skill 硬排程;先要求補 frontmatter。 - 不把模糊或互相衝突的時間描述自行猜成 cron。 - 不存取或輸出 Telegram token 內容;只驗證設定是否存在與可用。 ## Trigger ``` /skill-cron ``` 不帶參數時顯示主選單。也可帶子命令快速操作(如 `/skill-cron list`)。 --- ## 主選單 收到 `/skill-cron` 時,顯示以下選單(使用 AskUserQuestion 詢問): ``` ┌─ skill-cron 排程管理器 ─────────────┐ │ │ │ 1. 列出排程與狀態 │ │ 2. 新增排程 │ │ 3. 移除/啟停排程 │ │ 4. Telegram 設定 │ │ 5. 手動執行一次 │ │ │ └──────────────────────────────────────┘ ``` 詢問:「輸入編號 [1-5]」 使用者只能輸入 1-5。輸入其他內容時重新顯示選單。 --- ## 選項 1:列出排程與狀態 執行: ```bash python3 ${CLAUDE_SKILL_DIR}/scripts/cron_manager.py list ``` 將輸出整理成表格呈現。 --- ## 選項 2:新增排程 ### Step 2-1:選擇 skill 掃描 `~/.claude/skills/` 下所有包含 `headless-prompt` 的 SKILL.md,列出可排程的 skill: ``` 可排程的 skills: a. morning-brief — 每日晨間新聞摘要推播 b. xxx — ... 沒有找到?skill 需要在 SKILL.md frontmatter 加入 headless-prompt 欄位。 ``` 詢問:「選擇 skill [a/b/...]」 如果只有一個,直接選定並確認。 ### Step 2-2:排程時間(自然語言 → cron) 詢問:「排程時間(用自然語言描述,如『平日 9:00 18:00,假日不執行』)」 **你(Claude)負責將自然語言轉換為 cron 表達式。** 轉換規則: #### 時間詞彙對應 | 自然語言 | 對應 | |---------|------| | 平日/工作日 | 週一~五(1-5) | | 假日/週末 | 週六日(0,6) | | 每天 | 所有天(*) | | 週一/Monday | 1 | | 週二~週日 | 2~0 | | 不執行 | 該天不產生 cron entry | #### 衝突偵測(重要) 當使用者的描述中出現重疊時,**必須詢問**而非自行決定。 衝突範例: ``` 使用者:平日 9:00, 18:00 假日不執行 週一 9:30 ⚠ 偵測到衝突: 「平日 9:00」已涵蓋週一,但又指定「週一 9:30」 週一要怎麼處理? a. 9:00, 18:00(跟其他平日一樣,忽略 9:30) b. 9:30, 18:00(週一用 9:30 取代 9:00) c. 9:00, 9:30, 18:00(三個都要) ``` 衝突判定規則: - 「平日」和具體「週X」重疊 → 衝突 - 「每天」和「假日不執行」→ 衝突 - 「週六 10:00」和「假日不執行」→ 衝突 - 同一天同一時間重複出現 → 去重,不算衝突 #### 解析結果確認 轉換完成後,顯示解析結果表格讓使用者確認: ``` 解析結果: ┌──────────────────────────────────────┐ │ 週一 09:30, 18:00 │ │ 週二~五 09:00, 18:00 │ │ 週六~日 不執行 │ ├──────────────────────────────────────┤ │ 共 9 次/週 │ │ cron entries: │ │ 30 9 * * 1 │ │ 0 9 * * 2-5 │ │ 0 18 * * 1-5 │ └──────────────────────────────────────┘ 確認? [Y/重新輸入] ``` 使用者確認後才進入下一步。 ### Step 2-3:標籤 詢問:「給這組排程一個標籤(如『盤中追蹤』『每日報告』)」 ### Step 2-4:寫入 對每一條 cron entry 呼叫: ```bash python3 ${CLAUDE_SKILL_DIR}/scripts/cron_manager.py add <skill> "<cron_expr>" "<label>" ``` 如果一組自然語言產生多條 cron entries,每條各自建立一個 job。**label 必須各自唯一**——job id = `<skill>-<label>`(由 cron_manager 產生),同 skill 同 label 會被判為重複而拒建。 --- ## 選項 3:移除/啟停排程 先跑 `list` 顯示現有 jobs,然後: ``` 要做什麼? a. 移除排程 b. 啟用排程 c. 停用排程 d. 返回主選單 ``` 選擇後,讓使用者指定 job ID。 對應指令: ```bash python3 ${CLAUDE_SKILL_DIR}/scripts/cron_manager.py remove <job_id> python3 ${CLAUDE_SKILL_DIR}/scripts/cron_manager.py enable <job_id> python3 ${CLAUDE_SKILL_DIR}/scripts/cron_manager.py disable <job_id> ``` --- ## 選項 4:Telegram 設定 顯示目前狀態後: ``` Telegram 狀態:未設定 / 已設定(token: 123...ABC → channel: -100xxx) a. 設定 Bot Token + Channel ID b. 發送測試訊息 c. 移除設定 d. 返回主選單 ``` ### 4-a:設定 依序詢問: 1. 「貼上 Bot Token(從 Telegram @BotFather 取得):」 2. 「貼上 Channel ID(頻道或群組 ID,通常以 -100 開頭):」 拿到後: ```bash python3 ${CLAUDE_SKILL_DIR}/scripts/cron_manager.py telegram-set <bot_token> <channel_id> ``` 儲存後詢問:「要發送測試訊息嗎? [Y/n]」 ### 4-b:測試 ```bash python3 ${CLAUDE_SKILL_DIR}/scripts/cron_manager.py telegram-test ``` ### 4-c:移除 確認後: ```bash python3 ${CLAUDE_SKILL_DIR}/scripts/cron_manager.py telegram-remove ``` --- ## 選項 5:手動執行一次 先跑 `list` 顯示現有 jobs,讓使用者選擇要執行哪一個。 ```bash python3 ${CLAUDE_SKILL_DIR}/scripts/cron_manager.py run <job_id> ``` 顯示執行結果。如有 Telegram 設定,會自動推送。 --- ## 設定檔 位置:`~/.claude/configs/skill-cron.json` ```json { "telegram": { "bot_token": "123:ABC...", "channel_id": "-100..." }, "jobs": [ { "id": "morning-brief-晨間", "skill": "morning-brief", "cron": "30 9 * * 1", "label": "晨間", "enabled": true } ] } ``` ## Skill 整合規範 要讓一個 skill 支援 skill-cron 排程,需在其 SKILL.md frontmatter 中加入 `headless-prompt`: ```yaml --- name: morning-brief headless-prompt: "Run python3 ~/.claude/skills/morning-brief/scripts/fetch_news.py --top 5, then summarize..." --- ``` 規則: - 必須使用絕對路徑(`~` 可以) - 不能使用 `/skill` 語法(`-p` 模式不支援) - 要包含完整的指令描述(Claude 需要知道要做什麼) ## 日誌 排程執行的日誌存放在:`~/.claude/logs/skill-cron/` 每個 job 保留最近 50 筆 log,自動清理舊的。 ## Anti-patterns - ❌ **模糊時間自己猜成 cron** — 描述有重疊(「平日 9:00」+「週一 9:30」)必須問,不自行拍板哪個贏 - ❌ **替沒有 `headless-prompt` 的 skill 硬排程** — 先要求該 skill 補 frontmatter,`-p` 模式跑不了沒 headless prompt 的 skill - ❌ **輸出 / log 出 Telegram token** — 只驗證設定存在與可用,token 內容不回顯、不寫進對話 - ❌ **破壞性操作不確認** — 移除 / 覆蓋排程直接執行;每個都要先確認 - ❌ **headless-prompt 用 `/skill` 語法** — `-p` 模式不支援 slash command,會被 silently drop;一律寫完整絕對路徑指令 ## 互動規則 - **所有輸入都用選項或固定格式** — 不接受開放式自然語言(排程時間除外) - **排程時間是唯一例外** — 允許自然語言,但必須解析後確認才寫入 - **偵測到衝突必須問** — 不能自行決定衝突的解法 - **每個破壞性操作(移除、覆蓋)都要確認** — 不能直接執行 - **不認識的輸入重新顯示選單** — 不要嘗試理解使用者在說什麼 ## 注意事項 - 排程使用 macOS launchd(LaunchAgent),不使用 crontab(crontab 缺乏 OAuth 所需的 user session) - plist 檔案由 cron_manager.py sync 自動管理,存在 `~/Library/LaunchAgents/com.skill-cron.*.plist` - Telegram bot token 存在本地 config 中,不會被 git 追蹤 - `claude -p` 需要有效的 Claude 訂閱 - 排程執行時 Claude 會使用與互動模式相同的模型
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.