Claude Skill

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

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download kerberosclaw-kc_ai_skills-skill-cron-ad005ac.zip · 13 KB
Part of kerberosclaw/kc_ai_skills — 25 skills

Install

skills CLI npx skills add https://github.com/KerberosClaw/kc_ai_skills/tree/main/skill-cron
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install kerberosclaw-kc-ai-skills@llmmart
Git 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:設定

依序詢問:

  1. 「貼上 Bot Token(從 Telegram @BotFather 取得):」
  2. 「貼上 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.

No comments yet.

Reviews (0)

No reviews yet.

Related