feat: 多语言任务、WebUI 增强与 Agent MCP 集成

重构 lib 为扁平模块并支持 Windows schtasks;新增 JS/Bash/PowerShell 模板、WebUI 调度编辑,以及 Cursor Skill 与 MCP 工具供 Agent 管理定时任务。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 21:45:25 +08:00
parent 6c2db2dfa3
commit c10cacd5c6
59 changed files with 3989 additions and 800 deletions

11
.cursor/mcp.json Normal file
View File

@@ -0,0 +1,11 @@
{
"mcpServers": {
"sproutclaw-cron": {
"command": "python",
"args": ["mcp-server/server.py"],
"env": {
"SPROUTCLAW_CRON_ROOT": "${workspaceFolder}"
}
}
}
}

View File

@@ -0,0 +1,88 @@
---
name: sproutclaw-cron
description: >-
Manage SproutClaw Cron scheduled tasks: list/enable/disable/run tasks, read logs,
update schedules, create tasks from templates. Use when the user mentions cronctl,
sproutclaw-cron, scheduled tasks, cron jobs, task enable/disable, or creating
new cron tasks. Prefer MCP tools (cron_*) when available; fall back to cronctl.py CLI.
---
# SproutClaw Cron 定时任务技能
## 何时启用
用户提到:`cronctl`、定时任务、开关任务、试跑任务、新建 cron 任务、`sproutclaw-cron`、WebUI 管理面板,或需要查看任务日志/调度时。
## 优先顺序
1. **MCP 工具**(已配置 `.cursor/mcp.json``cron_list_tasks``cron_get_task``cron_run_task`
2. **CLI 兜底**`python cronctl.py <action> [task-id]`
3. **WebUI**`start-webui.bat`Windows`webui/start.sh`Linux
## 项目路径
- 根目录:仓库根(`cronctl.py` 所在目录)
- 可通过环境变量 `SPROUTCLAW_CRON_ROOT` 覆盖
- Linux 生产路径示例:`/shumengya/project/agent/sproutclaw-cron`
## MCP 工具速查
| 场景 | 工具 |
|------|------|
| 看有哪些任务 | `cron_list_tasks` |
| 看单个任务详情 | `cron_get_task` |
| 开/关/切换 | `cron_enable_task` / `cron_disable_task` / `cron_toggle_task` |
| 试跑 | `cron_run_task``cron_get_log` |
| 改调度 | `cron_update_schedule` |
| 新建任务 | `cron_list_templates``cron_create_task` |
| 同步系统 cron | `cron_sync_cron` |
## 新建任务流程
1. 确认 `task_id` 命名:`<主机名>-<功能描述>`(如 `smallmengya-gitea-repo-sync`
2. `cron_create_task(task_id=..., runtime="python")`**默认禁用**
3. 编辑 `<task-id>/run.py`(或对应入口)与 `schedule.cron`
4. `cron_run_task` 试跑,`cron_get_log` 确认
5. 用户明确要求后再 `cron_enable_task`
模板与约定详见根目录 [AGENTS.md](../../AGENTS.md)。
## CLI 兜底命令
```bash
python cronctl.py status
python cronctl.py status <task-id>
python cronctl.py enable|disable|toggle <task-id>
python cronctl.py run <task-id>
python cronctl.py sync-cron <task-id>
python cronctl.py enable all
```
Windows 将 `python` 换为实际 Python 路径Linux 可用 `python3`
## Agent 原则
- **默认新建任务保持禁用**,除非用户明确要求启用
- **不要修改** `_template*` 示例目录(除非用户要求)
- **schedule.cron** 调度入口保持 `cronctl.py run <task-id>` 形式
- Python 任务保留 `task_is_disabled` / `task_logging` / `acquire_cron_lock` 结构
- 非 Python 任务只改入口脚本;锁/日志/disable 由 `cronctl run` 统一处理
- 改完业务逻辑后先 `run``enable`
## 任务目录结构
```
<task-id>/
├── task.json # 可选runtime + entry + tags
├── run.py|run.js|run.sh|run.ps1
├── schedule.cron
├── logs/<task-id>.log
└── *.json # 可选配置
```
禁用任务位于 `.disabled/<task-id>/`
## 附加资源
- CLI 与 API 详情:[reference.md](reference.md)
- 环境检查脚本:[scripts/check_cron.py](scripts/check_cron.py)

View File

@@ -0,0 +1,83 @@
# SproutClaw Cron 参考
## cronctl.py 子命令
| 子命令 | 说明 | 示例 |
|--------|------|------|
| `status` | 列出或查看任务状态 | `cronctl.py status` |
| `enable` | 启用并同步 cron | `cronctl.py enable my-task` |
| `disable` | 禁用(移入 `.disabled/` | `cronctl.py disable my-task` |
| `toggle` | 切换状态 | `cronctl.py toggle my-task` |
| `run` | 同步执行一次 | `cronctl.py run my-task` |
| `sync-cron` | 仅同步 cron.d / schtasks | `cronctl.py sync-cron my-task` |
别名:`on``enable``off``disable`。批量操作传 `all`
## task.json
```json
{
"runtime": "python",
"entry": "run.py",
"tags": ["标签1", "标签2"]
}
```
`runtime``python` | `javascript` | `bash` | `powershell`
## schedule.cron 格式
- 首行注释为任务描述WebUI / MCP 会读取)
- cron 五段 + 用户 + 命令
- 命令应调用:`python3 <CRON_ROOT>/cronctl.py run <task-id>`
示例:
```cron
# 每天 08:00 同步仓库
0 8 * * * root /usr/bin/python3 /path/to/sproutclaw-cron/cronctl.py run my-task >/dev/null 2>&1
```
## WebUI APIFastAPI端口见 start 脚本)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/tasks` | 任务列表 |
| GET | `/api/tasks/{id}` | 任务详情 |
| POST | `/api/tasks/{id}/enable` | 启用 |
| POST | `/api/tasks/{id}/disable` | 禁用 |
| POST | `/api/tasks/{id}/run` | 后台试跑 |
| GET | `/api/tasks/{id}/log?lines=200` | 日志 |
| PATCH | `/api/tasks/{id}/schedule` | 更新调度 |
## 语言模板
| 模板目录 | runtime | 入口 |
|----------|---------|------|
| `_template` | python | `run.py` |
| `_template-javascript` | javascript | `run.js` |
| `_template-bash` | bash | `run.sh` |
| `_template-powershell` | powershell | `run.ps1` |
## 公共库Python 任务)
`lib/runner.py` 导入:
- `TaskContext.from_task_id(task_id)`
- `task_is_disabled(ctx)` / `task_logging(ctx)` / `acquire_cron_lock(...)`
`lib/notify.py` 导入:
- `TaskResult``send_task_summary(...)`
## 开关机制
- 启用:`.disabled/<id>/``<id>/`
- 禁用:`<id>/``.disabled/<id>/`
- 禁用后 cron 仍可触发,但 `cronctl run` 会直接跳过
## MCP 服务器
- 入口:`mcp-server/server.py`
- 配置:`.cursor/mcp.json`
- 依赖:`pip install -r mcp-server/requirements.txt`

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""SproutClaw Cron 环境检查(跨平台,仅标准库 + 可选 fastmcp"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
def main() -> int:
root = Path(__file__).resolve().parents[4]
lib = root / "lib"
cronctl = root / "cronctl.py"
mcp_server = root / "mcp-server" / "server.py"
checks: list[dict[str, object]] = []
def add(name: str, ok: bool, detail: str = "") -> None:
checks.append({"name": name, "ok": ok, "detail": detail})
add("cron_root", root.is_dir(), str(root))
add("cronctl.py", cronctl.is_file(), str(cronctl))
add("lib/", lib.is_dir(), str(lib))
add("mcp-server/server.py", mcp_server.is_file(), str(mcp_server))
try:
import fastmcp # noqa: F401
add("fastmcp", True, "已安装")
except ImportError:
add("fastmcp", False, "pip install -r mcp-server/requirements.txt")
if cronctl.is_file():
proc = subprocess.run(
[sys.executable, str(cronctl), "status"],
cwd=str(root),
capture_output=True,
text=True,
timeout=30,
)
add(
"cronctl status",
proc.returncode == 0,
(proc.stdout or proc.stderr).strip()[:500],
)
ok_all = all(c["ok"] for c in checks if c["name"] != "fastmcp")
print(json.dumps({"ok": ok_all, "checks": checks}, ensure_ascii=False, indent=2))
return 0 if ok_all else 1
if __name__ == "__main__":
raise SystemExit(main())

7
.gitignore vendored
View File

@@ -13,6 +13,10 @@ logs/
*.lock
*.log
# 内置邮件 API 凭据(本地 .env 不上传)
lib/vendor/mengya-mail-api/.env
lib/vendor/mengya-mail-api/.env.*
# 禁用任务目录(含本机专属脚本/凭据,不上传)
.disabled/
@@ -27,3 +31,6 @@ smallmengya-gitea-repo-sync/*.lock
.vscode/
*.swp
.DS_Store
# MCP 本地虚拟环境
mcp-server/.venv/

View File

@@ -2,37 +2,60 @@
## 新建定时任务
**必须**`_template/` 复制(`_template` 本身是可运行的 hello world 示例任务)。
**默认从 `_template/`Python复制**;也可选用其他语言模板:
| 模板目录 | 语言 | 入口 |
|---|---|---|
| `_template` | Python默认 | `run.py` |
| `_template-javascript` | JavaScript | `run.js` |
| `_template-bash` | Bash | `run.sh` |
| `_template-powershell` | PowerShell | `run.ps1` |
```bash
TASK_ID="<主机名>-<功能描述>"
CRON_ROOT="/shumengya/project/agent/sproutclaw-cron"
cp -a "$CRON_ROOT/_template" "$CRON_ROOT/$TASK_ID"
sed -i "s/_template/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron"
chmod +x "$CRON_ROOT/$TASK_ID/switch.sh"
TEMPLATE="_template" # 或 _template-javascript / _template-bash / _template-powershell
cp -a "$CRON_ROOT/$TEMPLATE" "$CRON_ROOT/$TASK_ID"
sed -i "s/$TEMPLATE/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron"
# Bash 模板额外chmod +x "$CRON_ROOT/$TASK_ID/run.sh"
```
然后:
1. 修改 `run.py`保留 `task_is_disabled` / `task_logging` / `acquire_cron_lock` 结构,替换 `hello world` 为实际逻辑
2. 修改 `schedule.cron`:调整 cron 表达式与注释说明
3. 可选:添加任务专属 JSON 配置(如 `targets.json`
4. `python3 cronctl.py run <task-id>` 试跑,确认日志正常
5. 默认保持关闭;用户要求启用时再 `cronctl enable <task-id>`
1. **Python**修改 `run.py`保留 `task_is_disabled` / `task_logging` / `acquire_cron_lock` 结构
2. **其他语言**:只改入口脚本(`run.js` / `run.sh` / `run.ps1`disable / 锁 / 日志由 `cronctl run` 统一处理
3. 修改 `schedule.cron`:调整 cron 表达式与注释说明
4. 可选:任务专属 JSON 配置(如 `targets.json`
5. `python3 cronctl.py run <task-id>` 试跑,确认日志正常
6. 默认保持关闭;用户要求启用时再 `cronctl enable <task-id>`
## task.json
可选清单,声明运行时与入口(无则按入口文件自动推断):
```json
{
"runtime": "python",
"entry": "run.py"
}
```
`runtime` 取值:`python` | `javascript` | `bash` | `powershell`
## 不要修改
- `_template/` 是 hello world 示例任务,也是新任务复制源;除非用户要求,不要改其示例行为
- `_template*` 是 hello world 示例任务,也是新任务复制源;除非用户要求,不要改其示例行为
- 不要删除或移动已有任务的 `schedule.cron` 里对 `cronctl.py run` 的调用方式
## 任务目录约定
```
<task-id>/
├── run.py
├── task.json # 可选runtime + entry
├── run.py|run.js|run.sh|run.ps1
├── schedule.cron
├── switch.sh # 可选
├── logs/<task-id>.log # 运行时自动创建
├── logs/<task-id>.log
└── *.json # 可选配置
```
@@ -46,4 +69,10 @@ python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run <task-id>
python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py enable <task-id>
```
WebUI`/shumengya/project/agent/sproutclaw-cron/webui/start.sh`
WebUI`/shumengya/project/agent/sproutclaw-cron/webui/start.sh` 或 Windows 下 `start-webui.bat`
## AI Agent 集成
- **Skill**`.cursor/skills/sproutclaw-cron/SKILL.md` — 操作规范与 workflow
- **MCP**`.cursor/mcp.json` 注册 `sproutclaw-cron` 服务器;工具前缀 `cron_*`
- MCP 依赖:`pip install -r mcp-server/requirements.txt`

View File

@@ -10,16 +10,21 @@
/shumengya/project/agent/sproutclaw-cron/
├── cronctl.py # 统一管理 CLIenable/disable/toggle/status
├── AGENTS.md # AI 新建任务规范(必读)
├── _template/ # 示例任务 + 复制模板(每天 08:00 hello world
├── _template/ # Python 示例 + 默认复制模板
├── _template-javascript/ # JavaScript (Node.js) 示例
├── _template-bash/ # Bash 示例
├── _template-powershell/ # PowerShell 示例
├── lib/
│ └── shumengya_cron/
│ ├── runner.py # TaskContext、日志轮转、flock 互斥锁
│ ├── runner.py # TaskContext、日志轮转、互斥锁
│ ├── task_manifest.py # task.json 解析、runtime 推断
│ ├── external_runner.py # 非 Python 任务 subprocess 启动
│ ├── notify.py # 飞书 Markdown 通知 + 邮件降级
│ └── ssh.py # 远端 SSH 辅助bash -lc
├── <task-id>/ # 任务目录(启用状态)
│ ├── run.py # 任务入口(唯一必须文件)
│ ├── task.json # 可选runtime + entry
│ ├── run.py / run.js / run.sh / run.ps1
│ ├── schedule.cron # 系统 cron 配置(复制到 /etc/cron.d/
│ ├── switch.sh # 一键开关(可用 cronctl 替代)
│ ├── targets.json # 可选任务自定义配置JSON
│ ├── logs/<task-id>.log # 运行日志(自动创建)
│ └── <task-id>.lock # flock 互斥锁文件(自动创建)
@@ -31,19 +36,6 @@
---
## 已有任务
| 任务 ID | 状态 | 说明 |
|---|---|---|
| `_template` | 开启 | 示例任务:每天 08:00 输出 hello world也是新任务复制模板 |
| `bigmengya-docker-container-restart` | disabled | SSH 重启 bigmengya 上的数据库类容器 |
| `bigmengya-docker-image-update` | disabled | SSH 拉取并重建 bigmengya 上的 compose 服务 |
| `smallmengya-ai-cli-update` | disabled | 更新本机 codex / claude / opencode |
| `smallmengya-ai-memory-export` | disabled | 导出 AI 记忆数据 |
| `smallmengya-gitea-repo-sync` | 开启 | 同步 Gitea 仓库到本地 |
---
## 快速上手
### 查看状态
@@ -65,32 +57,66 @@ python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py disable all #
# 推荐:通过 cronctl自动识别 .disabled/ 下的任务)
python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run <任务名>
# 也可直接运行(启用或 .disabled/<任务名> 目录均可)
# 也可直接运行 Python 任务(启用或 .disabled/<任务名> 目录均可)
python3 /shumengya/project/agent/sproutclaw-cron/.disabled/<任务名>/run.py
# 其他语言任务请始终用 cronctl run
```
### 安装到系统 cron
`cronctl enable` 会将 `schedule.cron` 复制到 `/etc/cron.d/<task-id>``disable` **只**把任务目录移入 `.disabled/<task-id>/`**不会**删除 `/etc/cron.d/` 条目。cron 到点仍会触发,但 `run.py` 检测到禁用后会直接跳过。
**Linux**`cronctl enable` 会将 `schedule.cron` 复制到 `/etc/cron.d/<task-id>``disable` **只**把任务目录移入 `.disabled/<task-id>/`**不会**删除 `/etc/cron.d/` 条目。cron 到点仍会触发,但禁用后任务会自动跳过。
```bash
python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py sync-cron all # 仅同步 cron.d不改开关
```
**Windows**`enable` / `sync-cron` 会通过 `schtasks` 注册任务计划(任务名 `sproutclaw-<task-id>`)。目前自动转换 `分 时 * * *` 形式的 cron 表达式为「每天 HH:MM」复杂表达式需手动创建计划任务。
```powershell
python D:\SmyProjects\AI\sproutclaw-cron\cronctl.py sync-cron all
python D:\SmyProjects\AI\sproutclaw-cron\cronctl.py enable <任务名>
```
开关机制与 Linux 相同:禁用后目录移入 `.disabled/`,计划任务仍会触发,但任务会自动跳过。
---
## 开关机制
- **启用**:从 `.disabled/<task-id>/` 移回 `<task-id>/`
- **关闭**:从 `<task-id>/` 移入 `.disabled/<task-id>/`
- `run.py` 在入口检查 `task_is_disabled(ctx)`,若关闭直接 `return 0`,不写日志、不发通知、不获取锁
- 禁用时 `cronctl run` 会直接跳过,不写日志、不获取锁
- `/etc/cron.d/<task-id>` 可一直保留;`schedule.cron` 通过 `cronctl.py run <task-id>` 调度
---
## 多语言支持
调度入口统一为 `cronctl.py run`Python。业务脚本语言由 `task.json` 或入口文件决定:
| runtime | 入口 | 依赖 |
|---|---|---|
| `python`(默认) | `run.py` | Python 3 |
| `javascript` | `run.js` | Node.js |
| `bash` | `run.sh` | bash |
| `powershell` | `run.ps1` | pwsh / powershell |
### task.json
```json
{
"runtime": "python",
"entry": "run.py"
}
```
`task.json` 时:有 `run.py` 视为 Python 任务(向后兼容)。
---
## 新建任务(模板)
**从 `_template/` 复制**`_template` 本身是可管理的示例任务(每天 08:00 输出 hello world
**默认从 `_template/`Python复制**JavaScript / Bash / PowerShell 见 `_template-javascript` 等目录
```bash
TASK_ID="smallmengya-my-new-task"
@@ -98,7 +124,6 @@ CRON_ROOT="/shumengya/project/agent/sproutclaw-cron"
cp -a "$CRON_ROOT/_template" "$CRON_ROOT/$TASK_ID"
sed -i "s/_template/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron"
chmod +x "$CRON_ROOT/$TASK_ID/switch.sh"
# 编辑 run.py 与 schedule.cron 后试跑
python3 "$CRON_ROOT/cronctl.py" run "$TASK_ID"
@@ -107,6 +132,12 @@ python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID"
详细约定见 `_template/README.md`AI 代理见根目录 `AGENTS.md`
### AI Agent 集成
- **Skill**`.cursor/skills/sproutclaw-cron/` — Cursor Agent 自动识别定时任务操作规范
- **MCP**`.cursor/mcp.json` + `mcp-server/server.py` — 暴露 `cron_list_tasks``cron_run_task` 等工具
- 首次使用 MCP`pip install -r mcp-server/requirements.txt`,然后在 Cursor 设置中确认 MCP 服务器已启用
---
## 公共库 API
@@ -204,9 +235,6 @@ python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID"
每个 `run.py` 顶部都有 3 行 `sys.path.insert` 代码,是为了支持 `schedule.cron` 直接调用 `python3 run.py`
若将 `schedule.cron` 改为调用 `cronctl run <task-id>`,则 `run.py` 里的路径注入可以全部删掉。
### 2. `switch.sh` 冗余
每个任务目录都有一份 `switch.sh`,其内部已经是代理调用 `cronctl.py`,新任务可以不再创建它,直接用 `cronctl enable/disable`
---
## 任务配置文件格式JSON
@@ -246,7 +274,7 @@ python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID"
## 依赖说明
这套系统**只依赖 Python 3 标准库**`fcntl``subprocess``pathlib` 等),无需安装任何 PyPI 包。
这套系统**只依赖 Python 3 标准库**`subprocess``pathlib` 等),无需安装任何 PyPI 包。文件锁在 Linux 用 `fcntl`Windows 用 `msvcrt`;定时调度在 Linux 用 `/etc/cron.d/`Windows 用任务计划程序(`schtasks`)。
通知功能依赖两个本地项目(非 PyPI 依赖,可缺失时降级):
- `lark-notice-api`:飞书通知,路径 `/shumengya/project/python/lark-notice-api/`

22
_template-bash/README.md Normal file
View File

@@ -0,0 +1,22 @@
# Bash 定时任务模板
Bash hello world 示例。业务逻辑写在 `run.sh`disable / 锁 / 日志由 cronctl 负责。
## 依赖
- Python 3cronctl
- BashLinux 自带Windows 需 Git Bash 或 WSL
## 新建任务
```bash
TASK_ID="smallmengya-my-bash-task"
CRON_ROOT="/shumengya/project/agent/sproutclaw-cron"
cp -a "$CRON_ROOT/_template-bash" "$CRON_ROOT/$TASK_ID"
sed -i "s/_template-bash/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron"
chmod +x "$CRON_ROOT/$TASK_ID/run.sh"
```
## 其他语言模板
`_template-javascript/README.md` 中的对照表。

2
_template-bash/run.sh Normal file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env bash
echo "hello world"

View File

@@ -0,0 +1,8 @@
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOME=/root
CRON_MAIL_ENABLED=1
# 每天 08:00 Bash hello world 示例
# 开关python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py enable|disable|toggle _template-bash
0 8 * * * root /usr/bin/python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run _template-bash >/dev/null 2>&1

8
_template-bash/task.json Normal file
View File

@@ -0,0 +1,8 @@
{
"runtime": "bash",
"entry": "run.sh",
"tags": [
"Bash",
"模板"
]
}

View File

@@ -0,0 +1,32 @@
# JavaScript 定时任务模板
Node.js hello world 示例。调度仍由 `cronctl.py run` 统一管理disable / 锁 / 日志)。
## 依赖
- Python 3cronctl
- Node.js`node` 在 PATH 中)
## 新建任务
```bash
TASK_ID="smallmengya-my-js-task"
CRON_ROOT="/shumengya/project/agent/sproutclaw-cron"
cp -a "$CRON_ROOT/_template-javascript" "$CRON_ROOT/$TASK_ID"
sed -i "s/_template-javascript/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron"
```
编辑 `run.js``schedule.cron` 后试跑:
```bash
python3 "$CRON_ROOT/cronctl.py" run "$TASK_ID"
```
## 其他语言模板
| 目录 | 语言 |
|---|---|
| `_template` | Python默认 |
| `_template-javascript` | JavaScript |
| `_template-bash` | Bash |
| `_template-powershell` | PowerShell |

View File

@@ -0,0 +1 @@
console.log("hello world");

View File

@@ -0,0 +1,8 @@
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOME=/root
CRON_MAIL_ENABLED=1
# 每天 08:00 JavaScript hello world 示例Node.js
# 开关python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py enable|disable|toggle _template-javascript
0 8 * * * root /usr/bin/python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run _template-javascript >/dev/null 2>&1

View File

@@ -0,0 +1,8 @@
{
"runtime": "javascript",
"entry": "run.js",
"tags": [
"JavaScript",
"模板"
]
}

View File

@@ -0,0 +1,27 @@
# PowerShell 定时任务模板
PowerShell hello world 示例。业务逻辑写在 `run.ps1`disable / 锁 / 日志由 cronctl 负责。
## 依赖
- Python 3cronctl
- PowerShell`pwsh` 或 Windows 自带 `powershell`
## 新建任务
```bash
TASK_ID="smallmengya-my-ps-task"
CRON_ROOT="/shumengya/project/agent/sproutclaw-cron"
cp -a "$CRON_ROOT/_template-powershell" "$CRON_ROOT/$TASK_ID"
sed -i "s/_template-powershell/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron"
```
Windows 上可直接:
```powershell
python D:\SmyProjects\AI\sproutclaw-cron\cronctl.py run _template-powershell
```
## 其他语言模板
`_template-javascript/README.md` 中的对照表。

View File

@@ -0,0 +1,2 @@
Write-Output "hello world"
exit 0

View File

@@ -0,0 +1,8 @@
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOME=/root
CRON_MAIL_ENABLED=1
# 每天 08:00 PowerShell hello world 示例
# 开关python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py enable|disable|toggle _template-powershell
0 8 * * * root /usr/bin/python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run _template-powershell >/dev/null 2>&1

View File

@@ -0,0 +1,8 @@
{
"runtime": "powershell",
"entry": "run.ps1",
"tags": [
"PowerShell",
"模板"
]
}

View File

@@ -1,7 +1,18 @@
# 定时任务模板 / 示例任务
# 定时任务模板 / 示例任务Python
`_template` 本身是可管理的真实任务:每天 08:00 输出 `hello world`
新建任务时复制本目录,改任务名与业务逻辑即可。
`_template`默认可管理的 Python hello world 任务(每天 08:00
新建 Python 任务时复制本目录,改任务名与业务逻辑即可。
## 多语言模板
| 目录 | 语言 | 入口 |
|---|---|---|
| `_template` | **Python默认** | `run.py` |
| `_template-javascript` | JavaScript | `run.js` |
| `_template-bash` | Bash | `run.sh` |
| `_template-powershell` | PowerShell | `run.ps1` |
非 Python 任务只需编写入口脚本disable / 锁 / 日志由 `cronctl run` 负责。
## 新建步骤
@@ -11,23 +22,22 @@ CRON_ROOT="/shumengya/project/agent/sproutclaw-cron"
cp -a "$CRON_ROOT/_template" "$CRON_ROOT/$TASK_ID"
sed -i "s/_template/$TASK_ID/g" "$CRON_ROOT/$TASK_ID/schedule.cron"
chmod +x "$CRON_ROOT/$TASK_ID/switch.sh"
# 编辑 run.py替换 hello world 为实际业务逻辑
# 编辑 schedule.cron调整 cron 表达式与注释
python3 "$CRON_ROOT/cronctl.py" status "$TASK_ID"
python3 "$CRON_ROOT/cronctl.py" run "$TASK_ID" # 手动试跑
python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID" # 开启并同步 /etc/cron.d/
python3 "$CRON_ROOT/cronctl.py" run "$TASK_ID"
python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID"
```
## 必须文件
| 文件 | 说明 |
|---|---|
| `run.py` | 任务入口,导出 `run(ctx) -> int` |
| `schedule.cron` | 系统 cron 配置cron 行须调用 `cronctl.py run <task-id>` |
| `switch.sh` | 可选,代理 `cronctl` 开关 |
| `task.json` | 可选;声明 `runtime``entry` |
| `run.py` | Python 任务入口,导出 `run(ctx) -> int` |
| `schedule.cron` | cron 配置,须调用 `cronctl.py run <task-id>` |
## run.py 约定
@@ -35,7 +45,7 @@ python3 "$CRON_ROOT/cronctl.py" enable "$TASK_ID" # 开启并同步 /etc/c
2. 使用 `task_logging(ctx)` 写日志到 `logs/<task-id>.log`
3. 使用 `acquire_cron_lock(ctx.lock_file, log=log)` 防止并发重入
4. 业务逻辑写在 `log("start")``log("end")` 之间
5. 需要汇总通知时使用 `shumengya_cron.notify.send_task_summary`
5. 需要汇总通知时使用 `notify.send_task_summary`
## schedule.cron 约定

View File

@@ -11,7 +11,7 @@ import sys as _sys, pathlib as _pl
_sys.path.insert(0, str(_pl.Path(__file__).resolve().parents[1] / "lib"))
del _sys, _pl
from shumengya_cron.runner import (
from runner import (
TaskContext,
acquire_cron_lock,
task_is_disabled,

View File

@@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
task_id="$(basename "$(dirname "$0")")"
action="${1:-toggle}"
shift || true
exec python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py "$action" "$task_id" "$@"

8
_template/task.json Normal file
View File

@@ -0,0 +1,8 @@
{
"runtime": "python",
"entry": "run.py",
"tags": [
"Python",
"模板"
]
}

View File

@@ -9,7 +9,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
from shumengya_cron.manager import (
from manager import (
cron_root,
get_context,
iter_task_ids,
@@ -18,7 +18,7 @@ from shumengya_cron.manager import (
set_task_state,
sync_cron_d,
)
from shumengya_cron.runner import task_enabled_text, task_is_enabled
from runner import task_enabled_text, task_is_enabled
CRON_ROOT = cron_root()
@@ -81,7 +81,8 @@ def main(argv: list[str] | None = None) -> int:
try:
if action == "run":
task_rc = run_task(task_id, CRON_ROOT)
print(f"{task_id} (exit={task_rc})")
marker = ">" if sys.platform == "win32" else ""
print(f"{marker} {task_id} (exit={task_rc})")
if task_rc != 0:
rc = task_rc
continue

5
lib/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""shumengya-cron共用库日志/锁、多语言调度、飞书通知、SSH 辅助)。"""
__all__ = ["__version__"]
__version__ = "0.1.0"

128
lib/cron_platform.py Normal file
View File

@@ -0,0 +1,128 @@
"""跨平台辅助文件锁、Windows 任务计划程序同步。"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
def is_windows() -> bool:
return sys.platform == "win32"
def lock_ex_nb(fd: int) -> None:
"""非阻塞独占锁;无法获取时抛出 BlockingIOError。"""
if is_windows():
import msvcrt
try:
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
except OSError as exc:
raise BlockingIOError from exc
return
import fcntl
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
def unlock(fd: int) -> None:
if is_windows():
import msvcrt
try:
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
except OSError:
pass
return
import fcntl
try:
fcntl.flock(fd, fcntl.LOCK_UN)
except OSError:
pass
def windows_task_name(task_id: str) -> str:
return f"sproutclaw-{task_id}"
def _cron_to_daily_time(expression: str) -> str | None:
"""将 `分 时 * * *` 转为 schtasks 的 HH:MM不支持则返回 None。"""
parts = expression.split()
if len(parts) != 5:
return None
minute, hour, dom, month, dow = parts
if dom != "*" or month != "*" or dow != "*":
return None
if not minute.isdigit() or not hour.isdigit():
return None
return f"{int(hour):02d}:{int(minute):02d}"
def sync_windows_task(
task_id: str,
cron_root: Path,
schedule_expression: str | None,
*,
enabled: bool,
) -> str | None:
"""在 Windows 任务计划程序中注册/更新定时任务。"""
if schedule_expression is None:
return "未找到 cron 表达式,跳过任务计划注册"
daily_time = _cron_to_daily_time(schedule_expression)
if daily_time is None:
return (
f"暂不支持 cron 表达式「{schedule_expression}」自动转换,"
"请手动创建任务计划或使用 `分 时 * * *` 形式"
)
python_exe = Path(sys.executable).resolve()
cronctl = (cron_root / "cronctl.py").resolve()
task_name = windows_task_name(task_id)
run_cmd = f'"{python_exe}" "{cronctl}" run {task_id}'
query = subprocess.run(
["schtasks", "/Query", "/TN", task_name],
capture_output=True,
text=True,
)
exists = query.returncode == 0
if exists:
subprocess.run(
["schtasks", "/Delete", "/TN", task_name, "/F"],
capture_output=True,
text=True,
check=False,
)
create = subprocess.run(
[
"schtasks",
"/Create",
"/TN",
task_name,
"/TR",
run_cmd,
"/SC",
"DAILY",
"/ST",
daily_time,
"/F",
],
capture_output=True,
text=True,
check=False,
)
if create.returncode != 0:
err = (create.stderr or create.stdout or "").strip()
return f"任务计划注册失败: {err or '未知错误'}"
state = "开启" if enabled else "关闭(计划仍触发,任务自动跳过)"
action = "已更新" if exists else "已创建"
return f"{action} Windows 任务「{task_name}」,每天 {daily_time}{state}"

91
lib/external_runner.py Normal file
View File

@@ -0,0 +1,91 @@
"""非 Python 任务:由 Python 编排 disable / 锁 / 日志subprocess 执行业务脚本。"""
from __future__ import annotations
import os
import shutil
import subprocess
from runner import (
TaskContext,
acquire_cron_lock,
append_raw_to_log,
task_is_disabled,
task_logging,
)
from task_manifest import TaskManifest
def _resolve_interpreter(runtime: str) -> list[str] | None:
if runtime == "javascript":
node = shutil.which("node")
return [node] if node else None
if runtime == "bash":
bash = shutil.which("bash")
return [bash] if bash else None
if runtime == "powershell":
pwsh = shutil.which("pwsh")
if pwsh:
return [pwsh, "-File"]
ps = shutil.which("powershell")
return [ps, "-File"] if ps else None
return None
def build_command(manifest: TaskManifest, ctx: TaskContext) -> list[str]:
interpreter = _resolve_interpreter(manifest.runtime)
if interpreter is None:
raise FileNotFoundError(f"未找到 {manifest.runtime} 解释器,请确认已安装并在 PATH 中")
entry = ctx.task_dir / manifest.entry
if manifest.runtime == "powershell":
return [*interpreter, str(entry)]
if manifest.runtime == "bash":
return [*interpreter, str(entry)]
return [*interpreter, str(entry)]
def _task_env(ctx: TaskContext) -> dict[str, str]:
env = os.environ.copy()
env["CRON_TASK_ID"] = ctx.task_id
env["CRON_TASK_DIR"] = str(ctx.task_dir)
env["CRON_ROOT"] = str(ctx.cron_root)
return env
def run_external_task(ctx: TaskContext, manifest: TaskManifest) -> int:
if task_is_disabled(ctx):
return 0
with task_logging(ctx) as log:
with acquire_cron_lock(ctx.lock_file, log=log) as locked:
if not locked:
return 0
log("start")
log(f"runtime={manifest.runtime} entry={manifest.entry}")
try:
cmd = build_command(manifest, ctx)
except FileNotFoundError as exc:
log(str(exc))
log("end")
return 1
proc = subprocess.run(
cmd,
cwd=str(ctx.task_dir),
env=_task_env(ctx),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
output = ((proc.stdout or "") + (proc.stderr or "")).strip()
if output:
append_raw_to_log(ctx.log_file, output)
if proc.returncode != 0:
log(f"exit={proc.returncode}")
log("end")
return proc.returncode

View File

@@ -2,30 +2,68 @@
from __future__ import annotations
import fcntl
import importlib.util
import os
import re
import shutil
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
from shumengya_cron.runner import (
from external_runner import run_external_task
from cron_platform import is_windows, lock_ex_nb, sync_windows_task, unlock
from runner import (
DISABLED_DIR_NAME,
LogMode,
TaskContext,
set_task_enabled,
task_is_enabled,
)
from task_manifest import (
effective_task_tags,
is_valid_task_dir,
load_task_manifest,
load_task_tags,
normalize_tags,
save_task_tags,
)
CRON_D = Path("/etc/cron.d")
_CRON_LINE = re.compile(
r"^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+\S+\s+.+$",
)
_DESC_SKIP_PREFIX = ("开关",)
def _is_description_comment(text: str) -> bool:
if not text:
return False
if text.startswith(_DESC_SKIP_PREFIX):
return False
if "cronctl" in text:
return False
return True
def _validate_cron_schedule(schedule: str) -> str:
parts = schedule.strip().split()
if len(parts) != 5:
raise ValueError("cron 表达式须为 5 段:分 时 日 月 周")
return " ".join(parts)
def _replace_cron_expression(line: str, new_schedule: str) -> str:
stripped = line.strip()
if not _CRON_LINE.match(stripped):
return line
parts = stripped.split(None, 6)
if len(parts) < 7:
raise ValueError("schedule.cron 中的 cron 行格式无法识别")
return f"{new_schedule} {parts[5]} {parts[6]}"
def cron_root() -> Path:
return Path(__file__).resolve().parents[2]
return Path(__file__).resolve().parents[1]
def migrate_legacy_disabled_layout(root: Path | None = None) -> None:
@@ -55,13 +93,13 @@ def iter_task_ids(root: Path | None = None) -> list[str]:
if path.name.endswith(".disabled"):
task_ids.add(path.name.removesuffix(".disabled"))
continue
if (path / "run.py").is_file():
if is_valid_task_dir(path):
task_ids.add(path.name)
disabled_root = base / DISABLED_DIR_NAME
if disabled_root.is_dir():
for path in sorted(disabled_root.iterdir()):
if path.is_dir() and (path / "run.py").is_file():
if path.is_dir() and is_valid_task_dir(path):
task_ids.add(path.name)
return sorted(task_ids)
@@ -71,8 +109,8 @@ def get_context(task_id: str, root: Path | None = None) -> TaskContext:
ctx = TaskContext.from_task_id(task_id, cron_root=root)
if not ctx.task_dir.is_dir():
raise FileNotFoundError(f"任务不存在: {task_id}")
if not (ctx.task_dir / "run.py").is_file():
raise FileNotFoundError(f"任务目录缺少 run.py: {task_id}")
if not is_valid_task_dir(ctx.task_dir):
raise FileNotFoundError(f"任务目录缺少 schedule.cron 或入口脚本: {task_id}")
return ctx
@@ -89,7 +127,7 @@ def _parse_schedule(task_dir: Path) -> tuple[str | None, str | None]:
if line.startswith("#"):
if description is None:
text = line.lstrip("#").strip()
if text and not text.startswith("开关") and "cronctl" not in text:
if _is_description_comment(text):
description = text
continue
match = _CRON_LINE.match(line)
@@ -103,15 +141,16 @@ def task_is_running(ctx: TaskContext) -> bool:
lock_file = ctx.lock_file
if not lock_file.is_file():
return False
fd = lock_file.open("r")
fd = os.open(str(lock_file), os.O_RDWR | os.O_CREAT, 0o644)
try:
try:
fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
lock_ex_nb(fd)
return False
except BlockingIOError:
return True
finally:
fd.close()
unlock(fd)
os.close(fd)
@dataclass
@@ -124,6 +163,8 @@ class TaskInfo:
log_size: int
log_updated: str | None
task_dir: str
runtime: str
tags: list[str]
def to_dict(self) -> dict[str, object]:
return asdict(self)
@@ -138,6 +179,7 @@ def build_task_info(task_id: str, root: Path | None = None) -> TaskInfo:
stat = ctx.log_file.stat()
log_size = stat.st_size
log_updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S")
manifest = load_task_manifest(ctx.task_dir)
return TaskInfo(
task_id=task_id,
enabled=task_is_enabled(ctx),
@@ -147,6 +189,8 @@ def build_task_info(task_id: str, root: Path | None = None) -> TaskInfo:
log_size=log_size,
log_updated=log_updated,
task_dir=str(ctx.task_dir),
runtime=manifest.runtime,
tags=effective_task_tags(ctx.task_dir, manifest),
)
@@ -156,13 +200,24 @@ def list_tasks(root: Path | None = None) -> list[TaskInfo]:
def sync_cron_d(ctx: TaskContext, enabled: bool) -> str | None:
"""将 schedule.cron 安装到 /etc/cron.d/。返回提示信息或 None"""
cron_d_file = CRON_D / ctx.task_id
"""安装定时调度Linux 写入 /etc/cron.d/Windows 注册任务计划程序"""
schedule = ctx.task_dir / "schedule.cron"
if not schedule.is_file():
return "未找到 schedule.cron跳过安装"
expression, _ = _parse_schedule(ctx.task_dir)
if is_windows():
return sync_windows_task(
ctx.task_id,
ctx.cron_root,
expression,
enabled=enabled,
)
cron_d_file = CRON_D / ctx.task_id
shutil.copy2(schedule, cron_d_file)
state = "开启" if enabled else "关闭cron 仍触发,run.py 自动跳过)"
state = "开启" if enabled else "关闭cron 仍触发,任务自动跳过)"
return f"已同步到 {cron_d_file}{state}"
@@ -180,10 +235,14 @@ def toggle_task(task_id: str, root: Path | None = None) -> tuple[TaskInfo, str |
def run_task(task_id: str, root: Path | None = None) -> int:
"""动态加载任务 run.py调用其 run(ctx) 并返回退出码"""
"""执行任务Python 动态加载 run.py runtime 由 external_runner 启动"""
ctx = get_context(task_id, root)
run_py = ctx.task_dir / "run.py"
manifest = load_task_manifest(ctx.task_dir)
if manifest.runtime != "python":
return run_external_task(ctx, manifest)
run_py = ctx.task_dir / manifest.entry
spec = importlib.util.spec_from_file_location(f"cron_run_{task_id}", run_py)
mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
spec.loader.exec_module(mod) # type: ignore[union-attr]
@@ -202,3 +261,65 @@ def read_log_tail(task_id: str, lines: int = 200, root: Path | None = None) -> s
if lines <= 0 or len(parts) <= lines:
return content
return "\n".join(parts[-lines:]) + "\n"
def update_task_schedule(
task_id: str,
*,
description: str,
schedule: str,
tags: list[str] | None = None,
root: Path | None = None,
sync: bool = True,
) -> tuple[TaskInfo, str | None]:
"""更新 schedule.cron 中的描述注释、cron 表达式前五段,以及 task.json 标签。"""
ctx = get_context(task_id, root)
schedule_file = ctx.task_dir / "schedule.cron"
if not schedule_file.is_file():
raise FileNotFoundError(f"未找到 schedule.cron: {task_id}")
new_schedule = _validate_cron_schedule(schedule)
desc_text = description.strip()
raw_lines = schedule_file.read_text(encoding="utf-8").splitlines()
desc_updated = False
cron_updated = False
new_lines: list[str] = []
for line in raw_lines:
stripped = line.strip()
if not desc_updated and stripped.startswith("#"):
text = stripped.lstrip("#").strip()
if _is_description_comment(text):
new_lines.append(f"# {desc_text}" if desc_text else "#")
desc_updated = True
continue
if _CRON_LINE.match(stripped):
new_lines.append(_replace_cron_expression(line, new_schedule))
cron_updated = True
continue
new_lines.append(line)
if not cron_updated:
raise ValueError("schedule.cron 中未找到 cron 调度行")
if not desc_updated and desc_text:
inserted: list[str] = []
for line in new_lines:
if _CRON_LINE.match(line.strip()) and (not inserted or inserted[-1] != ""):
inserted.append(f"# {desc_text}")
inserted.append("")
inserted.append(line)
new_lines = inserted
schedule_file.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
if tags is not None:
save_task_tags(ctx.task_dir, normalize_tags(tags))
message = None
if sync:
ctx = get_context(task_id, root)
message = sync_cron_d(ctx, task_is_enabled(ctx))
return build_task_info(task_id, root), message

View File

@@ -1,23 +1,35 @@
"""
飞书 / Lark Markdown 通知 + 任务结果汇总
内置 lark-notice-api mengya-mail-api无需外部 subprocess
"""
from __future__ import annotations
import os
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Callable
if TYPE_CHECKING:
from shumengya_cron.runner import TaskContext
from runner import TaskContext
CRON_FEISHU_WEBHOOK_DEFAULT = (
"https://open.feishu.cn/open-apis/bot/v2/hook/1b13f977-f848-4a42-8e43-1b16ad592a34"
)
CRON_LARK_NOTICE_API_SRC_DEFAULT = "/shumengya/project/python/lark-notice-api/src"
_VENDOR_ROOT = Path(__file__).resolve().parent / "vendor"
_LARK_SRC = _VENDOR_ROOT / "lark-notice-api" / "src"
_MAIL_SRC = _VENDOR_ROOT / "mengya-mail-api" / "src"
_MAIL_ENV_DEFAULT = _VENDOR_ROOT / "mengya-mail-api" / ".env"
def _ensure_vendor_imports() -> None:
for src in (_LARK_SRC, _MAIL_SRC):
text = str(src)
if src.is_dir() and text not in sys.path:
sys.path.insert(0, text)
@dataclass
@@ -76,11 +88,8 @@ def send_task_summary(
log: Callable[[str], None],
extra_fields: list[tuple[str, object]] | None = None,
) -> None:
"""发送标准任务汇总通知(飞书 + 邮件降级)。
extra_fields 会插入在 结束时间 总数 之间适合放主机名目标路径等任务特有字段
"""
from shumengya_cron.runner import task_output_prefix
"""发送标准任务汇总通知(飞书 + 邮件降级)。"""
from runner import task_output_prefix
end_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
subject = f"{task_output_prefix(ctx.task_id)} {result.status_cn} {end_time}"
@@ -114,95 +123,61 @@ def send_feishu_markdown(
*,
log: Callable[[str], None],
) -> None:
"""发送飞书 Markdown失败时自动降级为邮件通知,邮件也失败则结束)。"""
"""发送飞书 Markdown失败时自动降级为邮件通知"""
if not title or not markdown:
log("飞书通知内容为空,跳过。")
return
webhook = os.environ.get("LARK_NOTICE_WEBHOOK", CRON_FEISHU_WEBHOOK_DEFAULT)
api_src = os.environ.get("LARK_NOTICE_API_SRC", CRON_LARK_NOTICE_API_SRC_DEFAULT)
if not webhook:
log("未配置飞书 webhook跳过飞书通知。")
return
env = os.environ.copy()
pp = api_src
if env.get("PYTHONPATH"):
pp = f"{api_src}:{env['PYTHONPATH']}"
env["PYTHONPATH"] = pp
_ensure_vendor_imports()
from lark_notice_api.client import LarkNoticeError, LarkWebhookClient
feishu_ok = False
try:
r = subprocess.run(
[
sys.executable,
"-m",
"lark_notice_api",
"--webhook",
webhook,
"send-markdown",
"--title",
title,
"--markdown",
markdown,
],
cwd=api_src if os.path.isdir(api_src) else None,
env=env,
check=False,
capture_output=True,
text=True,
)
if r.returncode == 0:
feishu_ok = True
else:
detail = (r.stderr or r.stdout or "").strip()
if detail:
log(f"发送飞书通知失败(退出码 {r.returncode}{detail}")
else:
log(f"发送飞书通知失败(退出码 {r.returncode})。")
except OSError:
log("发送飞书通知失败lark-notice-api 调用异常。")
if feishu_ok:
LarkWebhookClient(webhook).send_markdown(title, markdown)
return
except LarkNoticeError as exc:
log(f"发送飞书通知失败:{exc}")
except ImportError:
log("发送飞书通知失败:内置 lark-notice-api 不可用。")
log("飞书通知失败,尝试通过邮件发送…")
_send_mail_fallback(title, markdown, log)
# ── 邮件降级 ──────────────────────────────────────────────
_MAIL_SCRIPT_DEFAULT = "/shumengya/project/skills/mengya-mail-skills/scripts/mengya-mail-api.py"
_MAIL_ENV_FILE_DEFAULT = "/shumengya/project/python/mengya-mail-api/.env"
def _send_mail_fallback(title: str, markdown: str, log: Callable[[str], None]) -> None:
"""通过 mengya-mail-api 发送报告邮件(失败仅记日志,不再继续降级)。"""
"""通过内置 mengya-mail-api 发送邮件(失败仅记日志)。"""
enabled = os.environ.get("CRON_MAIL_ENABLED", "0")
if enabled != "1":
log("邮件通知未开启export CRON_MAIL_ENABLED=1 可启用),跳过。")
return
script = os.environ.get("MAIL_API_SCRIPT", _MAIL_SCRIPT_DEFAULT)
env_file = os.environ.get("MAIL_API_ENV_FILE", _MAIL_ENV_FILE_DEFAULT)
env_file = os.environ.get("MAIL_API_ENV_FILE", str(_MAIL_ENV_DEFAULT))
if env_file and os.path.isfile(env_file):
os.environ.setdefault("MENGYA_MAIL_ENV_FILE", env_file)
mail_to = os.environ.get("MAIL_TO", "mail@smyhub.com")
from_name = os.environ.get("MAIL_FROM_NAME", "cron")
if not os.path.isfile(script):
log(f"邮件脚本不存在:{script},跳过邮件通知。")
return
_ensure_vendor_imports()
from mengya_mail_api.config import ConfigError, MailConfig
from mengya_mail_api.email_client import MailClient, MailClientError
try:
r = subprocess.run(
[sys.executable, script, "--env-file", env_file, "--format", "json",
"send-email", "--to", mail_to, "--subject", title,
"--from-name", from_name, "--html-body", markdown],
capture_output=True, text=True, check=False,
config = MailConfig.from_env()
MailClient(config).send_email(
to=mail_to,
subject=title,
html_body=markdown,
from_name=from_name,
)
if r.returncode == 0:
log("邮件通知发送成功。")
else:
detail = (r.stderr or r.stdout or "").strip()
log(f"邮件通知发送失败(退出码 {r.returncode}{detail or '无详细信息'}")
except OSError as exc:
log(f"邮件通知发送异常:{exc}")
log("邮件通知发送成功。")
except ConfigError as exc:
log(f"邮件配置错误:{exc}")
except MailClientError as exc:
log(f"邮件通知发送失败{exc}")
except ImportError:
log("邮件通知发送失败:内置 mengya-mail-api 不可用。")

View File

@@ -7,7 +7,6 @@
from __future__ import annotations
import fcntl
import os
import sys
from contextlib import contextmanager
@@ -17,6 +16,8 @@ from enum import Enum
from pathlib import Path
from typing import Callable, Iterator, TextIO
from cron_platform import lock_ex_nb, unlock
class LogMode(str, Enum):
"""日志模式:整段重定向到文件(多数任务)或直接写文件(如 AI CLI 更新)。"""
@@ -26,8 +27,8 @@ class LogMode(str, Enum):
def _cron_root() -> Path:
# .../lib/shumengya_cron/runner.py -> cron 根目录
return Path(__file__).resolve().parents[2]
# .../lib/runner.py -> cron 根目录
return Path(__file__).resolve().parents[1]
DISABLED_DIR_NAME = ".disabled"
@@ -184,7 +185,7 @@ def acquire_cron_lock(
) -> Iterator[bool]:
"""
获取互斥锁若已被占用则记录日志并 yield False调用方应 sys.exit(0)
使用 flock bash 版一致
Linux 使用 flockWindows 使用 msvcrt 文件锁
"""
msg = busy_message or os.environ.get(
"CRON_LOCK_BUSY_MSG", "已有同名任务在运行,跳过本次。"
@@ -193,17 +194,14 @@ def acquire_cron_lock(
fd = os.open(str(lock_file), os.O_RDWR | os.O_CREAT, 0o644)
try:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
lock_ex_nb(fd)
except BlockingIOError:
log(msg)
yield False
return
yield True
finally:
try:
fcntl.flock(fd, fcntl.LOCK_UN)
except OSError:
pass
unlock(fd)
os.close(fd)

114
lib/runtime_probe.py Normal file
View File

@@ -0,0 +1,114 @@
"""检测本机 Python / Node.js / Bash / PowerShell 是否可用及版本。"""
from __future__ import annotations
import re
import shutil
import subprocess
import sys
from dataclasses import asdict, dataclass
@dataclass
class RuntimeProbe:
id: str
name: str
available: bool
version: str | None = None
path: str | None = None
def to_dict(self) -> dict[str, object]:
return asdict(self)
def _first_line(text: str) -> str:
line = (text or "").strip().splitlines()[0].strip() if text else ""
return line
def _run(cmd: list[str], *, timeout: float = 8.0) -> tuple[int, str]:
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
out = _first_line((proc.stdout or "") + "\n" + (proc.stderr or ""))
return proc.returncode, out
except (OSError, subprocess.TimeoutExpired):
return 1, ""
def _probe_python() -> RuntimeProbe:
exe = sys.executable
code, out = _run([exe, "--version"])
version: str | None = None
if code == 0 and out:
version = out.removeprefix("Python ").strip() if out.lower().startswith("python") else out
elif sys.version:
version = sys.version.split()[0]
return RuntimeProbe(
id="python",
name="Python",
available=True,
version=version,
path=exe,
)
def _probe_nodejs() -> RuntimeProbe:
path = shutil.which("node")
if not path:
return RuntimeProbe(id="nodejs", name="JavaScript", available=False)
code, out = _run([path, "--version"])
if code != 0 or not out:
return RuntimeProbe(id="nodejs", name="JavaScript", available=False, path=path)
version = out.lstrip("vV").strip() or out
return RuntimeProbe(id="nodejs", name="JavaScript", available=True, version=version, path=path)
def _parse_bash_version(text: str) -> str | None:
match = re.search(r"version\s+([^\s(]+)", text, re.I)
return match.group(1) if match else (text or None)
def _probe_bash() -> RuntimeProbe:
path = shutil.which("bash")
if not path:
return RuntimeProbe(id="bash", name="Bash", available=False)
code, out = _run([path, "--version"])
if code != 0 or not out:
return RuntimeProbe(id="bash", name="Bash", available=False, path=path)
version = _parse_bash_version(out)
return RuntimeProbe(id="bash", name="Bash", available=True, version=version, path=path)
def _probe_powershell() -> RuntimeProbe:
for cmd_name in ("pwsh", "powershell"):
path = shutil.which(cmd_name)
if not path:
continue
code, out = _run(
[path, "-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"]
)
if code == 0 and out:
return RuntimeProbe(
id="powershell",
name="PowerShell",
available=True,
version=out.strip(),
path=path,
)
return RuntimeProbe(id="powershell", name="PowerShell", available=False)
def probe_all_runtimes() -> list[dict[str, object]]:
return [
_probe_python().to_dict(),
_probe_nodejs().to_dict(),
_probe_bash().to_dict(),
_probe_powershell().to_dict(),
]

View File

@@ -1,5 +0,0 @@
"""shumengya-cron共用库日志/锁、飞书通知、SSH 辅助)。各任务逻辑在任务目录 run.py。"""
__all__ = ["__version__"]
__version__ = "0.1.0"

165
lib/task_manifest.py Normal file
View File

@@ -0,0 +1,165 @@
"""任务清单 task.json 解析与 runtime 推断。"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
Runtime = Literal["python", "javascript", "bash", "powershell"]
RUNTIME_DEFAULT_ENTRY: dict[str, str] = {
"python": "run.py",
"javascript": "run.js",
"bash": "run.sh",
"powershell": "run.ps1",
}
RUNTIME_ALIASES: dict[str, Runtime] = {
"python": "python",
"py": "python",
"javascript": "javascript",
"js": "javascript",
"node": "javascript",
"bash": "bash",
"sh": "bash",
"shell": "bash",
"powershell": "powershell",
"ps1": "powershell",
"pwsh": "powershell",
}
ENTRY_TO_RUNTIME: dict[str, Runtime] = {
"run.py": "python",
"run.js": "javascript",
"run.sh": "bash",
"run.ps1": "powershell",
}
RUNTIME_LABEL: dict[str, str] = {
"python": "Python",
"javascript": "JavaScript",
"bash": "Bash",
"powershell": "PowerShell",
}
MAX_TASK_TAGS = 4
MAX_TAG_LENGTH = 32
@dataclass(frozen=True)
class TaskManifest:
runtime: Runtime
entry: str
@property
def entry_path(self) -> str:
return self.entry
@property
def label(self) -> str:
return RUNTIME_LABEL.get(self.runtime, self.runtime)
def _normalize_runtime(raw: str) -> Runtime | None:
key = raw.strip().lower()
return RUNTIME_ALIASES.get(key) # type: ignore[return-value]
def _infer_from_entry_files(task_dir: Path) -> TaskManifest | None:
for entry, runtime in ENTRY_TO_RUNTIME.items():
if (task_dir / entry).is_file():
return TaskManifest(runtime=runtime, entry=entry)
return None
def load_task_manifest(task_dir: Path) -> TaskManifest:
"""读取 task.json缺失时按入口文件推断默认 python + run.py。"""
manifest_file = task_dir / "task.json"
if manifest_file.is_file():
data = json.loads(manifest_file.read_text(encoding="utf-8"))
runtime_raw = str(data.get("runtime", "python"))
runtime = _normalize_runtime(runtime_raw)
if runtime is None:
raise ValueError(f"不支持的 runtime: {runtime_raw}")
entry = str(data.get("entry", RUNTIME_DEFAULT_ENTRY[runtime])).strip()
if not entry:
entry = RUNTIME_DEFAULT_ENTRY[runtime]
entry_path = task_dir / entry
if not entry_path.is_file():
raise FileNotFoundError(f"task.json 指定的入口不存在: {entry}")
return TaskManifest(runtime=runtime, entry=entry)
inferred = _infer_from_entry_files(task_dir)
if inferred is not None:
return inferred
if (task_dir / "run.py").is_file():
return TaskManifest(runtime="python", entry="run.py")
raise FileNotFoundError("未找到 task.json 或可识别的入口脚本")
def normalize_tags(raw: object) -> list[str]:
"""去重、去空白,最多 MAX_TASK_TAGS 个。"""
if not isinstance(raw, (list, tuple)):
return []
tags: list[str] = []
for item in raw:
s = str(item).strip()
if not s or len(s) > MAX_TAG_LENGTH:
continue
if s not in tags:
tags.append(s)
if len(tags) >= MAX_TASK_TAGS:
break
return tags
def load_task_tags(task_dir: Path) -> list[str]:
manifest_file = task_dir / "task.json"
if not manifest_file.is_file():
return []
try:
data = json.loads(manifest_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
return normalize_tags(data.get("tags", []))
def effective_task_tags(task_dir: Path, manifest: TaskManifest) -> list[str]:
"""已存标签优先;否则默认用运行时名称作为唯一标签。"""
stored = load_task_tags(task_dir)
if stored:
return stored
return [RUNTIME_LABEL.get(manifest.runtime, manifest.runtime)]
def save_task_tags(task_dir: Path, tags: list[str]) -> None:
"""写入 task.json 的 tags 字段;无文件时按当前 manifest 创建。"""
manifest = load_task_manifest(task_dir)
normalized = normalize_tags(tags)
manifest_file = task_dir / "task.json"
data: dict[str, object] = {}
if manifest_file.is_file():
data = json.loads(manifest_file.read_text(encoding="utf-8"))
data["runtime"] = manifest.runtime
data["entry"] = manifest.entry
data["tags"] = normalized
manifest_file.write_text(
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def is_valid_task_dir(task_dir: Path) -> bool:
try:
if not task_dir.is_dir():
return False
if not (task_dir / "schedule.cron").is_file():
return False
load_task_manifest(task_dir)
return True
except (OSError, ValueError, FileNotFoundError, json.JSONDecodeError):
return False

View File

@@ -0,0 +1,5 @@
from __future__ import annotations
__all__ = ["__version__"]
__version__ = "0.1.0"

View File

@@ -0,0 +1,7 @@
from __future__ import annotations
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,115 @@
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import Any
from .client import LarkNoticeError, LarkWebhookClient
def _split_global_args(argv: list[str]) -> tuple[list[str], str | None, float | None]:
cleaned: list[str] = []
webhook: str | None = None
timeout: float | None = None
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--webhook" and i + 1 < len(argv):
webhook = argv[i + 1]
i += 2
continue
if arg == "--timeout" and i + 1 < len(argv):
timeout = float(argv[i + 1])
i += 2
continue
cleaned.append(arg)
i += 1
return cleaned, webhook, timeout
def _client_from_args(args: argparse.Namespace) -> LarkWebhookClient:
webhook = args.webhook or os.getenv("LARK_NOTICE_WEBHOOK", "")
if not webhook:
raise LarkNoticeError("缺少 webhook请传 --webhook 或设置 LARK_NOTICE_WEBHOOK")
return LarkWebhookClient(webhook=webhook, timeout=args.timeout)
def _normalize_cli_markdown(markdown: str) -> str:
if "\n" in markdown or "\r" in markdown:
return markdown
return markdown.replace(r"\n", "\n").replace(r"\r", "\r")
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="lark-notice-api")
parser.add_argument("--webhook", help="Feishu webhook URL")
parser.add_argument("--timeout", type=float, default=10.0, help="HTTP timeout seconds")
sub = parser.add_subparsers(dest="command", required=True)
text = sub.add_parser("send-text", help="Send plain text message")
text.add_argument("--text", required=True)
post = sub.add_parser("send-post", help="Send Feishu post message")
post.add_argument("--title", required=True)
post.add_argument("--line", action="append", dest="lines", required=True)
md = sub.add_parser("send-markdown", help="Send interactive markdown card")
md.add_argument("--title", required=True)
md.add_argument("--markdown", required=True)
demo = sub.add_parser("demo", help="Send a markdown demo card")
demo.add_argument("--title", default="飞书消息示例")
demo.add_argument(
"--markdown",
default=(
"# 飞书 Markdown 通知测试\n"
"\n"
"这是一条 **JSON 2.0** 富文本消息。\n"
"\n"
"- 支持 *斜体*、**粗体**、~~删除线~~\n"
"- [打开示例网站](https://feishu.cn)\n"
"- 代码块示例:\n"
"\n"
"```json\n"
"{\"hello\": \"world\"}\n"
"```"
),
)
return parser
def _emit(result: dict[str, Any]) -> None:
sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
def main(argv: list[str] | None = None) -> int:
raw_argv = sys.argv[1:] if argv is None else argv
cleaned_argv, webhook, timeout = _split_global_args(raw_argv)
args = _parser().parse_args(cleaned_argv)
if webhook is not None:
args.webhook = webhook
if timeout is not None:
args.timeout = timeout
try:
client = _client_from_args(args)
if args.command == "send-text":
result = client.send_text(args.text)
elif args.command == "send-post":
result = client.send_post(args.title, args.lines)
elif args.command == "send-markdown":
result = client.send_markdown(args.title, _normalize_cli_markdown(args.markdown))
elif args.command == "demo":
result = client.send_markdown(args.title, args.markdown)
else:
raise LarkNoticeError(f"未知命令: {args.command}")
except LarkNoticeError as exc:
sys.stderr.write(f"ERROR: {exc}\n")
return 2
_emit(result)
return 0

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from urllib import error, request
class LarkNoticeError(RuntimeError):
pass
@dataclass(slots=True)
class LarkWebhookClient:
webhook: str
timeout: float = 10.0
def _post(self, payload: dict[str, Any]) -> dict[str, Any]:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = request.Request(
self.webhook,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with request.urlopen(req, timeout=self.timeout) as resp:
body = resp.read().decode("utf-8", errors="replace")
except error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
raise LarkNoticeError(f"HTTP {exc.code}: {body or exc.reason}") from exc
except error.URLError as exc:
raise LarkNoticeError(f"网络错误: {exc.reason}") from exc
try:
result = json.loads(body)
except json.JSONDecodeError:
raise LarkNoticeError(f"响应不是 JSON: {body}") from None
if result.get("code") not in (0, None):
raise LarkNoticeError(
f"飞书返回错误: code={result.get('code')} msg={result.get('msg') or result.get('message')}"
)
return result
def send_text(self, text: str) -> dict[str, Any]:
return self._post({"msg_type": "text", "content": {"text": text}})
def send_post(self, title: str, lines: list[str]) -> dict[str, Any]:
content = [[{"tag": "text", "text": line}] for line in lines]
return self._post(
{
"msg_type": "post",
"content": {
"post": {
"zh-CN": {
"title": title,
"content": content,
}
}
},
}
)
@staticmethod
def _normalize_markdown(markdown: str) -> str:
return markdown.replace("\r\n", "\n").replace("\r", "\n")
def send_markdown(self, title: str, markdown: str) -> dict[str, Any]:
return self._post(
{
"msg_type": "interactive",
"card": {
"schema": "2.0",
"config": {"wide_screen_mode": True, "enable_forward": True},
"header": {"title": {"tag": "plain_text", "content": title}},
"body": {
"elements": [
{
"tag": "markdown",
"content": self._normalize_markdown(markdown),
}
]
},
},
}
)

View File

@@ -0,0 +1,3 @@
__all__ = ["__version__"]
__version__ = "0.1.0"

View File

@@ -0,0 +1,7 @@
from __future__ import annotations
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,262 @@
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import Any
from .config import ConfigError, MailConfig
from .email_client import MailClient, MailClientError
from .templates import TemplateError, list_templates, render_template
def _load_env_file(path: str | None) -> None:
if path:
os.environ["MENGYA_MAIL_ENV_FILE"] = path
def _split_global_args(argv: list[str]) -> tuple[list[str], str | None, str | None]:
cleaned: list[str] = []
env_file: str | None = None
output_format: str | None = None
i = 0
while i < len(argv):
arg = argv[i]
if arg == "--env-file" and i + 1 < len(argv):
env_file = argv[i + 1]
i += 2
continue
if arg == "--format" and i + 1 < len(argv):
output_format = argv[i + 1]
i += 2
continue
cleaned.append(arg)
i += 1
return cleaned, env_file, output_format
def _client() -> MailClient:
return MailClient(MailConfig.from_env())
def _parse_var_pairs(values: list[str] | None) -> dict[str, str]:
result: dict[str, str] = {}
for item in values or []:
if "=" not in item:
raise ValueError(f"变量格式应为 key=value: {item}")
key, value = item.split("=", 1)
key = key.strip()
if not key:
raise ValueError(f"变量键不能为空: {item}")
result[key] = value
return result
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="mengya-mail-api")
parser.add_argument("--env-file", help="Load env vars from a specific .env file")
parser.add_argument(
"--format",
choices=("json", "md"),
default="json",
help="Output format",
)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("test-connection", help="Check SMTP/IMAP connectivity")
send = sub.add_parser("send-email", help="Send a mail")
send.add_argument("--to", nargs="+", required=True, help="Primary recipients")
send.add_argument("--subject", required=True, help="Subject")
send.add_argument("--text-body", help="Plain text body")
send.add_argument("--html-body", help="HTML body")
send.add_argument("--cc", nargs="+", help="CC recipients")
send.add_argument("--bcc", nargs="+", help="BCC recipients")
send.add_argument("--from-name", help="Display name of the sender")
listed = sub.add_parser("list-emails", help="List messages")
listed.add_argument("--folder", help="IMAP folder")
listed.add_argument("--criteria", nargs="+", help="IMAP search criteria")
listed.add_argument("--subject", help="Filter by subject")
listed.add_argument("--from-address", dest="from_address", help="Filter by sender")
listed.add_argument("--to-address", dest="to_address", help="Filter by recipient")
listed.add_argument("--limit", type=int, default=10, help="Max messages")
listed.add_argument("--mark-seen", action="store_true", help="Mark as seen")
read = sub.add_parser("read-email", help="Read one message by UID")
read.add_argument("--uid", required=True, help="Message UID")
read.add_argument("--folder", help="IMAP folder")
read.add_argument("--mark-seen", action="store_true", help="Mark as seen")
tmpl = sub.add_parser("send-template", help="Send a template mail")
tmpl.add_argument("--template", required=True, choices=sorted(list(item["key"] for item in list_templates())), help="Template key")
tmpl.add_argument("--to", nargs="+", required=True, help="Primary recipients")
tmpl.add_argument("--var", action="append", help="Template variable key=value")
tmpl.add_argument("--cc", nargs="+", help="CC recipients")
tmpl.add_argument("--bcc", nargs="+", help="BCC recipients")
tmpl.add_argument("--from-name", help="Display name of the sender")
sub.add_parser("templates", help="List template keys")
return parser
def _to_markdown(command: str, result: dict[str, Any]) -> str:
if command == "test-connection":
lines = [
"# Mail Connection",
"",
f"- Address: {result.get('address', '')}",
f"- SMTP: {result.get('smtp', '')}",
f"- IMAP: {result.get('imap', '')}",
f"- Ready: {result.get('ready', False)}",
]
return "\n".join(lines) + "\n"
if command == "templates":
templates = result.get("templates") or []
lines = ["# Templates", "", f"- Count: {result.get('count', 0)}"]
for item in templates:
lines.append(f"- {item.get('key', '')}: {item.get('title', '')}")
return "\n".join(lines) + "\n"
if command in {"send-email", "send-template"}:
lines = [
"# Mail Sent",
"",
f"- Status: {result.get('status', '')}",
f"- From: {result.get('from', '')}",
f"- To: {', '.join(result.get('to') or [])}",
f"- Subject: {result.get('subject', '')}",
]
if result.get("template"):
lines.append(f"- Template: {result.get('template')}")
if result.get("date"):
lines.append(f"- Date: {result.get('date')}")
if result.get("message_id"):
lines.append(f"- Message-ID: {result.get('message_id')}")
return "\n".join(lines) + "\n"
if command == "list-emails":
lines = [
"# Mail List",
"",
f"- Folder: {result.get('folder', '')}",
f"- Count: {result.get('count', 0)}",
]
for item in result.get("messages") or []:
lines.extend(
[
"",
f"## {item.get('subject', '(no subject)')}",
"",
f"- UID: {item.get('uid', '')}",
f"- From: {item.get('from', '')}",
f"- To: {item.get('to', '')}",
f"- Date: {item.get('date', '') or ''}",
f"- Seen: {item.get('seen', False)}",
f"- Attachments: {item.get('has_attachments', False)}",
f"- Snippet: {item.get('snippet', '')}",
]
)
return "\n".join(lines) + "\n"
if command == "read-email":
lines = [
"# Mail Detail",
"",
f"- UID: {result.get('uid', '')}",
f"- Subject: {result.get('subject', '')}",
f"- From: {result.get('from', '')}",
f"- To: {result.get('to', '')}",
f"- CC: {result.get('cc', '')}",
f"- Date: {result.get('date', '') or ''}",
f"- Message-ID: {result.get('message_id', '')}",
f"- Seen: {result.get('seen', False)}",
f"- Attachments: {result.get('has_attachments', False)}",
]
attachments = result.get("attachments") or []
if attachments:
lines.extend(["", "## Attachments", ""] + [f"- {item}" for item in attachments])
text_body = result.get("text_body") or ""
html_body = result.get("html_body") or ""
if text_body:
lines.extend(["", "## Text Body", "", text_body])
if html_body:
lines.extend(["", "## HTML Body", "", html_body])
return "\n".join(lines) + "\n"
return json.dumps(result, ensure_ascii=False, indent=2) + "\n"
def _emit(command: str, result: dict[str, Any], fmt: str) -> None:
if fmt == "md":
sys.stdout.write(_to_markdown(command, result))
else:
sys.stdout.write(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
def main(argv: list[str] | None = None) -> int:
raw_argv = sys.argv[1:] if argv is None else argv
cleaned_argv, env_file, output_format = _split_global_args(raw_argv)
args = _build_parser().parse_args(cleaned_argv)
_load_env_file(env_file or args.env_file)
try:
client = _client()
if args.command == "test-connection":
result = client.test_connection()
elif args.command == "send-email":
result = client.send_email(
to=args.to,
subject=args.subject,
text_body=args.text_body,
html_body=args.html_body,
cc=args.cc,
bcc=args.bcc,
from_name=args.from_name,
)
elif args.command == "list-emails":
result = client.list_emails(
folder=args.folder,
criteria=args.criteria,
limit=args.limit,
mark_seen=args.mark_seen,
subject=args.subject,
from_address=args.from_address,
to_address=args.to_address,
)
elif args.command == "read-email":
result = client.read_email(uid=args.uid, folder=args.folder, mark_seen=args.mark_seen)
elif args.command == "send-template":
variables = _parse_var_pairs(args.var)
subject, text_body, html_body = render_template(args.template, variables)
result = client.send_email(
to=args.to,
subject=subject,
text_body=text_body,
html_body=html_body,
cc=args.cc,
bcc=args.bcc,
from_name=args.from_name,
)
result["template"] = args.template
elif args.command == "templates":
template_items = list_templates()
result = {"count": len(template_items), "templates": template_items}
else:
sys.stderr.write(f"ERROR: unsupported command: {args.command}\n")
return 2
except (ConfigError, MailClientError, TemplateError, ValueError) as exc:
sys.stderr.write(f"ERROR: {exc}\n")
return 2
final_format = output_format or args.format
_emit(args.command, result, final_format)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
class ConfigError(ValueError):
pass
def _strip_quotes(value: str) -> str:
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
return value[1:-1]
return value
def _candidate_env_files() -> list[Path]:
candidates: list[Path] = []
explicit = os.getenv("MENGYA_MAIL_ENV_FILE")
if explicit:
candidates.append(Path(explicit).expanduser())
cwd_env = Path.cwd() / ".env"
package_env = Path(__file__).resolve().parents[2] / ".env"
candidates.extend([cwd_env, package_env])
unique: list[Path] = []
seen: set[Path] = set()
for candidate in candidates:
resolved = candidate.resolve(strict=False)
if resolved not in seen:
seen.add(resolved)
unique.append(candidate)
return unique
def _load_env_file(path: Path) -> None:
if not path.is_file():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[7:].strip()
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = _strip_quotes(value.strip())
if key and key not in os.environ:
os.environ[key] = value
def load_env_if_present() -> None:
for candidate in _candidate_env_files():
_load_env_file(candidate)
@dataclass(frozen=True)
class MailConfig:
address: str
password: str
smtp_host: str = "smtp.qiye.aliyun.com"
smtp_port: int = 465
imap_host: str = "imap.qiye.aliyun.com"
imap_port: int = 993
default_folder: str = "INBOX"
timeout_seconds: float = 30.0
@classmethod
def from_env(cls) -> "MailConfig":
load_env_if_present()
address = os.getenv("MENGYA_MAIL_ADDRESS") or os.getenv("ALIYUN_MAIL_ADDRESS")
password = os.getenv("MENGYA_MAIL_PASSWORD") or os.getenv("ALIYUN_MAIL_PASSWORD")
if not address:
raise ConfigError("缺少环境变量 MENGYA_MAIL_ADDRESS")
if not password:
raise ConfigError("缺少环境变量 MENGYA_MAIL_PASSWORD")
return cls(
address=address,
password=password,
smtp_host=os.getenv("MENGYA_MAIL_SMTP_HOST", "smtp.qiye.aliyun.com"),
smtp_port=int(os.getenv("MENGYA_MAIL_SMTP_PORT", "465")),
imap_host=os.getenv("MENGYA_MAIL_IMAP_HOST", "imap.qiye.aliyun.com"),
imap_port=int(os.getenv("MENGYA_MAIL_IMAP_PORT", "993")),
default_folder=os.getenv("MENGYA_MAIL_DEFAULT_FOLDER", "INBOX"),
timeout_seconds=float(os.getenv("MENGYA_MAIL_TIMEOUT_SECONDS", "30")),
)

View File

@@ -0,0 +1,425 @@
from __future__ import annotations
import email
import imaplib
import json
import re
import shlex
import smtplib
from dataclasses import dataclass
from datetime import datetime
from email import policy
from email.header import decode_header, make_header
from email.message import EmailMessage, Message
from email.utils import formataddr, formatdate, make_msgid, parsedate_to_datetime
from html.parser import HTMLParser
from typing import Any
from .config import MailConfig
class MailClientError(RuntimeError):
pass
@dataclass
class MailSummary:
uid: str
subject: str
from_address: str
to: str
date: str | None
seen: bool
has_attachments: bool
snippet: str
def to_dict(self) -> dict[str, Any]:
return {
"uid": self.uid,
"subject": self.subject,
"from": self.from_address,
"to": self.to,
"date": self.date,
"seen": self.seen,
"has_attachments": self.has_attachments,
"snippet": self.snippet,
}
class _HTMLTextExtractor(HTMLParser):
def __init__(self) -> None:
super().__init__()
self._chunks: list[str] = []
def handle_data(self, data: str) -> None:
if data:
self._chunks.append(data)
def text(self) -> str:
return " ".join(chunk.strip() for chunk in self._chunks if chunk.strip())
def _decode_header_value(value: str | None) -> str:
if not value:
return ""
try:
return str(make_header(decode_header(value)))
except Exception:
return value
def _normalize_recipients(value: str | list[str] | tuple[str, ...] | None) -> list[str]:
if value is None:
return []
if isinstance(value, str):
items = [part.strip() for part in value.split(",")]
return [item for item in items if item]
return [item.strip() for item in value if item and item.strip()]
def _strip_html(html: str) -> str:
parser = _HTMLTextExtractor()
parser.feed(html)
parser.close()
return parser.text()
def _clean_text(text: str, limit: int | None = None) -> str:
normalized = re.sub(r"\s+", " ", text).strip()
if limit is not None and len(normalized) > limit:
return normalized[: limit - 1] + ""
return normalized
def _pick_text_part(message: Message) -> tuple[str, str]:
text_body = ""
html_body = ""
if message.is_multipart():
for part in message.walk():
disposition = part.get_content_disposition()
if disposition == "attachment":
continue
content_type = part.get_content_type()
payload = part.get_payload(decode=True)
if payload is None:
continue
charset = part.get_content_charset() or "utf-8"
try:
body = payload.decode(charset, errors="replace")
except LookupError:
body = payload.decode("utf-8", errors="replace")
if content_type == "text/plain" and not text_body:
text_body = body
elif content_type == "text/html" and not html_body:
html_body = body
else:
payload = message.get_payload(decode=True)
if payload is not None:
charset = message.get_content_charset() or "utf-8"
try:
text_body = payload.decode(charset, errors="replace")
except LookupError:
text_body = payload.decode("utf-8", errors="replace")
if not text_body and html_body:
text_body = _strip_html(html_body)
return text_body.strip(), html_body.strip()
def _has_attachments(message: Message) -> bool:
for part in message.walk():
if part.get_content_disposition() == "attachment":
return True
return False
def _attachment_names(message: Message) -> list[str]:
names: list[str] = []
for part in message.walk():
if part.get_content_disposition() == "attachment":
filename = part.get_filename()
if filename:
names.append(_decode_header_value(filename))
return names
def _parse_date(value: str | None) -> str | None:
if not value:
return None
try:
dt = parsedate_to_datetime(value)
if isinstance(dt, datetime):
return dt.isoformat()
except Exception:
return value
return value
def _contains(haystack: str, needle: str | None) -> bool:
if not needle:
return True
return needle.casefold() in haystack.casefold()
class MailClient:
def __init__(self, config: MailConfig) -> None:
self.config = config
def send_email(
self,
*,
to: str | list[str],
subject: str,
text_body: str | None = None,
html_body: str | None = None,
cc: str | list[str] | None = None,
bcc: str | list[str] | None = None,
from_name: str | None = None,
) -> dict[str, Any]:
to_list = _normalize_recipients(to)
cc_list = _normalize_recipients(cc)
bcc_list = _normalize_recipients(bcc)
all_recipients = to_list + cc_list + bcc_list
if not to_list:
raise MailClientError("至少需要一个主收件人")
if not all_recipients:
raise MailClientError("至少需要一个收件人")
if not text_body and not html_body:
raise MailClientError("text_body 与 html_body 至少提供一个")
message = EmailMessage()
message["From"] = formataddr((from_name, self.config.address)) if from_name else self.config.address
message["To"] = ", ".join(to_list)
if cc_list:
message["Cc"] = ", ".join(cc_list)
message["Subject"] = subject
message["Date"] = formatdate(localtime=True)
message["Message-ID"] = make_msgid(domain=self.config.address.split("@", 1)[-1])
if text_body:
message.set_content(text_body)
elif html_body:
message.set_content(_strip_html(html_body) or "请查看 HTML 正文")
if html_body:
message.add_alternative(html_body, subtype="html")
try:
with smtplib.SMTP_SSL(
self.config.smtp_host,
self.config.smtp_port,
timeout=self.config.timeout_seconds,
) as smtp:
smtp.login(self.config.address, self.config.password)
smtp.send_message(message, from_addr=self.config.address, to_addrs=all_recipients)
except Exception as exc:
raise MailClientError(f"SMTP 发送失败: {exc}") from exc
return {
"status": "sent",
"from": message["From"],
"to": to_list,
"cc": cc_list,
"bcc": bcc_list,
"subject": subject,
"date": _parse_date(message["Date"]),
"message_id": message["Message-ID"],
}
def test_connection(self) -> dict[str, Any]:
smtp_status = "ok"
imap_status = "ok"
try:
with smtplib.SMTP_SSL(
self.config.smtp_host,
self.config.smtp_port,
timeout=self.config.timeout_seconds,
) as smtp:
smtp.login(self.config.address, self.config.password)
except Exception as exc:
smtp_status = f"failed: {exc}"
try:
with imaplib.IMAP4_SSL(
self.config.imap_host,
self.config.imap_port,
timeout=self.config.timeout_seconds,
) as imap:
imap.login(self.config.address, self.config.password)
except Exception as exc:
imap_status = f"failed: {exc}"
return {
"address": self.config.address,
"smtp": smtp_status,
"imap": imap_status,
"ready": smtp_status == "ok" and imap_status == "ok",
}
def list_emails(
self,
*,
folder: str | None = None,
criteria: str | list[str] | None = None,
limit: int = 10,
mark_seen: bool = False,
subject: str | None = None,
from_address: str | None = None,
to_address: str | None = None,
) -> dict[str, Any]:
selected_folder = folder or self.config.default_folder
if limit < 1:
raise MailClientError("limit 必须大于 0")
use_client_side_filters = any([subject, from_address, to_address])
base_search_terms = self._build_base_search_terms(criteria=criteria, use_all=use_client_side_filters)
try:
with imaplib.IMAP4_SSL(
self.config.imap_host,
self.config.imap_port,
timeout=self.config.timeout_seconds,
) as imap:
imap.login(self.config.address, self.config.password)
status, _ = imap.select(selected_folder, readonly=not mark_seen)
if status != "OK":
raise MailClientError(f"无法选择文件夹 {selected_folder}")
status, data = imap.uid("search", None, *base_search_terms)
if status != "OK":
raise MailClientError(f"IMAP 搜索失败: {' '.join(base_search_terms)}")
uids = [uid.decode() for uid in data[0].split()] if data and data[0] else []
candidate_limit = max(limit * 10, 50) if use_client_side_filters else limit
selected_uids = list(reversed(uids[-candidate_limit:]))
messages: list[dict[str, Any]] = []
for uid in selected_uids:
summary = self._fetch_summary(imap, uid, mark_seen=mark_seen)
if use_client_side_filters and not self._matches_summary_filters(
summary,
subject=subject,
from_address=from_address,
to_address=to_address,
):
continue
messages.append(summary.to_dict())
if len(messages) >= limit:
break
except MailClientError:
raise
except Exception as exc:
raise MailClientError(f"IMAP 列取邮件失败: {exc}") from exc
return {
"folder": selected_folder,
"criteria": base_search_terms,
"count": len(messages),
"messages": messages,
}
def read_email(
self,
*,
uid: str,
folder: str | None = None,
mark_seen: bool = False,
) -> dict[str, Any]:
selected_folder = folder or self.config.default_folder
fetch_query = "(RFC822 FLAGS)" if mark_seen else "(BODY.PEEK[] FLAGS)"
try:
with imaplib.IMAP4_SSL(
self.config.imap_host,
self.config.imap_port,
timeout=self.config.timeout_seconds,
) as imap:
imap.login(self.config.address, self.config.password)
status, _ = imap.select(selected_folder, readonly=not mark_seen)
if status != "OK":
raise MailClientError(f"无法选择文件夹 {selected_folder}")
status, data = imap.uid("fetch", uid, fetch_query)
if status != "OK" or not data or data[0] is None:
raise MailClientError(f"找不到 UID={uid} 的邮件")
metadata = data[0][0].decode(errors="replace") if isinstance(data[0], tuple) else str(data[0])
raw_message = data[0][1] if isinstance(data[0], tuple) else b""
message = email.message_from_bytes(raw_message, policy=policy.default)
except MailClientError:
raise
except Exception as exc:
raise MailClientError(f"读取邮件失败: {exc}") from exc
text_body, html_body = _pick_text_part(message)
return {
"uid": uid,
"subject": _decode_header_value(message.get("Subject")),
"from": _decode_header_value(message.get("From")),
"to": _decode_header_value(message.get("To")),
"cc": _decode_header_value(message.get("Cc")),
"date": _parse_date(message.get("Date")),
"message_id": _decode_header_value(message.get("Message-ID")),
"seen": "\\Seen" in metadata,
"has_attachments": _has_attachments(message),
"attachments": _attachment_names(message),
"text_body": text_body[:20000],
"html_body": html_body[:20000],
}
def _fetch_summary(self, imap: imaplib.IMAP4_SSL, uid: str, *, mark_seen: bool) -> MailSummary:
fetch_query = "(RFC822 FLAGS)" if mark_seen else "(BODY.PEEK[] FLAGS)"
status, data = imap.uid("fetch", uid, fetch_query)
if status != "OK" or not data or data[0] is None:
raise MailClientError(f"无法读取 UID={uid} 的邮件")
metadata = data[0][0].decode(errors="replace") if isinstance(data[0], tuple) else str(data[0])
raw_message = data[0][1] if isinstance(data[0], tuple) else b""
message = email.message_from_bytes(raw_message, policy=policy.default)
text_body, html_body = _pick_text_part(message)
snippet_source = text_body or _strip_html(html_body)
return MailSummary(
uid=uid,
subject=_decode_header_value(message.get("Subject")),
from_address=_decode_header_value(message.get("From")),
to=_decode_header_value(message.get("To")),
date=_parse_date(message.get("Date")),
seen="\\Seen" in metadata,
has_attachments=_has_attachments(message),
snippet=_clean_text(snippet_source, limit=160),
)
@staticmethod
def _build_base_search_terms(*, criteria: str | list[str] | None, use_all: bool) -> list[str]:
if criteria is None:
return ["ALL"] if use_all else ["UNSEEN"]
return MailClient._normalize_search_terms(criteria)
@staticmethod
def _matches_summary_filters(
summary: MailSummary,
*,
subject: str | None,
from_address: str | None,
to_address: str | None,
) -> bool:
return (
_contains(summary.subject, subject)
and _contains(summary.from_address, from_address)
and _contains(summary.to, to_address)
)
@staticmethod
def _normalize_search_terms(criteria: str | list[str] | None) -> list[str]:
if criteria is None:
return ["UNSEEN"]
if isinstance(criteria, str):
parsed = shlex.split(criteria)
return parsed or ["UNSEEN"]
if not criteria:
return ["UNSEEN"]
return [item for item in criteria if item]
def format_result(data: dict[str, Any]) -> str:
return json.dumps(data, ensure_ascii=False, indent=2)

View File

@@ -0,0 +1,170 @@
from __future__ import annotations
import os
from typing import Any
import uvicorn
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from . import __version__
from .config import ConfigError, MailConfig
from .email_client import MailClient, MailClientError
from .templates import TemplateError, list_templates as get_template_list, render_template
API_TOKEN_ENV = "MENGYA_MAIL_API_TOKEN"
DEFAULT_API_TOKEN = "shumengya520"
API_HOST_ENV = "MENGYA_MAIL_API_HOST"
API_PORT_ENV = "MENGYA_MAIL_API_PORT"
class SendEmailRequest(BaseModel):
to: str | list[str]
subject: str
text_body: str | None = None
html_body: str | None = None
cc: str | list[str] | None = None
bcc: str | list[str] | None = None
from_name: str | None = None
class ListEmailsRequest(BaseModel):
folder: str | None = None
criteria: str | list[str] | None = None
subject: str | None = None
from_address: str | None = None
to_address: str | None = None
limit: int | None = None
mark_seen: bool = False
class ReadEmailRequest(BaseModel):
uid: str
folder: str | None = None
mark_seen: bool = False
class SendTemplateRequest(BaseModel):
template: str
to: str | list[str]
variables: dict[str, str] | None = None
cc: str | list[str] | None = None
bcc: str | list[str] | None = None
from_name: str | None = None
def _get_api_token() -> str:
return os.getenv(API_TOKEN_ENV, DEFAULT_API_TOKEN)
def _extract_token(request: Request) -> str | None:
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
return auth_header[7:].strip() or None
return request.headers.get("x-auth-token")
def require_token(request: Request) -> None:
expected = _get_api_token()
provided = _extract_token(request)
if not provided or provided != expected:
raise HTTPException(status_code=401, detail="Unauthorized")
def create_app(mail_client: MailClient | None = None) -> FastAPI:
app = FastAPI(
title="萌芽邮箱 API",
version=__version__,
docs_url=None,
redoc_url=None,
)
client = mail_client or MailClient(MailConfig.from_env())
@app.exception_handler(ConfigError)
async def handle_config_error(_: Request, exc: ConfigError) -> JSONResponse:
return JSONResponse({"error": str(exc)}, status_code=400)
@app.exception_handler(MailClientError)
async def handle_mail_error(_: Request, exc: MailClientError) -> JSONResponse:
return JSONResponse({"error": str(exc)}, status_code=400)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/api/send-email")
async def send_email(
payload: SendEmailRequest,
_: None = Depends(require_token),
) -> dict[str, Any]:
result = client.send_email(
**payload.model_dump(exclude_none=True)
)
return result
@app.post("/api/list-emails")
async def list_emails(
payload: ListEmailsRequest,
_: None = Depends(require_token),
) -> dict[str, Any]:
result = client.list_emails(
**payload.model_dump(exclude_none=True)
)
return result
@app.post("/api/read-email")
async def read_email(
payload: ReadEmailRequest,
_: None = Depends(require_token),
) -> dict[str, Any]:
result = client.read_email(
**payload.model_dump(exclude_none=True)
)
return result
@app.get("/api/test-connection")
async def test_connection(_: None = Depends(require_token)) -> dict[str, Any]:
return client.test_connection()
@app.get("/api/templates")
async def list_templates(_: None = Depends(require_token)) -> dict[str, Any]:
templates = get_template_list()
return {"count": len(templates), "templates": templates}
@app.post("/api/send-template")
async def send_template(
payload: SendTemplateRequest,
_: None = Depends(require_token),
) -> dict[str, Any]:
try:
subject, text_body, html_body = render_template(payload.template, payload.variables)
except TemplateError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
result = client.send_email(
to=payload.to,
subject=subject,
text_body=text_body,
html_body=html_body,
cc=payload.cc,
bcc=payload.bcc,
from_name=payload.from_name,
)
result["template"] = payload.template
return result
return app
app = create_app()
def main() -> None:
host = os.getenv(API_HOST_ENV, "0.0.0.0")
port = int(os.getenv(API_PORT_ENV, "8080"))
uvicorn.run("mengya_mail_api.http_server:app", host=host, port=port)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,77 @@
from __future__ import annotations
import os
from pathlib import Path
DEFAULT_TEMPLATE_VARS = {
"name": "朋友",
"sender": "树萌芽",
}
TEMPLATES: dict[str, dict[str, str]] = {
"birthday": {
"title": "生日祝福",
"subject": "生日快乐,{name}",
"text": (
"亲爱的{name}\n\n"
"祝你生日快乐,愿新的一岁平安顺遂,心想事成!\n\n"
"{sender}"
),
"html_file": "birthday.html",
},
"new_year": {
"title": "元旦祝福",
"subject": "元旦快乐,{name}",
"text": (
"亲爱的{name}\n\n"
"新年伊始,愿你元旦快乐,万事顺遂,心想事成!\n\n"
"{sender}"
),
"html_file": "new_year.html",
},
}
class TemplateError(ValueError):
pass
def template_dir() -> Path:
env_dir = os.getenv("MENGYA_MAIL_TEMPLATE_DIR")
if env_dir:
return Path(env_dir).expanduser()
return Path(__file__).resolve().parents[2] / "template"
def read_template_file(filename: str) -> str:
path = template_dir() / filename
if not path.is_file():
raise TemplateError(f"Template file not found: {path}")
return path.read_text(encoding="utf-8")
def render_template(template_key: str, variables: dict[str, str] | None = None) -> tuple[str, str, str | None]:
template = TEMPLATES.get(template_key)
if not template:
raise TemplateError(f"Unknown template: {template_key}")
merged = {**DEFAULT_TEMPLATE_VARS, **(variables or {})}
try:
subject = template["subject"].format(**merged)
text_body = template["text"].format(**merged)
html_body = None
html_file = template.get("html_file")
if html_file:
html_body = read_template_file(html_file).format(**merged)
except KeyError as exc:
missing = exc.args[0] if exc.args else "unknown"
raise TemplateError(f"Missing template variable: {missing}") from exc
return subject, text_body, html_body
def list_templates() -> list[dict[str, str]]:
return [
{"key": key, "title": value.get("title", key)}
for key, value in TEMPLATES.items()
]

41
mcp-server/README.md Normal file
View File

@@ -0,0 +1,41 @@
# SproutClaw Cron MCP Server
通过 [Model Context Protocol](https://modelcontextprotocol.io/) 让 AI Agent 管理本项目的定时任务。
## 安装
```bash
pip install -r requirements.txt
# 或
pip install fastmcp
```
## 在 Cursor 中使用
项目已包含 `.cursor/mcp.json`。打开本仓库后 Cursor 会自动加载 `sproutclaw-cron` MCP 服务器。
手动测试stdio
```bash
python mcp-server/server.py
```
## 环境变量
| 变量 | 说明 |
|---|---|
| `SPROUTCLAW_CRON_ROOT` | 定时任务项目根目录;默认自动推断为 `mcp-server/` 的上级目录 |
## 工具一览
| 工具 | 说明 |
|---|---|
| `cron_list_tasks` | 列出全部任务 |
| `cron_get_task` | 任务详情 |
| `cron_enable_task` / `cron_disable_task` / `cron_toggle_task` | 开关 |
| `cron_run_task` | 立即执行 |
| `cron_get_log` | 读日志 |
| `cron_update_schedule` | 改 cron 表达式 / 描述 / 标签 |
| `cron_sync_cron` | 同步 cron.d / schtasks |
| `cron_create_task` | 从模板创建新任务 |
| `cron_list_templates` | 可用模板 |

12
mcp-server/pyproject.toml Normal file
View File

@@ -0,0 +1,12 @@
[project]
name = "sproutclaw-cron-mcp"
version = "0.1.0"
description = "MCP server for SproutClaw Cron task management"
requires-python = ">=3.10"
dependencies = [
"fastmcp>=3.0",
]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"

View File

@@ -0,0 +1 @@
fastmcp>=3.0

228
mcp-server/server.py Normal file
View File

@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""SproutClaw Cron MCP 服务器 — 通过 stdio 暴露定时任务管理工具。"""
from __future__ import annotations
import json
import os
import re
import shutil
import sys
from pathlib import Path
from typing import Literal
from fastmcp import FastMCP
MCP_DIR = Path(__file__).resolve().parent
CRON_ROOT = Path(os.environ.get("SPROUTCLAW_CRON_ROOT", MCP_DIR.parent)).resolve()
LIB_DIR = CRON_ROOT / "lib"
if str(LIB_DIR) not in sys.path:
sys.path.insert(0, str(LIB_DIR))
from manager import ( # noqa: E402
build_task_info,
get_context,
iter_task_ids,
list_tasks,
read_log_tail,
run_task,
set_task_state,
sync_cron_d,
toggle_task,
update_task_schedule,
)
from runner import task_is_enabled # noqa: E402
TEMPLATE_DIRS: dict[str, str] = {
"python": "_template",
"javascript": "_template-javascript",
"bash": "_template-bash",
"powershell": "_template-powershell",
}
TASK_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
DEFAULT_CRON_ROOTS = (
"/shumengya/project/agent/sproutclaw-cron",
str(CRON_ROOT),
str(CRON_ROOT).replace("\\", "/"),
)
mcp = FastMCP(
name="sproutclaw-cron",
instructions=(
"SproutClaw 定时任务系统。先 cron_list_tasks 了解任务;"
"新建任务用 cron_create_task默认禁用"
"改调度用 cron_update_schedule"
"试跑用 cron_run_task 后 cron_get_log 查日志。"
f" 项目根目录: {CRON_ROOT}"
),
)
def _root() -> Path:
return CRON_ROOT
def _task_dict(task_id: str) -> dict[str, object]:
return build_task_info(task_id, _root()).to_dict()
def _json(data: object) -> str:
return json.dumps(data, ensure_ascii=False, indent=2)
def _rewrite_schedule_cron(schedule_file: Path, template_name: str, task_id: str) -> None:
text = schedule_file.read_text(encoding="utf-8")
text = text.replace(template_name, task_id)
cron_root_posix = _root().as_posix()
for old in DEFAULT_CRON_ROOTS:
if old and old != cron_root_posix:
text = text.replace(old, cron_root_posix)
text = re.sub(
r"(/usr/bin/python3\s+)\S+/cronctl\.py",
rf"\g<1>{cron_root_posix}/cronctl.py",
text,
)
schedule_file.write_text(text, encoding="utf-8")
@mcp.tool(annotations={"readOnlyHint": True})
def cron_list_tasks() -> str:
"""列出所有定时任务(含 .disabled/ 下已关闭的任务及状态、调度、runtime。"""
tasks = [info.to_dict() for info in list_tasks(_root())]
return _json({"count": len(tasks), "tasks": tasks})
@mcp.tool(annotations={"readOnlyHint": True})
def cron_get_task(task_id: str) -> str:
"""获取单个任务的详细信息状态、cron 表达式、描述、日志大小、标签等)。"""
return _json(_task_dict(task_id))
@mcp.tool
def cron_enable_task(task_id: str) -> str:
"""启用任务:移出 .disabled/ 并同步系统 cron / Windows 计划任务。"""
info, message = set_task_state(task_id, True, _root())
return _json({"task": info.to_dict(), "message": message})
@mcp.tool
def cron_disable_task(task_id: str) -> str:
"""禁用任务:移入 .disabled/cron 条目可保留,到点自动跳过)。"""
info, message = set_task_state(task_id, False, _root())
return _json({"task": info.to_dict(), "message": message})
@mcp.tool
def cron_toggle_task(task_id: str) -> str:
"""切换任务启用/禁用状态。"""
info, message = toggle_task(task_id, _root())
return _json({"task": info.to_dict(), "message": message})
@mcp.tool
def cron_run_task(task_id: str) -> str:
"""立即执行一次任务(同步等待完成),返回 exit code。禁用的任务会直接跳过。"""
exit_code = run_task(task_id, _root())
return _json({"task_id": task_id, "exit_code": exit_code})
@mcp.tool(annotations={"readOnlyHint": True})
def cron_get_log(task_id: str, lines: int = 200) -> str:
"""读取任务日志末尾内容(默认 200 行,最多 2000"""
lines = max(1, min(lines, 2000))
content = read_log_tail(task_id, lines, _root())
return _json({"task_id": task_id, "lines": lines, "content": content})
@mcp.tool
def cron_update_schedule(
task_id: str,
schedule: str,
description: str = "",
tags: list[str] | None = None,
) -> str:
"""更新 schedule.cron 中的 cron 五段表达式、描述注释与 task.json 标签,并同步 cron.d。"""
info, message = update_task_schedule(
task_id,
description=description,
schedule=schedule,
tags=tags,
root=_root(),
sync=True,
)
return _json({"task": info.to_dict(), "message": message})
@mcp.tool
def cron_sync_cron(task_id: str) -> str:
"""将 schedule.cron 同步到 /etc/cron.d/Linux或 schtasksWindows不改开关状态。"""
ctx = get_context(task_id, _root())
message = sync_cron_d(ctx, task_is_enabled(ctx))
return _json({"task_id": task_id, "message": message or "已同步"})
@mcp.tool
def cron_create_task(
task_id: str,
runtime: Literal["python", "javascript", "bash", "powershell"] = "python",
enable: bool = False,
) -> str:
"""从语言模板复制创建新任务。命名约定 <主机名>-<功能描述>。默认创建后保持禁用。"""
if not TASK_ID_RE.match(task_id):
raise ValueError("task_id 仅允许字母数字、点、连字符与下划线,且不能以特殊符号开头")
template_name = TEMPLATE_DIRS[runtime]
template_dir = _root() / template_name
if not template_dir.is_dir():
raise FileNotFoundError(f"模板不存在: {template_name}")
if task_id in iter_task_ids(_root()):
raise ValueError(f"任务已存在: {task_id}")
target = _root() / task_id
shutil.copytree(template_dir, target)
schedule_file = target / "schedule.cron"
if schedule_file.is_file():
_rewrite_schedule_cron(schedule_file, template_name, task_id)
if runtime == "bash":
run_sh = target / "run.sh"
if run_sh.is_file():
run_sh.chmod(run_sh.stat().st_mode | 0o111)
message = "已创建"
if not enable:
info, msg = set_task_state(task_id, False, _root())
message = msg or "已创建并禁用"
return _json({"task": info.to_dict(), "message": message, "template": template_name})
info, msg = set_task_state(task_id, True, _root())
return _json({"task": info.to_dict(), "message": msg or message, "template": template_name})
@mcp.tool(annotations={"readOnlyHint": True})
def cron_list_templates() -> str:
"""列出可用于 cron_create_task 的语言模板。"""
templates = []
for runtime, dirname in TEMPLATE_DIRS.items():
path = _root() / dirname
templates.append(
{
"runtime": runtime,
"template_dir": dirname,
"exists": path.is_dir(),
"entry": {
"python": "run.py",
"javascript": "run.js",
"bash": "run.sh",
"powershell": "run.ps1",
}[runtime],
}
)
return _json({"templates": templates})
if __name__ == "__main__":
mcp.run()

View File

@@ -1,21 +0,0 @@
[
{"repo": "shumengya/ai-translate", "local": "/shumengya/project/cloudflare/ai-translate"},
{"repo": "shumengya/cf-doh", "local": "/shumengya/project/cloudflare/cf-doh"},
{"repo": "shumengya/cf-favicon", "local": "/shumengya/project/cloudflare/cf-favicon"},
{"repo": "shumengya/cf-ip-geo", "local": "/shumengya/project/cloudflare/cf-ip-geo"},
{"repo": "shumengya/mengya-nav", "local": "/shumengya/project/cloudflare/mengya-nav"},
{"repo": "shumengya/InfoGenie", "local": "/shumengya/project/frontend-backend/InfoGenie"},
{"repo": "shumengya/mengyaconnect", "local": "/shumengya/project/frontend-backend/mengyaconnect"},
{"repo": "shumengya/mengyadriftbottle", "local": "/shumengya/project/frontend-backend/mengyadriftbottle"},
{"repo": "shumengya/mengyakeyvault", "local": "/shumengya/project/frontend-backend/mengyakeyvault"},
{"repo": "shumengya/mengyalinkfly", "local": "/shumengya/project/frontend-backend/mengyalinkfly"},
{"repo": "shumengya/mengyamonitor", "local": "/shumengya/project/frontend-backend/mengyamonitor"},
{"repo": "shumengya/mengyanote", "local": "/shumengya/project/frontend-backend/mengyanote"},
{"repo": "shumengya/mengpost", "local": "/shumengya/project/frontend-backend/mengpost"},
{"repo": "shumengya/mengyaping", "local": "/shumengya/project/frontend-backend/mengyaping"},
{"repo": "shumengya/mengyaprofile", "local": "/shumengya/project/frontend-backend/mengyaprofile"},
{"repo": "shumengya/mengyastore", "local": "/shumengya/project/frontend-backend/mengyastore"},
{"repo": "shumengya/SproutGate", "local": "/shumengya/project/frontend-backend/SproutGate"},
{"repo": "shumengya/SmyWorkCollect", "local": "/shumengya/project/frontend-backend/SproutWorkCollect"},
{"repo": "shumengya/random-background-api","local": "/shumengya/project/frontend/random-background-api"}
]

View File

@@ -1,202 +0,0 @@
"""
Gitea 仓库同步:配置见本目录 repos.json。
同步逻辑:先 fetch 检查远端是否有新提交,有则 fast-forward merge无则跳过
不会无谓拉取;仓库依次串行处理,每条间隔 SYNC_DELAY_SECONDS 秒。
env 变量:
BASE_DIR 已废弃local 字段现在必填)
SYNC_DELAY_SECONDS 每个仓库处理间隔秒数(默认 2
GIT_SSH_COMMAND git SSH 参数
"""
from __future__ import annotations
import sys as _sys, pathlib as _pl
_sys.path.insert(0, str(_pl.Path(__file__).resolve().parents[1] / "lib"))
del _sys, _pl
import json
import os
import subprocess
import time
from datetime import datetime
from pathlib import Path
from shumengya_cron.notify import TaskResult, send_task_summary
from shumengya_cron.runner import (
TaskContext,
acquire_cron_lock,
append_raw_to_log,
task_is_disabled,
task_logging,
)
def _load_repos(path: Path) -> list[tuple[str, Path]]:
"""加载 repos.jsonlocal 字段必填,缺失的条目直接跳过。"""
if not path.is_file():
return []
data = json.loads(path.read_text(encoding="utf-8"))
result: list[tuple[str, Path]] = []
for item in data:
owner_repo = item.get("repo", "").strip()
local_s = item.get("local", "").strip()
if not owner_repo or not local_s:
continue
if "/" not in owner_repo:
owner_repo = f"shumengya/{owner_repo}"
result.append((owner_repo, Path(local_s)))
return result
def _git(args: list[str], *, cwd: Path | None = None) -> tuple[int, str]:
r = subprocess.run(
["git", *args],
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"},
)
return r.returncode, ((r.stdout or "") + (r.stderr or "")).strip()
def run(ctx: TaskContext) -> int:
if task_is_disabled(ctx):
return 0
os.environ.setdefault("HOME", "/root")
os.environ.setdefault(
"GIT_SSH_COMMAND",
"ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new",
)
repos_file = ctx.task_dir / "repos.json"
sync_delay = int(os.environ.get("SYNC_DELAY_SECONDS", "2"))
start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
result = TaskResult()
def sync_one(owner_repo: str, dir_path: Path, log) -> None:
parts = owner_repo.split("/")
owner, repo = parts[0], parts[-1]
remote_url = f"ssh://git@git.shumengya.top:8022/{owner}/{repo}.git"
# ── 仓库不存在:直接 clone ───────────────────────────
if not (dir_path / ".git").is_dir():
if dir_path.exists():
log(f"[{owner_repo}] 路径已存在但不是 git 仓库,跳过:{dir_path}")
result.add_skip(f"{owner_repo}:not_git")
return
dir_path.parent.mkdir(parents=True, exist_ok=True)
log(f"[{owner_repo}] clone -> {dir_path}")
rc, out = _git(["clone", remote_url, str(dir_path)])
if out:
append_raw_to_log(ctx.log_file, out)
if rc == 0:
log(f"[{owner_repo}] clone OK")
result.add_ok(f"{owner_repo}:clone")
else:
log(f"[{owner_repo}] clone 失败,请检查 SSH/权限/网络。")
result.add_fail(f"{owner_repo}:clone_failed")
return
# ── 仓库已存在fetch → 检查差异 → merge ─────────────
# 确认当前分支
rc, branch = _git(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd=dir_path)
branch = branch.splitlines()[0].strip() if branch else ""
if rc != 0 or not branch:
log(f"[{owner_repo}] detached HEAD跳过。")
result.add_skip(f"{owner_repo}:detached")
return
# 找远端名并更新 URL
rc, remote_name = _git(["config", "--get", f"branch.{branch}.remote"], cwd=dir_path)
remote_name = remote_name.splitlines()[0].strip() if remote_name else ""
if not remote_name:
rc2, rn = _git(["remote"], cwd=dir_path)
remote_name = rn.splitlines()[0].strip() if rc2 == 0 and rn else ""
if not remote_name:
log(f"[{owner_repo}] 未找到远端名,跳过。")
result.add_fail(f"{owner_repo}:no_remote")
return
_git(["remote", "set-url", remote_name, remote_url], cwd=dir_path)
# 工作区必须干净
rc, dirty = _git(["status", "--porcelain"], cwd=dir_path)
if dirty:
log(f"[{owner_repo}] 工作区有改动,跳过(避免覆盖本地改动)。")
result.add_skip(f"{owner_repo}:dirty")
return
# fetch 远端最新
rc, fetch_out = _git(["fetch", "--prune", remote_name, branch], cwd=dir_path)
if fetch_out:
append_raw_to_log(ctx.log_file, fetch_out)
if rc != 0:
log(f"[{owner_repo}] fetch 失败,跳过。")
result.add_fail(f"{owner_repo}:fetch_failed")
return
# 检查是否有新提交
rc, count = _git(["rev-list", "HEAD..FETCH_HEAD", "--count"], cwd=dir_path)
if rc == 0 and count.strip() == "0":
log(f"[{owner_repo}] 已是最新,跳过。")
result.add_skip(f"{owner_repo}:already_latest")
return
# 有更新fast-forward merge
log(f"[{owner_repo}] 发现 {count.strip()} 个新提交,合并中…")
rc, merge_out = _git(["merge", "--ff-only", "FETCH_HEAD"], cwd=dir_path)
if merge_out:
append_raw_to_log(ctx.log_file, merge_out)
if rc == 0:
log(f"[{owner_repo}] OK")
result.add_ok(f"{owner_repo}:updated")
else:
log(f"[{owner_repo}] merge 失败请手动检查git -C {dir_path} merge --ff-only FETCH_HEAD")
result.add_fail(f"{owner_repo}:merge_failed")
with task_logging(ctx) as log:
with acquire_cron_lock(
ctx.lock_file,
busy_message=os.environ.get("CRON_LOCK_BUSY_MSG", "已有同步任务在运行,跳过本次。"),
log=log,
) as locked:
if not locked:
return 0
log("start")
log(f"repos_file={repos_file}")
log(f"sync_delay_seconds={sync_delay}")
if not repos_file.is_file():
log(f"仓库列表文件不存在:{repos_file}")
result.add_fail("repos_file:missing")
send_task_summary(ctx, result, start_time, log=log)
return 1
repo_items = _load_repos(repos_file)
log(f"{len(repo_items)} 个仓库,依次处理")
for idx, (owner_repo, dir_path) in enumerate(repo_items):
sync_one(owner_repo, dir_path, log)
if sync_delay > 0 and idx < len(repo_items) - 1:
time.sleep(sync_delay)
log("end")
send_task_summary(ctx, result, start_time, log=log)
return 0 if result.success else 1
def main() -> int:
import pathlib
task_id = pathlib.Path(__file__).parent.name
ctx = TaskContext.from_task_id(task_id)
return run(ctx)
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,10 +0,0 @@
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOME=/root
CRON_MAIL_ENABLED=1
# 每天 00:00 按 repos.json 同步 Gitea 仓库到本地,仅在有新提交时 fast-forward 合并
# 将本文件复制到 /etc/cron.d/smallmengya-gitea-repo-sync 即可生效
# 开关bash /shumengya/project/agent/sproutclaw-cron/smallmengya-gitea-repo-sync/switch.sh on|off|toggle
# 统一管理python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py status|enable|disable|toggle smallmengya-gitea-repo-sync|all
0 0 * * * root /usr/bin/python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py run smallmengya-gitea-repo-sync >/dev/null 2>&1

View File

@@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
task_id="$(basename "$(dirname "$0")")"
action="${1:-toggle}"
shift || true
exec python3 /shumengya/project/agent/sproutclaw-cron/cronctl.py "$action" "$task_id" "$@"

32
start-webui.bat Normal file
View File

@@ -0,0 +1,32 @@
@echo off
setlocal EnableExtensions
cd /d "%~dp0"
set "BACKEND=%~dp0webui\backend"
set "VENV=%BACKEND%\.venv"
set "HOST=%SPROUTCLAW_CRON_WEB_HOST%"
set "PORT=%SPROUTCLAW_CRON_WEB_PORT%"
if not defined HOST set "HOST=0.0.0.0"
if not defined PORT set "PORT=8765"
if not exist "%VENV%\Scripts\python.exe" (
echo [webui] creating venv...
python -m venv "%VENV%"
if errorlevel 1 (
echo [webui] venv failed. Install Python and add it to PATH.
exit /b 1
)
echo [webui] installing deps...
"%VENV%\Scripts\python.exe" -m pip install -r "%BACKEND%\requirements.txt"
if errorlevel 1 exit /b 1
)
set "URL=http://127.0.0.1:%PORT%/"
echo [webui] starting: %URL%
start "" "%URL%"
"%VENV%\Scripts\uvicorn.exe" main:app --app-dir "%BACKEND%" --host %HOST% --port %PORT% --reload --reload-dir "%BACKEND%" --reload-dir "%~dp0lib"
endlocal

View File

@@ -17,16 +17,17 @@ FRONTEND_DIR = Path(__file__).resolve().parents[1] / "frontend"
sys.path.insert(0, str(CRON_ROOT / "lib"))
from shumengya_cron.manager import ( # noqa: E402
from manager import ( # noqa: E402
build_task_info,
list_tasks,
read_log_tail,
set_task_state,
sync_cron_d,
toggle_task,
update_task_schedule,
)
from shumengya_cron.manager import get_context # noqa: E402
from shumengya_cron.runner import task_is_enabled # noqa: E402
from manager import get_context # noqa: E402
from runner import task_is_enabled # noqa: E402
app = FastAPI(title="SproutClaw Cron", version="1.0.0")
@@ -45,6 +46,18 @@ class RunResponse(BaseModel):
pid: int | None = None
class ScheduleUpdate(BaseModel):
description: str = ""
schedule: str
tags: list[str] = []
class ScheduleUpdateResponse(BaseModel):
ok: bool = True
message: str = ""
task: dict[str, object]
def _task_not_found(exc: FileNotFoundError) -> HTTPException:
return HTTPException(status_code=404, detail=str(exc))
@@ -62,6 +75,13 @@ def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/runtimes")
def api_runtimes() -> list[dict[str, object]]:
from runtime_probe import probe_all_runtimes
return probe_all_runtimes()
@app.get("/api/tasks")
def api_list_tasks() -> list[dict[str, object]]:
return [task.to_dict() for task in list_tasks(CRON_ROOT)]
@@ -112,6 +132,27 @@ def api_sync_cron(task_id: str) -> MessageResponse:
raise _task_not_found(exc) from exc
@app.patch("/api/tasks/{task_id}/schedule", response_model=ScheduleUpdateResponse)
def api_update_schedule(task_id: str, body: ScheduleUpdate) -> ScheduleUpdateResponse:
try:
info, message = update_task_schedule(
task_id,
description=body.description,
schedule=body.schedule,
tags=body.tags,
root=CRON_ROOT,
sync=True,
)
return ScheduleUpdateResponse(
message=message or "已保存",
task=info.to_dict(),
)
except FileNotFoundError as exc:
raise _task_not_found(exc) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/tasks/{task_id}/run", response_model=RunResponse)
def api_run_task(task_id: str) -> RunResponse:
try:

View File

@@ -2,6 +2,7 @@ const API = "/api";
const REFRESH_INTERVAL = 15000;
const els = {
runtimes: document.getElementById("runtimes"),
taskBody: document.getElementById("task-body"),
taskCards: document.getElementById("task-cards"),
stats: document.getElementById("stats"),
@@ -13,12 +14,25 @@ const els = {
logTitle: document.getElementById("log-title"),
logSubtitle: document.getElementById("log-subtitle"),
logContent: document.getElementById("log-content"),
sidebar: document.getElementById("sidebar"),
overlay: document.getElementById("overlay"),
editDialog: document.getElementById("edit-dialog"),
editForm: document.getElementById("edit-form"),
editTitle: document.getElementById("edit-title"),
editDescription: document.getElementById("edit-description"),
editMinute: document.getElementById("edit-minute"),
editHour: document.getElementById("edit-hour"),
editDom: document.getElementById("edit-dom"),
editMonth: document.getElementById("edit-month"),
editDow: document.getElementById("edit-dow"),
editPreview: document.getElementById("edit-preview"),
cronPresets: document.getElementById("cron-presets"),
editTagListEl: document.getElementById("edit-tags"),
editTagInput: document.getElementById("edit-tag-input"),
};
let allTasks = [];
let currentLogTaskId = null;
let currentEditTaskId = null;
let editTagList = [];
let refreshTimer = null;
const escapeHtml = (str) =>
@@ -61,19 +75,23 @@ async function api(path, options = {}) {
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.detail || data.message || `请求失败 (${res.status})`);
const detail = data.detail;
const msg = Array.isArray(detail)
? detail.map((d) => d.msg || d).join("; ")
: detail || data.message || `请求失败 (${res.status})`;
throw new Error(msg);
}
return data;
}
function statusMarkup(task) {
if (task.running) {
return '<span class="status run"><span class="dot"></span>运行中</span>';
return '<span class="badge badge-run"><span class="dot"></span>运行中</span>';
}
if (task.enabled) {
return '<span class="status on"><span class="dot"></span>已开启</span>';
return '<span class="badge badge-on"><span class="dot"></span>已开启</span>';
}
return '<span class="status off"><span class="dot"></span>已关闭</span>';
return '<span class="badge badge-off"><span class="dot"></span>已关闭</span>';
}
function scheduleMarkup(task) {
@@ -97,39 +115,173 @@ function actionButtons(task) {
<div class="actions">
<button type="button" class="btn ${toggleClass}" data-action="toggle" data-id="${escapeHtml(task.task_id)}">${toggleLabel}</button>
<button type="button" class="btn" data-action="run" data-id="${escapeHtml(task.task_id)}" ${task.running ? "disabled" : ""}>运行</button>
<button type="button" class="btn" data-action="edit" data-id="${escapeHtml(task.task_id)}">修改</button>
<button type="button" class="btn" data-action="sync" data-id="${escapeHtml(task.task_id)}">同步</button>
<button type="button" class="btn" data-action="log" data-id="${escapeHtml(task.task_id)}">日志</button>
</div>
`;
}
const CRON_FIELD_KEYS = ["editMinute", "editHour", "editDom"];
function parseCron(expr) {
const parts = (expr || "0 8 * * *").trim().split(/\s+/);
return {
minute: parts[0] ?? "0",
hour: parts[1] ?? "8",
dom: parts[2] ?? "*",
month: parts[3] ?? "*",
dow: parts[4] ?? "*",
};
}
const CRON_DOW_OPTIONS = [
["*", "每"],
["0", "周日"],
["1", "周一"],
["2", "周二"],
["3", "周三"],
["4", "周四"],
["5", "周五"],
["6", "周六"],
];
const CRON_MONTH_OPTIONS = [
["*", "每"],
...Array.from({ length: 12 }, (_, i) => [String(i + 1), `${i + 1}`]),
];
function resetSelectOptions(selectEl, options, value) {
selectEl.innerHTML = options.map(
([val, label]) => `<option value="${val}">${label}</option>`
).join("");
if ([...selectEl.options].some((opt) => opt.value === value)) {
selectEl.value = value;
return;
}
const opt = document.createElement("option");
opt.value = value;
opt.textContent = value;
opt.selected = true;
selectEl.appendChild(opt);
}
function resetDowSelect(value) {
resetSelectOptions(els.editDow, CRON_DOW_OPTIONS, value);
}
function resetMonthSelect(value) {
resetSelectOptions(els.editMonth, CRON_MONTH_OPTIONS, value);
}
function fillCronForm(expr) {
const { minute, hour, dom, month, dow } = parseCron(expr);
els.editMinute.value = minute;
els.editHour.value = hour;
els.editDom.value = dom;
resetMonthSelect(month);
resetDowSelect(dow);
}
function buildScheduleFromForm() {
const values = CRON_FIELD_KEYS.map((key) => {
const raw = els[key].value.trim();
return raw === "" ? "*" : raw;
});
values.push(els.editMonth.value.trim() || "*");
values.push(els.editDow.value.trim() || "*");
return values.join(" ");
}
function updateEditPreview() {
try {
els.editPreview.textContent = buildScheduleFromForm() || "—";
} catch {
els.editPreview.textContent = "—";
}
}
const MAX_TASK_TAGS = 4;
const RUNTIME_LABELS = {
python: "Python",
javascript: "JavaScript",
bash: "Bash",
powershell: "PowerShell",
};
function runtimeLabel(task) {
return RUNTIME_LABELS[task.runtime] || task.runtime || "Python";
}
function displayTags(task) {
const tags = Array.isArray(task.tags) ? task.tags.filter(Boolean) : [];
if (tags.length) return tags.slice(0, MAX_TASK_TAGS);
return [runtimeLabel(task)];
}
function tagsMarkup(task) {
return displayTags(task)
.map((tag) => `<span class="task-tag">${escapeHtml(tag)}</span>`)
.join("");
}
function nameMarkup(task) {
return `
<div class="task-name">${escapeHtml(task.task_id)}</div>
<div class="task-name-row">
<span class="task-name">${escapeHtml(task.task_id)}</span>
${tagsMarkup(task)}
</div>
${task.description ? `<div class="task-desc">${escapeHtml(task.description)}</div>` : ""}
`;
}
function renderRuntimes(items) {
els.runtimes.innerHTML = items
.map((item) => {
const version = item.available
? escapeHtml(item.version || "—")
: '<span class="runtime-missing">无</span>';
const stateClass = item.available ? "is-ok" : "is-missing";
return `
<div class="runtime-card ${stateClass}">
<span class="runtime-name">${escapeHtml(item.name)}</span>
<span class="runtime-version">${version}</span>
</div>
`;
})
.join("");
}
async function loadRuntimes() {
try {
const items = await api("/runtimes");
renderRuntimes(items);
} catch {
els.runtimes.innerHTML = '<div class="runtime-card is-missing"><span class="runtime-name">运行时</span><span class="runtime-version"><span class="runtime-missing">检测失败</span></span></div>';
}
}
function renderStats(tasks) {
const enabled = tasks.filter((t) => t.enabled).length;
const running = tasks.filter((t) => t.running).length;
const off = tasks.length - enabled;
els.stats.innerHTML = `
<div class="meta-item">
<span class="label">任务总数</span>
<span class="value">${tasks.length}</span>
<div class="stat-card">
<span class="stat-label">任务总数</span>
<span class="stat-value">${tasks.length}</span>
</div>
<div class="meta-item is-accent">
<span class="label">已开启</span>
<span class="value">${enabled}</span>
<div class="stat-card stat-accent">
<span class="stat-label">已开启</span>
<span class="stat-value">${enabled}</span>
</div>
<div class="meta-item">
<span class="label">已关闭</span>
<span class="value">${off}</span>
<div class="stat-card">
<span class="stat-label">已关闭</span>
<span class="stat-value">${off}</span>
</div>
<div class="meta-item is-run">
<span class="label">运行中</span>
<span class="value">${running}</span>
<div class="stat-card stat-run">
<span class="stat-label">运行中</span>
<span class="stat-value">${running}</span>
</div>
`;
els.summaryText.textContent = running
@@ -167,18 +319,18 @@ function renderTasks(tasks) {
<div class="name">${nameMarkup(task)}</div>
${statusMarkup(task)}
</div>
<div class="task-card-body">
<div>
<div class="task-card-meta">
<div class="meta-block">
<span class="field-label">调度</span>
${scheduleMarkup(task)}
</div>
<div>
<div class="meta-block">
<span class="field-label">日志</span>
<span class="log-meta">${formatBytes(task.log_size)}</span>
<span class="log-meta muted">${task.log_updated ? escapeHtml(task.log_updated) : "无记录"}</span>
<div class="log-meta">${formatBytes(task.log_size)}</div>
<div class="log-meta muted">${task.log_updated ? escapeHtml(task.log_updated) : "无记录"}</div>
</div>
</div>
${actionButtons(task)}
<div class="task-card-actions">${actionButtons(task)}</div>
</article>
`
)
@@ -188,10 +340,14 @@ function renderTasks(tasks) {
function applyFilter() {
const q = els.filter.value.trim().toLowerCase();
const filtered = q
? allTasks.filter((t) =>
t.task_id.toLowerCase().includes(q) ||
(t.description || "").toLowerCase().includes(q)
)
? allTasks.filter((t) => {
const tagText = displayTags(t).join(" ").toLowerCase();
return (
t.task_id.toLowerCase().includes(q) ||
(t.description || "").toLowerCase().includes(q) ||
tagText.includes(q)
);
})
: allTasks;
renderTasks(filtered);
}
@@ -203,6 +359,7 @@ async function loadTasks() {
renderStats(tasks);
applyFilter();
els.lastRefresh.textContent = `已刷新 ${nowText()}`;
await loadRuntimes();
} catch (err) {
els.taskBody.innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(err.message)}</td></tr>`;
els.taskCards.innerHTML = `<div class="empty">${escapeHtml(err.message)}</div>`;
@@ -217,6 +374,10 @@ async function handleAction(action, taskId) {
await openLog(taskId);
return;
}
if (action === "edit") {
openEdit(taskId);
return;
}
if (action === "run") {
const res = await api(`/tasks/${encodeURIComponent(taskId)}/run`, { method: "POST" });
showToast(res.ok === false ? res.message : `已启动 ${taskId}`);
@@ -256,6 +417,95 @@ async function refreshLog() {
}
}
function renderEditTags() {
if (!els.editTagListEl) return;
els.editTagListEl.innerHTML = editTagList
.map(
(tag, index) => `
<span class="task-tag is-editable">
${escapeHtml(tag)}
<button type="button" class="tag-remove" data-index="${index}" aria-label="移除 ${escapeHtml(tag)}">×</button>
</span>
`
)
.join("");
const atLimit = editTagList.length >= MAX_TASK_TAGS;
if (els.editTagInput) els.editTagInput.disabled = atLimit;
const addBtn = document.getElementById("btn-tag-add");
if (addBtn) addBtn.disabled = atLimit;
}
function addEditTag() {
if (!els.editTagInput) return;
const value = els.editTagInput.value.trim();
if (!value) return;
if (editTagList.length >= MAX_TASK_TAGS) {
showToast(`最多 ${MAX_TASK_TAGS} 个标签`, true);
return;
}
if (editTagList.includes(value)) {
showToast("标签已存在", true);
return;
}
editTagList.push(value);
els.editTagInput.value = "";
renderEditTags();
}
function removeEditTag(index) {
if (index < 0 || index >= editTagList.length) return;
editTagList.splice(index, 1);
renderEditTags();
}
function openEdit(taskId) {
const task = allTasks.find((t) => t.task_id === taskId);
if (!task) {
showToast("任务不存在", true);
return;
}
currentEditTaskId = taskId;
els.editTitle.textContent = taskId;
els.editDescription.value = task.description || "";
editTagList = [...displayTags(task)];
if (els.editTagInput) els.editTagInput.value = "";
renderEditTags();
fillCronForm(task.schedule || "0 8 * * *");
updateEditPreview();
els.editDialog.showModal();
}
async function saveEdit(event) {
event.preventDefault();
if (!currentEditTaskId) return;
const schedule = buildScheduleFromForm();
if (!schedule || schedule.split(/\s+/).length !== 5) {
showToast("请填写完整的分、时、日、月、周", true);
return;
}
try {
els.btnEditSave.disabled = true;
const res = await api(`/tasks/${encodeURIComponent(currentEditTaskId)}/schedule`, {
method: "PATCH",
body: JSON.stringify({
description: els.editDescription.value.trim(),
schedule,
tags: editTagList,
}),
});
els.editDialog.close();
showToast(res.message || "已保存");
await loadTasks();
} catch (err) {
showToast(err.message, true);
} finally {
els.btnEditSave.disabled = false;
}
}
function bindActions(root) {
root.addEventListener("click", (event) => {
const btn = event.target.closest("[data-action]");
@@ -264,31 +514,53 @@ function bindActions(root) {
});
}
function closeSidebar() {
els.sidebar.classList.remove("open");
els.overlay.hidden = true;
}
document.getElementById("btn-refresh").addEventListener("click", loadTasks);
document.getElementById("btn-refresh-top").addEventListener("click", loadTasks);
document.getElementById("btn-log-refresh").addEventListener("click", refreshLog);
document.getElementById("btn-log-close").addEventListener("click", () => els.logDialog.close());
els.filter.addEventListener("input", applyFilter);
document.getElementById("menu-toggle").addEventListener("click", () => {
els.sidebar.classList.add("open");
els.overlay.hidden = false;
document.getElementById("btn-edit-close").addEventListener("click", () => els.editDialog.close());
document.getElementById("btn-edit-cancel").addEventListener("click", () => els.editDialog.close());
els.editForm.addEventListener("submit", saveEdit);
els.editForm.addEventListener("click", (event) => {
if (event.target.closest("#btn-tag-add")) {
event.preventDefault();
addEditTag();
return;
}
const removeBtn = event.target.closest(".tag-remove");
if (removeBtn) {
event.preventDefault();
removeEditTag(Number(removeBtn.dataset.index));
}
});
els.overlay.addEventListener("click", closeSidebar);
els.editForm.addEventListener("keydown", (event) => {
if (event.target !== els.editTagInput || event.key !== "Enter") return;
event.preventDefault();
addEditTag();
});
CRON_FIELD_KEYS.forEach((key) => els[key].addEventListener("input", updateEditPreview));
els.editMonth.addEventListener("change", updateEditPreview);
els.editDow.addEventListener("change", updateEditPreview);
els.cronPresets.addEventListener("click", (event) => {
const btn = event.target.closest("[data-preset]");
if (!btn) return;
fillCronForm(btn.dataset.preset);
updateEditPreview();
});
els.btnEditSave = document.getElementById("btn-edit-save");
els.filter.addEventListener("input", applyFilter);
els.logDialog.addEventListener("click", (event) => {
if (event.target === els.logDialog) els.logDialog.close();
});
els.editDialog.addEventListener("click", (event) => {
if (event.target === els.editDialog) els.editDialog.close();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
if (els.logDialog.open) els.logDialog.close();
else if (els.sidebar.classList.contains("open")) closeSidebar();
else if (els.editDialog.open) els.editDialog.close();
return;
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();

View File

@@ -8,54 +8,34 @@
</head>
<body>
<div class="layout">
<aside class="sidebar" id="sidebar" aria-label="主导航">
<div class="brand">
<div class="brand-text">
<h1>SproutClaw Cron</h1>
<p>定时任务管理面板</p>
</div>
</div>
<nav class="sidebar-nav">
<button type="button" class="nav-item active" data-view="tasks">
<span class="nav-dot" aria-hidden="true"></span>
<span>任务清单</span>
</button>
</nav>
<div class="sidebar-foot">
<button type="button" class="btn btn-ghost btn-block" id="btn-refresh">刷新列表</button>
</div>
</aside>
<div class="overlay" id="overlay" hidden></div>
<main class="main">
<header class="topbar">
<button type="button" class="menu-toggle" id="menu-toggle" aria-label="打开菜单">菜单</button>
<div class="masthead">
<h2>定时任务管理</h2>
<p class="brand-kicker">SproutClaw Cron</p>
<h1>定时任务管理</h1>
<p class="masthead-meta" id="summary-text">加载中…</p>
</div>
<button type="button" class="btn btn-primary" id="btn-refresh-top">刷新</button>
<button type="button" class="btn btn-primary" id="btn-refresh-top" aria-label="刷新">刷新</button>
</header>
<section class="document">
<div class="doc-meta" id="stats" aria-live="polite"></div>
<div class="runtime-grid" id="runtimes" aria-label="本机运行时环境"></div>
<div class="stats-grid" id="stats" aria-live="polite"></div>
<div class="toolbar">
<div class="search">
<svg class="search-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="7" cy="7" r="4.5" stroke="currentColor" stroke-width="1.5"/>
<path d="M10.5 10.5L14 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<input type="search" id="filter" placeholder="按任务名或说明过滤…" autocomplete="off" />
</div>
<span class="last-refresh" id="last-refresh"></span>
</div>
<section class="doc-section">
<div class="section-head">
<h3>任务清单</h3>
</div>
<div class="table-wrap">
<div class="table-card">
<table class="task-table" id="task-table">
<thead>
<tr>
@@ -78,6 +58,109 @@
</main>
</div>
<dialog class="panel-dialog" id="edit-dialog">
<form class="edit-form" id="edit-form">
<div class="panel-dialog-head">
<div class="panel-dialog-title">
<span class="kicker">编辑任务</span>
<h3 id="edit-title">任务</h3>
</div>
<button type="button" class="btn btn-ghost" id="btn-edit-close">关闭</button>
</div>
<div class="panel-dialog-body">
<label class="form-field">
<span class="form-label">任务描述</span>
<textarea id="edit-description" rows="3" placeholder="说明此任务的用途…"></textarea>
</label>
<fieldset class="tag-editor">
<legend class="form-label">标签</legend>
<p class="form-hint">最多 4 个,含运行时标签(如 Python、JavaScript</p>
<div class="tag-list" id="edit-tags"></div>
<div class="tag-add-row">
<input type="text" id="edit-tag-input" maxlength="32" placeholder="输入标签后回车或点击添加…" autocomplete="off" />
<button type="button" class="btn btn-ghost" id="btn-tag-add">添加</button>
</div>
</fieldset>
<fieldset class="cron-editor">
<legend class="form-label">执行调度</legend>
<div class="cron-grid">
<label class="cron-field">
<span class="cron-field-label"></span>
<input type="text" id="edit-minute" inputmode="numeric" placeholder="*" autocomplete="off" />
</label>
<label class="cron-field">
<span class="cron-field-label"></span>
<input type="text" id="edit-hour" inputmode="numeric" placeholder="*" autocomplete="off" />
</label>
<label class="cron-field">
<span class="cron-field-label"></span>
<input type="text" id="edit-dom" inputmode="numeric" placeholder="*" autocomplete="off" />
</label>
<label class="cron-field">
<span class="cron-field-label"></span>
<select id="edit-month">
<option value="*"></option>
<option value="1">1月</option>
<option value="2">2月</option>
<option value="3">3月</option>
<option value="4">4月</option>
<option value="5">5月</option>
<option value="6">6月</option>
<option value="7">7月</option>
<option value="8">8月</option>
<option value="9">9月</option>
<option value="10">10月</option>
<option value="11">11月</option>
<option value="12">12月</option>
</select>
</label>
<label class="cron-field">
<span class="cron-field-label"></span>
<select id="edit-dow">
<option value="*"></option>
<option value="0">周日</option>
<option value="1">周一</option>
<option value="2">周二</option>
<option value="3">周三</option>
<option value="4">周四</option>
<option value="5">周五</option>
<option value="6">周六</option>
</select>
</label>
</div>
<div class="cron-presets" id="cron-presets">
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 8 * * *">每天 08:00</button>
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 0 * * *">每天 00:00</button>
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 8 * * 1">每周一 08:00</button>
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 18 * * 5">每周五 18:00</button>
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 9 * * 1-5">工作日 09:00</button>
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 8 1 * *">每月 1 日 08:00</button>
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 12 15 * *">每月 15 日 12:00</button>
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 0 1 1 *">1 月 1 日 00:00</button>
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 9 1 6 *">6 月 1 日 09:00</button>
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 8 25 12 *">12 月 25 日 08:00</button>
<button type="button" class="btn btn-ghost btn-preset" data-preset="*/5 * * * *">每 5 分钟</button>
<button type="button" class="btn btn-ghost btn-preset" data-preset="0 */2 * * *">每 2 小时</button>
</div>
<ul class="cron-examples">
<li><code>0 8 * * *</code> 每天 08:00</li>
<li><code>0 8 * * 1</code> 每周一 08:00</li>
<li><code>0 8 1 * *</code> 每月 1 日 08:00</li>
<li><code>0 0 1 1 *</code> 每年 1 月 1 日 00:00</li>
<li><code>0 9 * * 1-5</code> 周一到周五 09:00</li>
<li><code>*/15 * * * *</code> 每 15 分钟</li>
</ul>
<p class="form-hint">Linux cron 五段:<strong>分 时 日 月 周</strong><code>*</code> 任意;日 131月 112周 0=周日 … 6=周六。日/周同时指定时取交集。</p>
<p class="form-preview">当前表达式:<code id="edit-preview"></code></p>
</fieldset>
</div>
<div class="panel-dialog-foot">
<button type="button" class="btn" id="btn-edit-cancel">取消</button>
<button type="submit" class="btn btn-primary" id="btn-edit-save">保存</button>
</div>
</form>
</dialog>
<dialog class="log-dialog" id="log-dialog">
<div class="log-dialog-head">
<div class="log-dialog-title">
@@ -95,6 +178,6 @@
<div class="toast" id="toast" hidden></div>
<script src="/static/app.js"></script>
<script src="/static/app.js?v=2"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -13,4 +13,5 @@ fi
HOST="${SPROUTCLAW_CRON_WEB_HOST:-0.0.0.0}"
PORT="${SPROUTCLAW_CRON_WEB_PORT:-8765}"
exec "$VENV/bin/uvicorn" main:app --app-dir "$BACKEND" --host "$HOST" --port "$PORT"
exec "$VENV/bin/uvicorn" main:app --app-dir "$BACKEND" --host "$HOST" --port "$PORT" \
--reload --reload-dir "$BACKEND" --reload-dir "$ROOT/lib"