diff --git a/.github/workflows/release_skill.yaml b/.github/workflows/release_skill.yaml index 90076f1..270c091 100644 --- a/.github/workflows/release_skill.yaml +++ b/.github/workflows/release_skill.yaml @@ -1,27 +1,13 @@ -# ----------------------------------------------------------------------------- -# 技能发布工作流(占位) -# ----------------------------------------------------------------------------- -# 不同组织使用不同的复用工作流、制品格式与加密策略。 -# 使用本模板时,请将下面 jobs 整段替换为你们自己的 workflow, -# 或删除本文件若暂不需要 GitHub Actions。 -# -# 设计原则(行业常见): -# - 通过 tag 触发发布(如 v*) -# - 制品与版本号可追溯,与 SKILL.md 中 version 对齐 -# ----------------------------------------------------------------------------- - -name: skill-release-placeholder - +name: 技能自动化发布 on: push: tags: ["v*"] jobs: release: - runs-on: ubuntu-latest - steps: - - name: Placeholder - run: | - echo "Replace this workflow with your organization's reusable workflow." - echo "Example pattern: jobs.release.uses: //.github/workflows/reusable-release-skill.yaml@ref" - exit 0 + uses: admin/jiangchang-platform-kit/.github/workflows/reusable-release-skill.yaml@main + secrets: + PYARMOR_REG_B64: ${{ secrets.PYARMOR_REG_B64 }} + with: + artifact_platform: windows + pyarmor_platform: windows.x86_64 diff --git a/.gitignore b/.gitignore index 670cd00..b4b21ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,3 @@ __pycache__/ *.py[cod] -*.pyo -.Python -.venv/ -venv/ -.env -.env.* -.claude/ \ No newline at end of file +.env \ No newline at end of file diff --git a/README.md b/README.md index c47e1c1..ae207e2 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,47 @@ -# Claw 技能项目模板(通用) +# OpenClaw 技能开发模板 -本目录是一个**与具体业务无关**的技能(Skill)工程骨架,供团队在各类 **Claw / Agent 宿主**(桌面端、网关、IDE 插件等)上交付可安装技能包时参考。 +这是一个**规范化的新技能模板仓库**,用于复制出新的 skill 项目;它本身**不是业务 skill**。 -## 你从这里能得到什么 +## 模板目标 -- **行业标准对齐**:技能以「清单文件 + 可执行入口 + 文档」组织——与常见的 Agent Skill、CLI 工具包、内部自动化脚本仓库的惯例一致;不绑定某一厂商私有协议。 -- **可移植约定**:数据目录、用户隔离、兄弟技能路径等通过**环境变量契约**描述(见 `docs/RUNTIME.md`);不同宿主只需注入同名变量或做一层别名映射。 -- **低学习成本**:每个文件顶部与关键步骤都有注释;按下面顺序做即可跑通第一个命令。 +- 对齐当前规范 skill 的目录结构:`assets/`、`references/`、`scripts/`、`tests/`、`evals/` +- 对齐当前规范脚手架分层:`scripts/cli`、`scripts/db`、`scripts/service`、`scripts/util`、`scripts/jiangchang_skill_core` +- 提供最小可运行入口:`python scripts/main.py health` / `version` +- 让新技能从一开始就按规范落地,不再沿用旧模板的 `docs/`、`optional/`、`skill_main.py` 结构 -## 建议的上手顺序(约 15~30 分钟) +## 新技能使用步骤 -1. **复制本模板**为新仓库或新目录,全局把占位符 `your-skill-slug` / `Your Skill Display Name` 换成你的技能标识(与 `SKILL.md` 里 `metadata.skill.slug` 一致)。 -2. **阅读** `docs/RUNTIME.md`,确认你的宿主会注入哪些环境变量;若宿主使用另一套名字,在宿主侧做映射,或改 `optional/paths_snippet.py` 中的读取顺序(文件内有说明)。 -3. **本地试跑**:`python scripts/skill_main.py health` 应输出成功信息。 -4. **扩展子命令**:在 `scripts/skill_main.py` 的 `dispatch` 中增加分支;业务逻辑放在同目录其它模块或子包中,保持入口轻薄。 -5. **编写/调整 `SKILL.md`**:只改「何时触发、如何调用、参数含义」,不要写实现细节;实现细节放在 `docs/` 或代码注释里。 -6. **发布**:若使用 GitHub Actions,编辑 `.github/workflows/release_skill.yaml`,把 `uses:` 指向**你们组织**的复用工作流;若不用 CI,可删除该目录。 -7. **一键打标签推送(与匠厂 monorepo 对齐)**:在技能仓库根目录执行 `.\release.ps1`(需与 `jiangchang-platform-kit` 位于同一父目录,以便调用 `..\jiangchang-platform-kit\tools\release.ps1`)。支持 `-DryRun`、`-AutoCommit`、`-CommitMessage` 等参数,与 `account-manager` / `sohu-publisher` 一致。 +1. 复制本目录为新的 skill 仓库。 +2. 全局替换 `your-skill-slug`、`your-platform-key`、`您的技能显示名称`、`你的平台名` 等占位内容。 +3. 修改 `SKILL.md`、`references/` 和 `scripts/util/constants.py`。 +4. 在 `scripts/service/` 中补业务 service 与真正的发布/执行逻辑。 +5. 用 `python scripts/main.py health` 和 `python scripts/main.py version` 做最小验证。 -## 目录一览 +开发教程入口: -| 路径 | 作用 | +- `references/DEVELOPMENT.md`:给技术人员的完整开发步骤说明 + +## 目录说明 + +| 路径 | 用途 | |------|------| -| `SKILL.md` | 技能清单(YAML 头 + Markdown 正文),供宿主与协作者阅读 | -| `release.ps1` | 转调平台套件的发布脚本(提交/推送/语义化 tag);依赖并列的 `jiangchang-platform-kit` | -| `scripts/skill_main.py` | 推荐唯一 CLI 入口;含 `health` / `version` 示例 | -| `docs/RUNTIME.md` | 环境与目录契约(多宿主通用) | -| `docs/SKILL_TYPES.md` | 常见技能形态与自检清单 | -| `docs/PORTABILITY.md` | 多 Claw 宿主差异与兼容建议 | -| `optional/` | 可选复制进项目的片段(路径、SQLite 示例),**不默认 import** | +| `SKILL.md` | 技能清单与触发说明模板 | +| `assets/` | 示例输出与轻量 schema | +| `references/` | 面向用户与编排的文档模板 | +| `scripts/` | 规范分层后的代码骨架 | +| `tests/` | 单元测试或最小回归测试 | +| `evals/` | 人工/半自动评估材料 | +| `.github/workflows/release_skill.yaml` | 标准发布工作流 | +| `release.ps1` | 对齐现有 skill 的发布脚本入口 | -## 不要做的事 +## 最小命令 -- 不要在模板中提交真实密钥、真实业务表结构或平台专用逻辑。 -- 不要把模板改成只支持某一种宿主;特殊项写在 `docs/PORTABILITY.md` 的「宿主附录」中。 +```bash +python scripts/main.py health +python scripts/main.py version +``` -## 版本 +## 注意 -模板自身版本见 `SKILL.md` 的 `version` 字段;与你技能的业务版本一致更新即可。 +- 不要再往模板里引入旧式 `docs/` 或 `optional/` 目录。 +- 新技能若不需要某些目录,也建议先保留结构,再按实际业务填充内容。 diff --git a/SKILL.md b/SKILL.md index d144e7b..97a4f82 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,56 +1,42 @@ --- -# --------------------------------------------------------------------------- -# 技能清单(Skill Manifest) -# 行业常见做法:YAML 头描述元数据,正文 Markdown 描述「何时用、怎么调」。 -# 不同 Claw 宿主解析字段可能略有差异;可移植标识请使用 metadata.skill.slug。 -# --------------------------------------------------------------------------- -name: 您的技能显示名称 -description: 一句话说明技能做什么(给编排层与人类阅读,不写实现细节)。 +name: 技能开发模板(复制后请修改) +description: "这是 OpenClaw 技能开发模板仓库,不直接作为业务技能发布。复制为新技能仓库后,按本模板替换 slug、名称、说明、CLI 子命令与 service 实现。" version: 1.0.0 author: 深圳匠厂科技有限公司 metadata: - skill: - # 机器可读、稳定标识:小写字母、数字、短横线;与数据子目录名一致。 + openclaw: slug: your-skill-slug emoji: "📦" category: "通用" -# 宿主若限制可调用的工具类型,在此列出(按宿主文档填写)。 allowed-tools: - bash --- -# Your Skill Display Name +# 技能开发模板(skill-template) -## 使用时机 +这是一个**用于复制的新技能模板**,不是业务技能本身。新建技能时,应复制本仓库结构,再把占位内容替换成你的真实业务实现。 - +## 模板使用方式 -- 示例:用户说「检查技能是否可用」「运行某某任务」 +1. 复制目录为你的新 skill 仓库。 +2. 全局替换 `your-skill-slug`、`技能开发模板(复制后请修改)` 等占位词。 +3. 按 `references/CLI.md`、`scripts/` 分层与 `README.md` 的说明补业务逻辑。 -## 执行步骤 +## 目录约定 - +- 根目录结构参考现有规范技能:`assets/`、`references/`、`scripts/`、`tests/`、`evals/`。 +- CLI 入口固定为 `scripts/main.py`。 +- 业务逻辑按 `cli / db / service / util / jiangchang_skill_core` 分层。 -### 健康检查(推荐自动化先跑) +## 最小命令 ```bash -python3 {baseDir}/scripts/skill_main.py health +python {baseDir}/scripts/main.py health +python {baseDir}/scripts/main.py version ``` -### 查看版本 +## 重要说明 -```bash -python3 {baseDir}/scripts/skill_main.py version -``` - -### 扩展子命令 - -在 `scripts/skill_main.py` 中增加新的子命令分支,并在此处追加对应示例命令与参数说明。 - -## 环境依赖 - -详见本仓库 `docs/RUNTIME.md`(`CLAW_DATA_ROOT`、`CLAW_USER_ID` 等)。 - -## 数据与隐私 - -本技能若产生持久化数据,应仅写入 `{CLAW_DATA_ROOT}/{CLAW_USER_ID}/your-skill-slug/` 下;不得将用户数据提交到版本库。 +- 复制后请同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION`。 +- 如技能无需持久化,可保留 `db/` 目录但不主动调用。 +- 面向用户与编排的文档写在 `references/`,不要再新增旧式 `docs/` / `optional/` 结构。 diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..9be5713 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,7 @@ +# assets + +- `examples/`:CLI 成功输出形状示例(虚构路径与数据)。 +- `schemas/`:轻量 JSON Schema 示例(`additionalProperties: true` 允许扩展字段)。 + +- 面向用户的介绍见 `references/README.md`。 +- 面向编排/CLI 的细节见 `references/CLI.md`、`RUNTIME.md`、`SCHEMA.md`。 diff --git a/assets/examples/README.md b/assets/examples/README.md new file mode 100644 index 0000000..d037552 --- /dev/null +++ b/assets/examples/README.md @@ -0,0 +1,4 @@ +# 示例 JSON + +- `version-response.json`:`python main.py version` 成功时 stdout 单行对象的形状参考。 +- `log-get-response.json`:若技能有日志表,`log-get` 成功时单条对象的形状参考。 diff --git a/assets/examples/log-get-response.json b/assets/examples/log-get-response.json new file mode 100644 index 0000000..75b7d12 --- /dev/null +++ b/assets/examples/log-get-response.json @@ -0,0 +1,10 @@ +{ + "id": 1, + "account_id": "demo_account_1", + "article_id": 12, + "article_title": "示例标题", + "status": "published", + "error_msg": null, + "created_at": "2026-04-01T10:00:00", + "updated_at": "2026-04-01T10:00:00" +} diff --git a/assets/examples/version-response.json b/assets/examples/version-response.json new file mode 100644 index 0000000..8b5992a --- /dev/null +++ b/assets/examples/version-response.json @@ -0,0 +1,4 @@ +{ + "version": "1.0.0", + "skill": "your-skill-slug" +} diff --git a/assets/schemas/publish-log-record.schema.json b/assets/schemas/publish-log-record.schema.json new file mode 100644 index 0000000..f6b33ad --- /dev/null +++ b/assets/schemas/publish-log-record.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://openclaw.local/skill-template/publish-log-record.schema.json", + "title": "PublishLogRecord", + "description": "发布型技能 log-get 返回的单条日志对象模板", + "type": "object", + "required": ["id", "account_id", "article_id", "article_title", "status", "created_at", "updated_at"], + "properties": { + "id": { "type": "integer" }, + "account_id": { "type": ["string", "integer"] }, + "article_id": { "type": "integer" }, + "article_title": { "type": "string" }, + "status": { "type": "string" }, + "error_msg": { "type": ["string", "null"] }, + "created_at": { "type": ["string", "null"] }, + "updated_at": { "type": ["string", "null"] } + }, + "additionalProperties": true +} diff --git a/docs/LOGGING.md b/docs/LOGGING.md deleted file mode 100644 index f5a1212..0000000 --- a/docs/LOGGING.md +++ /dev/null @@ -1,59 +0,0 @@ -# OpenClaw 技能日志约定(各技能统一) - -本文档为**规范**:新技能与 `account-manager` 等现有技能应遵循同一套路径、格式与环境变量,便于排查与运维。 - -## 目录与文件 - -- 日志目录与技能数据目录同级: - - `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/logs/` -- 主日志文件(示例): - - `{...}/account-manager/logs/account-manager.log` -- `skill_slug` 与 `SKILL.md` 中 `metadata.openclaw.slug` 一致。 - -## 轮转与编码 - -- 使用 Python `logging.handlers.TimedRotatingFileHandler`,`when=midnight`,保留约 30 个历史文件(可按技能调整 `backupCount`)。 -- 文件编码 **UTF-8**。 -- 首次写日志前创建 `logs/` 目录(`exist_ok=True`)。 - -## 日志格式(行文本) - -推荐单行格式(与 `account-manager` 一致): - -```text -%(asctime)s | %(levelname)-8s | %(name)s | %(message)s -``` - -`datefmt` 建议:`%Y-%m-%dT%H:%M:%S`(本地时间)。 - -## Logger 命名 - -- 使用分层命名,便于按前缀过滤,例如:`openclaw.skill.account_manager`。 -- 子进程若需写同一文件,可使用子 logger:`openclaw.skill.account_manager.login_child`,仍追加到**同一日志文件**(`logging.FileHandler(..., encoding='utf-8')` 追加模式)。 - -## 环境变量(全局) - -| 变量 | 说明 | -|------|------| -| `JIANGCHANG_LOG_LEVEL` | 默认 `INFO`;调试轮询等可设 `DEBUG`。 | -| `JIANGCHANG_LOG_TO_STDERR` | 设为 `1`/`true` 时,将 `WARNING` 及以上同时输出到 `stderr`(不替代文件)。 | -| `JIANGCHANG_{SKILL}_LOG_FILE` | 可选,覆盖该技能主日志**绝对路径**(需自行保证目录可写)。`account-manager` 使用 `JIANGCHANG_ACCOUNT_MANAGER_LOG_FILE`。 | - -## 级别与内容建议 - -- **INFO**:子命令入口、成功结束(含关键业务 id,避免仅打「成功」)。 -- **WARNING**:可恢复问题、校验失败、登录未检测到。 -- **ERROR**:依赖缺失、不可继续的异常。 -- **DEBUG**:高频轮询状态(URL 列表、规则命中说明);默认关闭。 -- 日志中可能含 **手机号、路径等敏感信息**,注意文件权限与留存策略;必要时仅记录后四位或 hash(按合规要求)。 - -## 与标准输出的关系 - -- 面向用户的 `print`(成功提示、`ERROR:` 前缀)可保留;**诊断细节以日志为准**。 -- 登录失败等场景可在终端提示日志文件路径,便于用户打开 `*.log`。 - -## 参考实现 - -- `account-manager/scripts/main.py`:`get_skill_logs_dir`、`get_skill_logger`、`get_skill_log_file_path`、login 子进程内 `FileHandler` 追加同一文件。 - -新技能可复制上述函数结构,替换 `SKILL_SLUG` 与 `LOG_LOGGER_NAME` 即可。 diff --git a/docs/PORTABILITY.md b/docs/PORTABILITY.md deleted file mode 100644 index 54a21a6..0000000 --- a/docs/PORTABILITY.md +++ /dev/null @@ -1,56 +0,0 @@ -# 多宿主(多 Claw)可移植性说明 - -## 设计目标 - -同一套技能仓库应能在**不同 Claw 实现**下工作,只要宿主满足本文档的**最小契约**:能启动进程、传入环境变量、并把 `SKILL.md` 提供给编排层阅读。 - -## 行业上常见的「技能包」形态 - -- **声明式清单**(Markdown + YAML 头):描述名称、描述、版本、工具权限、触发场景。 -- **可执行入口**:一至多个脚本/二进制,由宿主通过 `bash` / `python` 等调用。 -- **用户数据与代码分离**:持久化数据落在用户可写目录,不写在安装目录内。 - -本模板按上述惯例组织;具体宿主如何解析 `SKILL.md` 的 YAML 键名,以各宿主文档为准。 - -## 标识技能:`metadata.skill` - -本模板在 `SKILL.md` 中使用 **`metadata.skill.slug`** 作为**可移植**的机器可读标识(短横线命名,如 `my-skill`)。 - -若你的宿主仍要求其它键名(例如历史实现里的嵌套字段),请在宿主侧做**映射**,或在 `SKILL.md` 中**并列声明**两组 metadata(保持 `slug` 值一致)。不要在业务代码里写死某一宿主品牌名。 - -## 环境变量:推荐前缀 `CLAW_*` - -为减少对单一产品名的耦合,模板文档与可选片段推荐使用: - -| 变量 | 含义 | -|------|------| -| `CLAW_DATA_ROOT` | 用户数据根目录(多技能共享的上一级) | -| `CLAW_USER_ID` | 当前工作空间或用户标识,用于数据隔离 | -| `CLAW_SKILLS_ROOT` | 可选;多个技能并排安装时的根目录,便于 `subprocess` 调用兄弟技能 | - -宿主若已使用其它名称,推荐在**启动子进程时**注入别名,例如: - -- 将宿主内部的「数据根」映射为 `CLAW_DATA_ROOT` -- 将宿主内部「用户 ID」映射为 `CLAW_USER_ID` - -这样技能脚本无需分支判断宿主品牌。 - -## 路径布局约定(逻辑路径) - -在 `CLAW_DATA_ROOT` 与 `CLAW_USER_ID` 可用时,本技能推荐将私有数据放在: - -```text -{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/ -``` - -其中 `skill_slug` 与 `SKILL.md` 中 `metadata.skill.slug` 一致。若环境变量缺失,`optional/paths_snippet.py` 中提供了**仅用于开发机**的 fallback(见该文件注释),生产环境应由宿主注入变量。 - -## 发布与制品 - -不同组织对加密、签名、制品格式要求不同。`.github/workflows/release_skill.yaml` 仅作占位:**务必替换**为你们自己的复用工作流或删除。模板不假设必须使用某一家的发布流水线。 - -## 自检 - -- [ ] `SKILL.md` 中 `slug` 与目录名/制品名策略是否与宿主一致 -- [ ] 宿主文档要求的环境变量是否已全部注入 -- [ ] 是否在文档中说明了「未注入变量时的行为」(拒绝运行 / 本地 fallback) diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md deleted file mode 100644 index 004e8ce..0000000 --- a/docs/RUNTIME.md +++ /dev/null @@ -1,55 +0,0 @@ -# 运行时契约(环境变量与目录) - -本文档定义技能进程**建议依赖**的外部条件,便于不同 Claw 宿主统一接入。业务技能应**读取环境变量**,而不是在代码里写死路径或用户名。 - -## 必需程度说明 - -- **强烈建议**:生产环境由宿主注入;技能应对缺失给出明确错误提示,避免静默写到意外目录。 -- **可选**:没有时技能仍可部分运行(例如只读 `health`)。 - -## 变量一览 - -### `CLAW_DATA_ROOT`(强烈建议) - -用户数据根。多个技能、多个用户的数据都在此根之下分区。 - -- 典型场景:组织策略指定的盘符路径或 `~/.your-org-data`。 -- 未设置时:技能**不应**猜测网络盘;开发机 fallback 仅限 `optional/paths_snippet.py` 中说明的情形。 - -### `CLAW_USER_ID`(强烈建议) - -当前会话所代表的用户或工作空间 ID(字符串)。与数据隔离强相关。 - -- 用于拼接:`{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/` -- 未设置时:可用匿名占位(如 `_anon`)**仅用于开发**,生产应显式注入。 - -### `CLAW_SKILLS_ROOT`(可选) - -多个技能以并列目录安装时的根路径,例如: - -```text -{CLAW_SKILLS_ROOT}/skill-a/scripts/... -{CLAW_SKILLS_ROOT}/skill-b/scripts/... -``` - -编排型技能若需要通过子进程调用兄弟技能,应基于该变量定位脚本,避免写死绝对路径。 - -## 本技能推荐的数据目录 - -```text -{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/ -``` - -- `skill_slug`:与 `SKILL.md` 内 `metadata.skill.slug` 一致。 -- 在此目录下可放置 SQLite 文件、缓存、上传临时文件等;**不要**向版本库提交该目录内容。 - -## 标准输出约定(建议) - -为便于宿主与自动化解析,建议: - -- 致命错误:单行前缀 `ERROR:`,例如 `ERROR:MISSING_ENV_CLAW_DATA_ROOT` -- 成功:人类可读一行或多行;若有机读需求,可用 `JSON` 单行输出并在 `SKILL.md` 中说明格式。 - -## 与具体宿主的关系 - -若某宿主文档规定了另一套变量名,应在**宿主启动技能子进程时**注入为本文档中的 `CLAW_*` 名称,或在技能内使用一层极薄的 `getenv` 封装(见 `optional/paths_snippet.py` 注释示例)。**不要在业务模块中散落多套变量名判断。** diff --git a/docs/SKILL_TYPES.md b/docs/SKILL_TYPES.md deleted file mode 100644 index 80c8590..0000000 --- a/docs/SKILL_TYPES.md +++ /dev/null @@ -1,33 +0,0 @@ -# 技能形态与自检清单(无业务) - -开发前先选定形态,避免把「编排、存储、浏览器」混在一个脚本里难以测试。 - -## 类型 A:无状态工具型 - -- **特征**:不持久化用户数据,或只读配置文件;输入输出主要在 stdin/stdout。 -- **数据目录**:通常不需要 `CLAW_DATA_ROOT` 下的专属库;若需要缓存,仍建议放在契约目录下。 -- **自检**:离线可跑;`health` 不访问网络也可成功。 - -## 类型 B:本地持久化型 - -- **特征**:使用 SQLite、本地文件等保存用户数据。 -- **数据目录**:必须使用 `{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/`。 -- **自检**:首次运行自动建库/建表;文档中说明库文件路径与备份方式。 - -## 类型 C:编排型(调用其它技能或外部 CLI) - -- **特征**:自身逻辑薄,主要 `subprocess` 或 HTTP 调用其它组件。 -- **依赖**:在 `SKILL.md` 中明确写出**先决条件**(兄弟技能已安装、某 CLI 在 PATH 等)。 -- **自检**:`health` 可检查兄弟可执行文件是否存在;缺失时打印清晰错误。 - -## 类型 D:混合型 - -- **特征**:既有本地存储,又调用外部能力。 -- **建议**:拆模块(存储 / 编排 / 领域逻辑),入口脚本只做参数解析与调度。 - -## 发布前通用自检 - -- [ ] `SKILL.md` 中触发条件与示例命令与实际入口一致 -- [ ] 未注入 `CLAW_DATA_ROOT` / `CLAW_USER_ID` 时行为已文档化 -- [ ] 不向仓库提交用户数据、密钥、大型二进制 -- [ ] 错误信息包含「如何修复」(缺什么环境变量、缺哪个依赖) diff --git a/evals/README.md b/evals/README.md new file mode 100644 index 0000000..fadf59e --- /dev/null +++ b/evals/README.md @@ -0,0 +1,5 @@ +# evals + +这里放人工评估脚本、示例输入输出、回归验收清单等材料。 + +模板仓库保持最小占位,不再放旧式杂项代码片段。 diff --git a/optional/README.md b/optional/README.md deleted file mode 100644 index 9432856..0000000 --- a/optional/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# optional/ 目录说明 - -本目录下的文件**不会**被 `scripts/skill_main.py` 自动引用。 - -## 为什么要单独放 - -- 避免模板入口脚本依赖过多,同事从「最小 health」起步时零干扰。 -- 需要时**整文件复制**到 `scripts/` 或你们自己的包路径下,再按文件头注释改名、改常量。 - -## 文件列表 - -| 文件 | 用途 | -|------|------| -| `paths_snippet.py` | `CLAW_*` 数据目录解析与 fallback 说明 | -| `sqlite_minimal.py` | 无业务含义的 SQLite 建表示例 | - -复制后务必全局替换 `your-skill-slug` 与表名,避免与别的技能冲突。 diff --git a/optional/paths_snippet.py b/optional/paths_snippet.py deleted file mode 100644 index 21d3d96..0000000 --- a/optional/paths_snippet.py +++ /dev/null @@ -1,92 +0,0 @@ -# -*- coding: utf-8 -*- -""" -可选片段:路径与环境变量(请复制到 scripts/ 或作为独立模块使用) -================================================================ - -【用途】 - 统一解析「用户数据根」「用户 ID」「本技能私有目录」,避免在业务代码里重复 os.getenv。 - -【使用步骤】 - 1. 将本文件复制到 skills/your-slug/scripts/paths.py(或任意模块名)。 - 2. 把 SKILL_SLUG 改为与 SKILL.md 中 metadata.skill.slug 一致。 - 3. 在业务代码中: from paths import get_skill_data_dir (按实际包路径调整) - -【可移植性】 - - 优先读取标准名 CLAW_*(见 docs/RUNTIME.md)。 - - 若你的组织在宿主侧仍使用历史变量名,可在此文件 _aliases 列表中追加 (标准名, 备选名), - 由宿主注入其一即可;不要在业务里再写第三套名字。 - -【注意】 - - 未设置 CLAW_DATA_ROOT 时的 fallback 仅适合开发机;生产环境应由宿主注入。 -""" - -from __future__ import annotations - -import os -import sys - -# TODO: 复制本文件后改为你的 slug(与 SKILL.md metadata.skill.slug 一致) -SKILL_SLUG = "your-skill-slug" - - -def _getenv_first(names: tuple[str, ...]) -> str: - """按顺序读取多个环境变量名,返回第一个非空值。""" - for n in names: - v = (os.getenv(n) or "").strip() - if v: - return v - return "" - - -def get_data_root() -> str: - """ - 用户数据根目录。 - 顺序:CLAW_DATA_ROOT → (可选)宿主别名,见下方元组。 - 若皆空:Windows 默认 D:\\claw-data;其它系统默认 ~/.claw-data —— 仅开发便利,生产请注入 CLAW_DATA_ROOT。 - """ - root = _getenv_first( - ( - "CLAW_DATA_ROOT", - # 在此追加组织内别名,例如 "MYORG_USER_DATA_ROOT", - ) - ) - if root: - return root - if sys.platform == "win32": - return r"D:\claw-data" - return os.path.join(os.path.expanduser("~"), ".claw-data") - - -def get_user_id() -> str: - """当前用户或工作空间 ID;未设置时用 _anon(仅开发)。""" - uid = _getenv_first( - ( - "CLAW_USER_ID", - # 在此追加别名,例如 "MYORG_WORKSPACE_ID", - ) - ) - return uid or "_anon" - - -def get_skill_data_dir() -> str: - """ - 本技能可写目录:{数据根}/{用户ID}/{skill_slug}/ - 会自动 os.makedirs(..., exist_ok=True)。 - """ - path = os.path.join(get_data_root(), get_user_id(), SKILL_SLUG) - os.makedirs(path, exist_ok=True) - return path - - -def get_skills_root() -> str: - """ - 可选:并列安装的多技能根目录。 - 未设置 CLAW_SKILLS_ROOT 时,默认使用本文件所在仓库的上一级目录的上一级 - (即:.../skill-template/scripts/ → 误推断;复制后请改为 BASE_DIR 逻辑)。 - """ - root = _getenv_first(("CLAW_SKILLS_ROOT",)) - if root: - return root - # 模板占位:脚本位于 scripts/,仓库根为 dirname(dirname(__file__)) - here = os.path.dirname(os.path.abspath(__file__)) - return os.path.dirname(here) diff --git a/optional/sqlite_minimal.py b/optional/sqlite_minimal.py deleted file mode 100644 index 89c0b9b..0000000 --- a/optional/sqlite_minimal.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- coding: utf-8 -*- -""" -可选片段:最小 SQLite 示例(请复制后按需修改) -============================================== - -【用途】 - 演示「单表 + 自增主键 + INTEGER 时间戳」的一种严谨写法;与具体业务无关。 - -【使用步骤】 - 1. 复制到 scripts/db_example.py(或并入你的模块)。 - 2. 修改 TABLE_SQL 中的表名与字段;保持时间字段为 INTEGER Unix 秒(UTC)若需跨时区一致。 - 3. 在入口脚本中仅在需要持久化时 import。 - -【注意】 - - 不在模板中做迁移兼容;schema 变更请用你们组织的迁移策略。 - - 数据库文件路径建议:get_skill_data_dir() / "skill.db"(paths_snippet 中函数)。 -""" - -from __future__ import annotations - -import sqlite3 -import time - -# 示例表:与任何业务无关 -TABLE_SQL = """ -CREATE TABLE IF NOT EXISTS template_audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, -- 自增主键 - action TEXT NOT NULL, -- 动作标识,如 health_ok - created_at INTEGER NOT NULL -- Unix 秒 UTC -); -""" - - -def connect(db_path: str) -> sqlite3.Connection: - return sqlite3.connect(db_path) - - -def init_db(conn: sqlite3.Connection) -> None: - conn.execute(TABLE_SQL) - conn.commit() - - -def record_action(conn: sqlite3.Connection, action: str) -> None: - conn.execute( - "INSERT INTO template_audit (action, created_at) VALUES (?, ?)", - (action, int(time.time())), - ) - conn.commit() diff --git a/references/CLI.md b/references/CLI.md new file mode 100644 index 0000000..5f3c1f1 --- /dev/null +++ b/references/CLI.md @@ -0,0 +1,36 @@ +# skill-template CLI 模板 + +将 `{baseDir}` 替换为技能根目录(含 `SKILL.md`、`scripts/` 的目录)。所有命令通过 `python {baseDir}/scripts/main.py` 调用。 + +## 最小命令 + +```bash +python {baseDir}/scripts/main.py health +python {baseDir}/scripts/main.py version +``` + +## 若你的技能是发布型 + +建议继续扩展这些子命令: + +```bash +python {baseDir}/scripts/main.py publish +python {baseDir}/scripts/main.py publish -a <账号id> -i <文章id> +python {baseDir}/scripts/main.py logs +python {baseDir}/scripts/main.py log-get +``` + +## 若你的技能依赖兄弟技能 + +并列技能与 `{baseDir}` 同级时,兄弟技能路径写为: + +```bash +python {baseDir}/../content-manager/scripts/main.py ... +python {baseDir}/../account-manager/scripts/main.py ... +``` + +## 模板约定 + +- 最小模板至少保留 `health` / `version` +- 发布型技能建议使用 `publish` / `logs` / `log-get` +- 不要再用旧模板的 `scripts/skill_main.py` diff --git a/references/DEVELOPMENT.md b/references/DEVELOPMENT.md new file mode 100644 index 0000000..632b543 --- /dev/null +++ b/references/DEVELOPMENT.md @@ -0,0 +1,440 @@ +# 技能开发教程 + +这份文档是给**技术人员**看的,目标不是解释概念,而是让你拿到 `skill-template` 后,可以**一步一步开发出一个新的 skill**。 + +本文默认你开发的是当前最常见的一类技能: + +- 有明确的 `scripts/main.py` CLI 入口 +- 可能需要读写本地 SQLite +- 可能需要调用兄弟技能 +- 业务逻辑主要放在 `scripts/service/` + +如果你开发的是发布型 skill,这个模板就是直接可用的起点。 + +## 1. 先理解模板的定位 + +`skill-template` 不是业务 skill,它只是一个**新 skill 仓库模板**。 + +你不应该直接在这个仓库里开发业务,而应该: + +1. 复制这个目录 +2. 改成新 skill 的目录名 +3. 把占位内容替换掉 +4. 再开始写业务逻辑 + +## 2. 新 skill 的标准目录结构 + +复制模板后,你应该保留下面这套结构: + +```text +your-skill/ +├─ .github/ +├─ assets/ +├─ evals/ +├─ references/ +├─ scripts/ +│ ├─ cli/ +│ ├─ db/ +│ ├─ jiangchang_skill_core/ +│ ├─ service/ +│ └─ util/ +├─ tests/ +├─ .gitignore +├─ README.md +├─ release.ps1 +└─ SKILL.md +``` + +各目录职责如下: + +- `assets/`:放示例输出、schema、静态说明资源 +- `references/`:放研发和编排需要长期维护的文档 +- `scripts/`:放真正的代码 +- `tests/`:放自动化测试 +- `evals/`:放人工验收材料、评估清单、示例场景 + +## 3. `scripts/` 内部如何分层 + +`scripts/` 不是乱放 Python 文件,而是按职责分层: + +```text +scripts/ +├─ main.py +├─ cli/ +├─ db/ +├─ jiangchang_skill_core/ +├─ service/ +└─ util/ +``` + +每层的职责要明确: + +- `main.py` + 作用:唯一 CLI 入口 + 只做启动、编码兼容、引入 `cli.app` + +- `cli/` + 作用:参数解析、命令分发、用法说明 + 不写具体业务逻辑 + +- `db/` + 作用:SQLite 连接、建表、repository + 不写页面操作和业务编排 + +- `service/` + 作用:核心业务逻辑 + 比如发布流程、调用兄弟技能、浏览器自动化 + +- `util/` + 作用:常量、日志、路径、时间工具、通用帮助函数 + +- `jiangchang_skill_core/` + 作用:运行时环境与统一日志副本 + 一般按现有规范技能复制,不要自己乱改结构 + +## 4. 开发一个新 skill 的标准步骤 + +下面这套顺序建议严格按步骤做,不要一上来就直接写 `service`。 + +### 第一步:复制模板并改目录名 + +例如你要开发 `weibo-publisher`: + +1. 复制 `skill-template` +2. 新目录改成 `weibo-publisher` +3. 初始化为独立 git 仓库 +4. 关联它自己的远端仓库 + +目录名要和 skill slug 对齐,后面很多地方都依赖这个命名。 + +### 第二步:先改 4 个最关键的标识 + +复制后优先改下面这些地方: + +1. `SKILL.md` +2. `scripts/util/constants.py` +3. `references/` 下的文案 +4. `scripts/service/` 下的平台占位文件名 + +最先要统一的是: + +- 技能中文名 +- `slug` +- 平台中文名 +- 平台内部键 +- 日志 logger 名 + +## 5. 哪些占位内容必须替换 + +复制后,至少要全局检查并替换下面这些内容: + +- `your-skill-slug` +- `your-platform-key` +- `技能开发模板(复制后请修改)` +- `你的平台名` +- `platform_playwright.py` +- `openclaw.skill.your_skill_slug` + +如果你是做发布型 skill,通常还要替换: + +- `publish` 命令中的中文提示 +- `references/CLI.md` 的命令示例 +- `references/README.md` 的用户话术 +- `references/SCHEMA.md` 的数据库文件名 + +## 6. `SKILL.md` 应该怎么写 + +`SKILL.md` 是技能清单,不是设计文档。 + +应该重点写: + +- 技能名称 +- 技能描述 +- slug +- category +- dependencies +- 何时使用本技能 +- 对用户的引导话术 +- CLI 使用原则 + +不要在 `SKILL.md` 里写大量实现细节。 +实现细节放在: + +- `references/` +- 代码注释 +- `service/` 实现里 + +## 7. `references/` 应该放什么 + +`references/` 是当前规范 skill 的文档中心,建议至少有这些: + +- `README.md` + 面向内部说明和技能作用介绍 + +- `CLI.md` + 写清楚命令、参数、默认值、兄弟技能调用方式 + +- `RUNTIME.md` + 写清楚运行时目录、环境变量、入口约定 + +- `SCHEMA.md` + 写清楚数据库路径、核心表结构、日志表结构 + +- `DEVELOPMENT.md` + 写给技术人员的开发教程,也就是本文档 + +如果后面某个 skill 需要更细的说明,可以再加: + +- `ERRORS.md` +- `INTEGRATION.md` +- `PLATFORMS.md` + +## 8. `assets/` 应该放什么 + +`assets/` 不放业务代码,只放辅助材料。 + +建议放: + +- `examples/` + 比如 `version` 输出示例、`log-get` 输出示例 + +- `schemas/` + 比如日志记录、机读 JSON 的 schema + +不要把正式研发文档放到 `assets/`。 +文档应该进 `references/`。 + +## 9. `cli` 层怎么写 + +建议保持一个原则: + +- `cli` 只负责解析参数和路由 +- `service` 才负责干活 + +也就是说,`cli/app.py` 的职责是: + +1. 打印帮助 +2. 定义 `publish / logs / log-get / health / version` +3. 把参数转交给 `service.publish_service` + +不要在 `cli/app.py` 里直接写: + +- 浏览器自动化 +- 子进程调用兄弟技能 +- SQLite 逻辑 + +## 10. `service` 层怎么写 + +`service` 是核心层。 + +通常可以这样拆: + +- `publish_service.py` + 放命令编排、参数兜底、结果分流 + +- `sibling_bridge.py` + 放兄弟技能调用,例如调 `account-manager`、`content-manager` + +- `*_playwright.py` + 放浏览器后台自动化 + +- `entitlement_service.py` + 放鉴权逻辑 + +### 一个很重要的原则 + +不要把所有逻辑都堆进一个文件。 + +推荐流向是: + +`cli.app` -> `service.publish_service` -> `service.sibling_bridge` / `service.xxx_playwright` -> `db` + +## 11. `db` 层怎么写 + +如果你的 skill 需要记录日志或本地状态,建议: + +- `db/connection.py` + 只做连接和建表 + +- `db/publish_logs_repository.py` + 只做增删查改 + +不要在 `db` 层里: + +- 调浏览器 +- 打印用户提示 +- 拼接业务流程 + +## 12. 如何接兄弟技能 + +如果 skill 要依赖兄弟 skill,不要在业务代码里写死绝对路径。 + +统一通过: + +- `scripts/util/runtime_paths.py` +- `scripts/service/sibling_bridge.py` + +来做调用。 + +常见调用对象是: + +- `account-manager` +- `content-manager` + +调用原则: + +1. 通过 `get_skills_root()` 找到兄弟技能根目录 +2. 再拼出对应 `scripts/main.py` +3. 用子进程调用 +4. 机器可读输出优先 JSON + +## 13. 如何开发发布型 skill + +如果你开发的是 publisher 类 skill,建议按这个顺序做: + +1. 先把目录结构搭完整 +2. 先让 `health` / `version` 跑通 +3. 再让 `publish_service.py` 的骨架跑通 +4. 再接 `sibling_bridge.py` +5. 最后再写 `*_playwright.py` + +不要一开始就直接写页面选择器。 + +推荐先确保这些基础能力正常: + +- 能取到账号 +- 能取到文章 +- 能写日志 +- CLI 子命令通了 +- 错误返回值格式定好了 + +然后再进浏览器自动化。 + +## 14. 本地开发的最小验证顺序 + +建议每次新 skill 开发时按下面顺序验证: + +### 1. 验证入口 + +```bash +python scripts/main.py health +python scripts/main.py version +``` + +### 2. 验证 CLI 路由 + +```bash +python scripts/main.py -h +python scripts/main.py publish -h +``` + +### 3. 验证本地日志与数据库 + +如果是发布型 skill,再继续: + +```bash +python scripts/main.py logs +python scripts/main.py log-get 1 +``` + +### 4. 最后再验证真实业务 + +比如: + +```bash +python scripts/main.py publish +``` + +## 15. 发布前检查清单 + +每个新 skill 发布前,建议技术人员逐条确认: + +- [ ] 目录结构符合当前模板 +- [ ] `SKILL.md` 中 slug、名称、描述都已替换 +- [ ] `scripts/util/constants.py` 已修改 +- [ ] `references/CLI.md` 示例命令已改成真实命令 +- [ ] `service` 下的平台文件名已改对 +- [ ] 没有残留旧平台名 +- [ ] `health` / `version` 可运行 +- [ ] `.gitignore` 生效,没有把 `__pycache__` 提交进去 +- [ ] `release.ps1` 存在 +- [ ] `.github/workflows/release_skill.yaml` 存在 + +## 16. 常见错误 + +### 错误 1:只改了目录名,没改 slug + +表现: + +- 数据目录不对 +- 版本输出不对 +- 日志名不对 + +要检查: + +- `SKILL.md` +- `scripts/util/constants.py` + +### 错误 2:平台中文名改了,内部键没改 + +表现: + +- 兄弟技能筛选账号失败 +- 发布命令走错平台 + +要检查: + +- `publish_service.py` +- `sibling_bridge.py` +- `references/CLI.md` + +### 错误 3:把业务逻辑写进 CLI + +表现: + +- 文件越来越乱 +- 不方便测 +- 参数和业务耦合太重 + +要改回: + +- `cli` 只做参数解析 +- `service` 才做业务 + +### 错误 4:保留了旧模板结构 + +表现: + +- 仓库同时存在 `docs/`、`optional/`、`skill_main.py` +- 技术人员不知道看哪一套 + +现在的新模板原则是: + +- 不做旧结构兼容 +- 统一走 `references/` + `scripts/main.py` + +## 17. 推荐开发顺序总结 + +如果让一个新人照着做,我建议他按这个顺序: + +1. 复制模板并改目录名 +2. 改 `SKILL.md` +3. 改 `scripts/util/constants.py` +4. 改 `references/` +5. 改 `scripts/cli/app.py` +6. 改 `scripts/service/` +7. 跑 `health` / `version` +8. 再做业务联调 +9. 最后 release + +## 18. 这份模板的底线要求 + +以后新建 skill,至少要满足这几点: + +- 目录结构统一 +- 入口统一为 `scripts/main.py` +- 文档统一放 `references/` +- 业务核心逻辑统一放 `scripts/service/` +- 不再使用旧模板历史结构 + +如果做不到这些,后面 skill 一多,就会越来越乱。 diff --git a/references/README.md b/references/README.md new file mode 100644 index 0000000..3e3807e --- /dev/null +++ b/references/README.md @@ -0,0 +1,30 @@ +--- +description: "这是规范化的新技能模板说明,不直接作为业务技能使用。复制后请替换技能名、平台名、CLI 示例与 service 实现。" +--- + +# 技能模板说明 + +这个仓库是**给开发者复制的新技能模板**,不是终端用户直接调用的业务 skill。 + +## 它提供什么 + +- 标准目录结构 +- 最小 CLI 入口 +- 发布型技能常见的日志表骨架 +- `service` 层占位模块 +- 与现有规范 skill 一致的发布脚本与 GitHub workflow + +## 复制后你需要改什么 + +- `SKILL.md` 中的名称、描述、slug、触发说明 +- `references/CLI.md` 里的命令示例 +- `scripts/util/constants.py` 中的 slug / 版本 / logger 名 +- `scripts/service/` 下的真实业务实现 + +## 不建议再保留的旧结构 + +- 旧模板里的 `docs/` +- 旧模板里的 `optional/` +- 旧入口 `scripts/skill_main.py` + +新模板统一使用 `scripts/main.py` 作为入口。 diff --git a/references/RUNTIME.md b/references/RUNTIME.md new file mode 100644 index 0000000..0d5b529 --- /dev/null +++ b/references/RUNTIME.md @@ -0,0 +1,40 @@ +# 运行时约定 + +## 目录结构 + +新技能建议采用以下根目录结构: + +- `assets/` +- `references/` +- `scripts/` +- `tests/` +- `evals/` + +## `scripts/` 分层 + +- `scripts/main.py`:唯一 CLI 入口 +- `scripts/cli/`:参数解析与命令分发 +- `scripts/db/`:SQLite 或本地持久化层 +- `scripts/service/`:业务用例与外部交互 +- `scripts/util/`:通用工具、常量、日志、路径 +- `scripts/jiangchang_skill_core/`:运行时与统一日志副本 + +## 数据路径 + +推荐: + +```text +{CLAW_DATA_ROOT}/{CLAW_USER_ID}/{skill_slug}/ +``` + +数据库文件建议: + +```text +{...}/{skill_slug}.db +``` + +## 编码与输出 + +- Windows 终端建议在 `scripts/main.py` 里做 UTF-8 stdout/stderr 包装 +- 机读输出优先单行 JSON +- 错误前缀建议统一 `ERROR:` diff --git a/references/SCHEMA.md b/references/SCHEMA.md new file mode 100644 index 0000000..c3e59a3 --- /dev/null +++ b/references/SCHEMA.md @@ -0,0 +1,23 @@ +# 数据存储模板 + +## 数据库路径 + +`{DATA_ROOT}/{USER_ID}/your-skill-slug/your-skill-slug.db` + +## 发布型技能推荐日志表 + +| 字段 | 说明 | +|------|------| +| `id` | 自增主键 | +| `account_id` | 账号 id | +| `article_id` | 文章 id | +| `article_title` | 标题快照 | +| `status` | `published` / `failed` / `require_login` | +| `error_msg` | 错误说明 | +| `created_at` | Unix 时间戳 | +| `updated_at` | Unix 时间戳 | + +## 模板原则 + +- 模板不做历史迁移兼容设计 +- 新 skill 直接从当前 schema 起步 diff --git a/scripts/cli/app.py b/scripts/cli/app.py new file mode 100644 index 0000000..c1e6361 --- /dev/null +++ b/scripts/cli/app.py @@ -0,0 +1,104 @@ +"""CLI:argparse 与分发模板。""" + +from __future__ import annotations + +import argparse +import sys +from typing import List, Optional + +from service.publish_service import ( + cmd_health, + cmd_log_get, + cmd_logs, + cmd_publish, + cmd_version, +) +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 _handle_publish(args: argparse.Namespace) -> int: + tail = [str(x).strip() for x in (args.publish_tail or []) if str(x).strip()] + if len(tail) > 2: + print("❌ 参数过多。") + print("用法:python main.py publish [账号id [文章id]] | publish [-a 账号id] [-i 文章id]") + return 1 + + t_acc: Optional[str] = None + t_art: Optional[str] = None + if len(tail) == 2: + t_acc, t_art = tail[0], tail[1] + elif len(tail) == 1: + if tail[0].isdigit(): + t_art = tail[0] + else: + t_acc = tail[0] + + pick_a = _cli_str_or_none(getattr(args, "account_id", None)) + pick_i = _cli_str_or_none(getattr(args, "article_id", None)) + acc = pick_a or t_acc + art = pick_i or t_art + return cmd_publish(account_id=acc, article_id=art) + + +def _print_full_usage() -> None: + print("模板技能(main.py)可用命令:") + print(" python main.py publish [账号id [文章id]] [-a 账号] [-i 文章id]") + print(" python main.py logs [--limit N] [--status s] [--account-id a]") + print(" python main.py log-get ") + print(" python main.py health") + print(" python main.py version") + + +def build_parser() -> ZhArgumentParser: + p = ZhArgumentParser( + prog="main.py", + description="模板技能:发布命令骨架、日志查询、健康检查、版本输出。", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + sub = p.add_subparsers(dest="cmd", required=True, parser_class=ZhArgumentParser) + + sp = sub.add_parser("publish", help="发布型技能命令骨架") + sp.add_argument("--account-id", "-a", default=None, metavar="账号id") + sp.add_argument("--article-id", "-i", default=None, metavar="文章id") + sp.add_argument("publish_tail", nargs="*", metavar="位置参数") + sp.set_defaults(handler=_handle_publish) + + sp = sub.add_parser("logs", help="查看发布记录") + sp.add_argument("--limit", type=int, default=10) + sp.add_argument("--status", default=None) + sp.add_argument("--account-id", default=None) + sp.set_defaults(handler=lambda a: cmd_logs(limit=a.limit, status=a.status, account_id=a.account_id)) + + sp = sub.add_parser("log-get", help="按 log_id 查看单条发布记录(JSON)") + sp.add_argument("log_id") + sp.set_defaults(handler=lambda a: cmd_log_get(a.log_id)) + + 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()) + return p + + +def main(argv: Optional[List[str]] = None) -> int: + argv = argv if argv is not None else sys.argv[1:] + 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 + if len(argv) == 2 and argv[0] not in {"publish", "logs", "log-get", "health", "version", "-h", "--help"}: + return cmd_publish(account_id=argv[0], article_id=argv[1]) + parser = build_parser() + args = parser.parse_args(argv) + return int(args.handler(args)) diff --git a/scripts/db/connection.py b/scripts/db/connection.py new file mode 100644 index 0000000..7db382b --- /dev/null +++ b/scripts/db/connection.py @@ -0,0 +1,34 @@ +"""SQLite 连接与日志表迁移模板。""" + +from __future__ import annotations + +import sqlite3 + +from util.runtime_paths import get_db_path + + +def get_conn() -> sqlite3.Connection: + return sqlite3.connect(get_db_path()) + + +def init_db() -> None: + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE IF NOT EXISTS publish_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id TEXT NOT NULL, + article_id INTEGER NOT NULL, + article_title TEXT, + status TEXT NOT NULL, + error_msg TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + conn.commit() + finally: + conn.close() diff --git a/scripts/db/publish_logs_repository.py b/scripts/db/publish_logs_repository.py new file mode 100644 index 0000000..f5a5d34 --- /dev/null +++ b/scripts/db/publish_logs_repository.py @@ -0,0 +1,76 @@ +"""publish_logs 表读写模板。""" + +from __future__ import annotations + +from typing import Any, List, Optional, Tuple + +from db.connection import get_conn, init_db +from util.timeutil import now_unix + + +def save_publish_log( + account_id: str, + article_id: int, + article_title: str, + status: str, + error_msg: Optional[str] = None, +) -> int: + init_db() + now = now_unix() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + """ + INSERT INTO publish_logs (account_id, article_id, article_title, status, error_msg, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (account_id, int(article_id), article_title or "", status, error_msg, now, now), + ) + new_id = int(cur.lastrowid) + conn.commit() + finally: + conn.close() + return new_id + + +def list_publish_logs( + limit: int, + status: Optional[str] = None, + account_id: Optional[str] = None, +) -> List[Tuple[Any, ...]]: + init_db() + conn = get_conn() + try: + cur = conn.cursor() + sql = ( + "SELECT id, account_id, article_id, article_title, status, error_msg, created_at, updated_at " + "FROM publish_logs WHERE 1=1 " + ) + params: List[Any] = [] + if status: + sql += "AND status = ? " + params.append(status) + if account_id: + sql += "AND account_id = ? " + params.append(account_id) + sql += "ORDER BY created_at DESC, id DESC LIMIT ?" + params.append(int(limit)) + cur.execute(sql, tuple(params)) + return list(cur.fetchall()) + finally: + conn.close() + + +def get_publish_log_by_id(log_id: int) -> Optional[Tuple[Any, ...]]: + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + "SELECT id, account_id, article_id, article_title, status, error_msg, created_at, updated_at FROM publish_logs WHERE id = ?", + (int(log_id),), + ) + return cur.fetchone() + finally: + conn.close() diff --git a/scripts/jiangchang_skill_core/__init__.py b/scripts/jiangchang_skill_core/__init__.py new file mode 100644 index 0000000..61cf993 --- /dev/null +++ b/scripts/jiangchang_skill_core/__init__.py @@ -0,0 +1 @@ +# Vendored from jiangchang-platform-kit/sdk/jiangchang_skill_core/ — keep runtime_env + unified_logging in sync. diff --git a/scripts/jiangchang_skill_core/runtime_env.py b/scripts/jiangchang_skill_core/runtime_env.py new file mode 100644 index 0000000..d1d2611 --- /dev/null +++ b/scripts/jiangchang_skill_core/runtime_env.py @@ -0,0 +1,113 @@ +""" +JIANGCHANG_* 数据根与用户目录:解析规则 + 可选本地 CLI 默认值。 + +模板复制后通常无需大改;如组织环境不同,再按项目实际调整。 +""" + +from __future__ import annotations + +import os +import sys + +CLI_LOCAL_DEV_ENABLED = True +DEFAULT_LOCAL_USER_ID = "10032" +WIN_DEFAULT_DATA_ROOT = r"D:\jiangchang-data" +WIN_DEFAULT_JIANGCHANG_APP_ROOT = r"D:\AI\jiangchang" + + +def platform_default_data_root() -> str: + if sys.platform == "win32": + return WIN_DEFAULT_DATA_ROOT + return os.path.join(os.path.expanduser("~"), ".jiangchang-data") + + +def get_data_root() -> str: + env = ( + os.getenv("CLAW_DATA_ROOT") + or os.getenv("JIANGCHANG_DATA_ROOT") + or "" + ).strip() + if env: + return env + return platform_default_data_root() + + +def get_user_id() -> str: + return ( + os.getenv("CLAW_USER_ID") + or os.getenv("JIANGCHANG_USER_ID") + or "" + ).strip() or "_anon" + + +def _looks_like_skills_root(path: str) -> bool: + if not path or not os.path.isdir(path): + return False + for marker in ( + "llm-manager", + "content-manager", + "account-manager", + "sohu-publisher", + "toutiao-publisher", + "gongzhonghao-publisher", + "weibo-publisher", + "skill-template", + ): + if os.path.isdir(os.path.join(path, marker)): + return True + return False + + +def get_skills_root() -> str: + for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"): + v = (os.getenv(key) or "").strip() + if v: + return os.path.normpath(v) + + app = (os.getenv("JIANGCHANG_APP_ROOT") or "").strip() + if sys.platform == "win32" and not app: + app = WIN_DEFAULT_JIANGCHANG_APP_ROOT + if app: + nested = os.path.join(app, "skills") + if _looks_like_skills_root(nested): + return os.path.normpath(nested) + if _looks_like_skills_root(app): + return os.path.normpath(app) + + if sys.platform == "win32": + nested = os.path.join(WIN_DEFAULT_JIANGCHANG_APP_ROOT, "skills") + if _looks_like_skills_root(nested): + return os.path.normpath(nested) + if _looks_like_skills_root(WIN_DEFAULT_JIANGCHANG_APP_ROOT): + return os.path.normpath(WIN_DEFAULT_JIANGCHANG_APP_ROOT) + + return os.path.normpath(os.path.join(os.path.expanduser("~"), ".openclaw", "skills")) + + +def get_sibling_skills_root(skill_scripts_dir: str | None = None) -> str: + if skill_scripts_dir: + scripts = os.path.abspath(skill_scripts_dir) + skill_root = os.path.dirname(scripts) + inferred = os.path.dirname(skill_root) + if _looks_like_skills_root(inferred): + return os.path.normpath(inferred) + + for key in ("JIANGCHANG_SKILLS_ROOT", "CLAW_SKILLS_ROOT"): + v = (os.getenv(key) or "").strip() + if v: + return os.path.normpath(v) + + return get_skills_root() + + +def apply_cli_local_defaults() -> None: + enabled = CLI_LOCAL_DEV_ENABLED + if not enabled: + v = (os.getenv("JIANGCHANG_CLI_LOCAL_DEV") or "").strip().lower() + enabled = v in ("1", "true", "yes", "on") + if not enabled: + return + if not (os.getenv("CLAW_DATA_ROOT") or os.getenv("JIANGCHANG_DATA_ROOT") or "").strip(): + os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root() + if not (os.getenv("CLAW_USER_ID") or os.getenv("JIANGCHANG_USER_ID") or "").strip(): + os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip() diff --git a/scripts/jiangchang_skill_core/unified_logging.py b/scripts/jiangchang_skill_core/unified_logging.py new file mode 100644 index 0000000..dd6c2e7 --- /dev/null +++ b/scripts/jiangchang_skill_core/unified_logging.py @@ -0,0 +1,137 @@ +""" +统一文件日志:{DATA_ROOT}/{USER_ID}/logs/jiangchang.log +按日轮转;行内带 trace_id 与 skill_slug,便于跨技能排查。 +""" + +from __future__ import annotations + +import logging +import os +import sys +import uuid +from logging.handlers import TimedRotatingFileHandler +from typing import Optional + +from .runtime_env import get_data_root, get_user_id + +_skill_slug: str = "" +_logger_name: str = "" + + +def get_unified_logs_dir() -> str: + path = os.path.join(get_data_root(), get_user_id(), "logs") + os.makedirs(path, exist_ok=True) + return path + + +def get_skill_log_file_path() -> str: + override = (os.getenv("JIANGCHANG_LOG_FILE") or "").strip() + if override: + parent = os.path.dirname(os.path.abspath(override)) + if parent: + os.makedirs(parent, exist_ok=True) + return os.path.abspath(override) + return os.path.join(get_unified_logs_dir(), "jiangchang.log") + + +def ensure_trace_for_process() -> str: + existing = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() + if existing: + return existing + tid = uuid.uuid4().hex[:12] + os.environ["JIANGCHANG_TRACE_ID"] = tid + return tid + + +def subprocess_env_with_trace(environ: Optional[dict] = None) -> dict: + ensure_trace_for_process() + tid = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() or ensure_trace_for_process() + base = os.environ if environ is None else environ + return {**base, "JIANGCHANG_TRACE_ID": tid} + + +class _SkillContextFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + record.trace_id = (os.getenv("JIANGCHANG_TRACE_ID") or "").strip() or "-" + record.skill_slug = _skill_slug or "-" + return True + + +_FORMAT = "%(asctime)s | %(levelname)-8s | %(trace_id)s | %(skill_slug)s | %(name)s | %(message)s" +_DATEFMT = "%Y-%m-%dT%H:%M:%S" + + +def _log_level_from_env() -> int: + v = (os.getenv("JIANGCHANG_LOG_LEVEL") or "INFO").strip().upper() + return getattr(logging, v, None) or logging.INFO + + +def _backup_count() -> int: + try: + n = int((os.getenv("JIANGCHANG_LOG_BACKUP_COUNT") or "30").strip()) + return max(1, min(n, 365)) + except ValueError: + return 30 + + +def setup_skill_logging(skill_slug: str, logger_name: str) -> None: + global _skill_slug, _logger_name + ensure_trace_for_process() + _skill_slug = skill_slug + _logger_name = logger_name + + log = logging.getLogger(logger_name) + if log.handlers: + return + log.setLevel(_log_level_from_env()) + path = get_skill_log_file_path() + fh = TimedRotatingFileHandler( + path, + when="midnight", + interval=1, + backupCount=_backup_count(), + encoding="utf-8", + delay=True, + ) + fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT) + fh.setFormatter(fmt) + fh.addFilter(_SkillContextFilter()) + log.addHandler(fh) + if (os.getenv("JIANGCHANG_LOG_TO_STDERR") or "").strip().lower() in ("1", "true", "yes", "on"): + sh = logging.StreamHandler(sys.stderr) + sh.setLevel(logging.WARNING) + sh.setFormatter(fmt) + sh.addFilter(_SkillContextFilter()) + log.addHandler(sh) + log.propagate = False + + +def get_skill_logger() -> logging.Logger: + if not _logger_name: + raise RuntimeError("get_skill_logger: call setup_skill_logging first") + return logging.getLogger(_logger_name) + + +def attach_unified_file_handler( + log_path: str, + *, + skill_slug: str, + logger_name: str, + level: int = logging.DEBUG, +) -> logging.Logger: + global _skill_slug + ensure_trace_for_process() + _skill_slug = skill_slug + lg = logging.getLogger(logger_name) + lg.handlers.clear() + lg.setLevel(level) + parent = os.path.dirname(os.path.abspath(log_path)) + if parent: + os.makedirs(parent, exist_ok=True) + fh = logging.FileHandler(log_path, encoding="utf-8") + fmt = logging.Formatter(_FORMAT, datefmt=_DATEFMT) + fh.setFormatter(fmt) + fh.addFilter(_SkillContextFilter()) + lg.addHandler(fh) + lg.propagate = False + return lg diff --git a/scripts/main.py b/scripts/main.py new file mode 100644 index 0000000..8813480 --- /dev/null +++ b/scripts/main.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +skill-template CLI 入口。 + +复制为新技能后,请修改注释与常量,但保留当前分层结构: +cli(argv)→ service(业务编排)→ db(持久化)→ util(通用工具)。 +""" +from __future__ import annotations + +import os +import sys + +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) + +from jiangchang_skill_core.runtime_env import apply_cli_local_defaults + +apply_cli_local_defaults() + +from cli.app import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/service/entitlement_service.py b/scripts/service/entitlement_service.py new file mode 100644 index 0000000..f9e23df --- /dev/null +++ b/scripts/service/entitlement_service.py @@ -0,0 +1,46 @@ +"""在线鉴权(可选模板)。""" + +from __future__ import annotations + +import os +from typing import Tuple + +import requests + + +def check_entitlement(skill_slug: str) -> Tuple[bool, str]: + auth_base = (os.getenv("JIANGCHANG_AUTH_BASE_URL") or "").strip().rstrip("/") + if not auth_base: + return True, "" + user_id = (os.getenv("CLAW_USER_ID") or os.getenv("JIANGCHANG_USER_ID") or "").strip() + if not user_id: + return False, "鉴权失败:缺少用户身份(CLAW_USER_ID / JIANGCHANG_USER_ID)" + + auth_api_key = (os.getenv("JIANGCHANG_AUTH_API_KEY") or "").strip() + timeout = int((os.getenv("JIANGCHANG_AUTH_TIMEOUT_SECONDS") or "5").strip()) + headers = {"Content-Type": "application/json"} + if auth_api_key: + headers["Authorization"] = f"Bearer {auth_api_key}" + payload = { + "user_id": user_id, + "skill_slug": skill_slug, + "trace_id": (os.getenv("JIANGCHANG_TRACE_ID") or "").strip(), + "context": {"entry": "main.py"}, + } + try: + res = requests.post(f"{auth_base}/api/entitlements/check", json=payload, headers=headers, timeout=timeout) + except requests.RequestException as exc: + return False, f"鉴权请求失败:{exc}" + if res.status_code != 200: + return False, f"鉴权服务异常:HTTP {res.status_code}" + try: + body = res.json() + except ValueError: + return False, "鉴权服务异常:返回非 JSON" + code = body.get("code") + data = body.get("data") or {} + if code != 200: + return False, str(body.get("msg") or "鉴权失败") + if not data.get("allow", False): + return False, str(data.get("reason") or "未购买或已过期") + return True, "" diff --git a/scripts/service/platform_playwright.py b/scripts/service/platform_playwright.py new file mode 100644 index 0000000..ad7d971 --- /dev/null +++ b/scripts/service/platform_playwright.py @@ -0,0 +1,10 @@ +"""后台自动化占位模块模板。""" + +from __future__ import annotations + +from typing import Any, Dict + + +async def publish(account: Dict[str, Any], article: Dict[str, Any], account_id: str) -> str: + _ = (account, article, account_id) + return "ERROR:NOT_IMPLEMENTED 请复制模板后将本文件改名为具体平台模块并实现后台自动化逻辑" diff --git a/scripts/service/publish_service.py b/scripts/service/publish_service.py new file mode 100644 index 0000000..d480b8c --- /dev/null +++ b/scripts/service/publish_service.py @@ -0,0 +1,83 @@ +"""发布编排、日志查询模板。""" + +from __future__ import annotations + +import json +import sys +from typing import Optional + +from db import publish_logs_repository as plr +from service.entitlement_service import check_entitlement +from util.constants import SKILL_SLUG, SKILL_VERSION +from util.timeutil import unix_to_iso + + +def cmd_publish(account_id: Optional[str] = None, article_id: Optional[str] = None) -> int: + _ = (account_id, article_id) + ok, reason = check_entitlement(SKILL_SLUG) + if not ok: + print(f"❌ {reason}") + return 1 + print("❌ 这是模板仓库,请复制后在 scripts/service/ 中实现真正的发布逻辑。") + return 1 + + +def cmd_logs(limit: int = 10, status: Optional[str] = None, account_id: Optional[str] = None) -> int: + if limit <= 0: + limit = 10 + rows = plr.list_publish_logs(limit, status, account_id) + if not rows: + print("暂无发布记录") + return 0 + + sep_line = "_" * 39 + for idx, r in enumerate(rows): + rid, aid, arid, title, st, err, cat, uat = r + print(f"id:{rid}") + print(f"account_id:{aid or ''}") + print(f"article_id:{arid}") + print(f"article_title:{title or ''}") + print(f"status:{st or ''}") + print(f"error_msg:{err or ''}") + print(f"created_at:{unix_to_iso(cat) or str(cat or '')}") + print(f"updated_at:{unix_to_iso(uat) or str(uat or '')}") + if idx != len(rows) - 1: + print(sep_line) + print() + return 0 + + +def cmd_log_get(log_id: str) -> int: + if not str(log_id).isdigit(): + print("❌ log_id 必须是数字") + return 1 + row = plr.get_publish_log_by_id(int(log_id)) + if not row: + print("❌ 没有这条发布记录") + return 1 + rid, aid, arid, title, st, err, cat, uat = row + print( + json.dumps( + { + "id": int(rid), + "account_id": aid, + "article_id": int(arid), + "article_title": title, + "status": st, + "error_msg": err, + "created_at": unix_to_iso(cat), + "updated_at": unix_to_iso(uat), + }, + ensure_ascii=False, + ) + ) + return 0 + + +def cmd_health() -> int: + return 0 if sys.version_info >= (3, 10) else 1 + + +def cmd_version() -> int: + print(json.dumps({"version": SKILL_VERSION, "skill": SKILL_SLUG}, ensure_ascii=False)) + return 0 diff --git a/scripts/service/sibling_bridge.py b/scripts/service/sibling_bridge.py new file mode 100644 index 0000000..444b8a7 --- /dev/null +++ b/scripts/service/sibling_bridge.py @@ -0,0 +1,39 @@ +"""兄弟技能 CLI 调用模板。""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from typing import Any, Dict, List, Optional + +from util.logging_config import subprocess_env_with_trace +from util.runtime_paths import get_skills_root + + +def _call_json_script(script_path: str, args: List[str]) -> Optional[Dict[str, Any]]: + proc = subprocess.run( + [sys.executable, script_path, *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + env=subprocess_env_with_trace(), + ) + raw = (proc.stdout or "").strip() + if not raw or raw.startswith("ERROR"): + return None + try: + data = json.loads(raw) + except json.JSONDecodeError: + return None + return data if isinstance(data, dict) else None + + +def get_account_manager_main_path() -> str: + return os.path.join(get_skills_root(), "account-manager", "scripts", "main.py") + + +def get_content_manager_main_path() -> str: + return os.path.join(get_skills_root(), "content-manager", "scripts", "main.py") diff --git a/scripts/skill_main.py b/scripts/skill_main.py deleted file mode 100644 index 93a70b4..0000000 --- a/scripts/skill_main.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -技能入口脚本(模板) -==================== - -【职责】 - - 作为宿主调用时的统一 CLI 入口(子进程、终端、CI 均可)。 - - 只做:参数解析、环境检查、分发到具体子命令;复杂逻辑放到同目录其它模块。 - -【如何扩展】 - 1. 在 main() 的 dispatch 字典中增加 "your_cmd": handler 项。 - 2. 实现 handler(argv) 或 handler();出错时打印 ERROR: 前缀信息并 sys.exit(非0)。 - 3. 在仓库根目录 SKILL.md「执行步骤」中补充示例命令。 - -【多宿主注意】 - - 不要在本文件写死某一品牌宿主名。 - - 路径与环境变量约定见 ../docs/RUNTIME.md;可选辅助代码见 ../optional/paths_snippet.py(需自行复制或 import 路径按项目调整)。 - -【编码】 - Windows 下若宿主仍使用系统默认编码,可在宿主侧设置 UTF-8;本模板不强制改 sys.stdout(避免与宿主捕获冲突)。 -""" - -from __future__ import annotations - -import argparse -import json -import sys -from typing import Callable, Dict, List, Optional - -# 与 SKILL.md 中 metadata.skill.slug 保持一致(模板占位,复制后请修改) -SKILL_SLUG = "your-skill-slug" - - -def cmd_version(_args: argparse.Namespace) -> int: - """打印版本信息(与 SKILL.md frontmatter 中 version 应对齐,此处为占位)。""" - payload = { - "skill_slug": SKILL_SLUG, - "version": "0.1.0", - "entry": "skill_main.py", - } - print(json.dumps(payload, ensure_ascii=False)) - return 0 - - -def cmd_health(_args: argparse.Namespace) -> int: - """ - 健康检查:应快速、可离线(除非技能本身强依赖网络)。 - 失败时打印 ERROR: 前缀,便于宿主与自动化解析。 - """ - # 示例:检查 Python 版本(可按需改为检查关键依赖 import) - if sys.version_info < (3, 9): - print("ERROR:PYTHON_VERSION need >= 3.9", file=sys.stderr) - return 1 - print(f"OK skill={SKILL_SLUG} python={sys.version.split()[0]}") - return 0 - - -def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser( - description="Claw skill CLI template — replace SKILL_SLUG and add subcommands.", - ) - sub = p.add_subparsers(dest="command", required=True) - - sp = sub.add_parser("version", help="Print version JSON.") - sp.set_defaults(handler=cmd_version) - - sp = sub.add_parser("health", help="Quick health check.") - sp.set_defaults(handler=cmd_health) - - return p - - -def main(argv: Optional[List[str]] = None) -> int: - argv = argv if argv is not None else sys.argv[1:] - parser = build_parser() - args = parser.parse_args(argv) - handler: Callable[[argparse.Namespace], int] = args.handler - return handler(args) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/util/argparse_zh.py b/scripts/util/argparse_zh.py new file mode 100644 index 0000000..8ef2a0b --- /dev/null +++ b/scripts/util/argparse_zh.py @@ -0,0 +1,12 @@ +"""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/scripts/util/constants.py b/scripts/util/constants.py new file mode 100644 index 0000000..66b4c35 --- /dev/null +++ b/scripts/util/constants.py @@ -0,0 +1,5 @@ +"""技能标识与版本(复制后请修改)。""" + +SKILL_SLUG = "your-skill-slug" +SKILL_VERSION = "1.0.0" +LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug" diff --git a/scripts/util/logging_config.py b/scripts/util/logging_config.py new file mode 100644 index 0000000..3328400 --- /dev/null +++ b/scripts/util/logging_config.py @@ -0,0 +1,21 @@ +"""Re-export unified logging (implementation: jiangchang_skill_core.unified_logging).""" + +from jiangchang_skill_core.unified_logging import ( + attach_unified_file_handler, + ensure_trace_for_process, + get_skill_log_file_path, + get_skill_logger, + get_unified_logs_dir, + setup_skill_logging, + subprocess_env_with_trace, +) + +__all__ = [ + "attach_unified_file_handler", + "ensure_trace_for_process", + "get_skill_log_file_path", + "get_skill_logger", + "get_unified_logs_dir", + "setup_skill_logging", + "subprocess_env_with_trace", +] diff --git a/scripts/util/runtime_paths.py b/scripts/util/runtime_paths.py new file mode 100644 index 0000000..8af0fa5 --- /dev/null +++ b/scripts/util/runtime_paths.py @@ -0,0 +1,34 @@ +"""数据根、技能目录、兄弟技能根路径。""" + +from __future__ import annotations + +import os + +from jiangchang_skill_core.runtime_env import get_data_root, get_sibling_skills_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: + return os.path.dirname(_SCRIPTS_DIR) + + +def get_openclaw_root() -> str: + return get_sibling_skills_root(_SCRIPTS_DIR) + + +def get_skills_root() -> str: + return get_sibling_skills_root(_SCRIPTS_DIR) + + +def get_skill_data_dir() -> str: + 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: + name = filename or f"{SKILL_SLUG}.db" + return os.path.join(get_skill_data_dir(), name) diff --git a/scripts/util/timeutil.py b/scripts/util/timeutil.py new file mode 100644 index 0000000..318191f --- /dev/null +++ b/scripts/util/timeutil.py @@ -0,0 +1,20 @@ +"""时间戳与 ISO 展示。""" + +from __future__ import annotations + +import time +from datetime import datetime +from typing import Optional + + +def now_unix() -> int: + return int(time.time()) + + +def unix_to_iso(ts: Optional[int]) -> Optional[str]: + if ts is None: + return None + try: + return datetime.fromtimestamp(int(ts)).isoformat(timespec="seconds") + except (ValueError, OSError, OverflowError): + return None diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..c45d9ab --- /dev/null +++ b/tests/README.md @@ -0,0 +1,8 @@ +# tests + +这里放模板复制后的单元测试或最小回归测试。 + +模板仓库本身不强制附带业务测试,但建议新 skill 至少补: + +- `health` / `version` 最小冒烟测试 +- 关键 `service` 层函数的单元测试