diff --git a/.openclaw-skill-template b/.openclaw-skill-template deleted file mode 100644 index c4bef3c..0000000 --- a/.openclaw-skill-template +++ /dev/null @@ -1,5 +0,0 @@ -# 此文件仅存在于 skill-template 源仓库。 -# 用 tools/scaffold_skill 创建新技能时,脚本会从目标目录删除本文件。 -# 若业务技能仓库中仍存在本文件,说明未正确 scaffold,tests 会失败。 -template_slug: add-todo-all -template_version: "1.0.32" diff --git a/SKILL.md b/SKILL.md index c5b9148..78a82a8 100644 --- a/SKILL.md +++ b/SKILL.md @@ -18,23 +18,85 @@ allowed-tools: 提供待办事项的创建、查看、修改、标记完成、删除、批量操作能力;支持设置截止时间、优先级、分类标签;可生成待办清单文本输出;不持久化跨会话存储,会话结束数据丢失。 -## 关键模式映射 +## Agent 读取顺序 -| 模板铁律 | 本 demo 落地 | -|----------|-------------| -| `cli → service → db → util` 四层 | [scripts/](scripts/) 完整复刻同结构 | -| 单业务内核、多入口 | `service/todo_service.py` 是唯一 CRUD 内核,CLI/Action/数据管理按钮都调它 | -| CLI 固定入口 | [scripts/main.py](scripts/main.py) + `cli/app.py` argparse 分发 | -| Action manifest 正交字段 | [assets/actions.json](assets/actions.json) 覆盖 6 placements × sync/async | -| 数据管理元数据 | [scripts/db/connection.py](scripts/db/connection.py) 建 `todos` + `_jiangchang_*` | -| 长任务 async 范式 | [service/task_service.py](scripts/service/task_service.py) `cmd_remind` 用 `job_context`/`emit`/`finish` | +回答普通用户“是什么、能做什么、怎么开始”时,优先读取面向用户的 `README.md`;第一次操作步骤读 `TUTORIAL.md`。Agent 编排再按需读取 `references/CLI.md`、`references/ACTIONS.md` 和 `references/SCHEMA.md`,开发与浏览器账号细节见 `development/ACCOUNT_MANAGER.md`。 -## Agent 触发路由表 +市场四个材料分别是:`README.md` → `readme_md`、`TUTORIAL.md` → `tutorial_md`、`DEMO.md` → `demo_md` / `demo_video_url`、`CHANGELOG.md` → `changelog`。 -| 用户意图 | Agent 怎么做 | 进任务中心? | -|----------|--------------|--------------| -| 看版本 / 检查环境 | bash + 共享 Python `version` / `health` | 否 | -| 列出 / 统计 / 新增 / 完成 / 删除 | bash + `todo list/stats/add/done/delete` | 否(sync) | -| 扫描并提醒 | `run_skill_action` → `remind`(async) | **是** | +### 架构铁律(先读) + +- **宿主产品入口平级**:对话任务 / 数据管理 / 定时任务是同一技能能力的不同触发场景,**同等重要**;挂哪些由本技能 UX 定义(入口定位表),详见 `development/PRODUCT_SURFACES.md`。以后宿主若增加新入口,仍是「新触发场景 + 同一内核」。 +- **单业务内核、多入口**:数据管理 / 定时任务 / Agent / 技能详情 / 直接 CLI 必须复用同一 domain service;禁止按入口复制业务逻辑,禁止按 `source` 分叉业务行为。 +- **数据管理**:既是 **UI 执行面**(点按钮走与对话相同的闭环),也是数据与过程可见;不是「只读库」。 +- **`placements`**:只决定 Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail)。其中 `row` 表示**行级入口**(宿主可呈现为操作列、行右键、行内「⋯」等;技能配什么就显示什么,无独立菜单 DSL)。数据管理技能按钮顺序见 `actions` **数组相对顺序**(详见 `references/ACTIONS.md`),无独立 sortOrder。 +- **`bind.tables`**:只决定数据管理出现在哪些表;含 `toolbar` 时**必须**显式非空绑定。 +- **`executionProfile`**:只决定执行方式——`sync` 当场返回结果,`async` 进**任务中心**。与 placements / 入口**无关**。心智上多数为 sync,仅长任务 / RPA 标 async;宿主省略该字段时按 sync。每个 Action **仍必须**显式写 `sync` 或 `async`。 +- **`policy`(可选)**:需要按用户/角色控制数据管理按钮或行列时,在 `actions.json` 顶层启用 Data Policy,由技能 `policy` CLI 输出 Snapshot;**未启用 = 与今日一致(L0)**。详见 `references/POLICY.md`。用户/角色数据源(飞书/库/API)属技能内部,宿主不托管。 +- **`cellPresentation`(可选)**:需要数据管理「某格特殊标色/强调」时启用;技能按当前数据写入呈现(语义 tone),宿主映射主题样式;**未启用 = P0 无标色**。用途不限(超时、预警、驳回等)。详见 `references/CELL_PRESENTATION.md`。 +- **业务行生命周期(有处理队列时)**:对某一行做生成/触达/私信等处理时,默认只操作「可操作」角色状态;开始抢占「进行中」;结束回写成功或失败(失败带原因);重做靠人工改回可操作。状态名由本技能自定。详见 `references/WORK_ITEM_STATUS.md`。 +- **同步数据**写技能本地库、不生成导出文件;**导出当前表**由宿主数据管理负责;文件型特殊报告才另做 `export-*`(落 `output/`);**HTML 整页报告**写 `html/` 并用固定 Action id `render-html`(见 `references/ACTIONS.md`)。 +- **准备 vs 执行(适用则拆)**:若技能存在「先写入/准备本地数据」与「再对外产生副作用」两段,Agent 必须能分开触发;禁止默认绑成不可拆的一步。一键无准备态的技能可跳过本条。 + +### 如何触发(能力类型) + +| 你要做什么 | Agent 怎么做 | 进任务中心? | +|------------|--------------|--------------| +| 短查询 / 只读 / 秒级(`health` / `version` / `stats` / `list` / `get` 等) | OpenClaw bash/exec + **共享 Python** 跑 CLI(见下方「共享 Python」) | 否(CLI 当场结束) | +| 已在 `actions.json` 声明且 `executionProfile: "sync"` 的能力 | 可用 `run_skill_action`,或同等效果的短 CLI(同一内核) | 否 | +| 长耗时 / RPA / 浏览器 / 需要进度或取消;或 `executionProfile: "async"` | **必须** `run_skill_action`(先 `list_skill_actions` / `get_skill_action`) | **是**(仅因为该 Action 是 async) | +| 用户只问用法 / 能做什么 | 读根目录 `README.md`;逐步操作再读 `TUTORIAL.md`;**不要**为答用法去列目录或默认打开 `references/` | — | + +### 用户意图路由表 + +复制为业务技能后,**必须**用本技能真实意图改写下表(保留三列结构)。格子写清:短 CLI 子命令,或 `run_skill_action` → **具体 action id**(及主要 `input`)。勿留模板占位语上线。 + +| 用户意图 | 准备 vs 执行 | 路由 | +|------------------------|--------------|--------------| +| (例)查询 / 统计 / 列表 | 短 CLI:`` | 否 | +| (例)写入 / 导入 / 改本地状态 | 短 CLI:`` | 否 | +| (例)长副作用 / RPA / 需进度 | `run_skill_action` → ``,`input.…` | 是(因该 Action 为 async) | + +有长任务 / RPA 时:上表至少一行指向真实的 async action id,且该 id 须出现在 `assets/actions.json`;细节分流见 `references/CLI.md`。 + + + +## CLI 契约 + +必须使用宿主共享 Python runtime: + +```bash +python {baseDir}/scripts/main.py … +``` + +禁止使用 `uv run python`,也禁止向临时环境自行安装依赖。短 CLI 白名单为 `health`、`stats`、`version`、`config-path`、`init-db`、 和 `run` 是 Action 强制黑名单,必须通过 `run_skill_action` 执行。 + +## 面向用户问答(LLM 规则) + +- 本文(`SKILL.md`)是 LLM / OpenClaw 平台读取的技能入口,**不是**用户市场说明。 +- 根目录市场材料面向普通用户(见下方「文档分工」),**不是** Agent 调用契约。 + +当用户询问以下问题时,**必须优先读取**根目录 `README.md`(能力说明)与 `TUTORIAL.md`(逐步用法),并用用户能理解的业务语言回答: + +- 这个技能是做什么的 +- 这个技能怎么用 +- 适合什么场景 +- 使用前要准备什么 +- 执行后会得到什么 +- 能不能帮我完成某个业务任务 + +**回答「怎么用 / 能做什么」时的要求:** + +1. 「能做什么 / 场景 / FAQ」以 `README.md` 为主;「第一次怎么用完」以 `TUTORIAL.md` 为主。 +2. **禁止**在回答中粘贴 `python …/scripts/main.py …` 或其它命令行(除非用户明确索要命令,或自称技术人员/开发者)。 +3. **禁止**为答用法而去 `dir` / `ls` / 列技能目录,或默认打开 `references/`、`development/`;仅当用户明确问命令、参数、集成或排错时再读 `references/`。 +4. **不要**向普通用户暴露 Playwright、DOM、Python、RPA、库表名、`error_code` 等实现细节,除非用户明确询问技术实现。 + +**文档读取边界:** + +- **不要**把 `SKILL.md`、`references/`、`development/` 中的技术细节直接作为用户回答。 +- **只有当**用户明确询问命令、参数、输出结构、开发、调试、集成或排错时,才读取 `references/`;`development/` 主要给开发者 / 编码助手,对话 Agent **不必**默认打开。 +- 若市场材料与 `SKILL.md` / `references` 表述冲突:对用户展示以 `README.md` / `TUTORIAL.md` 为准;对执行契约以 `SKILL.md` / `references` 为准。 +- 用户未问技术实现时,**不要**讨论 slug 命名规则;开发者问命名或 slug 时,读取 `development/NAMING.md`。 diff --git a/examples/todo_skill/README.md b/examples/todo_skill/README.md deleted file mode 100644 index e90c7d6..0000000 --- a/examples/todo_skill/README.md +++ /dev/null @@ -1,126 +0,0 @@ -# 待办事项 demo(todo-skill) - -> 这是匠厂 `skill-template` 的**教学型 demo**,复刻模板的分层与 Action 契约, -> 自带一份最小 mock 平台库,可以脱离宿主在本机直接跑通。 -> 真实业务技能**不要** vendor `jiangchang_skill_core`(见模板 `SKILL.md`)。 - -## 这个 demo 演示什么 - -- **四层架构** `cli → service → db → util`(与 [../../scripts/](../../scripts) 一致) -- **CLI 入口**:`scripts/main.py` 子命令分发(argparse + 中文错误) -- **业务表 + 数据管理元数据**:`todos` 表 + `_jiangchang_tables` / `_jiangchang_columns` -- **Action 契约**:[assets/actions.json](assets/actions.json) 覆盖 `toolbar`/`row`/`batch`/`cron`/`agent`/`skill-detail` 六个 placement -- **sync / async 双形态**:CRUD 是 sync(当场返回);`remind` 是 async(演示 `job_context` / `emit` / `finish` 任务流) -- **mock 平台库**:`scripts/_mock_platform/jiangchang_skill_core/`,在没有宿主时自动注入 sys.path - -## 快速开始 - -```bash -# 用独立的数据目录跑(避免污染默认位置) -# Windows PowerShell -$env:JIANGCHANG_USER_ID="demo_test" -$env:JIANGCHANG_DATA_ROOT="$env:TEMP\jc_demo_run" - -# 1. 初始化数据库(幂等创建 todos 表 + 展示元数据) -python examples/todo_skill/scripts/main.py init-db - -# 2. 新增三条待办 -python examples/todo_skill/scripts/main.py todo add "写文档" -p high -python examples/todo_skill/scripts/main.py todo add "回邮件" -p normal -python examples/todo_skill/scripts/main.py todo add "买咖啡" -p low - -# 3. 列出 / 按状态过滤 -python examples/todo_skill/scripts/main.py todo list -python examples/todo_skill/scripts/main.py todo list -s pending - -# 4. 批量标记完成(演示逗号分隔 ids,对应 actions.json 的 batch 入口) -python examples/todo_skill/scripts/main.py todo done 1,2 - -# 5. 统计 -python examples/todo_skill/scripts/main.py todo stats - -# 6. async 提醒任务(演示 job_context/emit/finish 任务流) -python examples/todo_skill/scripts/main.py remind -s 2 - -# 7. 删除 / 健康检查 / 版本 -python examples/todo_skill/scripts/main.py todo delete 3 -python examples/todo_skill/scripts/main.py health -python examples/todo_skill/scripts/main.py version -python examples/todo_skill/scripts/main.py config-path -``` - -数据落在 `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/todo-skill/todo-skill.db`, -不设环境变量时回退到 `{系统临时目录}/jiangchang_demo_cache/demo_user/todo-skill/`。 - -## 目录结构 - -``` -examples/todo_skill/ -├── README.md # 本文(用户说明) -├── SKILL.md # 技能入口(Agent / LLM 读,对齐模板 frontmatter) -├── assets/ -│ └── actions.json # Action 清单(6 placements × sync/async) -└── scripts/ - ├── main.py # CLI 入口;含 mock 平台库回退注入 - ├── _mock_platform/ # mock jiangchang_skill_core(仅 demo 独立运行用) - │ └── jiangchang_skill_core/ - │ ├── __init__.py - │ ├── runtime_env.py # 数据根 / 用户 ID / apply_cli_local_defaults - │ ├── config.py # .env 加载与合并 - │ ├── activity.py # job_context / emit / finish - │ └── diagnostics.py # health 体检 - ├── cli/ - │ └── app.py # argparse 子命令分发 - ├── service/ - │ ├── task_service.py # 标准命令 + remind async 编排 - │ └── todo_service.py # 待办 CRUD 业务内核(多入口复用) - ├── db/ - │ ├── connection.py # SQLite 连接 + init_db(todos 表 + 元数据) - │ └── display_metadata.py # _jiangchang_tables/_columns 元数据 DDL - └── util/ - ├── constants.py # SKILL_SLUG / SKILL_VERSION - ├── runtime_paths.py # 数据目录与 DB 路径 - ├── argparse_zh.py # 中文 argparse 错误 - ├── logging_config.py # stdlib logging 薄封装 - └── config_bootstrap.py # .env.example → 用户 .env 合并 -``` - -## 与真实模板的差异 - -| 维度 | 真实模板 | 本 demo | -|------|----------|----------| -| `jiangchang_skill_core` 来源 | 宿主共享 Python runtime(`jiangchang-platform-kit`) | 自带 mock(`scripts/_mock_platform/`) | -| RPA / 视频录制 | `RpaVideoSession` 完整链路 | 未实现(remind 仅模拟进度,不开浏览器) | -| 任务中心 UI | 宿主「Run Journal」推送 + 进度面板 | stdout 打印 JSON Lines 模拟 | -| 鉴权 | `entitlement_service` 调宿主接口 | 完全省略 | -| HTML 报告 | `render-html` action + `html/*.html` | 未实现(教学范围外) | -| 日志 | `jiangchang_skill_core.unified_logging` 统一文件日志 | stdlib logging,仅 stdout | - -## 三大契约在 demo 中的对应 - -### 1. CLI 契约([scripts/cli/app.py](scripts/cli/app.py)) -固定子命令:`init-db` / `todo add|list|done|delete|stats` / `remind` / `health` / `version` / `config-path`。 - -### 2. Actions 契约([assets/actions.json](assets/actions.json)) - -| action id | executionProfile | placements | 说明 | -|-----------|------------------|------------|------| -| `health` / `version` | sync | skill-detail | 标准 | -| `todo-add` | sync | toolbar, agent, skill-detail | 表单新增 | -| `todo-list` | sync | skill-detail, agent | 只读查询 | -| `todo-done` | sync | row, batch, agent | 行级 / 批量,含 `confirmation` | -| `todo-delete` | sync | row, batch | 行级 / 批量删除 | -| `todo-stats` | sync | skill-detail, agent | 只读统计 | -| `remind` | **async** | toolbar, cron, agent, skill-detail | 演示任务中心入口 | - -### 3. 数据管理元数据([scripts/db/connection.py](scripts/db/connection.py)) -- 业务表:`todos`(id/title/status/priority/due_at/created_at/updated_at,字段顺序即列展示顺序) -- 元数据表:`_jiangchang_tables` + `_jiangchang_columns` -- `todos` 表 `readonly=1`:增删改通过技能 Action 走,宿主不直写 - -## 冒烟测试 - -```bash -cd examples/todo_skill -python -m unittest discover -s tests -``` diff --git a/examples/todo_skill/SKILL.md b/examples/todo_skill/SKILL.md deleted file mode 100644 index fdc8837..0000000 --- a/examples/todo_skill/SKILL.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -name: 待办事项 demo -description: "匠厂 skill-template 的教学型 demo:用待办事项场景演示分层架构、CLI 契约、Action manifest 与 sync/async 双形态。" -version: 0.1.0 -author: demo -metadata: - openclaw: - slug: todo-skill - platform_kit_min_version: "1.2.11" - emoji: "✅" - category: "通用" - developer_ids: - - 1 -allowed-tools: - - bash ---- - -# 待办事项 demo(todo-skill) - -这是匠厂 [`skill-template`](../../SKILL.md) 的**教学型 demo**,用最简单的待办事项业务 -复刻模板的所有关键模式。它**不是**真实业务技能,自带一份 mock 平台库以便脱离宿主运行。 - -## 关键模式映射 - -| 模板铁律 | 本 demo 落地 | -|----------|-------------| -| `cli → service → db → util` 四层 | [scripts/](scripts/) 完整复刻同结构 | -| 单业务内核、多入口 | `service/todo_service.py` 是唯一 CRUD 内核,CLI/Action/数据管理按钮都调它 | -| CLI 固定入口 | [scripts/main.py](scripts/main.py) + `cli/app.py` argparse 分发 | -| Action manifest 正交字段 | [assets/actions.json](assets/actions.json) 覆盖 6 placements × sync/async | -| 数据管理元数据 | [scripts/db/connection.py](scripts/db/connection.py) 建 `todos` + `_jiangchang_*` | -| 长任务 async 范式 | [service/task_service.py](scripts/service/task_service.py) `cmd_remind` 用 `job_context`/`emit`/`finish` | - -## Agent 触发路由表(demo) - -| 用户意图 | Agent 怎么做 | 进任务中心? | -|----------|--------------|--------------| -| 看版本 / 检查环境 | bash + 共享 Python `version` / `health` | 否 | -| 列出 / 统计 / 新增 / 完成 / 删除 | bash + `todo list/stats/add/done/delete` | 否(sync) | -| 扫描并提醒 | `run_skill_action` → `remind`(async) | **是** | - -## 独立运行 - -详见 [README.md](README.md)。设 `JIANGCHANG_DATA_ROOT` + `JIANGCHANG_USER_ID` 后可直接 `python scripts/main.py ` 跑通。 - -## 与真实模板的差异 - -本 demo 自带 `_mock_platform/jiangchang_skill_core`,**真实技能不得 vendor 平台库**。 -其它省略项:RPA/视频、鉴权、HTML 报告、统一文件日志(详见 README)。 diff --git a/examples/todo_skill/assets/actions.json b/examples/todo_skill/assets/actions.json deleted file mode 100644 index 5dc0e5b..0000000 --- a/examples/todo_skill/assets/actions.json +++ /dev/null @@ -1,189 +0,0 @@ -{ - "schemaVersion": 1, - "skill": "todo-skill", - "actions": [ - { - "id": "health", - "label": "运行检查", - "description": "检查 demo 运行环境(Python / mock 平台库 / 数据库路径),不启动浏览器。", - "placements": ["skill-detail", "agent"], - "executionProfile": "sync", - "entrypoint": { - "type": "cli", - "command": "health", - "args": [] - } - }, - { - "id": "version", - "label": "查看版本", - "description": "返回 demo 版本与 slug。", - "placements": ["skill-detail"], - "executionProfile": "sync", - "entrypoint": { - "type": "cli", - "command": "version", - "args": [] - } - }, - { - "id": "todo-add", - "label": "新增待办", - "description": "在 todos 表新增一条待办事项(走技能 CLI,不直接写库)。", - "placements": ["toolbar", "agent", "skill-detail"], - "executionProfile": "sync", - "interaction": "form", - "bind": { "tables": ["todos"] }, - "hostEffects": { "onSuccess": ["reloadTable"] }, - "inputSchema": { - "type": "object", - "required": ["title"], - "properties": { - "title": { - "type": "string", - "title": "标题", - "description": "待办事项内容" - }, - "priority": { - "type": "string", - "title": "优先级", - "enum": ["high", "normal", "low"], - "default": "normal" - }, - "dueAt": { - "type": "number", - "title": "截止时间", - "description": "Unix 秒;可空", - "display_type": "datetime_unix_seconds" - } - } - }, - "entrypoint": { - "type": "cli", - "command": "todo", - "args": ["add", "{{title}}", "--priority", "{{priority}}", "--due-at", "{{dueAt}}"] - } - }, - { - "id": "todo-list", - "label": "查看列表", - "description": "列出待办事项(可选按状态过滤),返回 JSON。", - "placements": ["skill-detail", "agent"], - "executionProfile": "sync", - "inputSchema": { - "type": "object", - "properties": { - "status": { - "type": "string", - "title": "状态过滤", - "enum": ["pending", "done"], - "description": "留空则返回全部" - }, - "limit": { - "type": "number", - "title": "返回数量", - "default": 100 - } - } - }, - "entrypoint": { - "type": "cli", - "command": "todo", - "args": ["list", "--status", "{{status}}", "--limit", "{{limit}}"] - } - }, - { - "id": "todo-done", - "label": "标记完成", - "description": "将当前行待办标记为完成。", - "placements": ["row", "batch", "agent"], - "executionProfile": "sync", - "interaction": "confirm", - "confirmation": { "message": "将所选待办标记为完成?" }, - "bind": { "tables": ["todos"] }, - "hostEffects": { "onSuccess": ["reloadTable", "clearSelection"] }, - "inputSchema": { - "type": "object", - "required": ["ids"], - "properties": { - "ids": { - "type": "string", - "title": "待办编号", - "description": "逗号分隔的待办 id(batch 入口由勾选映射)", - "readOnly": true - } - } - }, - "entrypoint": { - "type": "cli", - "command": "todo", - "args": ["done", "{{ids}}"] - } - }, - { - "id": "todo-delete", - "label": "删除待办", - "description": "删除当前行待办(技能级删除,会更新宿主表格)。", - "placements": ["row", "batch"], - "executionProfile": "sync", - "interaction": "confirm", - "confirmation": { "message": "确认删除所选待办?此操作不可撤销。" }, - "bind": { "tables": ["todos"] }, - "hostEffects": { "onSuccess": ["reloadTable", "clearSelection"] }, - "inputSchema": { - "type": "object", - "required": ["ids"], - "properties": { - "ids": { - "type": "string", - "title": "待办编号", - "description": "逗号分隔的待办 id(batch 入口由勾选映射)", - "readOnly": true - } - } - }, - "entrypoint": { - "type": "cli", - "command": "todo", - "args": ["delete", "{{ids}}"] - } - }, - { - "id": "todo-stats", - "label": "查看统计", - "description": "返回待办事项简单统计(总数 / 待办 / 完成)。", - "placements": ["skill-detail", "agent"], - "executionProfile": "sync", - "entrypoint": { - "type": "cli", - "command": "todo", - "args": ["stats"] - } - }, - { - "id": "remind", - "label": "提醒扫描", - "description": "演示 async 任务:扫描待办事项并模拟发出提醒。进入任务中心,支持进度与取消。", - "placements": ["toolbar", "cron", "agent", "skill-detail"], - "executionProfile": "async", - "bind": { "tables": ["todos"] }, - "inputSchema": { - "type": "object", - "properties": { - "seconds": { - "type": "number", - "title": "运行秒数", - "description": "模拟任务持续秒数,用于演示进度推送", - "default": 3, - "minimum": 1 - } - } - }, - "entrypoint": { - "type": "cli", - "command": "remind", - "args": ["--seconds", "{{seconds}}"] - } - } - ] -} diff --git a/examples/todo_skill/scripts/_mock_platform/jiangchang_skill_core/__init__.py b/examples/todo_skill/scripts/_mock_platform/jiangchang_skill_core/__init__.py deleted file mode 100644 index cddaed0..0000000 --- a/examples/todo_skill/scripts/_mock_platform/jiangchang_skill_core/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""jiangchang_skill_core mock 包(仅供 demo 独立运行使用)。 - -真实环境中本包来自宿主共享 Python runtime 的 ``jiangchang-platform-kit``, -不在技能仓库内 vendored。本 mock 只为让 demo 脱离宿主也能跑通,行为不 -等价于真实平台库。 -""" -from __future__ import annotations - -from . import config as config # noqa: F401 -from . import runtime_env as runtime_env # noqa: F401 -from .activity import JobStopped, emit, finish, job_context # noqa: F401 -from .diagnostics import collect_runtime_diagnostics, format_runtime_health_lines # noqa: F401 - -__version__ = "1.2.11-mock" diff --git a/examples/todo_skill/scripts/_mock_platform/jiangchang_skill_core/activity.py b/examples/todo_skill/scripts/_mock_platform/jiangchang_skill_core/activity.py deleted file mode 100644 index 6244807..0000000 --- a/examples/todo_skill/scripts/_mock_platform/jiangchang_skill_core/activity.py +++ /dev/null @@ -1,61 +0,0 @@ -"""activity mock:job_context / emit / finish 任务中心契约的最小实现。 - -真实平台 ``emit``/``finish`` 会写入 Run Journal(``{DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl``) -并推送到任务中心 UI;本 mock 只把进度打印到 stdout,便于在终端看到任务流。 -""" -from __future__ import annotations - -import json -import sys -import time -import uuid -from contextlib import contextmanager -from typing import Any, Iterator - - -class JobStopped(Exception): - """用户/宿主主动停止任务时抛出。真实平台由心跳检查触发;demo 不会自动抛。""" - - -def _emit_record(payload: dict[str, Any]) -> None: - """统一以 JSON Lines 打印到 stdout,模拟 Run Journal 一行。""" - line = json.dumps(payload, ensure_ascii=False, default=str) - print(f"[activity] {line}", flush=True) - - -def emit(message: str, *, type: str = "info", skill: str = "", **fields: Any) -> None: - """报告进度事件。""" - _emit_record({ - "ts": int(time.time()), - "event": "emit", - "type": type, - "skill": skill, - "message": message, - **fields, - }) - - -def finish(*, status: str, message: str = "", skill: str = "", **fields: Any) -> None: - """报告任务终态。``status`` ∈ ``ok``/``failed``/``stopped``。""" - _emit_record({ - "ts": int(time.time()), - "event": "finish", - "status": status, - "skill": skill, - "message": message, - **fields, - }) - # 真实平台 finish 后任务中心记录终态;mock 额外在 stderr 提示一句。 - print(f"[activity] task_finished status={status} message={message or '(empty)'}", file=sys.stderr) - - -@contextmanager -def job_context(*, skill: str) -> Iterator[dict[str, Any]]: - """任务上下文:开始时生成 jobId,结束时打印边界。""" - job_id = uuid.uuid4().hex - ctx = {"skill": skill, "job_id": job_id} - _emit_record({"ts": int(time.time()), "event": "job_start", "skill": skill, "job_id": job_id}) - try: - yield ctx - finally: - _emit_record({"ts": int(time.time()), "event": "job_end", "skill": skill, "job_id": job_id}) diff --git a/examples/todo_skill/scripts/_mock_platform/jiangchang_skill_core/config.py b/examples/todo_skill/scripts/_mock_platform/jiangchang_skill_core/config.py deleted file mode 100644 index d4651cf..0000000 --- a/examples/todo_skill/scripts/_mock_platform/jiangchang_skill_core/config.py +++ /dev/null @@ -1,89 +0,0 @@ -"""config mock:只读环境变量 + 简单 .env 合并。 - -真实平台有更完整的多层配置与缓存;本 mock 仅演示优先级: -进程环境变量 > 用户 .env > .env.example。 -""" -from __future__ import annotations - -import os -from typing import Optional - -_cache: dict[str, str] = {} -_env_path: Optional[str] = None - - -def _load_env_file(path: str) -> dict[str, str]: - if not path or not os.path.isfile(path): - return {} - data: dict[str, str] = {} - with open(path, encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, _, value = line.partition("=") - data[key.strip()] = _strip_comment(value.strip()) - return data - - -def _strip_comment(value: str) -> str: - """去掉行尾 `` # 注释``(保持简单,不处理引号内 #)。""" - if " #" in value: - value = value.split(" #", 1)[0].rstrip() - return value - - -def get(key: str, default: Optional[str] = None) -> Optional[str]: - """读配置:进程环境变量优先,其次已加载的 .env。""" - if key in os.environ: - return os.environ[key] - if key in _cache: - return _cache[key] - return default - - -def get_env_file_path() -> Optional[str]: - return _env_path - - -def ensure_env_file(skill_slug: str, example_path: str) -> str: - """确保用户 .env 存在,缺失时从 example 复制。""" - global _env_path - from .runtime_env import get_data_root, get_user_id - - user_env_dir = os.path.join(get_data_root(), get_user_id(), skill_slug) - os.makedirs(user_env_dir, exist_ok=True) - path = os.path.join(user_env_dir, ".env") - if not os.path.isfile(path) and os.path.isfile(example_path): - with open(example_path, encoding="utf-8") as src: - with open(path, "w", encoding="utf-8") as dst: - dst.write(src.read()) - _env_path = path - _refresh_cache(path) - return path - - -def merge_missing_env_keys(example_path: str, env_path: str, comment_skill: Optional[str] = None) -> None: - """把 example 中存在但 .env 缺失的键追加到 .env 末尾(带版本注释)。""" - if not os.path.isfile(example_path) or not os.path.isfile(env_path): - return - existing = _load_env_file(env_path) - example = _load_env_file(example_path) - missing = {k: v for k, v in example.items() if k not in existing} - if not missing: - return - with open(env_path, "a", encoding="utf-8") as f: - if comment_skill: - f.write(f"\n# === merged by {comment_skill} ===\n") - for k, v in missing.items(): - f.write(f"{k}={v}\n") - _refresh_cache(env_path) - - -def reset_cache() -> None: - _cache.clear() - - -def _refresh_cache(path: str) -> None: - _cache.clear() - _cache.update(_load_env_file(path)) diff --git a/examples/todo_skill/scripts/_mock_platform/jiangchang_skill_core/diagnostics.py b/examples/todo_skill/scripts/_mock_platform/jiangchang_skill_core/diagnostics.py deleted file mode 100644 index e82331c..0000000 --- a/examples/todo_skill/scripts/_mock_platform/jiangchang_skill_core/diagnostics.py +++ /dev/null @@ -1,89 +0,0 @@ -"""runtime diagnostics mock:``health`` 命令的只读环境体检。""" -from __future__ import annotations - -import os -import sys -from dataclasses import dataclass, field -from typing import Any - -from . import config -from .runtime_env import get_data_root, get_user_id - -# 避免循环导入:与 __init__.py 的 __version__ 保持同步 -_CORE_VERSION = "1.2.11-mock" - - -@dataclass -class RuntimeDiagnostics: - skill_slug: str - python_executable: str - platform_kit_version: str - platform_kit_min_version: str - platform_kit_version_ok: bool - jiangchang_skill_core_file: str - data_root: str - user_id: str - env_path: str - env_exists: bool - has_fatal_issues: bool = False - issues: list[dict[str, str]] = field(default_factory=list) - - -def collect_runtime_diagnostics( - *, - skill_slug: str, - platform_kit_min_version: str, - skill_root: str, -) -> RuntimeDiagnostics: - """收集只读运行时诊断。致命问题(缺 python、版本不符等)置 has_fatal_issues。""" - issues: list[dict[str, str]] = [] - - # 版本检查(mock 总是自报合格,便于 demo 跑通) - version_ok = True - if platform_kit_min_version and _CORE_VERSION < platform_kit_min_version: - version_ok = False - issues.append({ - "level": "error", - "code": "PLATFORM_KIT_VERSION_TOO_LOW", - "message": f"jiangchang_skill_core {_CORE_VERSION} < 要求 {platform_kit_min_version}", - }) - - env_path = config.get_env_file_path() or "" - env_exists = bool(env_path and os.path.isfile(env_path)) - if not env_exists: - issues.append({ - "level": "warning", - "code": "ENV_FILE_MISSING", - "message": f"用户 .env 不存在:{env_path or '(未设置)'}", - }) - - return RuntimeDiagnostics( - skill_slug=skill_slug, - python_executable=sys.executable, - platform_kit_version=_CORE_VERSION, - platform_kit_min_version=platform_kit_min_version, - platform_kit_version_ok=version_ok, - jiangchang_skill_core_file=__file__, - data_root=get_data_root(), - user_id=get_user_id(), - env_path=env_path, - env_exists=env_exists, - has_fatal_issues=any(i["level"] == "error" for i in issues), - issues=issues, - ) - - -def format_runtime_health_lines(runtime: RuntimeDiagnostics) -> list[str]: - """格式化为可打印行,对齐真实 ``health`` 输出风格。""" - lines = [ - f"python_executable: {runtime.python_executable}", - f"platform_kit_version: {runtime.platform_kit_version}", - f"platform_kit_min_version: {runtime.platform_kit_min_version}", - f"platform_kit_version_ok: {runtime.platform_kit_version_ok}", - f"jiangchang_skill_core_file: {runtime.jiangchang_skill_core_file}", - f"data_root: {runtime.data_root}", - f"user_id: {runtime.user_id}", - ] - for issue in runtime.issues: - lines.append(f"runtime_issue[{issue['level']}]: {issue['code']} - {issue['message']}") - return lines diff --git a/examples/todo_skill/scripts/_mock_platform/jiangchang_skill_core/runtime_env.py b/examples/todo_skill/scripts/_mock_platform/jiangchang_skill_core/runtime_env.py deleted file mode 100644 index 7cb512e..0000000 --- a/examples/todo_skill/scripts/_mock_platform/jiangchang_skill_core/runtime_env.py +++ /dev/null @@ -1,34 +0,0 @@ -"""runtime_env mock:解析数据根目录与用户 ID。 - -真实平台用宿主注入的环境变量;本 mock 在没有宿主时用本地临时目录兜底。 -""" -from __future__ import annotations - -import os -import tempfile - -_DEFAULT_USER_ID = "demo_user" - - -def get_data_root() -> str: - """数据根目录:真实平台来自 ``JIANGCHANG_DATA_ROOT``,demo 用本地缓存目录。""" - root = os.environ.get("JIANGCHANG_DATA_ROOT") - if root: - return root - cache = os.path.join(tempfile.gettempdir(), "jiangchang_demo_cache") - os.makedirs(cache, exist_ok=True) - return cache - - -def get_user_id() -> str: - return os.environ.get("JIANGCHANG_USER_ID") or _DEFAULT_USER_ID - - -def get_sibling_skills_root(scripts_dir: str) -> str: - """同级技能根:父目录的父目录(即 ``examples/`` 之类)。""" - return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(scripts_dir)))) - - -def apply_cli_local_defaults() -> None: - """CLI 本地默认配置:真实平台会调整 PATH/VIRTUAL_ENV 等;mock 仅占位。""" - return None diff --git a/examples/todo_skill/scripts/cli/__init__.py b/examples/todo_skill/scripts/cli/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/todo_skill/scripts/cli/app.py b/examples/todo_skill/scripts/cli/app.py deleted file mode 100644 index db8866b..0000000 --- a/examples/todo_skill/scripts/cli/app.py +++ /dev/null @@ -1,128 +0,0 @@ -"""CLI:argparse 解析与分发(业务内核在 service,本层只做参数→service 映射)。""" -from __future__ import annotations - -import argparse -import sys -from typing import List, Optional - -from service.task_service import ( - cmd_add, - cmd_config_path, - cmd_delete, - cmd_done, - cmd_health, - cmd_init_db, - cmd_list, - cmd_remind, - cmd_stats, - cmd_version, -) -from util.config_bootstrap import bootstrap_skill_config -from util.argparse_zh import ZhArgumentParser -from util.constants import LOG_LOGGER_NAME, SKILL_SLUG -from util.logging_config import get_skill_logger, setup_skill_logging - - -def _cli_str_or_none(raw: Optional[str]) -> Optional[str]: - if raw is None: - return None - v = str(raw).strip() - return v or None - - -def _parse_ids(raw: str) -> list[int]: - """解析 ``1`` 或 ``1,2,3`` 为 int 列表。""" - parts = [p.strip() for p in (raw or "").split(",") if p.strip()] - try: - return [int(p) for p in parts] - except ValueError as exc: - raise SystemExit(f"参数错误:ids 必须是数字或逗号分隔的数字列表,收到 {raw!r}\n{exc}") - - -def _print_full_usage() -> None: - print("待办事项 demo(main.py)可用命令:") - print(" python main.py init-db # 初始化数据库与元数据") - print(" python main.py todo add [-p high|normal|low] [-d <unix秒>") - print(" python main.py todo list [-s pending|done] [-n <limit>]") - print(" python main.py todo done <id>") - print(" python main.py todo delete <id>") - print(" python main.py todo stats") - print(" python main.py remind [-s <秒>]") - print(" python main.py health / version / config-path") - - -def _add_todo_subparser(sub) -> None: - sp = sub.add_parser("todo", help="待办事项 CRUD") - todo_sub = sp.add_subparsers(dest="todo_cmd", required=True, parser_class=ZhArgumentParser) - - p = todo_sub.add_parser("add", help="新增一条待办") - p.add_argument("title", help="待办标题(必填)") - p.add_argument("-p", "--priority", default="normal", - choices=["high", "normal", "low"], help="优先级,默认 normal") - p.add_argument("-d", "--due-at", type=int, default=None, - help="截止时间(Unix 秒,可选)") - p.set_defaults(handler=lambda a: cmd_add( - title=a.title, priority=a.priority, due_at=a.due_at)) - - p = todo_sub.add_parser("list", help="列出待办") - p.add_argument("-s", "--status", default=None, - choices=["pending", "done"], help="按状态过滤") - p.add_argument("-n", "--limit", type=int, default=100, help="返回数量上限") - p.set_defaults(handler=lambda a: cmd_list(status=a.status, limit=a.limit)) - - p = todo_sub.add_parser("done", help="标记完成(可传逗号分隔的多个 id)") - p.add_argument("ids", help="待办 id 或逗号分隔的 id 列表(如 1 或 1,2,3)") - p.set_defaults(handler=lambda a: cmd_done(todo_ids=_parse_ids(a.ids))) - - p = todo_sub.add_parser("delete", help="删除(可传逗号分隔的多个 id)") - p.add_argument("ids", help="待办 id 或逗号分隔的 id 列表") - p.set_defaults(handler=lambda a: cmd_delete(todo_ids=_parse_ids(a.ids))) - - p = todo_sub.add_parser("stats", help="简单统计") - p.set_defaults(handler=lambda _a: cmd_stats()) - - -def _add_remind_subparser(sub) -> None: - sp = sub.add_parser("remind", help="演示 async 提醒任务(job_context/emit/finish)") - sp.add_argument("-s", "--seconds", type=int, default=3, - help="模拟运行秒数,默认 3") - sp.set_defaults(handler=lambda a: cmd_remind(seconds=a.seconds)) - - -def build_parser() -> ZhArgumentParser: - p = ZhArgumentParser( - prog="main.py", - description="待办事项 demo:演示匠厂 skill-template 的分层与 Action 契约。", - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - sub = p.add_subparsers(dest="cmd", required=True, parser_class=ZhArgumentParser) - - sp = sub.add_parser("init-db", help="幂等初始化 SQLite 与数据管理元数据") - sp.set_defaults(handler=lambda _a: cmd_init_db()) - - _add_todo_subparser(sub) - _add_remind_subparser(sub) - - sp = sub.add_parser("health", help="健康检查") - sp.set_defaults(handler=lambda _a: cmd_health()) - - sp = sub.add_parser("version", help="版本信息(JSON)") - sp.set_defaults(handler=lambda _a: cmd_version()) - - sp = sub.add_parser("config-path", help="输出 .env 与模板路径") - sp.set_defaults(handler=lambda _a: cmd_config_path()) - - return p - - -def main(argv: Optional[List[str]] = None) -> int: - argv = argv if argv is not None else sys.argv[1:] - bootstrap_skill_config() - setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME) - get_skill_logger().info("cli_start argv=%s", sys.argv) - if not argv: - _print_full_usage() - return 1 - parser = build_parser() - args = parser.parse_args(argv) - return int(args.handler(args)) diff --git a/examples/todo_skill/scripts/db/__init__.py b/examples/todo_skill/scripts/db/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/todo_skill/scripts/db/connection.py b/examples/todo_skill/scripts/db/connection.py deleted file mode 100644 index 79b07aa..0000000 --- a/examples/todo_skill/scripts/db/connection.py +++ /dev/null @@ -1,96 +0,0 @@ -"""SQLite 连接与幂等初始化。 - -``init_db`` 建两张表: -1. ``todos`` — 业务表(demo:待办事项) -2. ``_jiangchang_tables`` / ``_jiangchang_columns`` — 宿主数据管理展示元数据 - -业务表字段顺序就是宿主列展示顺序(cid 顺序)。 -""" -from __future__ import annotations - -import sqlite3 - -from db.display_metadata import ( - init_display_metadata_tables, - seed_column_metadata_batch, - upsert_table_metadata, -) -from util.runtime_paths import get_db_path - -# ---- 业务表 DDL(待办事项)---- -# 字段顺序 = 数据管理列展示顺序;状态/优先级用 options_json 给宿主做下拉筛选 -_TODOS_DDL = """ -CREATE TABLE IF NOT EXISTS todos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - priority TEXT NOT NULL DEFAULT 'normal', - due_at INTEGER, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL -) -""" - -_TODOS_INDEX = """ -CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(status) -""" - - -def get_conn() -> sqlite3.Connection: - return sqlite3.connect(get_db_path()) - - -def _seed_todos_display_metadata(cursor) -> None: - """写 ``todos`` 表与字段的中文展示元数据。""" - upsert_table_metadata( - cursor, - table_name="todos", - display_name="待办事项", - description="本技能的核心业务表:每条待办事项一行。", - grid_help="状态变更走技能 CLI(todo done / todo delete),不要在表里直接改。", - visible=1, - readonly=1, # 整表只读:增删改通过技能 Action 走,宿主不直写 - default_sort_json='[{"column":"created_at","direction":"desc"}]', - default_visible_columns_json='["title","status","priority","due_at","created_at"]', - ) - seed_column_metadata_batch( - cursor, - table_name="todos", - columns=[ - {"column_name": "id", "display_name": "编号", "description": "主键", - "visible": 1, "searchable": 1, "editable": 0, "display_type": "number"}, - {"column_name": "title", "display_name": "标题", "description": "待办事项内容", - "grid_help": "简明描述要做的事", "visible": 1, "searchable": 1, "editable": 0, - "display_type": "text"}, - {"column_name": "status", "display_name": "状态", "description": "pending/done", - "grid_help": "pending=待办,done=完成", "visible": 1, "searchable": 1, "editable": 0, - "display_type": "enum", - "options_json": '[{"value":"pending","label":"待办"},{"value":"done","label":"完成"}]'}, - {"column_name": "priority", "display_name": "优先级", "description": "high/normal/low", - "visible": 1, "searchable": 1, "editable": 0, "display_type": "enum", - "options_json": '[{"value":"high","label":"高"},{"value":"normal","label":"中"},{"value":"low","label":"低"}]'}, - {"column_name": "due_at", "display_name": "截止时间", "description": "Unix 秒;可空", - "visible": 1, "searchable": 1, "editable": 0, "display_type": "datetime_unix_seconds"}, - {"column_name": "created_at", "display_name": "创建时间", "description": "Unix 秒", - "visible": 1, "searchable": 0, "editable": 0, "display_type": "datetime_unix_seconds"}, - {"column_name": "updated_at", "display_name": "更新时间", "description": "Unix 秒", - "visible": 0, "searchable": 0, "editable": 0, "display_type": "datetime_unix_seconds"}, - ], - ) - - -def init_db() -> None: - """幂等创建/迁移本地 SQLite 与数据管理元数据。""" - conn = get_conn() - try: - cur = conn.cursor() - # 1. 元数据表(先建,方便其它步骤 upsert) - init_display_metadata_tables(cur) - # 2. 业务表 - cur.execute(_TODOS_DDL) - cur.execute(_TODOS_INDEX) - # 3. 业务表展示元数据 - _seed_todos_display_metadata(cur) - conn.commit() - finally: - conn.close() diff --git a/examples/todo_skill/scripts/db/display_metadata.py b/examples/todo_skill/scripts/db/display_metadata.py deleted file mode 100644 index 0607cbe..0000000 --- a/examples/todo_skill/scripts/db/display_metadata.py +++ /dev/null @@ -1,160 +0,0 @@ -"""匠厂数据管理展示元数据(``_jiangchang_tables`` / ``_jiangchang_columns``)。 - -SQLite 没有原生表/字段 COMMENT,宿主「数据管理」页面靠这两张元数据表 -拿到中文表名/列名/可编辑性等。字段顺序由 ``CREATE TABLE`` + ``PRAGMA table_info`` -的 ``cid`` 决定,不读 ``display_order``。 - -本文件是模板 ``scripts/db/display_metadata.py`` 的最小可用子集: -- ``init_display_metadata_tables``:建元数据表 -- ``upsert_table_metadata`` / ``upsert_column_metadata``:写入展示元数据 -- ``list_physical_column_names``:按 cid 列出物理字段名 -""" -from __future__ import annotations - -from typing import Iterable - -# 宿主隐藏这些表,不作为业务表展示 -INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"}) - -_JIANGCHANG_TABLES_DDL = """ -CREATE TABLE IF NOT EXISTS _jiangchang_tables ( - table_name TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - description TEXT, - grid_help TEXT, - visible INTEGER NOT NULL DEFAULT 1, - readonly INTEGER NOT NULL DEFAULT 0, - default_sort_json TEXT, - default_visible_columns_json TEXT -) -""" - -_JIANGCHANG_COLUMNS_DDL = """ -CREATE TABLE IF NOT EXISTS _jiangchang_columns ( - table_name TEXT NOT NULL, - column_name TEXT NOT NULL, - display_name TEXT NOT NULL, - description TEXT, - grid_help TEXT, - display_order INTEGER, - visible INTEGER NOT NULL DEFAULT 1, - searchable INTEGER NOT NULL DEFAULT 1, - editable INTEGER NOT NULL DEFAULT 1, - display_type TEXT, - options_json TEXT, - PRIMARY KEY (table_name, column_name) -) -""" - - -def init_display_metadata_tables(cursor) -> None: - cursor.execute(_JIANGCHANG_TABLES_DDL) - cursor.execute(_JIANGCHANG_COLUMNS_DDL) - - -def upsert_table_metadata( - cursor, - *, - table_name: str, - display_name: str, - description: str | None = None, - grid_help: str | None = None, - visible: int = 1, - readonly: int = 0, - default_sort_json: str | None = None, - default_visible_columns_json: str | None = None, -) -> None: - cursor.execute( - """ - INSERT INTO _jiangchang_tables ( - table_name, display_name, description, grid_help, visible, readonly, - default_sort_json, default_visible_columns_json - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(table_name) DO UPDATE SET - display_name = excluded.display_name, - description = excluded.description, - grid_help = excluded.grid_help, - visible = excluded.visible, - readonly = excluded.readonly, - default_sort_json = excluded.default_sort_json, - default_visible_columns_json = excluded.default_visible_columns_json - """, - ( - table_name, display_name, description, grid_help, visible, readonly, - default_sort_json, default_visible_columns_json, - ), - ) - - -def upsert_column_metadata( - cursor, - *, - table_name: str, - column_name: str, - display_name: str, - description: str | None = None, - grid_help: str | None = None, - visible: int = 1, - searchable: int = 1, - editable: int = 1, - display_type: str | None = None, - options_json: str | None = None, -) -> None: - cursor.execute( - """ - INSERT INTO _jiangchang_columns ( - table_name, column_name, display_name, description, grid_help, - display_order, visible, searchable, editable, display_type, options_json - ) - VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?) - ON CONFLICT(table_name, column_name) DO UPDATE SET - display_name = excluded.display_name, - description = excluded.description, - grid_help = excluded.grid_help, - display_order = NULL, - visible = excluded.visible, - searchable = excluded.searchable, - editable = excluded.editable, - display_type = excluded.display_type, - options_json = excluded.options_json - """, - ( - table_name, column_name, display_name, description, grid_help, - visible, searchable, editable, display_type, options_json, - ), - ) - - -def seed_column_metadata_batch( - cursor, - *, - table_name: str, - columns: Iterable[dict[str, object]], -) -> None: - """批量 upsert 字段元数据。""" - for column in columns: - upsert_column_metadata( - cursor, - table_name=table_name, - column_name=str(column["column_name"]), - display_name=str(column["display_name"]), - description=str(column["description"]) if column.get("description") else None, - grid_help=str(column["grid_help"]) if column.get("grid_help") else None, - visible=int(column.get("visible", 1)), - searchable=int(column.get("searchable", 1)), - editable=int(column.get("editable", 1)), - display_type=str(column["display_type"]) if column.get("display_type") else None, - options_json=str(column["options_json"]) if column.get("options_json") else None, - ) - - -def _quote_identifier(name: str) -> str: - return '"' + name.replace('"', '""') + '"' - - -def list_physical_column_names(cursor, table_name: str) -> list[str]: - cursor.execute(f"PRAGMA table_info({_quote_identifier(table_name)})") - rows = cursor.fetchall() - rows.sort(key=lambda row: int(row[0])) - return [str(row[1]) for row in rows] diff --git a/examples/todo_skill/scripts/main.py b/examples/todo_skill/scripts/main.py deleted file mode 100644 index 6d2fc90..0000000 --- a/examples/todo_skill/scripts/main.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""待办事项 demo CLI 入口(结构对齐 skill-template 的 main.py)。 - -分层:cli(argv)→ service(业务编排)→ db(持久化)→ util(通用工具)。 - -独立运行说明: - 真实技能依赖宿主共享 Python runtime 的 ``jiangchang_skill_core``。 - 本 demo 自带一份最小 mock(``scripts/_mock_platform/``),在没有宿主时 - 由本文件自动注入 sys.path,使 demo 在任意 Python 3.10+ 环境可直接跑通。 - 设置环境变量 ``JIANGCHANG_DATA_ROOT`` 即可切到真实平台 runtime 模式。 -""" -from __future__ import annotations - -import os -import sys - -# Windows 终端 UTF-8(对齐模板) -if sys.platform == "win32": - import io - sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") - sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") - -_scripts_dir = os.path.dirname(os.path.abspath(__file__)) -if _scripts_dir not in sys.path: - sys.path.insert(0, _scripts_dir) - -# 没有真实平台库时启用 mock(demo 自带,便于脱离宿主独立运行); -# 设环境变量 ``TODO_SKILL_USE_REAL_PLATFORM=1`` 可强制走真实 runtime。 -if not os.environ.get("TODO_SKILL_USE_REAL_PLATFORM"): - try: - import jiangchang_skill_core # noqa: F401 - except ImportError: - _mock_platform = os.path.join(_scripts_dir, "_mock_platform") - if _mock_platform not in sys.path: - sys.path.insert(0, _mock_platform) - -from jiangchang_skill_core.runtime_env import apply_cli_local_defaults # noqa: E402 - -apply_cli_local_defaults() - -from util.config_bootstrap import bootstrap_skill_config # noqa: E402 - -bootstrap_skill_config() - -from cli.app import main # noqa: E402 - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/examples/todo_skill/scripts/service/__init__.py b/examples/todo_skill/scripts/service/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/todo_skill/scripts/service/task_service.py b/examples/todo_skill/scripts/service/task_service.py deleted file mode 100644 index 9b7f3bf..0000000 --- a/examples/todo_skill/scripts/service/task_service.py +++ /dev/null @@ -1,189 +0,0 @@ -"""任务编排层(对齐模板 task_service.py 的职责分工)。 - -- ``cmd_health`` / ``cmd_version`` / ``cmd_config_path`` / ``cmd_init_db``:标准 CLI; -- ``cmd_remind``:演示 async 任务范式(``job_context`` + ``emit`` + ``finish``), - 真实平台会推送到任务中心 UI;本 demo 在 stdout 打印 JSON Lines 模拟。 - -业务内核(CRUD)在 ``todo_service``,本模块只做编排,不复制业务逻辑。 -""" -from __future__ import annotations - -import asyncio -import json -import os -import time -from typing import Any, Optional - -from jiangchang_skill_core import collect_runtime_diagnostics, config, format_runtime_health_lines -from jiangchang_skill_core.activity import JobStopped, emit, finish, job_context - -from db.connection import init_db -from service import todo_service -from util.constants import LOG_LOGGER_NAME, PLATFORM_KIT_MIN_VERSION, SKILL_SLUG, SKILL_VERSION -from util.logging_config import get_skill_logger, setup_skill_logging -from util.runtime_paths import get_db_path, get_skill_root - - -def _get_task_logger(): - try: - return get_skill_logger() - except RuntimeError: - setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME) - return get_skill_logger() - - -# ---------------- 标准命令(sync,当场返回) ---------------- - -def cmd_config_path() -> int: - example_path = os.path.join(get_skill_root(), ".env.example") - env_path = config.get_env_file_path() or "" - print(json.dumps( - {"skill": SKILL_SLUG, "env_path": env_path, "example_path": os.path.abspath(example_path)}, - ensure_ascii=False, - )) - return 0 - - -def cmd_health() -> int: - runtime = collect_runtime_diagnostics( - skill_slug=SKILL_SLUG, - platform_kit_min_version=PLATFORM_KIT_MIN_VERSION, - skill_root=get_skill_root(), - ) - example_path = os.path.join(get_skill_root(), ".env.example") - env_path = config.get_env_file_path() or "" - env_exists = bool(env_path and os.path.isfile(env_path)) - - health_status = "failed" if runtime.has_fatal_issues else "ok" - lines = [ - f"{SKILL_SLUG} health: {health_status}", - *format_runtime_health_lines(runtime), - f"env_path: {env_path}", - f"env_exists: {env_exists}", - f"example_path: {os.path.abspath(example_path)}", - f"db_path: {get_db_path()}", - ] - for line in lines: - print(line) - return 1 if runtime.has_fatal_issues else 0 - - -def cmd_version() -> int: - print(json.dumps({"version": SKILL_VERSION, "skill": SKILL_SLUG}, ensure_ascii=False)) - return 0 - - -def cmd_init_db() -> int: - """幂等创建本地 SQLite 与数据管理元数据。""" - try: - init_db() - except Exception as exc: # CLI 边界统一成可机读失败 - print(json.dumps( - {"ok": False, "skill": SKILL_SLUG, "error": str(exc)}, - ensure_ascii=False, - )) - return 1 - print(json.dumps( - {"ok": True, "skill": SKILL_SLUG, "db_path": get_db_path()}, - ensure_ascii=False, - )) - return 0 - - -# ---------------- 业务命令(sync) ---------------- - -def cmd_list(*, status: Optional[str] = None, limit: int = 100) -> int: - todos = todo_service.list_todos(status=status, limit=limit) - print(json.dumps( - {"count": len(todos), "items": [t.to_dict() for t in todos]}, - ensure_ascii=False, - )) - return 0 - - -def cmd_add(*, title: str, priority: str = "normal", due_at: Optional[int] = None) -> int: - try: - todo = todo_service.add_todo(title=title, priority=priority, due_at=due_at) - except ValueError as exc: - print(f"ERROR:TODO_INVALID: {exc}") - return 1 - print(json.dumps(todo.to_dict(), ensure_ascii=False)) - return 0 - - -def cmd_done(*, todo_ids: list[int]) -> int: - """批量标记完成。返回 JSON 列表,每项含 ok/id/error。""" - results: list[dict] = [] - for tid in todo_ids: - try: - todo = todo_service.mark_done(tid) - results.append({"ok": True, "id": tid, "todo": todo.to_dict()}) - except ValueError as exc: - results.append({"ok": False, "id": tid, "error": str(exc)}) - print(json.dumps({"count": len(results), "items": results}, ensure_ascii=False)) - return 0 if all(r["ok"] for r in results) else 1 - - -def cmd_delete(*, todo_ids: list[int]) -> int: - """批量删除。""" - results: list[dict] = [] - for tid in todo_ids: - deleted = todo_service.delete_todo(tid) - results.append({"ok": deleted, "id": tid}) - print(json.dumps({"count": len(results), "items": results}, ensure_ascii=False)) - return 0 if all(r["ok"] for r in results) else 1 - - -def cmd_stats() -> int: - print(json.dumps(todo_service.stats(), ensure_ascii=False)) - return 0 - - -# ---------------- async 任务范式(demo) ---------------- - -async def _run_remind_async(*, seconds: int) -> dict[str, Any]: - """模拟长耗时任务:每秒 emit 一次进度,最后 finish 终态。 - - 真实平台会推送到任务中心 UI 并支持取消;本 demo 在 stdout 打印 JSON Lines。 - """ - steps = max(1, int(seconds)) - for i in range(1, steps + 1): - await asyncio.sleep(1) - emit(f"提醒任务进度 {i}/{steps}", skill=SKILL_SLUG, stage="remind", progress=i, total=steps) - # 真实业务在这里会查 todos 表挑出 due/pending 项发出提醒;demo 不做真实推送 - summary = todo_service.stats() - return {"remind": True, "duration_seconds": steps, "stats": summary} - - -def cmd_remind(*, seconds: int = 3) -> int: - """演示 async 任务编排。CLI 入口仍走同一内核;Agent 真实调度时 - 通过 ``run_skill_action``(executionProfile: async)触发。""" - log = _get_task_logger() - task_type = "remind" - with job_context(skill=SKILL_SLUG): - log.info("task_start task_type=%s stage=remind seconds=%s", task_type, seconds) - emit("开始执行提醒任务", skill=SKILL_SLUG, stage="remind") - try: - result = asyncio.run(_run_remind_async(seconds=seconds)) - log.info("task_done task_type=%s status=ok", task_type) - finish( - status="ok", - message=f"提醒任务完成,处理 {seconds} 秒", - skill=SKILL_SLUG, - stage="remind", - **result, - ) - print(json.dumps({"ok": True, "task_type": task_type, **result}, ensure_ascii=False)) - return 0 - except JobStopped: - raise - except Exception: - log.exception("task_failed task_type=%s", task_type) - emit("任务失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="remind") - finish( - status="failed", - message="提醒任务执行异常,详见统一日志", - skill=SKILL_SLUG, - stage="remind", - ) - return 1 diff --git a/examples/todo_skill/scripts/service/todo_service.py b/examples/todo_skill/scripts/service/todo_service.py deleted file mode 100644 index d07abb9..0000000 --- a/examples/todo_skill/scripts/service/todo_service.py +++ /dev/null @@ -1,167 +0,0 @@ -"""待办事项领域服务(业务内核)。 - -按模板铁律:「单业务内核、多入口」——CLI / Action / 数据管理按钮 / 定时任务 -都调用这里的同一个函数,禁止按入口分叉业务行为。 - -直接用 sqlite3 做持久化(demo 简化,未抽 repository)。 -""" -from __future__ import annotations - -import time -from dataclasses import dataclass -from typing import Optional - -from db.connection import get_conn - -# 业务常量(不暴露给宿主,技能自定) -STATUS_PENDING = "pending" -STATUS_DONE = "done" -VALID_STATUS = {STATUS_PENDING, STATUS_DONE} - -PRIORITY_HIGH = "high" -PRIORITY_NORMAL = "normal" -PRIORITY_LOW = "low" -VALID_PRIORITY = {PRIORITY_HIGH, PRIORITY_NORMAL, PRIORITY_LOW} - - -@dataclass -class Todo: - id: int - title: str - status: str - priority: str - due_at: Optional[int] - created_at: int - updated_at: int - - def to_dict(self) -> dict: - return { - "id": self.id, - "title": self.title, - "status": self.status, - "priority": self.priority, - "due_at": self.due_at, - "created_at": self.created_at, - "updated_at": self.updated_at, - } - - -def _now() -> int: - return int(time.time()) - - -def add_todo( - *, - title: str, - priority: str = PRIORITY_NORMAL, - due_at: Optional[int] = None, -) -> Todo: - """新增一条待办。领域校验:标题非空、优先级合法。""" - title = (title or "").strip() - if not title: - raise ValueError("标题不能为空") - if priority not in VALID_PRIORITY: - raise ValueError(f"优先级不合法,可选:{sorted(VALID_PRIORITY)}") - now = _now() - conn = get_conn() - try: - cur = conn.cursor() - cur.execute( - "INSERT INTO todos (title, status, priority, due_at, created_at, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?)", - (title, STATUS_PENDING, priority, due_at, now, now), - ) - new_id = cur.lastrowid - conn.commit() - return Todo(new_id, title, STATUS_PENDING, priority, due_at, now, now) - finally: - conn.close() - - -def list_todos(*, status: Optional[str] = None, limit: int = 100) -> list[Todo]: - """列出待办;可按状态过滤。默认按创建时间倒序。""" - sql = "SELECT id, title, status, priority, due_at, created_at, updated_at FROM todos" - params: list = [] - if status: - if status not in VALID_STATUS: - raise ValueError(f"状态不合法,可选:{sorted(VALID_STATUS)}") - sql += " WHERE status = ?" - params.append(status) - sql += " ORDER BY created_at DESC LIMIT ?" - params.append(limit) - conn = get_conn() - try: - cur = conn.cursor() - cur.execute(sql, params) - rows = cur.fetchall() - return [Todo(*row) for row in rows] - finally: - conn.close() - - -def get_todo(todo_id: int) -> Optional[Todo]: - conn = get_conn() - try: - cur = conn.cursor() - cur.execute( - "SELECT id, title, status, priority, due_at, created_at, updated_at " - "FROM todos WHERE id = ?", - (todo_id,), - ) - row = cur.fetchone() - return Todo(*row) if row else None - finally: - conn.close() - - -def mark_done(todo_id: int) -> Todo: - """标记一条待办为完成。""" - todo = get_todo(todo_id) - if not todo: - raise ValueError(f"待办 {todo_id} 不存在") - if todo.status == STATUS_DONE: - raise ValueError(f"待办 {todo_id} 已是完成状态") - now = _now() - conn = get_conn() - try: - cur = conn.cursor() - cur.execute( - "UPDATE todos SET status = ?, updated_at = ? WHERE id = ?", - (STATUS_DONE, now, todo_id), - ) - conn.commit() - todo.status = STATUS_DONE - todo.updated_at = now - return todo - finally: - conn.close() - - -def delete_todo(todo_id: int) -> bool: - """删除一条待办;返回是否真的删了一行。""" - conn = get_conn() - try: - cur = conn.cursor() - cur.execute("DELETE FROM todos WHERE id = ?", (todo_id,)) - conn.commit() - return cur.rowcount > 0 - finally: - conn.close() - - -def stats() -> dict: - """简单统计:用于 ``todo stats`` 命令与 demo 报告。""" - conn = get_conn() - try: - cur = conn.cursor() - cur.execute("SELECT status, COUNT(*) FROM todos GROUP BY status") - by_status = {row[0]: int(row[1]) for row in cur.fetchall()} - cur.execute("SELECT COUNT(*) FROM todos") - total = int(cur.fetchone()[0]) - return { - "total": total, - "pending": by_status.get(STATUS_PENDING, 0), - "done": by_status.get(STATUS_DONE, 0), - } - finally: - conn.close() diff --git a/examples/todo_skill/scripts/util/__init__.py b/examples/todo_skill/scripts/util/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/todo_skill/scripts/util/argparse_zh.py b/examples/todo_skill/scripts/util/argparse_zh.py deleted file mode 100644 index 4012778..0000000 --- a/examples/todo_skill/scripts/util/argparse_zh.py +++ /dev/null @@ -1,11 +0,0 @@ -"""argparse 中文错误说明(与模板一致)。""" -from __future__ import annotations - -import argparse -import sys - - -class ZhArgumentParser(argparse.ArgumentParser): - def error(self, message: str) -> None: - print(f"参数错误:{message}\n请执行:python main.py -h 查看帮助", file=sys.stderr) - self.exit(2) diff --git a/examples/todo_skill/scripts/util/config_bootstrap.py b/examples/todo_skill/scripts/util/config_bootstrap.py deleted file mode 100644 index a12141d..0000000 --- a/examples/todo_skill/scripts/util/config_bootstrap.py +++ /dev/null @@ -1,24 +0,0 @@ -"""配置初始化:从 .env.example 复制到用户 .env,并合并缺失键。 - -行为对齐模板:``main.py`` 与 ``cli.app.main()`` 启动时均会调用一次。 -""" -from __future__ import annotations - -import os - -from jiangchang_skill_core import config - -from util.constants import SKILL_SLUG, SKILL_VERSION -from util.runtime_paths import get_skill_root - - -def bootstrap_skill_config() -> str: - """确保用户 .env 存在,并追加 .env.example 中的新配置项。""" - example_path = os.path.join(get_skill_root(), ".env.example") - if not os.path.isfile(example_path): - # demo 没有强制 .env.example,直接返回空路径 - return "" - env_path = config.ensure_env_file(SKILL_SLUG, example_path) - comment = f"{SKILL_SLUG} v{SKILL_VERSION}" if SKILL_VERSION else None - config.merge_missing_env_keys(example_path, env_path, comment_skill=comment) - return env_path diff --git a/examples/todo_skill/scripts/util/constants.py b/examples/todo_skill/scripts/util/constants.py deleted file mode 100644 index 4808b3b..0000000 --- a/examples/todo_skill/scripts/util/constants.py +++ /dev/null @@ -1,11 +0,0 @@ -"""技能标识与版本(demo:待办事项)。 - -真实技能需保证 slug = 目录名 = DB 文件名 = ``metadata.openclaw.slug``。 -""" -from __future__ import annotations - -# 英文 kebab-case 机器标识;与目录名、actions.json 的 ``skill`` 字段一致 -SKILL_SLUG = "todo-skill" -SKILL_VERSION = "0.1.0" -LOG_LOGGER_NAME = "openclaw.skill.todo_skill" -PLATFORM_KIT_MIN_VERSION = "1.2.11" diff --git a/examples/todo_skill/scripts/util/logging_config.py b/examples/todo_skill/scripts/util/logging_config.py deleted file mode 100644 index a00dcc8..0000000 --- a/examples/todo_skill/scripts/util/logging_config.py +++ /dev/null @@ -1,29 +0,0 @@ -"""日志:直接用 stdlib logging(真实模板用 jiangchang_skill_core.unified_logging)。 - -为保持与模板 ``from util.logging_config import get_skill_logger, setup_skill_logging`` -一致的调用面,本 demo 提供同名薄封装。 -""" -from __future__ import annotations - -import logging - -from util.constants import LOG_LOGGER_NAME - -_configured = False - - -def setup_skill_logging(skill_slug: str, logger_name: str) -> None: - global _configured - if _configured: - return - handler = logging.StreamHandler() - handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")) - logger = logging.getLogger(logger_name) - logger.setLevel(logging.INFO) - if not logger.handlers: - logger.addHandler(handler) - _configured = True - - -def get_skill_logger() -> logging.Logger: - return logging.getLogger(LOG_LOGGER_NAME) diff --git a/examples/todo_skill/scripts/util/runtime_paths.py b/examples/todo_skill/scripts/util/runtime_paths.py deleted file mode 100644 index c9228d5..0000000 --- a/examples/todo_skill/scripts/util/runtime_paths.py +++ /dev/null @@ -1,32 +0,0 @@ -"""技能数据目录与 DB 路径解析(简化版)。 - -真实模板用 ``jiangchang_skill_core.runtime_env``;本 demo 复用其 mock。 -数据目录 = ``{data_root}/{user_id}/{slug}/``,DB 即 ``{slug}.db``。 -""" -from __future__ import annotations - -import os - -from jiangchang_skill_core.runtime_env import get_data_root, get_user_id - -from util.constants import SKILL_SLUG - -_SCRIPTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - - -def get_skill_root() -> str: - """技能根目录(含 SKILL.md / scripts/ 的目录)。""" - return os.path.dirname(_SCRIPTS_DIR) - - -def get_skill_data_dir() -> str: - """本技能数据目录:``{data_root}/{user_id}/{slug}/``。""" - path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG) - os.makedirs(path, exist_ok=True) - return path - - -def get_db_path(filename: str | None = None) -> str: - """SQLite 文件路径,默认 ``{slug}.db``。""" - name = filename or f"{SKILL_SLUG}.db" - return os.path.join(get_skill_data_dir(), name) diff --git a/examples/todo_skill/tests/__init__.py b/examples/todo_skill/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/todo_skill/tests/conftest.py b/examples/todo_skill/tests/conftest.py deleted file mode 100644 index d398718..0000000 --- a/examples/todo_skill/tests/conftest.py +++ /dev/null @@ -1,25 +0,0 @@ -"""pytest 全局 fixture:把 ``scripts/`` 注入 sys.path 并隔离数据目录。""" -from __future__ import annotations - -import os -import sys -import tempfile - -import pytest - -_SCRIPTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts") -if _SCRIPTS_DIR not in sys.path: - sys.path.insert(0, _SCRIPTS_DIR) - - -@pytest.fixture -def isolated_env(tmp_path, monkeypatch): - """隔离的数据根目录,避免污染本机真实数据。""" - data_root = tmp_path / "data_root" - data_root.mkdir() - monkeypatch.setenv("JIANGCHANG_DATA_ROOT", str(data_root)) - monkeypatch.setenv("JIANGCHANG_USER_ID", "test_user") - # 重置 config mock 的缓存,让下一次 get 重新读 env - from jiangchang_skill_core import config - config.reset_cache() - return data_root diff --git a/examples/todo_skill/tests/test_smoke.py b/examples/todo_skill/tests/test_smoke.py deleted file mode 100644 index b6ea747..0000000 --- a/examples/todo_skill/tests/test_smoke.py +++ /dev/null @@ -1,168 +0,0 @@ -"""冒烟测试:覆盖 demo 关键路径。 - -不依赖 pytest,可用 ``python -m unittest discover -s tests`` 跑。 -但 fixture 用法对齐 pytest;这里把 fixture 用 ``setUp`` 模拟。 -""" -from __future__ import annotations - -import io -import json -import os -import sys -import tempfile -import unittest -from contextlib import redirect_stderr, redirect_stdout - -# 注入 scripts/ 到 sys.path -_HERE = os.path.dirname(os.path.abspath(__file__)) -_SCRIPTS_DIR = os.path.join(os.path.dirname(_HERE), "scripts") -if _SCRIPTS_DIR not in sys.path: - sys.path.insert(0, _SCRIPTS_DIR) - -# 与 main.py 同样的 mock 注入:没有真实平台库时启用 mock -if not os.environ.get("TODO_SKILL_USE_REAL_PLATFORM"): - try: - import jiangchang_skill_core # noqa: F401 - except ImportError: - _mock_platform = os.path.join(_SCRIPTS_DIR, "_mock_platform") - if _mock_platform not in sys.path: - sys.path.insert(0, _mock_platform) - - -class IsolatedEnv: - """临时数据根目录,测试结束自动清理。""" - - def __init__(self): - self._tmp = tempfile.mkdtemp(prefix="todo_test_") - self._prev_root = os.environ.get("JIANGCHANG_DATA_ROOT") - self._prev_user = os.environ.get("JIANGCHANG_USER_ID") - os.environ["JIANGCHANG_DATA_ROOT"] = os.path.join(self._tmp, "data") - os.environ["JIANGCHANG_USER_ID"] = "test_user" - os.makedirs(os.environ["JIANGCHANG_DATA_ROOT"], exist_ok=True) - - def reset_config(self): - from jiangchang_skill_core import config - config.reset_cache() - - def __enter__(self): - self.reset_config() - return self - - def __exit__(self, *exc): - if self._prev_root is None: - os.environ.pop("JIANGCHANG_DATA_ROOT", None) - else: - os.environ["JIANGCHANG_DATA_ROOT"] = self._prev_root - if self._prev_user is None: - os.environ.pop("JIANGCHANG_USER_ID", None) - else: - os.environ["JIANGCHANG_USER_ID"] = self._prev_user - return False - - -def _run_cli(argv: list[str]) -> tuple[int, str, str]: - """调用 cli.app.main,返回 (rc, stdout, stderr)。""" - from cli.app import main - out = io.StringIO() - err = io.StringIO() - with redirect_stdout(out), redirect_stderr(err): - rc = main(argv) - return rc, out.getvalue(), err.getvalue() - - -class TestCliSmoke(unittest.TestCase): - def test_empty_argv_shows_usage_and_nonzero(self): - rc, out, _ = _run_cli([]) - self.assertEqual(rc, 1) - self.assertIn("待办事项 demo", out) - - def test_version_json(self): - with IsolatedEnv(): - rc, out, _ = _run_cli(["version"]) - self.assertEqual(rc, 0) - payload = json.loads(out.strip()) - self.assertEqual(payload["skill"], "todo-skill") - self.assertIn("version", payload) - - def test_init_db_creates_tables_and_is_idempotent(self): - with IsolatedEnv(): - rc, out, _ = _run_cli(["init-db"]) - self.assertEqual(rc, 0) - payload = json.loads(out.strip()) - self.assertTrue(payload["ok"]) - self.assertTrue(os.path.isfile(payload["db_path"])) - # 再来一次应仍成功 - rc2, out2, _ = _run_cli(["init-db"]) - self.assertEqual(rc2, 0) - self.assertEqual(json.loads(out2.strip())["db_path"], payload["db_path"]) - - def test_add_list_done_stats_full_flow(self): - with IsolatedEnv(): - _run_cli(["init-db"]) - rc, out, _ = _run_cli(["todo", "add", "任务A", "-p", "high"]) - self.assertEqual(rc, 0) - item = json.loads(out.strip()) - self.assertEqual(item["title"], "任务A") - self.assertEqual(item["status"], "pending") - todo_id = item["id"] - - rc, out, _ = _run_cli(["todo", "list"]) - self.assertEqual(rc, 0) - listing = json.loads(out.strip()) - self.assertEqual(listing["count"], 1) - - rc, out, _ = _run_cli(["todo", "done", str(todo_id)]) - self.assertEqual(rc, 0) - result = json.loads(out.strip()) - self.assertTrue(result["items"][0]["ok"]) - self.assertEqual(result["items"][0]["todo"]["status"], "done") - - rc, out, _ = _run_cli(["todo", "stats"]) - stats = json.loads(out.strip()) - self.assertEqual(stats, {"total": 1, "pending": 0, "done": 1}) - - def test_done_batch_with_comma_ids(self): - with IsolatedEnv(): - _run_cli(["init-db"]) - _run_cli(["todo", "add", "T1"]) - _run_cli(["todo", "add", "T2"]) - rc, out, _ = _run_cli(["todo", "done", "1,2"]) - self.assertEqual(rc, 0) - result = json.loads(out.strip()) - self.assertEqual(result["count"], 2) - self.assertTrue(all(r["ok"] for r in result["items"])) - - def test_done_invalid_id_returns_error(self): - with IsolatedEnv(): - _run_cli(["init-db"]) - rc, out, _ = _run_cli(["todo", "done", "999"]) - self.assertEqual(rc, 1) - result = json.loads(out.strip()) - self.assertFalse(result["items"][0]["ok"]) - - def test_remind_async_pattern_emits_progress(self): - with IsolatedEnv(): - _run_cli(["init-db"]) - _run_cli(["todo", "add", "T1"]) - rc, out, _ = _run_cli(["remind", "-s", "1"]) - self.assertEqual(rc, 0) - # stdout 应含 emit 进度事件(mock 把 activity 打到 stdout) - self.assertIn('"event": "emit"', out) - self.assertIn('"event": "finish"', out) - self.assertIn('"status": "ok"', out) - # 最后一行是 CLI 结果 JSON - last_line = [l for l in out.strip().splitlines() if l.startswith("{")][-1] - payload = json.loads(last_line) - self.assertTrue(payload["ok"]) - self.assertEqual(payload["task_type"], "remind") - - def test_health_returns_zero(self): - with IsolatedEnv(): - rc, out, _ = _run_cli(["health"]) - self.assertEqual(rc, 0) - self.assertIn("todo-skill health: ok", out) - self.assertIn("platform_kit_version:", out) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/cli/app.py b/scripts/cli/app.py index 172b404..58e1079 100644 --- a/scripts/cli/app.py +++ b/scripts/cli/app.py @@ -41,19 +41,19 @@ def _parse_ids(raw: str) -> list[int]: def _print_full_usage() -> None: - print("待办事项 add-todo-all(main.py)可用命令:") + print("通用业务技能模板(main.py)可用命令:") print(" python main.py init-db # 初始化数据库与元数据") - print(" python main.py run add <title> [-p high|normal|low] [-d <unix秒>") - print(" python main.py run list [-s pending|done] [-n <limit>]") - print(" python main.py run done <id>") - print(" python main.py run delete <id>") - print(" python main.py run stats") + print(" python main.py todo add <title> [-p high|normal|low] [-d <unix秒>") + print(" python main.py todo list [-s pending|done] [-n <limit>]") + print(" python main.py todo done <id>") + print(" python main.py todo delete <id>") + print(" python main.py todo stats") print(" python main.py remind [-s <秒>]") print(" python main.py health / version / config-path") def _add_run_subparser(sub) -> None: - sp = sub.add_parser("run", help="待办事项操作") + sp = sub.add_parser("todo", help="待办事项操作") todo_sub = sp.add_subparsers(dest="todo_cmd", required=True, parser_class=ZhArgumentParser) p = todo_sub.add_parser("add", help="新增一条待办") @@ -114,6 +114,9 @@ def build_parser() -> ZhArgumentParser: sp = sub.add_parser("config-path", help="输出 .env 与模板路径") sp.set_defaults(handler=lambda _a: cmd_config_path()) + sp = sub.add_parser("run", help="运行占位符") + sp.set_defaults(handler=lambda _a: cmd_run()) + return p diff --git a/scripts/db/connection.py b/scripts/db/connection.py index 79b07aa..c9fe017 100644 --- a/scripts/db/connection.py +++ b/scripts/db/connection.py @@ -16,6 +16,9 @@ from db.display_metadata import ( upsert_table_metadata, ) from util.runtime_paths import get_db_path +from db.timestamp_columns import init_updated_at_trigger + +ALL_BUSINESS_TABLES:tuple[str, ...] = ("todos") # ---- 业务表 DDL(待办事项)---- # 字段顺序 = 数据管理列展示顺序;状态/优先级用 options_json 给宿主做下拉筛选 @@ -26,8 +29,8 @@ CREATE TABLE IF NOT EXISTS todos ( status TEXT NOT NULL DEFAULT 'pending', priority TEXT NOT NULL DEFAULT 'normal', due_at INTEGER, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ) """ @@ -40,6 +43,12 @@ def get_conn() -> sqlite3.Connection: return sqlite3.connect(get_db_path()) +def _init_business_timestamp_triggers(cursor) -> None: + """ for table_name in ALL_BUSINESS_TABLES: + init_updated_at_trigger(cursor, table_name) """ + init_updated_at_trigger(cursor, "todos") + + def _seed_todos_display_metadata(cursor) -> None: """写 ``todos`` 表与字段的中文展示元数据。""" upsert_table_metadata( @@ -70,11 +79,11 @@ def _seed_todos_display_metadata(cursor) -> None: "visible": 1, "searchable": 1, "editable": 0, "display_type": "enum", "options_json": '[{"value":"high","label":"高"},{"value":"normal","label":"中"},{"value":"low","label":"低"}]'}, {"column_name": "due_at", "display_name": "截止时间", "description": "Unix 秒;可空", - "visible": 1, "searchable": 1, "editable": 0, "display_type": "datetime_unix_seconds"}, + "visible": 1, "searchable": 1, "editable": 0, "display_type": "datetime_unix_seconds","default":"(unixepoch())"}, {"column_name": "created_at", "display_name": "创建时间", "description": "Unix 秒", - "visible": 1, "searchable": 0, "editable": 0, "display_type": "datetime_unix_seconds"}, + "visible": 1, "searchable": 0, "editable": 0, "display_type": "datetime_unix_seconds","default":"(unixepoch())"}, {"column_name": "updated_at", "display_name": "更新时间", "description": "Unix 秒", - "visible": 0, "searchable": 0, "editable": 0, "display_type": "datetime_unix_seconds"}, + "visible": 0, "searchable": 0, "editable": 0, "display_type": "datetime_unix_seconds","default":"(unixepoch())"}, ], ) @@ -89,8 +98,11 @@ def init_db() -> None: # 2. 业务表 cur.execute(_TODOS_DDL) cur.execute(_TODOS_INDEX) + _init_business_timestamp_triggers(cur) # 3. 业务表展示元数据 _seed_todos_display_metadata(cur) + + conn.commit() finally: conn.close() diff --git a/scripts/service/task_service.py b/scripts/service/task_service.py index 14ad4b7..6d056e1 100644 --- a/scripts/service/task_service.py +++ b/scripts/service/task_service.py @@ -13,6 +13,7 @@ import json import os import time from typing import Any, Optional +import uuid from jiangchang_skill_core import collect_runtime_diagnostics, config, format_runtime_health_lines from jiangchang_skill_core.activity import JobStopped, emit, finish, job_context @@ -68,7 +69,7 @@ async def _run_template_demo(target: Optional[str], input_id: Optional[str]) -> def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int: """通用任务执行入口模板。复制后请实现真实业务逻辑。""" log = _get_task_logger() - task_type = "cmd_run" + task_type = "demo" with job_context(skill=SKILL_SLUG): log.info( @@ -223,7 +224,7 @@ def cmd_add(*, title: str, priority: str = "normal", due_at: Optional[int] = Non try: todo = todo_service.add_todo(title=title, priority=priority, due_at=due_at) except ValueError as exc: - print(f"ERROR:TODO_INVALID: {exc}") + _get_task_logger().error(f"ERROR:TODO_INVALID: {exc}") return 1 _get_task_logger().info(json.dumps(todo.to_dict(), ensure_ascii=False)) return 0 @@ -266,7 +267,7 @@ async def _run_remind_async(*, seconds: int) -> dict[str, Any]: """ steps = max(1, int(seconds)) for i in range(1, steps + 1): - await asyncio.sleep(1) + await interruptible_sleep(1) emit(f"提醒任务进度 {i}/{steps}", skill=SKILL_SLUG, stage="remind", progress=i, total=steps) # 真实业务在这里会查 todos 表挑出 due/pending 项发出提醒;demo 不做真实推送 summary = todo_service.stats() diff --git a/scripts/service/todo_service.py b/scripts/service/todo_service.py index d07abb9..dbc95a6 100644 --- a/scripts/service/todo_service.py +++ b/scripts/service/todo_service.py @@ -10,8 +10,12 @@ from __future__ import annotations import time from dataclasses import dataclass from typing import Optional +import logging from db.connection import get_conn +from util.logging_config import get_skill_logger, setup_skill_logging + +logger = logging.getLogger(__name__) # 业务常量(不暴露给宿主,技能自定) STATUS_PENDING = "pending" @@ -59,8 +63,10 @@ def add_todo( """新增一条待办。领域校验:标题非空、优先级合法。""" title = (title or "").strip() if not title: + logger.warning("标题不能为空") raise ValueError("标题不能为空") if priority not in VALID_PRIORITY: + logger.warning(f"优先级不合法,可选:{sorted(VALID_PRIORITY)}") raise ValueError(f"优先级不合法,可选:{sorted(VALID_PRIORITY)}") now = _now() conn = get_conn() @@ -84,6 +90,7 @@ def list_todos(*, status: Optional[str] = None, limit: int = 100) -> list[Todo]: params: list = [] if status: if status not in VALID_STATUS: + logger.warning(f"状态不合法,可选:{sorted(VALID_STATUS)}") raise ValueError(f"状态不合法,可选:{sorted(VALID_STATUS)}") sql += " WHERE status = ?" params.append(status) @@ -118,8 +125,10 @@ def mark_done(todo_id: int) -> Todo: """标记一条待办为完成。""" todo = get_todo(todo_id) if not todo: + logger.warning(f"待办 {todo_id} 不存在") raise ValueError(f"待办 {todo_id} 不存在") if todo.status == STATUS_DONE: + logger.warning(f"待办 {todo_id} 已是完成状态") raise ValueError(f"待办 {todo_id} 已是完成状态") now = _now() conn = get_conn() diff --git a/scripts/util/constants.py b/scripts/util/constants.py index e1f7f3c..e554c1b 100644 --- a/scripts/util/constants.py +++ b/scripts/util/constants.py @@ -9,3 +9,7 @@ SKILL_SLUG = "add-todo-all" SKILL_VERSION = "0.1.0" LOG_LOGGER_NAME = "openclaw.skill.add-todo-all" PLATFORM_KIT_MIN_VERSION = "1.2.11" + +#定义业务表 +TODOS_TABLE = "todos" +BUSINESS_TABLES:tuple[str, ...] = (TODOS_TABLE,) diff --git a/tests/test_actions_manifest.py b/tests/test_actions_manifest.py index 4416723..00aabab 100644 --- a/tests/test_actions_manifest.py +++ b/tests/test_actions_manifest.py @@ -355,8 +355,10 @@ class TestActionsManifest(unittest.TestCase): def test_bindable_business_tables_helper_loads_template_db(self) -> None: """helper 本身必须可用:模板默认无业务表,隔离库仍应可读且不含元数据表。""" + from util.constants import BUSINESS_TABLES + bindable = _load_bindable_business_tables() - self.assertEqual(bindable, set()) + self.assertEqual(bindable, set(BUSINESS_TABLES)) self.assertNotIn("_jiangchang_tables", bindable) self.assertNotIn("_jiangchang_columns", bindable) diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 46a9ee0..61bb379 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -96,7 +96,7 @@ class TestCliSmoke(unittest.TestCase): config.reset_cache() buf = io.StringIO() with redirect_stdout(buf), redirect_stderr(io.StringIO()): - rc = main("run") + rc = main(["run"]) self.assertNotEqual(rc, 0) self.assertIn("模板", buf.getvalue()) finally: diff --git a/tests/test_db_smoke.py b/tests/test_db_smoke.py index feedb2a..551d933 100644 --- a/tests/test_db_smoke.py +++ b/tests/test_db_smoke.py @@ -12,6 +12,7 @@ class TestDbSmoke(unittest.TestCase): with IsolatedDataRoot(): from db.connection import get_conn, init_db from db.display_metadata import METADATA_TABLES, iter_user_tables + from util.constants import BUSINESS_TABLES init_db() init_db() @@ -26,9 +27,9 @@ class TestDbSmoke(unittest.TestCase): self.assertIn("_jiangchang_tables", tables) self.assertIn("_jiangchang_columns", tables) self.assertNotIn("task_logs", tables) - self.assertEqual(list(iter_user_tables(cur)), []) - leftover = tables - METADATA_TABLES - self.assertEqual(leftover, set()) + self.assertEqual(set(iter_user_tables(cur)), set(BUSINESS_TABLES)) + leftover = set(tables) - set(METADATA_TABLES) + self.assertEqual(leftover, set(BUSINESS_TABLES)) finally: conn.close() diff --git a/tests/test_display_metadata.py b/tests/test_display_metadata.py index 470b168..caa2400 100644 --- a/tests/test_display_metadata.py +++ b/tests/test_display_metadata.py @@ -19,6 +19,7 @@ class TestDisplayMetadata(unittest.TestCase): def test_init_db_metadata_idempotent(self) -> None: with IsolatedDataRoot(): from db.connection import get_conn, init_db + from util.constants import BUSINESS_TABLES init_db() init_db() @@ -27,9 +28,9 @@ class TestDisplayMetadata(unittest.TestCase): try: cur = conn.cursor() cur.execute("SELECT COUNT(*) FROM _jiangchang_tables") - self.assertEqual(int(cur.fetchone()[0]), 0) + self.assertEqual(int(cur.fetchone()[0]), len(BUSINESS_TABLES)) cur.execute("SELECT COUNT(*) FROM _jiangchang_columns") - self.assertEqual(int(cur.fetchone()[0]), 0) + self.assertGreater(int(cur.fetchone()[0]), 0) cur.execute( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_logs'" ) @@ -203,12 +204,13 @@ class TestDisplayMetadata(unittest.TestCase): with IsolatedDataRoot(): from db.connection import get_conn, init_db from db.display_metadata import iter_user_tables + from util.constants import BUSINESS_TABLES init_db() conn = get_conn() try: tables = list(iter_user_tables(conn.cursor())) - self.assertEqual(tables, []) + self.assertEqual(set(tables), set(BUSINESS_TABLES)) finally: conn.close() diff --git a/tests/test_scaffold_guard.py b/tests/test_scaffold_guard.py index d2a2e43..958db76 100644 --- a/tests/test_scaffold_guard.py +++ b/tests/test_scaffold_guard.py @@ -10,8 +10,8 @@ import unittest from _support import get_skill_root TEMPLATE_MARKER = ".openclaw-skill-template" -TEMPLATE_SLUG_KEBAB = "add-todo-all" -TEMPLATE_SLUG_UNDERSCORE = "add_todo_all" +TEMPLATE_SLUG_KEBAB = "your-skill-slug" +TEMPLATE_SLUG_UNDERSCORE = "your_skill_slug" SCAFFOLD_TEST_SLUG = "query-demo-records" SCAFFOLD_TEST_SLUG_UNDERSCORE = "query_demo_records" diff --git a/tests/test_skill_metadata.py b/tests/test_skill_metadata.py index cc4a44f..fa2d2ce 100644 --- a/tests/test_skill_metadata.py +++ b/tests/test_skill_metadata.py @@ -44,9 +44,9 @@ class TestSkillMetadata(unittest.TestCase): if skill_dir == "skill-template": # 模板仓库本体:允许占位 slug - self.assertEqual(SKILL_SLUG, "add-todo-all") + self.assertEqual(SKILL_SLUG, "your-skill-slug") else: - self.assertNotEqual(SKILL_SLUG, "add-todo-all", "复制为真实技能后不得保留占位 slug") + self.assertNotEqual(SKILL_SLUG, "your-skill-slug", "复制为真实技能后不得保留占位 slug") self.assertEqual(SKILL_SLUG, skill_dir) self.assertEqual(slug_md, skill_dir) diff --git a/tests/test_slug_naming.py b/tests/test_slug_naming.py index bfc1f5b..c431b8d 100644 --- a/tests/test_slug_naming.py +++ b/tests/test_slug_naming.py @@ -30,8 +30,8 @@ class TestSlugNaming(unittest.TestCase): self.assertEqual(classify_slug_form(segments, slug="generate-article-all"), "scope") def test_classify_legacy_exempt(self) -> None: - segments = parse_slug_segments("add-todo-all") - self.assertEqual(classify_slug_form(segments, slug="add-todo-all"), "legacy_exempt") + segments = parse_slug_segments("your-skill-slug") + self.assertEqual(classify_slug_form(segments, slug="your-skill-slug"), "legacy_exempt") def test_valid_hyphenated_platform_slug(self) -> None: slug = "fill-export-declaration-single-window" @@ -79,7 +79,7 @@ class TestSlugNaming(unittest.TestCase): self.assertTrue(any("48" in err for err in errors)) def test_legacy_exempt_set(self) -> None: - self.assertIn("add-todo-all", LEGACY_EXEMPT_SLUGS) + self.assertIn("your-skill-slug", LEGACY_EXEMPT_SLUGS) self.assertIn("account-manager", LEGACY_EXEMPT_SLUGS)