lov-china-website-filing
面向中国大陆网站的一站式备案与上线 Skill Kit:当用户说“办 ICP 备案”“做公安联网备案”“备案后绑定域名”或 "handle China website filing" 时,按权威页面完成材料、审核、域名切换、合规展示与巡检留痕。
Install
npx skills add https://github.com/lovstudio/skills/tree/main/skills/china-website-filing
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lovstudio-skills@llmmart
git clone https://github.com/lovstudio/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lovstudio/skills collection as a plugin from our marketplace. Git is the plain clone.
README
网站备案助手 · Website Filing Assistant
中国大陆网站备案与上线 Skill Kit,覆盖 ICP、备案后域名切换、公安联网备案、安全评估分支和状态巡检。
本地安装
export SKILL_SOURCE_DIR="$(pwd)"
mkdir -p "${SKILL_SKILLS_INSTALL_DIR:?请设置本地 Skills 目录}"
ln -s "$SKILL_SOURCE_DIR" \
"$SKILL_SKILLS_INSTALL_DIR/lov-china-website-filing"
当前 Creator 初始化流程会在指定安装目录创建同名软链接。
使用
- “给这个公司网站做 ICP 备案,域名在腾讯云,先检查准备条件。”输出场景分类、材料与实名/接入资源缺口、不能提前开放的门槛。
- “ICP 已通过,继续绑定域名并完成公安联网备案。”输出部署验收、公安表单字段、人工确认点、权威状态与巡检记录。
- “每天检查这个备案订单,没变化就静默。”追加台账并仅在状态变化或需要用户动作时报告。
自包含模块
filing-readinessicp-filingdomain-cutoverpublic-security-filingfiling-monitor
组合关系见 references/skill-composition.md,机器可读流水线见 kit.yaml。
台账 CLI
python3 scripts/filing_record.py --help
python3 scripts/filing_record.py check --path ./website-filing-record.md
可信度卡与用户案例
质量门
python3 scripts/validate_skill.py .
python3 -m unittest discover -s tests -v
依赖
- Python 3.8+
- PyYAML(仅源校验)
- 实时操作所需的浏览器控制与已登录权威页面会话
License
MIT
Skill manifest
网站备案助手 · Website Filing Assistant
把备案准备、ICP、备案后上线、公安联网备案和持续巡检收敛为一条有证据、可暂停、可恢复的流程。每一步以政府或接入商权威页面为准;验证码、承诺、最终提交和额外安全评估始终保留人工授权。
Product contract
- 输入:主办者类型与证件信息、网站服务名称、域名、接入商/云资源、上线目标、已有订单或备案号。
- 输出:准备清单、状态快照、材料缺口、需用户执行的动作、备案/上线验收结果,以及追加式巡检记录。
- 不承诺:不保证监管审核通过或时限;不把搜索结果、缓存记录、短信或接入商中间状态冒充最终权威结果。
Triggers
Activate when
- 用户说“给公司网站做 ICP 备案”“继续公安联网备案”“备案通过后部署并绑定域名”“每天检查备案状态”或“网站底部加备案号”。
- User asks to "handle China website filing", "prepare an ICP filing", "submit a public security filing", or "monitor a mainland website filing".
- 用户给出备案订单、域名、云服务商控制台或全国互联网安全管理服务平台页面,希望代理完成一段或整段流程。
Do not activate when
- 只生成隐私政策或服务条款页面;交给
lov-legal-pages。 - 只做普通网页表单预填,且不需要备案状态机、权威证据或提交门控;可交给
lov-fill-web-form。 - 只做通用生产构建、应用商店发布、ICP备案查询以外的法律意见,分别交给生产、发布或法律专业能力。
- 域名或服务器完全位于中国大陆境外,且用户没有中国大陆 ICP、接入或公安联网备案目标。
User Profile (cross-session)
每次运行读取 skill.yaml 声明的 user-profile/v1 上下文,包括用户、品牌、工作区和 skills.lov-china-website-filing。解析顺序为:当前请求、当前项目、Skill 记录、共享偏好、品牌/用户 Profile、安全默认值。
只有用户直接说明并希望以后复用的品牌或备案偏好,才通过 scripts/profile_store.py record --confirm 持久化。证件号码、手机号、验证码、Cookie、密钥和扫描件内容不得写入 Profile 或 Skill 源码。完整约定见 User Profile contract。
Skill Kit Modules
运行前读取 kit.yaml,并按目标加载以下模块:
$SKILL_DIR/skills/filing-readiness/SKILL.md— 主体、域名、接入资源、材料和业务类型准备。$SKILL_DIR/skills/icp-filing/SKILL.md— ICP 申请、接入商审核、短信核验与管局结果。$SKILL_DIR/skills/domain-cutover/SKILL.md— 备案通过后的部署、DNS、TLS 与 ICP 展示验收。$SKILL_DIR/skills/public-security-filing/SKILL.md— 公安联网备案及安全评估分支。$SKILL_DIR/skills/filing-monitor/SKILL.md— 权威状态巡检、差异比较、通知与完成门槛。
主流水线 mainland-website-launch 的顺序固定为:
filing-readiness → icp-filing → domain-cutover
→ public-security-filing → filing-monitor
模块可单独运行,但跨阶段交接必须携带域名、主体/服务名称、权威入口、当前状态、证据时间和下一动作。
Skill Group Composition
先读 组合记录。相邻 Skill 只能作为显式、可选的制品级交接;本 Kit 不把任何 sibling Skill 当作隐藏依赖。
Workflow (MANDATORY)
必须按以下顺序执行。
Step 0: Resolve root, context, and authoritative sources
- 使用
SKILL_DIR,否则从当前 Skill 上下文推断根目录;验证五个模块、references/、scripts/filing_record.py和assets/filing-record-template.md。 - 读取 Profile,但不要把私人路径或证件信息复制进输出或源码。
- 阅读 权威规则、状态词表 和 提交门控。
- 对会变化的规则、页面字段、时限或地方要求,运行时重新打开官方/接入商权威页面核验;不要仅凭本 Skill 的快照。
Step 1: Classify the filing scenario
确定以下事实,缺少且会改变路线时最多问一个聚焦问题:
- 主办者是单位还是个人;服务是网站、APP、小程序还是仅 API。
- 首次备案、新增服务、接入备案、变更、注销,或仅办理公安联网备案。
- 域名注册人实名是否与主办者匹配;接入资源和服务器是否在中国大陆。
- 网站是否已公网开放、是否有用户发布/评论/群组/直播/信息分享、算法或生成式 AI 等能力。
把结论写为 verified、user-stated、inferred 或 unknown,不要把推断写成已核验事实。
Step 2: Create or resume the evidence ledger
首次运行可执行:
python3 "$SKILL_DIR/scripts/filing_record.py" init \
--path ./website-filing-record.md \
--subject "示例主办者" --service "示例服务" \
--domain example.cn --provider "示例接入商"
已有记录时先运行 check 和 compare,读取最后一条权威状态。台账只保存必要的公开标识和脱敏证据摘要;不保存 Cookie、验证码、完整证件号或扫描件。
Step 3: Run the selected module or pipeline
- 完整上线走
mainland-website-launch。 - 仅 ICP 走
icp-only。 - 已取得 ICP 后上线并办公安备案走
post-icp-launch。 - 单点任务可直接进入对应模块,但先验证上游门槛,例如没有 ICP 通过证据就不得执行大陆网站域名切换。
浏览器操作优先复用用户当前已登录会话。登录、验证码、短信核验、扫码、人脸、电子签名、责任书、安全评估结论和最终提交都按 提交门控 处理。
Step 4: Record authoritative evidence
每次状态观察后追加一行:
python3 "$SKILL_DIR/scripts/filing_record.py" append \
--path ./website-filing-record.md \
--time 2026-08-14T10:00:00+08:00 \
--authority "接入商备案订单详情" \
--stage icp --status authority-review \
--domain-status held-off \
--action "等待管局审核" \
--evidence "订单详情显示已提交管局"
先比较上一条记录。状态无变化且无需动作时保持静默,只追加记录;状态变化、人工阻塞或新动作出现时才通知用户。通知渠道由当前宿主和用户明确要求决定,不在 Skill 内硬编码。
Step 5: Apply completion gates
- ICP 完成:工信部查询或管局/接入商权威结果明确显示审核通过,并能读取对应服务备案号。
- 上线完成:预期域名真实解析、HTTPS 可用、页面内容与备案服务一致、ICP 号在首页底部展示并链接工信部系统;旧域名只在用户授权后解绑或替换。
- 公安备案完成:全国互联网安全管理服务平台或属地公安权威结果明确显示审核通过,并取得公安备案号。
- 全流程完成:上述门槛均满足,公安号与图标已按平台代码在网站展示并完成线上回读;额外安全评估若适用,也必须有单独的真实状态。
未达到门槛时使用 pending、blocked-user-action、rejected 或 partially-verified,不得称“已完成”。
Step 6: Close browser work and report
按照所用浏览器控制能力的标签页规则收尾:保留需要用户登录/验证码/确认的 handoff 页面,关闭本次新开的无关页面,避免关闭用户原有标签页。
最终只报告:最新权威状态、与上次相比的变化、证据入口与时间、下一动作、已更新记录。对于无变化且无需动作的定时巡检,遵守用户的静默策略。
Step 7: Validate the Skill source
维护本 Skill 时运行:
python3 scripts/validate_skill.py .
python3 -m unittest discover -s tests -v
同时验证一个中文触发、一个英文触发、一个非触发,并运行至少一次 mainland-website-launch 的离线台账演练。
Dependencies
- Python 3.8+ 标准库,用于确定性的台账脚本。
- 实时办理时需要网络、浏览器控制能力,以及用户已登录的政府/接入商会话。
- PyYAML 仅用于运行 Skill Creator 自带的源校验器。
- 无外部 sibling Skill 硬依赖;无凭据时仍可生成准备清单和离线台账。
通用反馈闭环
用户在 Skill 驱动任务中提出修改意见时,继续当前产物前必须执行:
- 先判断意见是
task-specific(仅本次)还是reusable(可跨任务复用)。 task-specific只修改当前任务,不改 Skill。reusable先确定作用域:领域规则先更新对应 canonical Skill;适用于所有 Skill 的规则先更新共享规范。- 完成规则更新、版本、lint 与分发核验后,再把修改应用到当前任务。
reusable修改会使此前的“确认”“继续”“发吧”失效;完成当前产物修改和回读后必须停下,等待用户下一步指示,不自动进入发布、提交或其他外部写入。
Files (skills)
-
assets
-
filing-record-template.md 743 B
--- title: 网站备案巡检记录 status: active timezone: Asia/Shanghai --- # 网站备案巡检记录 ## 备案对象 | 字段 | 值 | | --- | --- | | 主办者 | 待填写 | | 服务 | 待填写 | | 域名 | example.cn | | 接入商 | 待填写 | ## 权威入口 - 工信部备案管理系统:https://beian.miit.gov.cn/ - 全国互联网安全管理服务平台:https://beian.mps.gov.cn/ - 接入商订单:运行时填写不含会话令牌的 URL ## 阶段门 - ICP:未核验 - 域名上线:未核验 - 公安联网备案:未核验 - 安全评估:待判断 ## 每日巡检记录 | 时间 | 权威来源 | 阶段 | 状态 | 域名状态 | 用户动作 | 证据与备注 | | --- | --- | --- | --- | --- | --- | --- |
-
-
cases
-
cases.json 1.4 KB
[ { "type": "case", "title": "LovStudio 从新增服务 ICP 到公安联网备案提交", "description": "真实企业网站案例:先保持域名未开放,等待 ICP 管局通过;随后部署并绑定备案域名,展示 ICP 号,再在全国互联网安全管理服务平台提交公安联网备案。", "input": { "items": [ "主办单位:北京飞脑科技有限公司", "服务名称:飞脑科技", "域名:lovstudio.cn", "腾讯云 ICP 订单:30178537319491747", "管局下发服务备案号:京ICP备2025108424号-3" ] }, "prompt": "ICP 备案通过了,继续部署绑定域名,并完成公安联网备案;遇到提交、登录或安全评估时让我确认。", "output": { "items": [ "ICP 通过后才执行大陆域名切换,lovstudio.cn 已解析并以 HTTPS 提供服务", "首页底部展示京ICP备2025108424号-3并链接工信部备案系统", "公安联网备案于 2026-08-14 00:54:17 提交,权威状态为待审核,审核单位为海淀驻区大队", "平台提示的舆论属性/社会动员能力安全评估被保留为独立分支,未在缺少确认时擅自申报" ] }, "evidence": [ "https://beian.miit.gov.cn/", "https://beian.mps.gov.cn/", "https://lovstudio.cn/" ] } ]
-
-
references
-
authority-gates.md 1.9 KB
# 权威、授权与提交门控 ## 证据优先级 从高到低使用: 1. 主管部门系统中的具体主体/服务详情或正式通知。 2. 接入商备案订单详情、实名状态、审核记录和管局回传。 3. 真实 DNS、TLS、HTTP 页面与备案页脚回读。 4. 用户当前陈述、邮件或短信截图。 5. 历史记录、搜索摘要和推断。 低优先级证据可用于导航,不得覆盖更高优先级的当前状态。若页面要求重新登录,结论应为“本次无法核验”,而不是沿用旧状态。 ## 必须停下等待用户的动作 - 登录失效、扫码、人脸、验证码、短信核验或设备确认。 - 上传或确认营业执照、身份证、负责人证件、域名证书等敏感材料。 - 对业务性质、服务内容、负责人身份、网站能力作实质性声明。 - 同意责任书、承诺书或真实性声明。 - 点击最终提交、撤回、注销、变更、删除、解绑旧域名或产生费用的操作。 - 启动“具有舆论属性或社会动员能力”安全评估、算法备案或其他专项申报。 如果用户在当前上下文已对一个明确目标说“确认提交”“同意解绑旧域名”等,可以执行该具体动作;授权不得扩展到另一个申请或后续新增确认。 ## 可自动执行的动作 - 读取页面、提取字段、比较历史状态和生成脱敏草稿。 - 在用户已经明确授权的流程范围内填写非敏感且可验证字段。 - 保存未提交草稿、上传用户明确指定的材料,前提是平台动作可逆且未形成法律承诺。 - 运行 DNS/TLS/HTTP 回读和本地台账脚本。 ## 浏览器收尾 - 需要用户接管时,保留最小 handoff 标签页并指出需要做什么。 - 成功后关闭本轮新开的登录中转、文档和重复页面;不要关闭用户原有页面。 - 记录最终权威 URL 的用途,但不要把带会话令牌或隐私查询参数的 URL 写入持久文件。 -
official-rules.md 4 KB
# 权威规则快照 检索日期:2026-08-14。此文件用于确定流程骨架,不代替运行时重新核验。法规、地方口径、接入商材料和平台字段都可能变化。 ## ICP 与网站开放 1. [工业和信息化部《非经营性互联网信息服务备案管理办法(2024 修订)》](https://www.miit.gov.cn/gyhxxhb/jgsj/cyzcyfgs/bmgz/xxtxl/art/2024/art_84a0cfa0ebd049bbbe751dca9a008e56.html) - 在中国境内提供非经营性互联网信息服务应依法备案,未经备案不得提供服务。 - 材料齐全时,省通信管理局应在二十个工作日内予以备案并发放编号;不齐全时通知并说明理由。 - 网站开通时,应在主页底部中央位置标明备案编号,并按要求链接工信部备案系统。 2. [工信部备案管理系统](https://beian.miit.gov.cn/) - 用于短信核验、公共查询和备案信息回读;最终服务备案号应以这里或管局/接入商的权威结果为准。 3. [腾讯云 ICP 备案流程](https://cloud.tencent.com/document/product/243/18909) - 典型流程为域名验证、材料填写、接入商审核、短信核验和管局审核。 - 接入商提交管局后,短信验证码通常需要在二十四小时内完成核验;运行时以实际短信和系统截止时间为准。 4. [腾讯云备案场景](https://cloud.tencent.com/document/product/243/18910) - 备案类型包括首次、新增服务、接入等;未完成 ICP 前不要开放中国大陆网站访问。 5. [腾讯云备案号悬挂说明](https://cloud.tencent.com/document/product/243/61412) - 网站底部展示准确服务备案号并链接工信部系统;备案域名和 `www` 的访问/展示要求按当前接入商与管局规则核验。 ## 公安联网备案 1. [《计算机信息网络国际联网安全保护管理办法》](https://www.miit.gov.cn/jgsj/xxjsfzs/xxgk/art/2020/art_25af2145083b40e98cc4993438703d78.html) - 第十二条要求相关单位自网络正式联通之日起三十日内,到所在地公安机关指定受理机关办理备案。 2. [全国互联网安全管理服务平台](https://beian.mps.gov.cn/) - 网站、APP、小程序等公安联网备案及安全评估的权威操作与状态入口。 3. [腾讯云公安联网备案流程指引](https://cloud.tencent.com/document/api/243/19142) - ICP 通过并取得备案号后,在网站/APP 开通后三十日内办理;是否可用数据码、字段与材料以实时页面为准。 4. [腾讯云公安联网备案常见问题](https://cloud.tencent.com/document/product/243/19616) - 企业或单位优先法人用户;特殊情况下平台允许个人账号办理企业/单位备案时,不等于主办者变成个人,申报主体仍须真实一致。 - 审核通过后,从公安平台复制备案号、HTML 代码和图标,并在网站底部展示。 ## 舆论属性或社会动员能力安全评估 1. [中央网信办、公安部《具有舆论属性或社会动员能力的互联网信息服务安全评估规定》说明与正文](https://www.cac.gov.cn/2019-03/20/c_1124259405.htm) - 论坛、博客、微博客、聊天室、通讯群组、公众账号、短视频、直播、信息分享、小程序等服务或相应功能可能进入适用范围。 - 符合规定情形时由服务提供者自行或委托第三方评估,并通过全国互联网安全管理服务平台提交报告。 - 新上线或新增相关功能等情形的提交时点不同;必须依据产品真实能力和平台/属地主管部门要求判断。 ## 使用边界 - 本 Skill 不是法律意见。若经营性 ICP、新闻、出版、教育、医疗、药品、文化、视听、宗教、算法、生成式 AI 等专项许可或备案可能适用,列出缺口并转交主管部门或专业顾问。 - 接入商文档是办理层面的权威说明,但法规效力和最终行政结论以政府主管部门为准。 - 搜索摘要、旧截图、历史巡检和消息通知均不能替代当前权威页面。 -
skill-card-standard.md 660 B
# LovStudio Skill Card standard `skill-card.yaml` follows the minimum release-record idea of NVIDIA Skill Cards: description, owner, license/terms, use case, deployment, requirements, risks/mitigations, references, output contract, version, and ethical considerations. LovStudio adds evidence that helps a user decide whether the Skill is credible: 1. A real user case with Input → Prompt → Output. 2. A dimension map with named evidence, not an unexplained score. 3. A pricing basis, including the free boundary and review trigger. 4. Explicit paid and free distribution states. Never claim a case, score, channel, or price that has not been verified. -
skill-composition.md 2.7 KB
# Skill Group Composition ## Nearby Skills Inspected | Skill | Classification | Routing contract and decision | | --- | --- | --- | | `lov-fill-web-form` | optional upstream atom | 从网页字段和知识库生成预填 Markdown;不拥有权威页面状态、备案阶段门或最终提交,因此只可提供字段草稿。 | | `lov-fact-check` | optional upstream atom | 适合核验当前法规或地方口径;本 Kit 仍自行保存备案专用的一手来源、状态词表与完成门,实时检索结果以制品级摘要交接。 | | `lov-dev-to-prod` | optional upstream/downstream atom | 拥有通用生产构建与本地制品验证;不拥有 ICP 审核、DNS 开放时机或备案页脚。若网站尚未具备可部署制品,可先生成生产制品再交回本 Kit。 | | `lov-legal-pages` | adjacent, not composed | 生成隐私政策和服务条款页面,不办理备案,也不决定公安安全评估适用性。 | | `automation-workflows` | optional downstream atom | 能设计通用定时工作流;备案巡检的权威状态比较和静默/通知策略由内嵌 `filing-monitor` 模块拥有,宿主调度可作为外部触发器。 | 已搜索本地 Skill 源与安装目录,没有发现一个同时拥有 ICP、备案后域名切换、公安联网备案和权威状态巡检的现有 Skill。 ## Atomic Handoffs ```text optional: lov-fact-check dated official-rule evidence | v lov-china-website-filing readiness -> ICP -> cutover -> public security -> monitor | +--> optional lov-dev-to-prod | production web artifact | +--> optional host automation scheduled trigger only ``` 每个交接必须包含具体制品:事实核验交付带日期和链接的规则摘要;生产能力交付可部署制品与验证结果;调度器只触发巡检,不拥有状态解释。 ## Overlap Decisions - 网页填表只复用“字段草稿”概念,不复制深度知识库检索或通用表单输出格式。 - 法规核验遵循一手来源原则,但本 Kit 内保留最小运行规则,避免把外部 Skill 变成硬依赖。 - 域名切换只覆盖“ICP备案后的大陆站点开放与备案展示”,不复制通用应用生产构建或发布全流程。 - 巡检模块内嵌,因为它必须理解 ICP、域名、公安和安全评估的状态关系;调度实现由宿主决定。 ## Composition Decision 选择 **self-contained Skill Kit**。五个阶段都可独立产生有价值结果,但完整上线必须共享同一主体/域名证据、严格顺序和完成门。把它们拆成外部依赖会丢失状态一致性;把它们压成一个单体流程又会阻碍用户从中间阶段恢复。 -
status-taxonomy.md 2.1 KB
# 状态词表与完成门 台账中的 `status` 使用稳定的英文状态,`evidence` 保留页面原文摘要。 ## 通用状态 | 状态 | 含义 | 用户动作 | | --- | --- | --- | | `not-started` | 尚未创建申请或执行该阶段 | 准备输入 | | `draft` | 页面或材料草稿已保存,未提交 | 复核并确认 | | `blocked-user-action` | 登录、验证码、承诺、材料或最终确认阻塞 | 必须处理 | | `provider-review` | 接入商审核中 | 通常等待 | | `authority-review` | 管局或公安机关审核中 | 通常等待 | | `supplement-required` | 权威页面明确要求补充/修改 | 按要求处理 | | `rejected` | 申请被驳回或不予受理 | 查看理由并修正 | | `approved` | 该阶段权威审核通过 | 进入下一阶段 | | `partially-verified` | 有部分真实证据但未满足完成门 | 补证 | | `completed` | 该阶段全部完成门通过 | 无 | | `unable-to-verify` | 本次无法打开或登录权威页面 | 恢复会话 | ## 域名状态 | 状态 | 含义 | | --- | --- | | `unverified` | 未做 DNS/TLS/HTTP 检查 | | `held-off` | 按备案类型主动保持未解析/未开放 | | `resolving` | DNS 已解析,尚未完成整体验收 | | `https-live` | 目标域名 HTTPS 可访问 | | `footer-verified` | ICP 或公安备案展示已在线回读 | | `invalid-host` | 旧/错误域名不再绑定或被目标服务拒绝 | ## 分阶段完成门 ### ICP - 权威状态为通过; - 能读取主办者、域名/服务与服务备案号的对应关系; - 不是仅有“已提交”“短信核验完成”或“管局审核中”。 ### 域名上线 - DNS 指向预期中国大陆接入资源; - 证书覆盖所绑定主机名; - HTTP(S) 返回预期站点而非默认页; - ICP 服务备案号展示并链接工信部系统; - 旧域名变更符合用户明确授权。 ### 公安联网备案 - 全国互联网安全管理服务平台或属地公安权威结果显示通过; - 取得与网站对应的公安备案号; - 备案号、平台链接和图标已按平台代码上线回读。 安全评估是独立状态,不因公安备案提交或通过而自动视为完成。 -
trigger-routing-tests.md 1.1 KB
# Trigger routing tests | Prompt | Expected route | Reason | | --- | --- | --- | | “备案前检查一下域名实名和材料” | `filing-readiness` | 目标是提交前准备,不是审核状态。 | | “继续 ICP 备案,看看短信核验到哪了” | `icp-filing` | 目标是接入商、工信部与管局链路。 | | “备案通过了,部署并绑定域名” | `domain-cutover` | 已满足 ICP 上游门,目标是上线验收。 | | “继续公安备案,最终提交前让我确认” | `public-security-filing` | 目标是全国互联网安全管理服务平台申请。 | | “每天检查备案状态,没变化就静默” | `filing-monitor` | 目标是有历史比较和通知策略的周期巡检。 | | “给网站生成隐私政策和服务条款” | non-trigger → `lov-legal-pages` | 不涉及备案状态、权威页面或上线门。 | | “把桌面 App 打一个生产包” | non-trigger → `lov-dev-to-prod` | 不是网站备案或备案后域名开放。 | 自动测试验证每个正向短语和非触发条件存在于对应模块;语义路由仍由宿主模型结合上下文完成。 -
user-profile.md 2.7 KB
# User Profile contract Every Skill created by Skill Creator declares `user-profile/v1` in `skill.yaml`. The contract connects independent sessions to one user-owned JSON Profile while keeping the Skill source portable across users and brands. ## Shared shape The host supplies the Profile through `SKILL_PROFILE_PATH` (or the runtime's configured profile path). The stable shared scopes are: - `user`: user identity, language, timezone, and other personal working defaults. - `brand`: public brand facts, site, logo, tone, profile, and design guidance. - `workspace`: project roots and output locations. - `preferences`: shared preference values when the host stores them in the Profile. - `skills.<skill_id>.profile`: Skill-specific defaults. - `skills.<skill_id>.records`: durable decisions and preferences learned from direct user statements for this Skill. The Profile may also use the runtime's canonical `identity` fields. Manifest field aliases bridge `identity.*` and the portable `user.*` / `brand.*` names. ## Read on every run 1. Read the current request and project context. 2. Read the shared Profile and the `skills.<skill_id>` namespace. 3. Resolve values in this order: current request, project context, Skill records, shared preferences, shared user/brand Profile, safe defaults. 4. Keep `profile_scope` and field provenance available for the final result. Do not copy resolved personal paths, brand values, or private records into the committed Skill source. ## Persist directly stated values When the user explicitly gives a value meant to survive later sessions, save it immediately after the user statement and report the canonical path: ```bash python3 scripts/profile_store.py record \ --skill-id lov-example \ --path records.subtitle_level \ --value '"cet4"' \ --confirm ``` For shared facts, use `--path brand.<field>` or `--path user.<field>`. The script writes JSON atomically, preserves unrelated Profile data, increments a numeric Profile revision when present, and never echoes the stored value. Inferred information, credentials, tokens, cookies, and secret-like fields stay out of durable records. If the user has not stated that a value should persist, keep it in the current request context. ## Read the connected context ```bash python3 scripts/profile_store.py read \ --skill-id lov-example \ --pretty ``` The result contains `user`, `brand`, `workspace`, `preferences`, `skill`, and `records` scopes. A host using `skill-runtime/v1` also returns the same binding as `profile_scope` and `profile_contract`. ## Compatibility `--user-config` remains accepted by the Creator as a compatibility flag for old invocations. The Profile contract is now always generated; users do not choose an initialization mode.
-
-
scripts
-
filing_record.py 8.2 KB
#!/usr/bin/env python3 """Create and maintain an append-only mainland website filing ledger.""" from __future__ import annotations import argparse import json import re import sys from dataclasses import asdict, dataclass from datetime import datetime from pathlib import Path from typing import Optional HEADING = "## 每日巡检记录" HEADER = "| 时间 | 权威来源 | 阶段 | 状态 | 域名状态 | 用户动作 | 证据与备注 |" SEPARATOR = "| --- | --- | --- | --- | --- | --- | --- |" REQUIRED_HEADINGS = ("## 备案对象", "## 权威入口", "## 阶段门", HEADING) STAGES = ("readiness", "icp", "cutover", "public-security", "security-assessment", "monitor") class RecordError(ValueError): """Raised when a ledger cannot be safely read or updated.""" @dataclass(frozen=True) class Observation: time: str authority: str stage: str status: str domain_status: str action: str evidence: str def clean_cell(value: str) -> str: return re.sub(r"\s+", " ", value.strip()).replace("|", "\\|") def parse_time(value: str) -> str: try: parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError as exc: raise RecordError("--time must be ISO 8601, for example 2026-08-14T10:00:00+08:00") from exc if parsed.tzinfo is None: raise RecordError("--time must include a timezone offset") return value def render_record(subject: str, service: str, domain: str, provider: str, timezone: str) -> str: return f"""--- title: {clean_cell(service)} 网站备案巡检记录 status: active timezone: {clean_cell(timezone)} --- # {clean_cell(service)} 网站备案巡检记录 ## 备案对象 | 字段 | 值 | | --- | --- | | 主办者 | {clean_cell(subject)} | | 服务 | {clean_cell(service)} | | 域名 | {clean_cell(domain)} | | 接入商 | {clean_cell(provider)} | ## 权威入口 - 工信部备案管理系统:https://beian.miit.gov.cn/ - 全国互联网安全管理服务平台:https://beian.mps.gov.cn/ - 接入商订单:运行时填写不含会话令牌的 URL ## 阶段门 - ICP:未核验 - 域名上线:未核验 - 公安联网备案:未核验 - 安全评估:待判断 {HEADING} {HEADER} {SEPARATOR} """ def validate_record(text: str) -> list[str]: issues = [f"missing heading: {heading}" for heading in REQUIRED_HEADINGS if heading not in text] if HEADER not in text: issues.append("missing canonical inspection table header") if SEPARATOR not in text: issues.append("missing canonical inspection table separator") return issues def split_cells(line: str) -> list[str]: raw = line.strip().strip("|") cells = re.split(r"(?<!\\)\|", raw) return [cell.strip().replace("\\|", "|") for cell in cells] def last_observation(text: str) -> Optional[Observation]: marker = text.find(HEADING) if marker < 0: raise RecordError(f"record is missing {HEADING}") rows = [] for line in text[marker:].splitlines(): if not line.startswith("|") or line in (HEADER, SEPARATOR): continue cells = split_cells(line) if len(cells) == 7: rows.append(Observation(cells[0], cells[1], cells[2], cells[3], cells[4], cells[5], cells[6])) return rows[-1] if rows else None def changed(previous: Optional[Observation], current: Observation) -> bool: if previous is None: return True return any( getattr(previous, field) != getattr(current, field) for field in ("authority", "stage", "status", "domain_status", "action") ) def read_valid(path: Path) -> str: if not path.is_file(): raise RecordError(f"record does not exist: {path}") text = path.read_text(encoding="utf-8") issues = validate_record(text) if issues: raise RecordError("; ".join(issues)) return text def observation_from_args(args: argparse.Namespace) -> Observation: return Observation( time=parse_time(args.time), authority=clean_cell(args.authority), stage=args.stage, status=clean_cell(args.status), domain_status=clean_cell(args.domain_status), action=clean_cell(args.action), evidence=clean_cell(args.evidence), ) def result_payload(path: Path, previous: Optional[Observation], current: Optional[Observation]) -> dict: return { "path": str(path), "previous": asdict(previous) if previous else None, "current": asdict(current) if current else None, "changed": changed(previous, current) if current else None, "needs_user_action": bool(current and current.status == "blocked-user-action"), } def command_init(args: argparse.Namespace) -> int: path = args.path.expanduser().resolve() if path.exists(): raise RecordError(f"refusing to overwrite existing record: {path}") path.parent.mkdir(parents=True, exist_ok=True) path.write_text(render_record(args.subject, args.service, args.domain, args.provider, args.timezone), encoding="utf-8") print(json.dumps({"created": str(path)}, ensure_ascii=False)) return 0 def command_check(args: argparse.Namespace) -> int: path = args.path.expanduser().resolve() text = read_valid(path) print(json.dumps({"valid": True, "path": str(path), "last": asdict(last_observation(text)) if last_observation(text) else None}, ensure_ascii=False)) return 0 def command_compare(args: argparse.Namespace) -> int: path = args.path.expanduser().resolve() previous = last_observation(read_valid(path)) current = observation_from_args(args) print(json.dumps(result_payload(path, previous, current), ensure_ascii=False)) return 0 def command_append(args: argparse.Namespace) -> int: path = args.path.expanduser().resolve() text = read_valid(path) previous = last_observation(text) current = observation_from_args(args) if previous and previous.time == current.time: raise RecordError(f"an observation already exists for timestamp {current.time}") row = "| " + " | ".join(clean_cell(value) for value in asdict(current).values()) + " |\n" with path.open("a", encoding="utf-8") as handle: handle.write(row) print(json.dumps(result_payload(path, previous, current), ensure_ascii=False)) return 0 def add_observation_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument("--time", required=True) parser.add_argument("--authority", required=True) parser.add_argument("--stage", required=True, choices=STAGES) parser.add_argument("--status", required=True) parser.add_argument("--domain-status", required=True) parser.add_argument("--action", required=True) parser.add_argument("--evidence", required=True) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="command", required=True) init_parser = subparsers.add_parser("init", help="create a new filing ledger") init_parser.add_argument("--path", required=True, type=Path) init_parser.add_argument("--subject", required=True) init_parser.add_argument("--service", required=True) init_parser.add_argument("--domain", required=True) init_parser.add_argument("--provider", required=True) init_parser.add_argument("--timezone", default="Asia/Shanghai") init_parser.set_defaults(handler=command_init) check_parser = subparsers.add_parser("check", help="validate a filing ledger") check_parser.add_argument("--path", required=True, type=Path) check_parser.set_defaults(handler=command_check) compare_parser = subparsers.add_parser("compare", help="compare without writing") compare_parser.add_argument("--path", required=True, type=Path) add_observation_arguments(compare_parser) compare_parser.set_defaults(handler=command_compare) append_parser = subparsers.add_parser("append", help="append one observation") append_parser.add_argument("--path", required=True, type=Path) add_observation_arguments(append_parser) append_parser.set_defaults(handler=command_append) return parser def main() -> int: args = build_parser().parse_args() try: return args.handler(args) except RecordError as exc: print(f"ERROR: {exc}", file=sys.stderr) return 2 if __name__ == "__main__": raise SystemExit(main()) -
profile_store.py 8.3 KB
#!/usr/bin/env python3 """Read and persist a Skill's user-owned profile context. The command keeps shared identity and brand facts in the profile root while putting Skill-specific durable records under ``skills.<skill_id>.records``. Writes require an explicit confirmation flag and use an atomic replacement. """ from __future__ import annotations import argparse import json import os import re import sys import tempfile from pathlib import Path from typing import Any, Iterable USER_PROFILE_SCHEMA = "user-profile/v1" SKILL_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") SAFE_ROOTS = {"user", "brand", "workspace", "preferences"} SENSITIVE_PARTS = {"token", "secret", "password", "credential", "cookie", "api_key", "apikey"} def config_dir() -> Path: configured = os.environ.get("SKILLS_CONFIG_DIR") if configured: return Path(os.path.expandvars(configured)).expanduser() xdg = os.environ.get("XDG_CONFIG_HOME") if xdg: return Path(os.path.expandvars(xdg)).expanduser() / "agent-skills" return Path.home() / ".config" / "agent-skills" def profile_path(explicit: Path | None = None) -> Path: if explicit: return explicit.expanduser() configured = ( os.environ.get("SKILL_PROFILE_PATH") or os.environ.get("SKILLS_PROFILE_PATH") ) if configured: return Path(os.path.expandvars(configured)).expanduser() candidates = ( Path.home() / ".lovstudio" / "skills" / "profile.json", Path.home() / ".skill-publisher" / "skills" / "profile.json", config_dir() / "profile.json", ) return next((candidate for candidate in candidates if candidate.exists()), candidates[-1]) def read_profile(path: Path) -> dict[str, Any]: if not path.exists(): return {} try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ValueError(f"profile cannot be read: {path}: {exc}") from exc if not isinstance(value, dict): raise ValueError("profile root must be an object") return value def profile_skeleton(skill_id: str) -> dict[str, Any]: return { "schema": "skill-profile/v1", "profile_id": f"profile-{skill_id}", "revision": 1, "status": "draft", "identity": {}, "purpose": {}, "brand": {}, "user": {}, "workspace": {}, "skills": {}, "extensions": {}, } def atomic_write(path: Path, value: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) mode = 0o600 if path.exists(): mode = path.stat().st_mode & 0o777 temporary_name: str | None = None try: with tempfile.NamedTemporaryFile( "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False, ) as handle: temporary_name = handle.name handle.write(json.dumps(value, ensure_ascii=False, indent=2) + "\n") handle.flush() os.fsync(handle.fileno()) os.chmod(temporary_name, mode) os.replace(temporary_name, path) finally: if temporary_name: temporary = Path(temporary_name) if temporary.exists(): temporary.unlink() def set_path(root: dict[str, Any], parts: list[str], value: Any) -> None: current: dict[str, Any] = root for part in parts[:-1]: child = current.get(part) if not isinstance(child, dict): child = {} current[part] = child current = child current[parts[-1]] = value def target_parts(skill_id: str, requested_path: str) -> tuple[list[str], str]: if not SKILL_ID_RE.fullmatch(skill_id): raise ValueError("skill_id must be kebab-case") path = requested_path.strip().strip(".") if not path: raise ValueError("path is required") parts = [part for part in path.split(".") if part] if any(not re.fullmatch(r"[A-Za-z0-9_-]+", part) for part in parts): raise ValueError("path contains an invalid segment") if any(part.casefold() in SENSITIVE_PARTS for part in parts): raise ValueError("profile records do not accept secret-like fields") if parts[0] == "records": target = ["skills", skill_id, "records", *parts[1:]] return target, ".".join(target) if parts[0] == "profile": target = ["skills", skill_id, "profile", *parts[1:]] return target, ".".join(target) if parts[:3] == ["skills", skill_id, "records"]: return parts, ".".join(parts) if parts[:3] == ["skills", skill_id, "profile"]: return parts, ".".join(parts) if parts[0] in SAFE_ROOTS: return parts, ".".join(parts) expected = f"records.<field> or one of: {', '.join(sorted(SAFE_ROOTS))}.<field>" raise ValueError(f"path must use {expected}") def parse_value(raw: str) -> Any: try: return json.loads(raw) except json.JSONDecodeError: return raw def read_command(args: argparse.Namespace) -> dict[str, Any]: path = profile_path(args.profile) profile = read_profile(path) skills = profile.get("skills", {}) if not isinstance(skills, dict): skills = {} skill = skills.get(args.skill_id, {}) if not isinstance(skill, dict): skill = {} user = profile.get("user") if not isinstance(user, dict) or not user: user = profile.get("identity", {}) if not isinstance(user, dict): user = {} brand = profile.get("brand", {}) if not isinstance(brand, dict): brand = {} workspace = profile.get("workspace", {}) if not isinstance(workspace, dict): workspace = {} preferences = profile.get("preferences", {}) if not isinstance(preferences, dict): preferences = {} records = skill.get("records", {}) if not isinstance(records, dict): records = {} return { "status": "ready", "schema": USER_PROFILE_SCHEMA, "skill_id": args.skill_id, "profile_path": str(path), "user": user, "brand": brand, "workspace": workspace, "preferences": preferences, "skill": skill, "records": records, } def record_command(args: argparse.Namespace) -> dict[str, Any]: if not args.confirm: raise PermissionError("record requires --confirm after the user has stated the value") parts, canonical_path = target_parts(args.skill_id, args.path) path = profile_path(args.profile) profile = read_profile(path) if not profile: profile = profile_skeleton(args.skill_id) set_path(profile, parts, parse_value(args.value)) revision = profile.get("revision") if isinstance(revision, int) and not isinstance(revision, bool): profile["revision"] = revision + 1 atomic_write(path, profile) return { "status": "saved", "schema": USER_PROFILE_SCHEMA, "skill_id": args.skill_id, "profile_path": str(path), "path": canonical_path, } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--profile", type=Path, default=None, help="Shared profile JSON path") subparsers = parser.add_subparsers(dest="command", required=True) read_parser = subparsers.add_parser("read", help="Read shared and Skill-specific profile context") read_parser.add_argument("--skill-id", required=True) read_parser.add_argument("--pretty", action="store_true") record_parser = subparsers.add_parser("record", help="Persist a user-stated profile value") record_parser.add_argument("--skill-id", required=True) record_parser.add_argument("--path", required=True, help="records.<field>, brand.<field>, or user.<field>") record_parser.add_argument("--value", required=True) record_parser.add_argument("--confirm", action="store_true") args = parser.parse_args() try: result = read_command(args) if args.command == "read" else record_command(args) except (OSError, PermissionError, ValueError) as exc: print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False), file=sys.stderr) return 2 indent = 2 if getattr(args, "pretty", False) else None print(json.dumps(result, ensure_ascii=False, indent=indent, sort_keys=bool(indent))) return 0 if __name__ == "__main__": raise SystemExit(main()) -
validate_skill.py 24.6 KB
#!/usr/bin/env python3 """Validate a portable local Skill Publisher Skill source directory.""" from __future__ import annotations import argparse import json import re import sys from pathlib import Path from typing import Any, Iterable try: import yaml except ImportError: print( "ERROR: PyYAML is required. Install it with: python3 -m pip install PyYAML", file=sys.stderr, ) raise SystemExit(2) FRONTMATTER_KEYS = {"name", "description", "license", "allowed-tools", "metadata"} TEXT_SUFFIXES = {".md", ".json", ".yaml", ".yml", ".txt", ".svg", ".py"} JUNK_NAMES = {"__pycache__", ".DS_Store"} JUNK_SUFFIXES = {".pyc", ".pyo"} SKIP_DIRS = {".git", "dist", ".venv", "venv", "node_modules"} SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$") NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*]\(([^)]+)\)") SKILL_PATH_RE = re.compile(r"\$(SKILL_DIR|KIT_DIR)/([A-Za-z0-9_./-]+)") CARD_STANDARD = "lovstudio/skill-card/v1" PRICING_CARD_SCHEMA = "lovstudio/pricing-card/v1" MANIFEST_SCHEMA = "skill-manifest/v1" RUNTIME_VERSION = "skill-runtime/v1" PROFILE_SCHEMA = "user-profile/v1" class ValidationFailure(Exception): """Raised when source metadata cannot be parsed.""" def read_text(path: Path) -> str: return path.read_text(encoding="utf-8") def compact_text(value: Any) -> str: return re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else "" def split_frontmatter(path: Path) -> tuple[dict[str, Any], str]: text = read_text(path) if not text.startswith("---\n"): raise ValidationFailure(f"{path}: missing YAML frontmatter") marker = text.find("\n---\n", 4) if marker < 0: raise ValidationFailure(f"{path}: frontmatter is not closed") try: data = yaml.safe_load(text[4:marker]) except yaml.YAMLError as exc: raise ValidationFailure( f"{path}: standard YAML parser rejected frontmatter: {exc}" ) from exc if not isinstance(data, dict): raise ValidationFailure(f"{path}: frontmatter must be a mapping") return data, text[marker + 5 :] def iter_files(root: Path) -> Iterable[Path]: for path in root.rglob("*"): if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue if path.is_file(): yield path def is_relative_to(path: Path, parent: Path) -> bool: try: path.relative_to(parent) return True except ValueError: return False def validate_skill_file(path: Path, errors: list[str]) -> dict[str, Any] | None: try: data, body = split_frontmatter(path) except ValidationFailure as exc: errors.append(str(exc)) return None unexpected = sorted(set(data) - FRONTMATTER_KEYS) if unexpected: errors.append(f"{path}: unsupported frontmatter keys: {', '.join(unexpected)}") name = compact_text(data.get("name")) if not NAME_RE.fullmatch(name) or len(name) > 64: errors.append(f"{path}: name must be kebab-case and at most 64 characters") description = compact_text(data.get("description")) if not 50 <= len(description) <= 200: errors.append( f"{path}: description must contain 50-200 characters " f"(found {len(description)})" ) metadata = data.get("metadata") if not isinstance(metadata, dict): errors.append(f"{path}: metadata must be a mapping") else: if not compact_text(metadata.get("author")): errors.append(f"{path}: metadata.author is required") if not SEMVER_RE.fullmatch(compact_text(metadata.get("version"))): errors.append(f"{path}: metadata.version must use SemVer") tags = metadata.get("tags") if not isinstance(tags, list) or not tags or not all( isinstance(tag, str) and tag.strip() for tag in tags ): errors.append(f"{path}: metadata.tags must be a non-empty list") dependencies = metadata.get("dependencies", []) if not isinstance(dependencies, list): errors.append(f"{path}: metadata.dependencies must be a list") if "card_standard" in metadata and metadata.get("card_standard") != CARD_STANDARD: errors.append(f"{path}: metadata.card_standard must be {CARD_STANDARD}") trigger_block = re.search( r"(?ms)^##\s+Triggers\s*$([\s\S]*?)(?=^##\s+|\Z)", body ) if not trigger_block: errors.append(f"{path}: add an explicit '## Triggers' section") else: block = trigger_block.group(1) if len(re.findall(r"(?m)^\s*-\s+\S", block)) < 3: errors.append(f"{path}: add two activation examples and one non-trigger") if not re.search(r"[\u3400-\u9fff]", block): errors.append(f"{path}: add a concrete Chinese trigger phrase") if not re.search(r"(?i)\b(?:the|a|an|create|build|help|publish|review|use)\b", block): errors.append(f"{path}: add a concrete English trigger phrase") if not re.search( r"(?mi)^###\s+(?:Do not activate when|Non-triggers?|不应触发|不要触发)\s*$", body, ): errors.append(f"{path}: add explicit non-trigger conditions") if len(read_text(path).splitlines()) >= 500: errors.append(f"{path}: keep SKILL.md below 500 lines") if not body.strip(): errors.append(f"{path}: body is empty") return data def load_yaml(path: Path, errors: list[str]) -> dict[str, Any] | None: try: data = yaml.safe_load(read_text(path)) except yaml.YAMLError as exc: errors.append(f"{path}: standard YAML parser rejected file: {exc}") return None if not isinstance(data, dict): errors.append(f"{path}: expected a YAML mapping") return None return data def validate_runtime_manifest( skill_root: Path, expected_skill_id: str, errors: list[str] ) -> None: manifest_path = skill_root / "skill.yaml" if not manifest_path.is_file(): errors.append(f"{manifest_path}: user-profile runtime manifest is required") return data = load_yaml(manifest_path, errors) if data is None: return if data.get("schema") != MANIFEST_SCHEMA: errors.append(f"{manifest_path}: schema must be {MANIFEST_SCHEMA}") if data.get("id") != expected_skill_id: errors.append(f"{manifest_path}: id must match {expected_skill_id}") version = compact_text(data.get("version")) if not SEMVER_RE.fullmatch(version): errors.append(f"{manifest_path}: version must use SemVer") if data.get("runtime") != RUNTIME_VERSION: errors.append(f"{manifest_path}: runtime must be {RUNTIME_VERSION}") context = data.get("context") if not isinstance(context, dict): errors.append(f"{manifest_path}: context must be a mapping") return profile = context.get("profile") preferences = context.get("preferences") interaction = context.get("interaction") if not isinstance(profile, dict): errors.append(f"{manifest_path}: context.profile must be a mapping") else: if profile.get("schema") != PROFILE_SCHEMA: errors.append(f"{manifest_path}: context.profile.schema must be {PROFILE_SCHEMA}") if profile.get("source") != "shared-profile": errors.append(f"{manifest_path}: context.profile.source must be shared-profile") reads = profile.get("read") if not isinstance(reads, list) or not reads or not all( isinstance(item, str) and item.strip() for item in reads ): errors.append(f"{manifest_path}: context.profile.read must be a non-empty list") persist = profile.get("persist") if not isinstance(persist, dict): errors.append(f"{manifest_path}: context.profile.persist must be a mapping") else: if persist.get("enabled") is not True: errors.append(f"{manifest_path}: profile persistence must be enabled") if persist.get("namespace") != f"skills.{expected_skill_id}": errors.append(f"{manifest_path}: persist.namespace must target skills.{expected_skill_id}") if persist.get("records_path") != f"skills.{expected_skill_id}.records": errors.append(f"{manifest_path}: persist.records_path must target Skill records") if persist.get("write_policy") != "direct-user-statement": errors.append(f"{manifest_path}: persist.write_policy must be direct-user-statement") if persist.get("atomic") is not True: errors.append(f"{manifest_path}: profile persistence must be atomic") fields = profile.get("fields") if not isinstance(fields, list) or not fields: errors.append(f"{manifest_path}: context.profile.fields must be a non-empty list") else: validate_manifest_fields(manifest_path, "profile", fields, errors) if not isinstance(preferences, dict): errors.append(f"{manifest_path}: context.preferences must be a mapping") else: fields = preferences.get("fields") if not isinstance(fields, list): errors.append(f"{manifest_path}: context.preferences.fields must be a list") else: validate_manifest_fields(manifest_path, "preferences", fields, errors) if not isinstance(interaction, dict): errors.append(f"{manifest_path}: context.interaction must be a mapping") else: if not isinstance(interaction.get("ask_missing"), bool): errors.append(f"{manifest_path}: interaction.ask_missing must be boolean") max_questions = interaction.get("max_questions") if not isinstance(max_questions, int) or isinstance(max_questions, bool) or not 1 <= max_questions <= 3: errors.append(f"{manifest_path}: interaction.max_questions must be 1-3") def validate_manifest_fields( manifest_path: Path, source_name: str, fields: list[Any], errors: list[str] ) -> None: for index, field in enumerate(fields): label = f"{manifest_path}: context.{source_name}.fields[{index}]" if not isinstance(field, dict): errors.append(f"{label} must be a mapping") continue if not compact_text(field.get("path")): errors.append(f"{label}.path is required") if not isinstance(field.get("required"), bool): errors.append(f"{label}.required must be boolean") if not compact_text(field.get("question")): errors.append(f"{label}.question is required") aliases = field.get("aliases", []) if isinstance(aliases, str): aliases = [aliases] if not isinstance(aliases, list) or not all( isinstance(alias, str) and alias.strip() for alias in aliases ): errors.append(f"{label}.aliases must be a list of paths") def has_content(value: Any) -> bool: if isinstance(value, str): return bool(value.strip()) if isinstance(value, list): return any(has_content(item) for item in value) if isinstance(value, dict): return any(has_content(item) for item in value.values()) return value is not None def contains_placeholder(value: Any) -> bool: if isinstance(value, str): return bool(re.search(r"\bTODO\b|\{[^}]+\}", value, re.I)) if isinstance(value, list): return any(contains_placeholder(item) for item in value) if isinstance(value, dict): return any(contains_placeholder(item) for item in value.values()) return False def validate_card_bundle(skill_root: Path, errors: list[str]) -> None: card_path = skill_root / "skill-card.yaml" card_doc_path = skill_root / "skill-card.md" cases_path = skill_root / "cases" / "cases.json" pricing_path = skill_root / "pricing-card.yaml" for path in (card_path, card_doc_path, cases_path, pricing_path): if not path.is_file(): errors.append(f"{path}: required Skill trust-bundle file is missing") card = load_yaml(card_path, errors) if card_path.is_file() else None if card is not None: if card.get("schema") != CARD_STANDARD: errors.append(f"{card_path}: schema must be {CARD_STANDARD}") required = ( "description", "owner", "license", "use_case", "deployment", "requirements", "risks", "references", "output", "version", "ethical_considerations", "dimensions", "pricing", "distribution", ) for key in required: if key not in card or not has_content(card.get(key)): errors.append(f"{card_path}: required field '{key}' is missing or empty") dimensions = card.get("dimensions") if not isinstance(dimensions, list) or len(dimensions) < 3: errors.append(f"{card_path}: dimensions must contain at least three named dimensions") else: ids: set[str] = set() for index, dimension in enumerate(dimensions): label = f"{card_path}: dimensions[{index}]" if not isinstance(dimension, dict): errors.append(f"{label}: expected a mapping") continue dimension_id = compact_text(dimension.get("id")) if not dimension_id or dimension_id in ids: errors.append(f"{label}: id is required and must be unique") ids.add(dimension_id) for key in ("label", "description", "evidence"): if not compact_text(dimension.get(key)): errors.append(f"{label}: '{key}' is required") risks = card.get("risks") if not isinstance(risks, list) or not risks: errors.append(f"{card_path}: risks must contain at least one risk and mitigation") else: for index, risk in enumerate(risks): if not isinstance(risk, dict) or not compact_text(risk.get("risk")) or not compact_text(risk.get("mitigation")): errors.append(f"{card_path}: risks[{index}] needs risk and mitigation") distribution = card.get("distribution") if not isinstance(distribution, dict) or not isinstance(distribution.get("paid"), list) or not isinstance(distribution.get("free"), list): errors.append(f"{card_path}: distribution must declare paid and free lists") if contains_placeholder(card): errors.append(f"{card_path}: replace unresolved TODO or template placeholders") if card_doc_path.is_file(): card_doc = read_text(card_doc_path) required_headings = ( "Description", "Owner", "License", "Use Case", "Deployment Geography", "Requirements", "Known Risks", "References", "Skill Output", "Skill Version", "Ethical Considerations", "User Cases", "Dimension Map", "Pricing Basis", "Distribution", ) for heading in required_headings: if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", card_doc): errors.append(f"{card_doc_path}: add the '{heading}' section") if re.search(r"\bTODO\b|\{[^}]+\}", card_doc, re.I): errors.append(f"{card_doc_path}: replace unresolved TODO or template placeholders") if cases_path.is_file(): try: cases = json.loads(read_text(cases_path)) except json.JSONDecodeError as exc: errors.append(f"{cases_path}: invalid JSON: {exc}") cases = [] if not isinstance(cases, list) or not cases: errors.append(f"{cases_path}: include at least one real user case") else: for index, case in enumerate(cases): label = f"{cases_path}: cases[{index}]" if not isinstance(case, dict): errors.append(f"{label}: expected a mapping") continue for key in ("title", "description", "input", "prompt", "output"): if not has_content(case.get(key)): errors.append(f"{label}: '{key}' is required") if contains_placeholder(case): errors.append(f"{label}: replace unresolved TODO or template placeholders") for image_key in ("cover", "gallery"): image_values = case.get(image_key, []) if isinstance(image_values, str): image_values = [image_values] if not isinstance(image_values, list): errors.append(f"{label}: '{image_key}' must be a string or list") continue for image in image_values: if not isinstance(image, str) or not image.strip(): errors.append(f"{label}: '{image_key}' contains an empty path") elif not re.match(r"^(?:https?:|/|data:)", image) and not (skill_root / image).is_file(): errors.append(f"{label}: case asset does not exist: {image}") pricing = load_yaml(pricing_path, errors) if pricing_path.is_file() else None if pricing is not None: if pricing.get("schema") != PRICING_CARD_SCHEMA: errors.append(f"{pricing_path}: schema must be {PRICING_CARD_SCHEMA}") for key in ("model", "currency", "list_price_cny", "basis", "boundary", "review_trigger", "confidence"): if key not in pricing or (key != "list_price_cny" and not has_content(pricing.get(key))): errors.append(f"{pricing_path}: required field '{key}' is missing or empty") if contains_placeholder(pricing): errors.append(f"{pricing_path}: replace unresolved TODO or template placeholders") def validate_composition_reference(skill_root: Path, errors: list[str]) -> None: path = skill_root / "references" / "skill-composition.md" if not path.is_file(): errors.append(f"{path}: required Skill group composition record is missing") return text = read_text(path) required_headings = ( "Nearby Skills Inspected", "Atomic Handoffs", "Overlap Decisions", "Composition Decision", ) for heading in required_headings: if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", text): errors.append(f"{path}: add the '{heading}' section") if re.search(r"\bTODO\b|\{[^}]+\}", text, re.I): errors.append(f"{path}: replace unresolved TODO or template placeholders") def validate_kit(root: Path, skill_names: set[str], errors: list[str]) -> None: manifest = root / "kit.yaml" if not manifest.exists(): return data = load_yaml(manifest, errors) if data is None: return modules = data.get("modules") if not isinstance(modules, list) or not modules: errors.append(f"{manifest}: modules must be a non-empty list") return module_ids: set[str] = set() for index, module in enumerate(modules): label = f"{manifest}: modules[{index}]" if not isinstance(module, dict): errors.append(f"{label}: expected a mapping") continue module_id = compact_text(module.get("id")) skill_name = compact_text(module.get("skill")) relative = compact_text(module.get("path")) if not module_id or module_id in module_ids: errors.append(f"{label}: id is required and must be unique") module_ids.add(module_id) module_path = (root / relative).resolve() if ( not relative or not is_relative_to(module_path, root.resolve()) or not (module_path / "SKILL.md").is_file() ): errors.append(f"{label}: missing module at '{relative}/SKILL.md'") if skill_name not in skill_names: errors.append(f"{label}: unresolved child skill '{skill_name}'") pipelines = data.get("pipelines") if not isinstance(pipelines, dict) or not pipelines: errors.append(f"{manifest}: pipelines must be a non-empty mapping") return for pipeline, sequence in pipelines.items(): if not isinstance(sequence, list) or not sequence: errors.append(f"{manifest}: pipeline '{pipeline}' must be a non-empty list") continue missing = [str(item) for item in sequence if item not in module_ids] if missing: errors.append( f"{manifest}: pipeline '{pipeline}' has unknown modules: " + ", ".join(missing) ) def validate_local_references(root: Path, errors: list[str]) -> None: for path in iter_files(root): if path.suffix.lower() != ".md": continue text = read_text(path) for raw in MARKDOWN_LINK_RE.findall(text): target = raw.strip().split(maxsplit=1)[0].strip("<>").split("#", 1)[0] if ( not target or re.match(r"^[a-z][a-z0-9+.-]*:", target, re.I) or any(token in target for token in ("TODO", "{", "}")) ): continue if not (path.parent / target).resolve().exists(): errors.append(f"{path}: broken local link '{target}'") skill_root = path.parent if path.name == "SKILL.md" else root for variable, target in SKILL_PATH_RE.findall(text): if "TODO" in target: continue base = skill_root if variable == "SKILL_DIR" else root resolved = (base / target.rstrip(".,;:)")).resolve() if not is_relative_to(resolved, root.resolve()) or not resolved.exists(): errors.append(f"{path}: missing required resource '${variable}/{target}'") def validate_hygiene(root: Path, errors: list[str]) -> None: private_path = re.compile(r"(?:/Users/[^/\s]+/|[A-Za-z]:\\\\Users\\\\[^\\\s]+\\\\)") for path in root.rglob("*"): if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue if path.name in JUNK_NAMES or path.suffix.lower() in JUNK_SUFFIXES: errors.append(f"{path}: generated/cache artifact must not ship") for path in iter_files(root): if path.suffix.lower() not in TEXT_SUFFIXES or path.name == "validate_skill.py": continue text = read_text(path) if private_path.search(text): errors.append(f"{path}: contains a private absolute user path") if path.name != "init_skill.py" and re.search(r"\bTODO\s*[::]", text): errors.append(f"{path}: unresolved TODO placeholder") for relative in ("workbuddy", "scripts/build_workbuddy.py"): if (root / relative).exists(): errors.append( f"{root / relative}: platform distribution artifacts belong to skill-publish" ) def validate_source(root: Path, errors: list[str]) -> None: root_skill = root / "SKILL.md" skill_files = [root_skill, *sorted((root / "skills").glob("*/SKILL.md"))] if not root_skill.is_file(): errors.append(f"{root_skill}: file is required") return parsed: list[tuple[Path, dict[str, Any]]] = [] for path in skill_files: data = validate_skill_file(path, errors) if data: parsed.append((path, data)) names = {compact_text(data.get("name")) for _, data in parsed} if len(names) != len(parsed): errors.append(f"{root}: every embedded Skill must have a unique name") for path, data in parsed: metadata = data.get("metadata") if isinstance(metadata, dict) and metadata.get("card_standard") == CARD_STANDARD: validate_card_bundle(path.parent, errors) validate_composition_reference(path.parent, errors) validate_runtime_manifest(path.parent, compact_text(data.get("name")), errors) validate_kit(root, names, errors) readme = root / "README.md" if not readme.is_file(): errors.append(f"{readme}: file is required") elif parsed: metadata = parsed[0][1].get("metadata") version = compact_text(metadata.get("version")) if isinstance(metadata, dict) else "" if version and f"version-{version}-" not in read_text(readme): errors.append(f"{readme}: version badge must match {version}") validate_hygiene(root, errors) validate_local_references(root, errors) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("path", type=Path, help="Local Skill source directory") args = parser.parse_args() root = args.path.expanduser().resolve() if not root.is_dir(): print(f"ERROR: directory does not exist: {root}", file=sys.stderr) return 2 errors: list[str] = [] validate_source(root, errors) if errors: print(f"FAILED: {len(errors)} issue(s)") for error in errors: print(f"- {error}") return 1 print(f"PASSED: source validation ({root})") return 0 if __name__ == "__main__": raise SystemExit(main())
-
-
skills
-
domain-cutover
-
cases
-
cases.json 661 B
[ { "type": "case", "title": "lovstudio.cn 备案后切换上线", "description": "部署已备案站点、绑定根域、配置证书与 DNS、添加 ICP 页脚,并替换旧域名。", "input": { "items": [ "域名:lovstudio.cn", "ICP备案号:京ICP备2025108424号-3", "目标:替换旧域名" ] }, "prompt": "同意部署并绑定域名,替换旧域名,验证后继续。", "output": { "items": [ "lovstudio.cn HTTPS 可访问", "ICP备案号在线展示并链接工信部系统", "旧域名解绑且目标服务拒绝旧 Host" ] } } ]
-
-
references
-
skill-card-standard.md 660 B
# LovStudio Skill Card standard `skill-card.yaml` follows the minimum release-record idea of NVIDIA Skill Cards: description, owner, license/terms, use case, deployment, requirements, risks/mitigations, references, output contract, version, and ethical considerations. LovStudio adds evidence that helps a user decide whether the Skill is credible: 1. A real user case with Input → Prompt → Output. 2. A dimension map with named evidence, not an unexplained score. 3. A pricing basis, including the free boundary and review trigger. 4. Explicit paid and free distribution states. Never claim a case, score, channel, or price that has not been verified. -
skill-composition.md 1 KB
# Skill Group Composition ## Nearby Skills Inspected | Skill or module | Classification | Decision | | --- | --- | --- | | `lov-china-website-filing` | owning Kit | 提供共享 Profile、权威规则、提交门、状态词表和统一台账。 | | `lov-dev-to-prod` | optional external atom | 只接受明确的制品级交接,不作为隐藏依赖。 | | Kit sibling modules | upstream/downstream atoms | 通过域名、阶段状态、证据时间和下一动作交接。 | ## Atomic Handoffs - 上游:icp-filing 的权威通过证据;可选 lov-dev-to-prod 的可部署网站制品 - 本模块:备案后域名上线。 - 下游:将网站开通时间、域名、IP/接入商与页脚证据交给 public-security-filing ## Overlap Decisions 只拥有备案后的大陆域名开放与合规展示,不复制通用生产构建或完整发布流程。 ## Composition Decision 本模块是 `lov-china-website-filing` 的**内嵌原子模块**。它可单独调用,但跨阶段时依赖 Kit 内共享的证据和完成门;不拆成外部安装依赖。 -
user-profile.md 507 B
# User Profile contract 本模块使用 Kit 根目录的 [user-profile/v1 contract](../../../references/user-profile.md) 和 `skills.lov-domain-cutover` 命名空间。 每次运行按当前请求、项目上下文、Skill 记录、共享偏好、品牌/用户 Profile、安全默认值的顺序解析。只有用户直接说明且希望长期复用的非敏感事实才可通过 `$KIT_DIR/scripts/profile_store.py` 持久化;证件、手机号、验证码、Cookie、密钥和扫描件不得保存。
-
-
scripts
-
profile_store.py 8.3 KB
#!/usr/bin/env python3 """Read and persist a Skill's user-owned profile context. The command keeps shared identity and brand facts in the profile root while putting Skill-specific durable records under ``skills.<skill_id>.records``. Writes require an explicit confirmation flag and use an atomic replacement. """ from __future__ import annotations import argparse import json import os import re import sys import tempfile from pathlib import Path from typing import Any, Iterable USER_PROFILE_SCHEMA = "user-profile/v1" SKILL_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") SAFE_ROOTS = {"user", "brand", "workspace", "preferences"} SENSITIVE_PARTS = {"token", "secret", "password", "credential", "cookie", "api_key", "apikey"} def config_dir() -> Path: configured = os.environ.get("SKILLS_CONFIG_DIR") if configured: return Path(os.path.expandvars(configured)).expanduser() xdg = os.environ.get("XDG_CONFIG_HOME") if xdg: return Path(os.path.expandvars(xdg)).expanduser() / "agent-skills" return Path.home() / ".config" / "agent-skills" def profile_path(explicit: Path | None = None) -> Path: if explicit: return explicit.expanduser() configured = ( os.environ.get("SKILL_PROFILE_PATH") or os.environ.get("SKILLS_PROFILE_PATH") ) if configured: return Path(os.path.expandvars(configured)).expanduser() candidates = ( Path.home() / ".lovstudio" / "skills" / "profile.json", Path.home() / ".skill-publisher" / "skills" / "profile.json", config_dir() / "profile.json", ) return next((candidate for candidate in candidates if candidate.exists()), candidates[-1]) def read_profile(path: Path) -> dict[str, Any]: if not path.exists(): return {} try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ValueError(f"profile cannot be read: {path}: {exc}") from exc if not isinstance(value, dict): raise ValueError("profile root must be an object") return value def profile_skeleton(skill_id: str) -> dict[str, Any]: return { "schema": "skill-profile/v1", "profile_id": f"profile-{skill_id}", "revision": 1, "status": "draft", "identity": {}, "purpose": {}, "brand": {}, "user": {}, "workspace": {}, "skills": {}, "extensions": {}, } def atomic_write(path: Path, value: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) mode = 0o600 if path.exists(): mode = path.stat().st_mode & 0o777 temporary_name: str | None = None try: with tempfile.NamedTemporaryFile( "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False, ) as handle: temporary_name = handle.name handle.write(json.dumps(value, ensure_ascii=False, indent=2) + "\n") handle.flush() os.fsync(handle.fileno()) os.chmod(temporary_name, mode) os.replace(temporary_name, path) finally: if temporary_name: temporary = Path(temporary_name) if temporary.exists(): temporary.unlink() def set_path(root: dict[str, Any], parts: list[str], value: Any) -> None: current: dict[str, Any] = root for part in parts[:-1]: child = current.get(part) if not isinstance(child, dict): child = {} current[part] = child current = child current[parts[-1]] = value def target_parts(skill_id: str, requested_path: str) -> tuple[list[str], str]: if not SKILL_ID_RE.fullmatch(skill_id): raise ValueError("skill_id must be kebab-case") path = requested_path.strip().strip(".") if not path: raise ValueError("path is required") parts = [part for part in path.split(".") if part] if any(not re.fullmatch(r"[A-Za-z0-9_-]+", part) for part in parts): raise ValueError("path contains an invalid segment") if any(part.casefold() in SENSITIVE_PARTS for part in parts): raise ValueError("profile records do not accept secret-like fields") if parts[0] == "records": target = ["skills", skill_id, "records", *parts[1:]] return target, ".".join(target) if parts[0] == "profile": target = ["skills", skill_id, "profile", *parts[1:]] return target, ".".join(target) if parts[:3] == ["skills", skill_id, "records"]: return parts, ".".join(parts) if parts[:3] == ["skills", skill_id, "profile"]: return parts, ".".join(parts) if parts[0] in SAFE_ROOTS: return parts, ".".join(parts) expected = f"records.<field> or one of: {', '.join(sorted(SAFE_ROOTS))}.<field>" raise ValueError(f"path must use {expected}") def parse_value(raw: str) -> Any: try: return json.loads(raw) except json.JSONDecodeError: return raw def read_command(args: argparse.Namespace) -> dict[str, Any]: path = profile_path(args.profile) profile = read_profile(path) skills = profile.get("skills", {}) if not isinstance(skills, dict): skills = {} skill = skills.get(args.skill_id, {}) if not isinstance(skill, dict): skill = {} user = profile.get("user") if not isinstance(user, dict) or not user: user = profile.get("identity", {}) if not isinstance(user, dict): user = {} brand = profile.get("brand", {}) if not isinstance(brand, dict): brand = {} workspace = profile.get("workspace", {}) if not isinstance(workspace, dict): workspace = {} preferences = profile.get("preferences", {}) if not isinstance(preferences, dict): preferences = {} records = skill.get("records", {}) if not isinstance(records, dict): records = {} return { "status": "ready", "schema": USER_PROFILE_SCHEMA, "skill_id": args.skill_id, "profile_path": str(path), "user": user, "brand": brand, "workspace": workspace, "preferences": preferences, "skill": skill, "records": records, } def record_command(args: argparse.Namespace) -> dict[str, Any]: if not args.confirm: raise PermissionError("record requires --confirm after the user has stated the value") parts, canonical_path = target_parts(args.skill_id, args.path) path = profile_path(args.profile) profile = read_profile(path) if not profile: profile = profile_skeleton(args.skill_id) set_path(profile, parts, parse_value(args.value)) revision = profile.get("revision") if isinstance(revision, int) and not isinstance(revision, bool): profile["revision"] = revision + 1 atomic_write(path, profile) return { "status": "saved", "schema": USER_PROFILE_SCHEMA, "skill_id": args.skill_id, "profile_path": str(path), "path": canonical_path, } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--profile", type=Path, default=None, help="Shared profile JSON path") subparsers = parser.add_subparsers(dest="command", required=True) read_parser = subparsers.add_parser("read", help="Read shared and Skill-specific profile context") read_parser.add_argument("--skill-id", required=True) read_parser.add_argument("--pretty", action="store_true") record_parser = subparsers.add_parser("record", help="Persist a user-stated profile value") record_parser.add_argument("--skill-id", required=True) record_parser.add_argument("--path", required=True, help="records.<field>, brand.<field>, or user.<field>") record_parser.add_argument("--value", required=True) record_parser.add_argument("--confirm", action="store_true") args = parser.parse_args() try: result = read_command(args) if args.command == "read" else record_command(args) except (OSError, PermissionError, ValueError) as exc: print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False), file=sys.stderr) return 2 indent = 2 if getattr(args, "pretty", False) else None print(json.dumps(result, ensure_ascii=False, indent=indent, sort_keys=bool(indent))) return 0 if __name__ == "__main__": raise SystemExit(main()) -
validate_skill.py 24.6 KB
#!/usr/bin/env python3 """Validate a portable local Skill Publisher Skill source directory.""" from __future__ import annotations import argparse import json import re import sys from pathlib import Path from typing import Any, Iterable try: import yaml except ImportError: print( "ERROR: PyYAML is required. Install it with: python3 -m pip install PyYAML", file=sys.stderr, ) raise SystemExit(2) FRONTMATTER_KEYS = {"name", "description", "license", "allowed-tools", "metadata"} TEXT_SUFFIXES = {".md", ".json", ".yaml", ".yml", ".txt", ".svg", ".py"} JUNK_NAMES = {"__pycache__", ".DS_Store"} JUNK_SUFFIXES = {".pyc", ".pyo"} SKIP_DIRS = {".git", "dist", ".venv", "venv", "node_modules"} SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$") NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*]\(([^)]+)\)") SKILL_PATH_RE = re.compile(r"\$(SKILL_DIR|KIT_DIR)/([A-Za-z0-9_./-]+)") CARD_STANDARD = "lovstudio/skill-card/v1" PRICING_CARD_SCHEMA = "lovstudio/pricing-card/v1" MANIFEST_SCHEMA = "skill-manifest/v1" RUNTIME_VERSION = "skill-runtime/v1" PROFILE_SCHEMA = "user-profile/v1" class ValidationFailure(Exception): """Raised when source metadata cannot be parsed.""" def read_text(path: Path) -> str: return path.read_text(encoding="utf-8") def compact_text(value: Any) -> str: return re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else "" def split_frontmatter(path: Path) -> tuple[dict[str, Any], str]: text = read_text(path) if not text.startswith("---\n"): raise ValidationFailure(f"{path}: missing YAML frontmatter") marker = text.find("\n---\n", 4) if marker < 0: raise ValidationFailure(f"{path}: frontmatter is not closed") try: data = yaml.safe_load(text[4:marker]) except yaml.YAMLError as exc: raise ValidationFailure( f"{path}: standard YAML parser rejected frontmatter: {exc}" ) from exc if not isinstance(data, dict): raise ValidationFailure(f"{path}: frontmatter must be a mapping") return data, text[marker + 5 :] def iter_files(root: Path) -> Iterable[Path]: for path in root.rglob("*"): if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue if path.is_file(): yield path def is_relative_to(path: Path, parent: Path) -> bool: try: path.relative_to(parent) return True except ValueError: return False def validate_skill_file(path: Path, errors: list[str]) -> dict[str, Any] | None: try: data, body = split_frontmatter(path) except ValidationFailure as exc: errors.append(str(exc)) return None unexpected = sorted(set(data) - FRONTMATTER_KEYS) if unexpected: errors.append(f"{path}: unsupported frontmatter keys: {', '.join(unexpected)}") name = compact_text(data.get("name")) if not NAME_RE.fullmatch(name) or len(name) > 64: errors.append(f"{path}: name must be kebab-case and at most 64 characters") description = compact_text(data.get("description")) if not 50 <= len(description) <= 200: errors.append( f"{path}: description must contain 50-200 characters " f"(found {len(description)})" ) metadata = data.get("metadata") if not isinstance(metadata, dict): errors.append(f"{path}: metadata must be a mapping") else: if not compact_text(metadata.get("author")): errors.append(f"{path}: metadata.author is required") if not SEMVER_RE.fullmatch(compact_text(metadata.get("version"))): errors.append(f"{path}: metadata.version must use SemVer") tags = metadata.get("tags") if not isinstance(tags, list) or not tags or not all( isinstance(tag, str) and tag.strip() for tag in tags ): errors.append(f"{path}: metadata.tags must be a non-empty list") dependencies = metadata.get("dependencies", []) if not isinstance(dependencies, list): errors.append(f"{path}: metadata.dependencies must be a list") if "card_standard" in metadata and metadata.get("card_standard") != CARD_STANDARD: errors.append(f"{path}: metadata.card_standard must be {CARD_STANDARD}") trigger_block = re.search( r"(?ms)^##\s+Triggers\s*$([\s\S]*?)(?=^##\s+|\Z)", body ) if not trigger_block: errors.append(f"{path}: add an explicit '## Triggers' section") else: block = trigger_block.group(1) if len(re.findall(r"(?m)^\s*-\s+\S", block)) < 3: errors.append(f"{path}: add two activation examples and one non-trigger") if not re.search(r"[\u3400-\u9fff]", block): errors.append(f"{path}: add a concrete Chinese trigger phrase") if not re.search(r"(?i)\b(?:the|a|an|create|build|help|publish|review|use)\b", block): errors.append(f"{path}: add a concrete English trigger phrase") if not re.search( r"(?mi)^###\s+(?:Do not activate when|Non-triggers?|不应触发|不要触发)\s*$", body, ): errors.append(f"{path}: add explicit non-trigger conditions") if len(read_text(path).splitlines()) >= 500: errors.append(f"{path}: keep SKILL.md below 500 lines") if not body.strip(): errors.append(f"{path}: body is empty") return data def load_yaml(path: Path, errors: list[str]) -> dict[str, Any] | None: try: data = yaml.safe_load(read_text(path)) except yaml.YAMLError as exc: errors.append(f"{path}: standard YAML parser rejected file: {exc}") return None if not isinstance(data, dict): errors.append(f"{path}: expected a YAML mapping") return None return data def validate_runtime_manifest( skill_root: Path, expected_skill_id: str, errors: list[str] ) -> None: manifest_path = skill_root / "skill.yaml" if not manifest_path.is_file(): errors.append(f"{manifest_path}: user-profile runtime manifest is required") return data = load_yaml(manifest_path, errors) if data is None: return if data.get("schema") != MANIFEST_SCHEMA: errors.append(f"{manifest_path}: schema must be {MANIFEST_SCHEMA}") if data.get("id") != expected_skill_id: errors.append(f"{manifest_path}: id must match {expected_skill_id}") version = compact_text(data.get("version")) if not SEMVER_RE.fullmatch(version): errors.append(f"{manifest_path}: version must use SemVer") if data.get("runtime") != RUNTIME_VERSION: errors.append(f"{manifest_path}: runtime must be {RUNTIME_VERSION}") context = data.get("context") if not isinstance(context, dict): errors.append(f"{manifest_path}: context must be a mapping") return profile = context.get("profile") preferences = context.get("preferences") interaction = context.get("interaction") if not isinstance(profile, dict): errors.append(f"{manifest_path}: context.profile must be a mapping") else: if profile.get("schema") != PROFILE_SCHEMA: errors.append(f"{manifest_path}: context.profile.schema must be {PROFILE_SCHEMA}") if profile.get("source") != "shared-profile": errors.append(f"{manifest_path}: context.profile.source must be shared-profile") reads = profile.get("read") if not isinstance(reads, list) or not reads or not all( isinstance(item, str) and item.strip() for item in reads ): errors.append(f"{manifest_path}: context.profile.read must be a non-empty list") persist = profile.get("persist") if not isinstance(persist, dict): errors.append(f"{manifest_path}: context.profile.persist must be a mapping") else: if persist.get("enabled") is not True: errors.append(f"{manifest_path}: profile persistence must be enabled") if persist.get("namespace") != f"skills.{expected_skill_id}": errors.append(f"{manifest_path}: persist.namespace must target skills.{expected_skill_id}") if persist.get("records_path") != f"skills.{expected_skill_id}.records": errors.append(f"{manifest_path}: persist.records_path must target Skill records") if persist.get("write_policy") != "direct-user-statement": errors.append(f"{manifest_path}: persist.write_policy must be direct-user-statement") if persist.get("atomic") is not True: errors.append(f"{manifest_path}: profile persistence must be atomic") fields = profile.get("fields") if not isinstance(fields, list) or not fields: errors.append(f"{manifest_path}: context.profile.fields must be a non-empty list") else: validate_manifest_fields(manifest_path, "profile", fields, errors) if not isinstance(preferences, dict): errors.append(f"{manifest_path}: context.preferences must be a mapping") else: fields = preferences.get("fields") if not isinstance(fields, list): errors.append(f"{manifest_path}: context.preferences.fields must be a list") else: validate_manifest_fields(manifest_path, "preferences", fields, errors) if not isinstance(interaction, dict): errors.append(f"{manifest_path}: context.interaction must be a mapping") else: if not isinstance(interaction.get("ask_missing"), bool): errors.append(f"{manifest_path}: interaction.ask_missing must be boolean") max_questions = interaction.get("max_questions") if not isinstance(max_questions, int) or isinstance(max_questions, bool) or not 1 <= max_questions <= 3: errors.append(f"{manifest_path}: interaction.max_questions must be 1-3") def validate_manifest_fields( manifest_path: Path, source_name: str, fields: list[Any], errors: list[str] ) -> None: for index, field in enumerate(fields): label = f"{manifest_path}: context.{source_name}.fields[{index}]" if not isinstance(field, dict): errors.append(f"{label} must be a mapping") continue if not compact_text(field.get("path")): errors.append(f"{label}.path is required") if not isinstance(field.get("required"), bool): errors.append(f"{label}.required must be boolean") if not compact_text(field.get("question")): errors.append(f"{label}.question is required") aliases = field.get("aliases", []) if isinstance(aliases, str): aliases = [aliases] if not isinstance(aliases, list) or not all( isinstance(alias, str) and alias.strip() for alias in aliases ): errors.append(f"{label}.aliases must be a list of paths") def has_content(value: Any) -> bool: if isinstance(value, str): return bool(value.strip()) if isinstance(value, list): return any(has_content(item) for item in value) if isinstance(value, dict): return any(has_content(item) for item in value.values()) return value is not None def contains_placeholder(value: Any) -> bool: if isinstance(value, str): return bool(re.search(r"\bTODO\b|\{[^}]+\}", value, re.I)) if isinstance(value, list): return any(contains_placeholder(item) for item in value) if isinstance(value, dict): return any(contains_placeholder(item) for item in value.values()) return False def validate_card_bundle(skill_root: Path, errors: list[str]) -> None: card_path = skill_root / "skill-card.yaml" card_doc_path = skill_root / "skill-card.md" cases_path = skill_root / "cases" / "cases.json" pricing_path = skill_root / "pricing-card.yaml" for path in (card_path, card_doc_path, cases_path, pricing_path): if not path.is_file(): errors.append(f"{path}: required Skill trust-bundle file is missing") card = load_yaml(card_path, errors) if card_path.is_file() else None if card is not None: if card.get("schema") != CARD_STANDARD: errors.append(f"{card_path}: schema must be {CARD_STANDARD}") required = ( "description", "owner", "license", "use_case", "deployment", "requirements", "risks", "references", "output", "version", "ethical_considerations", "dimensions", "pricing", "distribution", ) for key in required: if key not in card or not has_content(card.get(key)): errors.append(f"{card_path}: required field '{key}' is missing or empty") dimensions = card.get("dimensions") if not isinstance(dimensions, list) or len(dimensions) < 3: errors.append(f"{card_path}: dimensions must contain at least three named dimensions") else: ids: set[str] = set() for index, dimension in enumerate(dimensions): label = f"{card_path}: dimensions[{index}]" if not isinstance(dimension, dict): errors.append(f"{label}: expected a mapping") continue dimension_id = compact_text(dimension.get("id")) if not dimension_id or dimension_id in ids: errors.append(f"{label}: id is required and must be unique") ids.add(dimension_id) for key in ("label", "description", "evidence"): if not compact_text(dimension.get(key)): errors.append(f"{label}: '{key}' is required") risks = card.get("risks") if not isinstance(risks, list) or not risks: errors.append(f"{card_path}: risks must contain at least one risk and mitigation") else: for index, risk in enumerate(risks): if not isinstance(risk, dict) or not compact_text(risk.get("risk")) or not compact_text(risk.get("mitigation")): errors.append(f"{card_path}: risks[{index}] needs risk and mitigation") distribution = card.get("distribution") if not isinstance(distribution, dict) or not isinstance(distribution.get("paid"), list) or not isinstance(distribution.get("free"), list): errors.append(f"{card_path}: distribution must declare paid and free lists") if contains_placeholder(card): errors.append(f"{card_path}: replace unresolved TODO or template placeholders") if card_doc_path.is_file(): card_doc = read_text(card_doc_path) required_headings = ( "Description", "Owner", "License", "Use Case", "Deployment Geography", "Requirements", "Known Risks", "References", "Skill Output", "Skill Version", "Ethical Considerations", "User Cases", "Dimension Map", "Pricing Basis", "Distribution", ) for heading in required_headings: if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", card_doc): errors.append(f"{card_doc_path}: add the '{heading}' section") if re.search(r"\bTODO\b|\{[^}]+\}", card_doc, re.I): errors.append(f"{card_doc_path}: replace unresolved TODO or template placeholders") if cases_path.is_file(): try: cases = json.loads(read_text(cases_path)) except json.JSONDecodeError as exc: errors.append(f"{cases_path}: invalid JSON: {exc}") cases = [] if not isinstance(cases, list) or not cases: errors.append(f"{cases_path}: include at least one real user case") else: for index, case in enumerate(cases): label = f"{cases_path}: cases[{index}]" if not isinstance(case, dict): errors.append(f"{label}: expected a mapping") continue for key in ("title", "description", "input", "prompt", "output"): if not has_content(case.get(key)): errors.append(f"{label}: '{key}' is required") if contains_placeholder(case): errors.append(f"{label}: replace unresolved TODO or template placeholders") for image_key in ("cover", "gallery"): image_values = case.get(image_key, []) if isinstance(image_values, str): image_values = [image_values] if not isinstance(image_values, list): errors.append(f"{label}: '{image_key}' must be a string or list") continue for image in image_values: if not isinstance(image, str) or not image.strip(): errors.append(f"{label}: '{image_key}' contains an empty path") elif not re.match(r"^(?:https?:|/|data:)", image) and not (skill_root / image).is_file(): errors.append(f"{label}: case asset does not exist: {image}") pricing = load_yaml(pricing_path, errors) if pricing_path.is_file() else None if pricing is not None: if pricing.get("schema") != PRICING_CARD_SCHEMA: errors.append(f"{pricing_path}: schema must be {PRICING_CARD_SCHEMA}") for key in ("model", "currency", "list_price_cny", "basis", "boundary", "review_trigger", "confidence"): if key not in pricing or (key != "list_price_cny" and not has_content(pricing.get(key))): errors.append(f"{pricing_path}: required field '{key}' is missing or empty") if contains_placeholder(pricing): errors.append(f"{pricing_path}: replace unresolved TODO or template placeholders") def validate_composition_reference(skill_root: Path, errors: list[str]) -> None: path = skill_root / "references" / "skill-composition.md" if not path.is_file(): errors.append(f"{path}: required Skill group composition record is missing") return text = read_text(path) required_headings = ( "Nearby Skills Inspected", "Atomic Handoffs", "Overlap Decisions", "Composition Decision", ) for heading in required_headings: if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", text): errors.append(f"{path}: add the '{heading}' section") if re.search(r"\bTODO\b|\{[^}]+\}", text, re.I): errors.append(f"{path}: replace unresolved TODO or template placeholders") def validate_kit(root: Path, skill_names: set[str], errors: list[str]) -> None: manifest = root / "kit.yaml" if not manifest.exists(): return data = load_yaml(manifest, errors) if data is None: return modules = data.get("modules") if not isinstance(modules, list) or not modules: errors.append(f"{manifest}: modules must be a non-empty list") return module_ids: set[str] = set() for index, module in enumerate(modules): label = f"{manifest}: modules[{index}]" if not isinstance(module, dict): errors.append(f"{label}: expected a mapping") continue module_id = compact_text(module.get("id")) skill_name = compact_text(module.get("skill")) relative = compact_text(module.get("path")) if not module_id or module_id in module_ids: errors.append(f"{label}: id is required and must be unique") module_ids.add(module_id) module_path = (root / relative).resolve() if ( not relative or not is_relative_to(module_path, root.resolve()) or not (module_path / "SKILL.md").is_file() ): errors.append(f"{label}: missing module at '{relative}/SKILL.md'") if skill_name not in skill_names: errors.append(f"{label}: unresolved child skill '{skill_name}'") pipelines = data.get("pipelines") if not isinstance(pipelines, dict) or not pipelines: errors.append(f"{manifest}: pipelines must be a non-empty mapping") return for pipeline, sequence in pipelines.items(): if not isinstance(sequence, list) or not sequence: errors.append(f"{manifest}: pipeline '{pipeline}' must be a non-empty list") continue missing = [str(item) for item in sequence if item not in module_ids] if missing: errors.append( f"{manifest}: pipeline '{pipeline}' has unknown modules: " + ", ".join(missing) ) def validate_local_references(root: Path, errors: list[str]) -> None: for path in iter_files(root): if path.suffix.lower() != ".md": continue text = read_text(path) for raw in MARKDOWN_LINK_RE.findall(text): target = raw.strip().split(maxsplit=1)[0].strip("<>").split("#", 1)[0] if ( not target or re.match(r"^[a-z][a-z0-9+.-]*:", target, re.I) or any(token in target for token in ("TODO", "{", "}")) ): continue if not (path.parent / target).resolve().exists(): errors.append(f"{path}: broken local link '{target}'") skill_root = path.parent if path.name == "SKILL.md" else root for variable, target in SKILL_PATH_RE.findall(text): if "TODO" in target: continue base = skill_root if variable == "SKILL_DIR" else root resolved = (base / target.rstrip(".,;:)")).resolve() if not is_relative_to(resolved, root.resolve()) or not resolved.exists(): errors.append(f"{path}: missing required resource '${variable}/{target}'") def validate_hygiene(root: Path, errors: list[str]) -> None: private_path = re.compile(r"(?:/Users/[^/\s]+/|[A-Za-z]:\\\\Users\\\\[^\\\s]+\\\\)") for path in root.rglob("*"): if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue if path.name in JUNK_NAMES or path.suffix.lower() in JUNK_SUFFIXES: errors.append(f"{path}: generated/cache artifact must not ship") for path in iter_files(root): if path.suffix.lower() not in TEXT_SUFFIXES or path.name == "validate_skill.py": continue text = read_text(path) if private_path.search(text): errors.append(f"{path}: contains a private absolute user path") if path.name != "init_skill.py" and re.search(r"\bTODO\s*[::]", text): errors.append(f"{path}: unresolved TODO placeholder") for relative in ("workbuddy", "scripts/build_workbuddy.py"): if (root / relative).exists(): errors.append( f"{root / relative}: platform distribution artifacts belong to skill-publish" ) def validate_source(root: Path, errors: list[str]) -> None: root_skill = root / "SKILL.md" skill_files = [root_skill, *sorted((root / "skills").glob("*/SKILL.md"))] if not root_skill.is_file(): errors.append(f"{root_skill}: file is required") return parsed: list[tuple[Path, dict[str, Any]]] = [] for path in skill_files: data = validate_skill_file(path, errors) if data: parsed.append((path, data)) names = {compact_text(data.get("name")) for _, data in parsed} if len(names) != len(parsed): errors.append(f"{root}: every embedded Skill must have a unique name") for path, data in parsed: metadata = data.get("metadata") if isinstance(metadata, dict) and metadata.get("card_standard") == CARD_STANDARD: validate_card_bundle(path.parent, errors) validate_composition_reference(path.parent, errors) validate_runtime_manifest(path.parent, compact_text(data.get("name")), errors) validate_kit(root, names, errors) readme = root / "README.md" if not readme.is_file(): errors.append(f"{readme}: file is required") elif parsed: metadata = parsed[0][1].get("metadata") version = compact_text(metadata.get("version")) if isinstance(metadata, dict) else "" if version and f"version-{version}-" not in read_text(readme): errors.append(f"{readme}: version badge must match {version}") validate_hygiene(root, errors) validate_local_references(root, errors) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("path", type=Path, help="Local Skill source directory") args = parser.parse_args() root = args.path.expanduser().resolve() if not root.is_dir(): print(f"ERROR: directory does not exist: {root}", file=sys.stderr) return 2 errors: list[str] = [] validate_source(root, errors) if errors: print(f"FAILED: {len(errors)} issue(s)") for error in errors: print(f"- {error}") return 1 print(f"PASSED: source validation ({root})") return 0 if __name__ == "__main__": raise SystemExit(main())
-
-
pricing-card.yaml 546 B
schema: lovstudio/pricing-card/v1 version: "0.1.0" model: free currency: CNY list_price_cny: 0 value_anchor: "防止未备案提前开放、证书主机名错误、DNS 误切和备案号只在代码中存在却未上线。" basis: "作为 lov-china-website-filing 的自包含模块免费提供。" boundary: "包含本阶段流程、证据门与输出契约;不包含代办保证、政府/云费用或法律意见。" review_trigger: "法规、平台、案例覆盖或人工服务边界重大变化时复评。" confidence: internal-verified -
skill-card.md 2.1 KB
# 备案域名上线 · Domain Launch · Skill Card ## Description 在 ICP 通过后安全配置部署、DNS、证书和备案页脚,用真实域名回读验证 HTTPS 与站点内容,并按授权处理旧域名。 ## Owner LovStudio;由本地仓库维护者负责维护。 ## License / Terms MIT。可使用、修改和分发;实时监管结论以主管部门和接入商页面为准。 ## Use Case 面向已取得 ICP 服务备案号并准备开放中国大陆网站的站点负责人。核心任务:绑定备案域名并配置 DNS/TLS;添加并核验 ICP 服务备案号;按明确授权处理旧域名并保留回读证据。 ## Deployment Geography 中国大陆备案场景;作为 `lov-china-website-filing` 的自包含模块运行。 ## Requirements / Dependencies 离线输出无外部依赖;实时操作使用用户自己的已登录会话。不得持久化验证码、Cookie 或完整证件数据。 ## Known Risks and Mitigations 平台与地方规则可能变化,关键动作前重读权威页面;敏感资料最小化处理;完成状态必须满足本模块的真实证据门。 ## References - [Module instructions](SKILL.md) - [Composition record](references/skill-composition.md) ## Skill Output Markdown 上线验收报告,包含 DNS、证书、HTTP 页面、备案页脚和旧域名结果。 ## Skill Version 0.1.0 ## Ethical Considerations 不伪造材料、不规避监管、不把推断写成权威结论;关键提交保留用户授权。 ## LovStudio Evidence ### User Cases 见 [cases/cases.json](cases/cases.json):lovstudio.cn 备案后切换上线。 ### Dimension Map - 正确性:单一真实案例已验证。 - 安全性:提交门与隐私边界已审阅。 - 可追溯性:来源、时间、状态和下一动作已结构化。 当前不设置缺少多地区基线的数字评分。 ### Pricing Basis 免费内嵌模块。防止未备案提前开放、证书主机名错误、DNS 误切和备案号只在代码中存在却未上线。 不包含代办保证、政府/云费用或法律意见。 ### Distribution `lovstudio` 为本地 Kit 内嵌;`github`、`workbuddy`、`skillpay` 均未发布。 -
skill-card.yaml 3.3 KB
schema: lovstudio/skill-card/v1 version: "0.1.0" description: "在 ICP 通过后安全配置部署、DNS、证书和备案页脚,用真实域名回读验证 HTTPS 与站点内容,并按授权处理旧域名。" owner: team: LovStudio contact: local repository maintainers license: name: MIT terms: "可使用、修改和分发;实时监管结论以主管部门和接入商页面为准。" url: "../../LICENSE" use_case: audience: "已取得 ICP 服务备案号并准备开放中国大陆网站的站点负责人" scenario: "备案后域名上线" tasks: - "绑定备案域名并配置 DNS/TLS" - "添加并核验 ICP 服务备案号" - "按明确授权处理旧域名并保留回读证据" deployment: geography: mainland-china environments: - "lov-china-website-filing self-contained Skill Kit" requirements: credentials: "离线输出不需要凭据;实时页面使用用户自己的已登录会话。" dependencies: [] runtime: - "Portable Agent Skills" risks: - risk: "平台或地方规则变化导致旧字段和时限失效。" mitigation: "关键动作前重新读取政府或接入商权威页面并记录时间。" - risk: "敏感身份信息被复制到持久输出。" mitigation: "只保存公开标识和脱敏证据,不保存验证码、Cookie 或完整证件数据。" references: - title: "Module instructions" path: "SKILL.md" - title: "Composition record" path: "references/skill-composition.md" output: types: - "备案后域名上线" formats: - markdown parameters: - "subject, service, domain, authority evidence" validation: - "authoritative evidence and timestamp" - "stage-specific completion gate" description: "Markdown 上线验收报告,包含 DNS、证书、HTTP 页面、备案页脚和旧域名结果。" ethical_considerations: "不伪造材料、不规避监管、不把推断写成权威结论;关键提交保留用户授权。" dimensions: - id: correctness label: "正确性" description: "使用与本阶段匹配的权威来源和完成门。" evidence: "lovstudio.cn 在 ICP 通过后上线并显示京ICP备2025108424号-3;旧 cs-magic.cn 经授权解绑后返回 INVALID_HOST。" score: null status: verified-with-one-real-case - id: safety label: "安全性" description: "保护敏感资料并保留人工提交门。" evidence: "模块遵循 Kit 的 authority-gates.md。" score: null status: policy-reviewed - id: traceability label: "可追溯性" description: "输出记录来源、时间、状态和下一动作。" evidence: "真实案例和统一台账字段已记录。" score: null status: locally-validated pricing: model: free currency: CNY list_price_cny: 0 basis: "防止未备案提前开放、证书主机名错误、DNS 误切和备案号只在代码中存在却未上线。" boundary: "不包含代办保证、政府/云费用或法律意见。" review_trigger: "法规、平台或人工服务边界重大变化时复评。" confidence: internal-verified distribution: paid: - channel: workbuddy status: not-published - channel: skillpay status: not-published free: - channel: github status: not-published - channel: lovstudio status: embedded-local -
SKILL.md 2.4 KB
--- name: lov-domain-cutover description: > 在 ICP 通过后把备案域名安全切换到中国大陆网站,核验部署、DNS、证书、HTTPS、ICP 页脚和旧域名处理;用户说“备案通过后绑定域名”或 "cut over the filed domain" 时使用。 license: MIT metadata: author: LovStudio version: "0.1.0" card_standard: lovstudio/skill-card/v1 tags: - domain-cutover - dns - tls - icp-footer compatibility: "Embedded module of lov-china-website-filing; provider-specific tools are selected at runtime." dependencies: [] --- # 备案域名上线 · Domain Launch ## Input and output - 输入:ICP 通过证据与服务备案号、目标域名、部署服务、DNS/证书现状和旧域名策略。 - 输出:可访问的 HTTPS 域名、ICP备案展示、旧域名结果和真实回读证据。 ## Triggers ### Activate when - 用户说“备案通过了,部署并绑定域名”“把新域名替换旧域名”“网站底部加 ICP 号”。 - User asks to "cut over the filed domain" or "verify DNS, TLS, and the ICP footer". ### Do not activate when - ICP 尚未通过或服务备案号无法权威回读;不得开放中国大陆网站,返回上游。 - 只做通用构建、不涉及备案域名开放;使用生产构建或部署能力。 ## Workflow (MANDATORY) 1. 读取 `$KIT_DIR/references/status-taxonomy.md`、`authority-gates.md` 和本模块组合记录。 2. 重新核验 ICP 通过、备案号、域名和目标接入资源的对应关系。 3. 先验证可部署制品和服务健康,再申请/绑定证书并配置 DNS;不要用浏览器缓存代替 DNS/TLS/HTTP 检查。 4. 仅在用户明确授权时解绑、替换或使旧域名失效。保留可回滚路径,避免误删 DNS 区域或证书。 5. 在首页底部展示准确服务备案号并链接 `https://beian.miit.gov.cn/`;按当前管局/接入商要求核验根域与 `www`。 6. 从外部真实访问域名,检查解析、证书主机名、HTTP 状态、预期页面、备案号文字与链接。 7. 输出 `completed` 或 `partially-verified`,并向公安备案模块交接网站开通时间、域名、IP/接入商和公开页脚证据。 ## Completion gate 控制台显示“绑定成功”不够;DNS、TLS、页面内容和 ICP 页脚必须从真实域名回读。旧域名结果必须与用户授权一致。 ## Dependencies The site's existing deployment, DNS and certificate providers; no provider is hard-coded. -
skill.yaml 1.6 KB
schema: skill-manifest/v1 id: lov-domain-cutover version: "0.1.0" runtime: skill-runtime/v1 context: profile: schema: user-profile/v1 source: shared-profile read: - user - brand - workspace - preferences - skills.lov-domain-cutover persist: enabled: true namespace: skills.lov-domain-cutover records_path: skills.lov-domain-cutover.records write_policy: direct-user-statement atomic: true fields: - path: user.name aliases: - identity.name required: false question: 如果本次输出需要用户身份,请提供名称。 - path: user.language required: false question: 希望使用哪种语言输出? - path: user.timezone required: false question: 需要使用哪个时区处理日期和时间? - path: brand.name aliases: - identity.name required: false question: 如果本次输出需要品牌身份,请提供品牌名称。 - path: brand.site required: false question: 如果需要品牌官网,请提供地址。 - path: brand.tone required: false question: 如果已有品牌语气或审美关键词,请提供它们。 preferences: namespace: lov_domain_cutover fields: - path: user.language required: false question: 希望使用哪种语言输出? - path: user.timezone required: false question: 需要使用哪个时区处理日期和时间? interaction: ask_missing: true max_questions: 1
-
-
filing-monitor
-
cases
-
cases.json 741 B
[ { "type": "case", "title": "lovstudio.cn ICP 每日静默巡检", "description": "每日检查指定腾讯云订单,仅在状态变化或需要登录、短信核验、补充材料时通知。", "input": { "items": [ "订单:30178537319491747", "频率:每天北京时间 10:00", "策略:无变化且无需动作时静默" ] }, "prompt": "每天检查最新状态,只追加巡检记录;有变化或需要我操作时再通知。", "output": { "items": [ "权威页面为唯一当前状态来源", "登录失效记录为 unable-to-verify", "无变化只追加记录", "达到最终权威门后才暂停" ] } } ]
-
-
references
-
skill-card-standard.md 660 B
# LovStudio Skill Card standard `skill-card.yaml` follows the minimum release-record idea of NVIDIA Skill Cards: description, owner, license/terms, use case, deployment, requirements, risks/mitigations, references, output contract, version, and ethical considerations. LovStudio adds evidence that helps a user decide whether the Skill is credible: 1. A real user case with Input → Prompt → Output. 2. A dimension map with named evidence, not an unexplained score. 3. A pricing basis, including the free boundary and review trigger. 4. Explicit paid and free distribution states. Never claim a case, score, channel, or price that has not been verified. -
skill-composition.md 1 KB
# Skill Group Composition ## Nearby Skills Inspected | Skill or module | Classification | Decision | | --- | --- | --- | | `lov-china-website-filing` | owning Kit | 提供共享 Profile、权威规则、提交门、状态词表和统一台账。 | | `automation-workflows` | optional external atom | 只接受明确的制品级交接,不作为隐藏依赖。 | | Kit sibling modules | upstream/downstream atoms | 通过域名、阶段状态、证据时间和下一动作交接。 | ## Atomic Handoffs - 上游:任一备案模块提供的权威入口、上一状态、完成门与通知策略 - 本模块:备案状态巡检。 - 下游:宿主调度/通知系统只消费 changed、needs_user_action 和完成信号 ## Overlap Decisions 内嵌状态比较和备案语义;通用自动化只负责触发与投递,不重新解释状态。 ## Composition Decision 本模块是 `lov-china-website-filing` 的**内嵌原子模块**。它可单独调用,但跨阶段时依赖 Kit 内共享的证据和完成门;不拆成外部安装依赖。 -
user-profile.md 507 B
# User Profile contract 本模块使用 Kit 根目录的 [user-profile/v1 contract](../../../references/user-profile.md) 和 `skills.lov-filing-monitor` 命名空间。 每次运行按当前请求、项目上下文、Skill 记录、共享偏好、品牌/用户 Profile、安全默认值的顺序解析。只有用户直接说明且希望长期复用的非敏感事实才可通过 `$KIT_DIR/scripts/profile_store.py` 持久化;证件、手机号、验证码、Cookie、密钥和扫描件不得保存。
-
-
scripts
-
profile_store.py 8.3 KB
#!/usr/bin/env python3 """Read and persist a Skill's user-owned profile context. The command keeps shared identity and brand facts in the profile root while putting Skill-specific durable records under ``skills.<skill_id>.records``. Writes require an explicit confirmation flag and use an atomic replacement. """ from __future__ import annotations import argparse import json import os import re import sys import tempfile from pathlib import Path from typing import Any, Iterable USER_PROFILE_SCHEMA = "user-profile/v1" SKILL_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") SAFE_ROOTS = {"user", "brand", "workspace", "preferences"} SENSITIVE_PARTS = {"token", "secret", "password", "credential", "cookie", "api_key", "apikey"} def config_dir() -> Path: configured = os.environ.get("SKILLS_CONFIG_DIR") if configured: return Path(os.path.expandvars(configured)).expanduser() xdg = os.environ.get("XDG_CONFIG_HOME") if xdg: return Path(os.path.expandvars(xdg)).expanduser() / "agent-skills" return Path.home() / ".config" / "agent-skills" def profile_path(explicit: Path | None = None) -> Path: if explicit: return explicit.expanduser() configured = ( os.environ.get("SKILL_PROFILE_PATH") or os.environ.get("SKILLS_PROFILE_PATH") ) if configured: return Path(os.path.expandvars(configured)).expanduser() candidates = ( Path.home() / ".lovstudio" / "skills" / "profile.json", Path.home() / ".skill-publisher" / "skills" / "profile.json", config_dir() / "profile.json", ) return next((candidate for candidate in candidates if candidate.exists()), candidates[-1]) def read_profile(path: Path) -> dict[str, Any]: if not path.exists(): return {} try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ValueError(f"profile cannot be read: {path}: {exc}") from exc if not isinstance(value, dict): raise ValueError("profile root must be an object") return value def profile_skeleton(skill_id: str) -> dict[str, Any]: return { "schema": "skill-profile/v1", "profile_id": f"profile-{skill_id}", "revision": 1, "status": "draft", "identity": {}, "purpose": {}, "brand": {}, "user": {}, "workspace": {}, "skills": {}, "extensions": {}, } def atomic_write(path: Path, value: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) mode = 0o600 if path.exists(): mode = path.stat().st_mode & 0o777 temporary_name: str | None = None try: with tempfile.NamedTemporaryFile( "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False, ) as handle: temporary_name = handle.name handle.write(json.dumps(value, ensure_ascii=False, indent=2) + "\n") handle.flush() os.fsync(handle.fileno()) os.chmod(temporary_name, mode) os.replace(temporary_name, path) finally: if temporary_name: temporary = Path(temporary_name) if temporary.exists(): temporary.unlink() def set_path(root: dict[str, Any], parts: list[str], value: Any) -> None: current: dict[str, Any] = root for part in parts[:-1]: child = current.get(part) if not isinstance(child, dict): child = {} current[part] = child current = child current[parts[-1]] = value def target_parts(skill_id: str, requested_path: str) -> tuple[list[str], str]: if not SKILL_ID_RE.fullmatch(skill_id): raise ValueError("skill_id must be kebab-case") path = requested_path.strip().strip(".") if not path: raise ValueError("path is required") parts = [part for part in path.split(".") if part] if any(not re.fullmatch(r"[A-Za-z0-9_-]+", part) for part in parts): raise ValueError("path contains an invalid segment") if any(part.casefold() in SENSITIVE_PARTS for part in parts): raise ValueError("profile records do not accept secret-like fields") if parts[0] == "records": target = ["skills", skill_id, "records", *parts[1:]] return target, ".".join(target) if parts[0] == "profile": target = ["skills", skill_id, "profile", *parts[1:]] return target, ".".join(target) if parts[:3] == ["skills", skill_id, "records"]: return parts, ".".join(parts) if parts[:3] == ["skills", skill_id, "profile"]: return parts, ".".join(parts) if parts[0] in SAFE_ROOTS: return parts, ".".join(parts) expected = f"records.<field> or one of: {', '.join(sorted(SAFE_ROOTS))}.<field>" raise ValueError(f"path must use {expected}") def parse_value(raw: str) -> Any: try: return json.loads(raw) except json.JSONDecodeError: return raw def read_command(args: argparse.Namespace) -> dict[str, Any]: path = profile_path(args.profile) profile = read_profile(path) skills = profile.get("skills", {}) if not isinstance(skills, dict): skills = {} skill = skills.get(args.skill_id, {}) if not isinstance(skill, dict): skill = {} user = profile.get("user") if not isinstance(user, dict) or not user: user = profile.get("identity", {}) if not isinstance(user, dict): user = {} brand = profile.get("brand", {}) if not isinstance(brand, dict): brand = {} workspace = profile.get("workspace", {}) if not isinstance(workspace, dict): workspace = {} preferences = profile.get("preferences", {}) if not isinstance(preferences, dict): preferences = {} records = skill.get("records", {}) if not isinstance(records, dict): records = {} return { "status": "ready", "schema": USER_PROFILE_SCHEMA, "skill_id": args.skill_id, "profile_path": str(path), "user": user, "brand": brand, "workspace": workspace, "preferences": preferences, "skill": skill, "records": records, } def record_command(args: argparse.Namespace) -> dict[str, Any]: if not args.confirm: raise PermissionError("record requires --confirm after the user has stated the value") parts, canonical_path = target_parts(args.skill_id, args.path) path = profile_path(args.profile) profile = read_profile(path) if not profile: profile = profile_skeleton(args.skill_id) set_path(profile, parts, parse_value(args.value)) revision = profile.get("revision") if isinstance(revision, int) and not isinstance(revision, bool): profile["revision"] = revision + 1 atomic_write(path, profile) return { "status": "saved", "schema": USER_PROFILE_SCHEMA, "skill_id": args.skill_id, "profile_path": str(path), "path": canonical_path, } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--profile", type=Path, default=None, help="Shared profile JSON path") subparsers = parser.add_subparsers(dest="command", required=True) read_parser = subparsers.add_parser("read", help="Read shared and Skill-specific profile context") read_parser.add_argument("--skill-id", required=True) read_parser.add_argument("--pretty", action="store_true") record_parser = subparsers.add_parser("record", help="Persist a user-stated profile value") record_parser.add_argument("--skill-id", required=True) record_parser.add_argument("--path", required=True, help="records.<field>, brand.<field>, or user.<field>") record_parser.add_argument("--value", required=True) record_parser.add_argument("--confirm", action="store_true") args = parser.parse_args() try: result = read_command(args) if args.command == "read" else record_command(args) except (OSError, PermissionError, ValueError) as exc: print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False), file=sys.stderr) return 2 indent = 2 if getattr(args, "pretty", False) else None print(json.dumps(result, ensure_ascii=False, indent=indent, sort_keys=bool(indent))) return 0 if __name__ == "__main__": raise SystemExit(main()) -
validate_skill.py 24.6 KB
#!/usr/bin/env python3 """Validate a portable local Skill Publisher Skill source directory.""" from __future__ import annotations import argparse import json import re import sys from pathlib import Path from typing import Any, Iterable try: import yaml except ImportError: print( "ERROR: PyYAML is required. Install it with: python3 -m pip install PyYAML", file=sys.stderr, ) raise SystemExit(2) FRONTMATTER_KEYS = {"name", "description", "license", "allowed-tools", "metadata"} TEXT_SUFFIXES = {".md", ".json", ".yaml", ".yml", ".txt", ".svg", ".py"} JUNK_NAMES = {"__pycache__", ".DS_Store"} JUNK_SUFFIXES = {".pyc", ".pyo"} SKIP_DIRS = {".git", "dist", ".venv", "venv", "node_modules"} SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$") NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*]\(([^)]+)\)") SKILL_PATH_RE = re.compile(r"\$(SKILL_DIR|KIT_DIR)/([A-Za-z0-9_./-]+)") CARD_STANDARD = "lovstudio/skill-card/v1" PRICING_CARD_SCHEMA = "lovstudio/pricing-card/v1" MANIFEST_SCHEMA = "skill-manifest/v1" RUNTIME_VERSION = "skill-runtime/v1" PROFILE_SCHEMA = "user-profile/v1" class ValidationFailure(Exception): """Raised when source metadata cannot be parsed.""" def read_text(path: Path) -> str: return path.read_text(encoding="utf-8") def compact_text(value: Any) -> str: return re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else "" def split_frontmatter(path: Path) -> tuple[dict[str, Any], str]: text = read_text(path) if not text.startswith("---\n"): raise ValidationFailure(f"{path}: missing YAML frontmatter") marker = text.find("\n---\n", 4) if marker < 0: raise ValidationFailure(f"{path}: frontmatter is not closed") try: data = yaml.safe_load(text[4:marker]) except yaml.YAMLError as exc: raise ValidationFailure( f"{path}: standard YAML parser rejected frontmatter: {exc}" ) from exc if not isinstance(data, dict): raise ValidationFailure(f"{path}: frontmatter must be a mapping") return data, text[marker + 5 :] def iter_files(root: Path) -> Iterable[Path]: for path in root.rglob("*"): if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue if path.is_file(): yield path def is_relative_to(path: Path, parent: Path) -> bool: try: path.relative_to(parent) return True except ValueError: return False def validate_skill_file(path: Path, errors: list[str]) -> dict[str, Any] | None: try: data, body = split_frontmatter(path) except ValidationFailure as exc: errors.append(str(exc)) return None unexpected = sorted(set(data) - FRONTMATTER_KEYS) if unexpected: errors.append(f"{path}: unsupported frontmatter keys: {', '.join(unexpected)}") name = compact_text(data.get("name")) if not NAME_RE.fullmatch(name) or len(name) > 64: errors.append(f"{path}: name must be kebab-case and at most 64 characters") description = compact_text(data.get("description")) if not 50 <= len(description) <= 200: errors.append( f"{path}: description must contain 50-200 characters " f"(found {len(description)})" ) metadata = data.get("metadata") if not isinstance(metadata, dict): errors.append(f"{path}: metadata must be a mapping") else: if not compact_text(metadata.get("author")): errors.append(f"{path}: metadata.author is required") if not SEMVER_RE.fullmatch(compact_text(metadata.get("version"))): errors.append(f"{path}: metadata.version must use SemVer") tags = metadata.get("tags") if not isinstance(tags, list) or not tags or not all( isinstance(tag, str) and tag.strip() for tag in tags ): errors.append(f"{path}: metadata.tags must be a non-empty list") dependencies = metadata.get("dependencies", []) if not isinstance(dependencies, list): errors.append(f"{path}: metadata.dependencies must be a list") if "card_standard" in metadata and metadata.get("card_standard") != CARD_STANDARD: errors.append(f"{path}: metadata.card_standard must be {CARD_STANDARD}") trigger_block = re.search( r"(?ms)^##\s+Triggers\s*$([\s\S]*?)(?=^##\s+|\Z)", body ) if not trigger_block: errors.append(f"{path}: add an explicit '## Triggers' section") else: block = trigger_block.group(1) if len(re.findall(r"(?m)^\s*-\s+\S", block)) < 3: errors.append(f"{path}: add two activation examples and one non-trigger") if not re.search(r"[\u3400-\u9fff]", block): errors.append(f"{path}: add a concrete Chinese trigger phrase") if not re.search(r"(?i)\b(?:the|a|an|create|build|help|publish|review|use)\b", block): errors.append(f"{path}: add a concrete English trigger phrase") if not re.search( r"(?mi)^###\s+(?:Do not activate when|Non-triggers?|不应触发|不要触发)\s*$", body, ): errors.append(f"{path}: add explicit non-trigger conditions") if len(read_text(path).splitlines()) >= 500: errors.append(f"{path}: keep SKILL.md below 500 lines") if not body.strip(): errors.append(f"{path}: body is empty") return data def load_yaml(path: Path, errors: list[str]) -> dict[str, Any] | None: try: data = yaml.safe_load(read_text(path)) except yaml.YAMLError as exc: errors.append(f"{path}: standard YAML parser rejected file: {exc}") return None if not isinstance(data, dict): errors.append(f"{path}: expected a YAML mapping") return None return data def validate_runtime_manifest( skill_root: Path, expected_skill_id: str, errors: list[str] ) -> None: manifest_path = skill_root / "skill.yaml" if not manifest_path.is_file(): errors.append(f"{manifest_path}: user-profile runtime manifest is required") return data = load_yaml(manifest_path, errors) if data is None: return if data.get("schema") != MANIFEST_SCHEMA: errors.append(f"{manifest_path}: schema must be {MANIFEST_SCHEMA}") if data.get("id") != expected_skill_id: errors.append(f"{manifest_path}: id must match {expected_skill_id}") version = compact_text(data.get("version")) if not SEMVER_RE.fullmatch(version): errors.append(f"{manifest_path}: version must use SemVer") if data.get("runtime") != RUNTIME_VERSION: errors.append(f"{manifest_path}: runtime must be {RUNTIME_VERSION}") context = data.get("context") if not isinstance(context, dict): errors.append(f"{manifest_path}: context must be a mapping") return profile = context.get("profile") preferences = context.get("preferences") interaction = context.get("interaction") if not isinstance(profile, dict): errors.append(f"{manifest_path}: context.profile must be a mapping") else: if profile.get("schema") != PROFILE_SCHEMA: errors.append(f"{manifest_path}: context.profile.schema must be {PROFILE_SCHEMA}") if profile.get("source") != "shared-profile": errors.append(f"{manifest_path}: context.profile.source must be shared-profile") reads = profile.get("read") if not isinstance(reads, list) or not reads or not all( isinstance(item, str) and item.strip() for item in reads ): errors.append(f"{manifest_path}: context.profile.read must be a non-empty list") persist = profile.get("persist") if not isinstance(persist, dict): errors.append(f"{manifest_path}: context.profile.persist must be a mapping") else: if persist.get("enabled") is not True: errors.append(f"{manifest_path}: profile persistence must be enabled") if persist.get("namespace") != f"skills.{expected_skill_id}": errors.append(f"{manifest_path}: persist.namespace must target skills.{expected_skill_id}") if persist.get("records_path") != f"skills.{expected_skill_id}.records": errors.append(f"{manifest_path}: persist.records_path must target Skill records") if persist.get("write_policy") != "direct-user-statement": errors.append(f"{manifest_path}: persist.write_policy must be direct-user-statement") if persist.get("atomic") is not True: errors.append(f"{manifest_path}: profile persistence must be atomic") fields = profile.get("fields") if not isinstance(fields, list) or not fields: errors.append(f"{manifest_path}: context.profile.fields must be a non-empty list") else: validate_manifest_fields(manifest_path, "profile", fields, errors) if not isinstance(preferences, dict): errors.append(f"{manifest_path}: context.preferences must be a mapping") else: fields = preferences.get("fields") if not isinstance(fields, list): errors.append(f"{manifest_path}: context.preferences.fields must be a list") else: validate_manifest_fields(manifest_path, "preferences", fields, errors) if not isinstance(interaction, dict): errors.append(f"{manifest_path}: context.interaction must be a mapping") else: if not isinstance(interaction.get("ask_missing"), bool): errors.append(f"{manifest_path}: interaction.ask_missing must be boolean") max_questions = interaction.get("max_questions") if not isinstance(max_questions, int) or isinstance(max_questions, bool) or not 1 <= max_questions <= 3: errors.append(f"{manifest_path}: interaction.max_questions must be 1-3") def validate_manifest_fields( manifest_path: Path, source_name: str, fields: list[Any], errors: list[str] ) -> None: for index, field in enumerate(fields): label = f"{manifest_path}: context.{source_name}.fields[{index}]" if not isinstance(field, dict): errors.append(f"{label} must be a mapping") continue if not compact_text(field.get("path")): errors.append(f"{label}.path is required") if not isinstance(field.get("required"), bool): errors.append(f"{label}.required must be boolean") if not compact_text(field.get("question")): errors.append(f"{label}.question is required") aliases = field.get("aliases", []) if isinstance(aliases, str): aliases = [aliases] if not isinstance(aliases, list) or not all( isinstance(alias, str) and alias.strip() for alias in aliases ): errors.append(f"{label}.aliases must be a list of paths") def has_content(value: Any) -> bool: if isinstance(value, str): return bool(value.strip()) if isinstance(value, list): return any(has_content(item) for item in value) if isinstance(value, dict): return any(has_content(item) for item in value.values()) return value is not None def contains_placeholder(value: Any) -> bool: if isinstance(value, str): return bool(re.search(r"\bTODO\b|\{[^}]+\}", value, re.I)) if isinstance(value, list): return any(contains_placeholder(item) for item in value) if isinstance(value, dict): return any(contains_placeholder(item) for item in value.values()) return False def validate_card_bundle(skill_root: Path, errors: list[str]) -> None: card_path = skill_root / "skill-card.yaml" card_doc_path = skill_root / "skill-card.md" cases_path = skill_root / "cases" / "cases.json" pricing_path = skill_root / "pricing-card.yaml" for path in (card_path, card_doc_path, cases_path, pricing_path): if not path.is_file(): errors.append(f"{path}: required Skill trust-bundle file is missing") card = load_yaml(card_path, errors) if card_path.is_file() else None if card is not None: if card.get("schema") != CARD_STANDARD: errors.append(f"{card_path}: schema must be {CARD_STANDARD}") required = ( "description", "owner", "license", "use_case", "deployment", "requirements", "risks", "references", "output", "version", "ethical_considerations", "dimensions", "pricing", "distribution", ) for key in required: if key not in card or not has_content(card.get(key)): errors.append(f"{card_path}: required field '{key}' is missing or empty") dimensions = card.get("dimensions") if not isinstance(dimensions, list) or len(dimensions) < 3: errors.append(f"{card_path}: dimensions must contain at least three named dimensions") else: ids: set[str] = set() for index, dimension in enumerate(dimensions): label = f"{card_path}: dimensions[{index}]" if not isinstance(dimension, dict): errors.append(f"{label}: expected a mapping") continue dimension_id = compact_text(dimension.get("id")) if not dimension_id or dimension_id in ids: errors.append(f"{label}: id is required and must be unique") ids.add(dimension_id) for key in ("label", "description", "evidence"): if not compact_text(dimension.get(key)): errors.append(f"{label}: '{key}' is required") risks = card.get("risks") if not isinstance(risks, list) or not risks: errors.append(f"{card_path}: risks must contain at least one risk and mitigation") else: for index, risk in enumerate(risks): if not isinstance(risk, dict) or not compact_text(risk.get("risk")) or not compact_text(risk.get("mitigation")): errors.append(f"{card_path}: risks[{index}] needs risk and mitigation") distribution = card.get("distribution") if not isinstance(distribution, dict) or not isinstance(distribution.get("paid"), list) or not isinstance(distribution.get("free"), list): errors.append(f"{card_path}: distribution must declare paid and free lists") if contains_placeholder(card): errors.append(f"{card_path}: replace unresolved TODO or template placeholders") if card_doc_path.is_file(): card_doc = read_text(card_doc_path) required_headings = ( "Description", "Owner", "License", "Use Case", "Deployment Geography", "Requirements", "Known Risks", "References", "Skill Output", "Skill Version", "Ethical Considerations", "User Cases", "Dimension Map", "Pricing Basis", "Distribution", ) for heading in required_headings: if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", card_doc): errors.append(f"{card_doc_path}: add the '{heading}' section") if re.search(r"\bTODO\b|\{[^}]+\}", card_doc, re.I): errors.append(f"{card_doc_path}: replace unresolved TODO or template placeholders") if cases_path.is_file(): try: cases = json.loads(read_text(cases_path)) except json.JSONDecodeError as exc: errors.append(f"{cases_path}: invalid JSON: {exc}") cases = [] if not isinstance(cases, list) or not cases: errors.append(f"{cases_path}: include at least one real user case") else: for index, case in enumerate(cases): label = f"{cases_path}: cases[{index}]" if not isinstance(case, dict): errors.append(f"{label}: expected a mapping") continue for key in ("title", "description", "input", "prompt", "output"): if not has_content(case.get(key)): errors.append(f"{label}: '{key}' is required") if contains_placeholder(case): errors.append(f"{label}: replace unresolved TODO or template placeholders") for image_key in ("cover", "gallery"): image_values = case.get(image_key, []) if isinstance(image_values, str): image_values = [image_values] if not isinstance(image_values, list): errors.append(f"{label}: '{image_key}' must be a string or list") continue for image in image_values: if not isinstance(image, str) or not image.strip(): errors.append(f"{label}: '{image_key}' contains an empty path") elif not re.match(r"^(?:https?:|/|data:)", image) and not (skill_root / image).is_file(): errors.append(f"{label}: case asset does not exist: {image}") pricing = load_yaml(pricing_path, errors) if pricing_path.is_file() else None if pricing is not None: if pricing.get("schema") != PRICING_CARD_SCHEMA: errors.append(f"{pricing_path}: schema must be {PRICING_CARD_SCHEMA}") for key in ("model", "currency", "list_price_cny", "basis", "boundary", "review_trigger", "confidence"): if key not in pricing or (key != "list_price_cny" and not has_content(pricing.get(key))): errors.append(f"{pricing_path}: required field '{key}' is missing or empty") if contains_placeholder(pricing): errors.append(f"{pricing_path}: replace unresolved TODO or template placeholders") def validate_composition_reference(skill_root: Path, errors: list[str]) -> None: path = skill_root / "references" / "skill-composition.md" if not path.is_file(): errors.append(f"{path}: required Skill group composition record is missing") return text = read_text(path) required_headings = ( "Nearby Skills Inspected", "Atomic Handoffs", "Overlap Decisions", "Composition Decision", ) for heading in required_headings: if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", text): errors.append(f"{path}: add the '{heading}' section") if re.search(r"\bTODO\b|\{[^}]+\}", text, re.I): errors.append(f"{path}: replace unresolved TODO or template placeholders") def validate_kit(root: Path, skill_names: set[str], errors: list[str]) -> None: manifest = root / "kit.yaml" if not manifest.exists(): return data = load_yaml(manifest, errors) if data is None: return modules = data.get("modules") if not isinstance(modules, list) or not modules: errors.append(f"{manifest}: modules must be a non-empty list") return module_ids: set[str] = set() for index, module in enumerate(modules): label = f"{manifest}: modules[{index}]" if not isinstance(module, dict): errors.append(f"{label}: expected a mapping") continue module_id = compact_text(module.get("id")) skill_name = compact_text(module.get("skill")) relative = compact_text(module.get("path")) if not module_id or module_id in module_ids: errors.append(f"{label}: id is required and must be unique") module_ids.add(module_id) module_path = (root / relative).resolve() if ( not relative or not is_relative_to(module_path, root.resolve()) or not (module_path / "SKILL.md").is_file() ): errors.append(f"{label}: missing module at '{relative}/SKILL.md'") if skill_name not in skill_names: errors.append(f"{label}: unresolved child skill '{skill_name}'") pipelines = data.get("pipelines") if not isinstance(pipelines, dict) or not pipelines: errors.append(f"{manifest}: pipelines must be a non-empty mapping") return for pipeline, sequence in pipelines.items(): if not isinstance(sequence, list) or not sequence: errors.append(f"{manifest}: pipeline '{pipeline}' must be a non-empty list") continue missing = [str(item) for item in sequence if item not in module_ids] if missing: errors.append( f"{manifest}: pipeline '{pipeline}' has unknown modules: " + ", ".join(missing) ) def validate_local_references(root: Path, errors: list[str]) -> None: for path in iter_files(root): if path.suffix.lower() != ".md": continue text = read_text(path) for raw in MARKDOWN_LINK_RE.findall(text): target = raw.strip().split(maxsplit=1)[0].strip("<>").split("#", 1)[0] if ( not target or re.match(r"^[a-z][a-z0-9+.-]*:", target, re.I) or any(token in target for token in ("TODO", "{", "}")) ): continue if not (path.parent / target).resolve().exists(): errors.append(f"{path}: broken local link '{target}'") skill_root = path.parent if path.name == "SKILL.md" else root for variable, target in SKILL_PATH_RE.findall(text): if "TODO" in target: continue base = skill_root if variable == "SKILL_DIR" else root resolved = (base / target.rstrip(".,;:)")).resolve() if not is_relative_to(resolved, root.resolve()) or not resolved.exists(): errors.append(f"{path}: missing required resource '${variable}/{target}'") def validate_hygiene(root: Path, errors: list[str]) -> None: private_path = re.compile(r"(?:/Users/[^/\s]+/|[A-Za-z]:\\\\Users\\\\[^\\\s]+\\\\)") for path in root.rglob("*"): if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue if path.name in JUNK_NAMES or path.suffix.lower() in JUNK_SUFFIXES: errors.append(f"{path}: generated/cache artifact must not ship") for path in iter_files(root): if path.suffix.lower() not in TEXT_SUFFIXES or path.name == "validate_skill.py": continue text = read_text(path) if private_path.search(text): errors.append(f"{path}: contains a private absolute user path") if path.name != "init_skill.py" and re.search(r"\bTODO\s*[::]", text): errors.append(f"{path}: unresolved TODO placeholder") for relative in ("workbuddy", "scripts/build_workbuddy.py"): if (root / relative).exists(): errors.append( f"{root / relative}: platform distribution artifacts belong to skill-publish" ) def validate_source(root: Path, errors: list[str]) -> None: root_skill = root / "SKILL.md" skill_files = [root_skill, *sorted((root / "skills").glob("*/SKILL.md"))] if not root_skill.is_file(): errors.append(f"{root_skill}: file is required") return parsed: list[tuple[Path, dict[str, Any]]] = [] for path in skill_files: data = validate_skill_file(path, errors) if data: parsed.append((path, data)) names = {compact_text(data.get("name")) for _, data in parsed} if len(names) != len(parsed): errors.append(f"{root}: every embedded Skill must have a unique name") for path, data in parsed: metadata = data.get("metadata") if isinstance(metadata, dict) and metadata.get("card_standard") == CARD_STANDARD: validate_card_bundle(path.parent, errors) validate_composition_reference(path.parent, errors) validate_runtime_manifest(path.parent, compact_text(data.get("name")), errors) validate_kit(root, names, errors) readme = root / "README.md" if not readme.is_file(): errors.append(f"{readme}: file is required") elif parsed: metadata = parsed[0][1].get("metadata") version = compact_text(metadata.get("version")) if isinstance(metadata, dict) else "" if version and f"version-{version}-" not in read_text(readme): errors.append(f"{readme}: version badge must match {version}") validate_hygiene(root, errors) validate_local_references(root, errors) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("path", type=Path, help="Local Skill source directory") args = parser.parse_args() root = args.path.expanduser().resolve() if not root.is_dir(): print(f"ERROR: directory does not exist: {root}", file=sys.stderr) return 2 errors: list[str] = [] validate_source(root, errors) if errors: print(f"FAILED: {len(errors)} issue(s)") for error in errors: print(f"- {error}") return 1 print(f"PASSED: source validation ({root})") return 0 if __name__ == "__main__": raise SystemExit(main())
-
-
pricing-card.yaml 533 B
schema: lovstudio/pricing-card/v1 version: "0.1.0" model: free currency: CNY list_price_cny: 0 value_anchor: "减少重复人工查看和无效提醒,同时保留可审计的状态变化与人工接管点。" basis: "作为 lov-china-website-filing 的自包含模块免费提供。" boundary: "包含本阶段流程、证据门与输出契约;不包含代办保证、政府/云费用或法律意见。" review_trigger: "法规、平台、案例覆盖或人工服务边界重大变化时复评。" confidence: internal-verified -
skill-card.md 2.1 KB
# 备案哨兵 · Filing Monitor · Skill Card ## Description 按计划打开备案权威页面,比较上一条状态并追加台账;无变化时静默,状态变化、登录阻塞、补充材料或完成门满足时再通知。 ## Owner LovStudio;由本地仓库维护者负责维护。 ## License / Terms MIT。可使用、修改和分发;实时监管结论以主管部门和接入商页面为准。 ## Use Case 面向需要持续跟进 ICP、公安备案或安全评估的个人、企业和运维负责人。核心任务:读取权威订单或申请详情;比较并追加状态台账;按用户策略通知、保留 handoff 页面或暂停巡检。 ## Deployment Geography 中国大陆备案场景;作为 `lov-china-website-filing` 的自包含模块运行。 ## Requirements / Dependencies 离线输出无外部依赖;实时操作使用用户自己的已登录会话。不得持久化验证码、Cookie 或完整证件数据。 ## Known Risks and Mitigations 平台与地方规则可能变化,关键动作前重读权威页面;敏感资料最小化处理;完成状态必须满足本模块的真实证据门。 ## References - [Module instructions](SKILL.md) - [Composition record](references/skill-composition.md) ## Skill Output Markdown 追加式巡检记录与 JSON 差异结果,包含 changed 和 needs_user_action。 ## Skill Version 0.1.0 ## Ethical Considerations 不伪造材料、不规避监管、不把推断写成权威结论;关键提交保留用户授权。 ## LovStudio Evidence ### User Cases 见 [cases/cases.json](cases/cases.json):lovstudio.cn ICP 每日静默巡检。 ### Dimension Map - 正确性:单一真实案例已验证。 - 安全性:提交门与隐私边界已审阅。 - 可追溯性:来源、时间、状态和下一动作已结构化。 当前不设置缺少多地区基线的数字评分。 ### Pricing Basis 免费内嵌模块。减少重复人工查看和无效提醒,同时保留可审计的状态变化与人工接管点。 不包含代办保证、政府/云费用或法律意见。 ### Distribution `lovstudio` 为本地 Kit 内嵌;`github`、`workbuddy`、`skillpay` 均未发布。 -
skill-card.yaml 3.3 KB
schema: lovstudio/skill-card/v1 version: "0.1.0" description: "按计划打开备案权威页面,比较上一条状态并追加台账;无变化时静默,状态变化、登录阻塞、补充材料或完成门满足时再通知。" owner: team: LovStudio contact: local repository maintainers license: name: MIT terms: "可使用、修改和分发;实时监管结论以主管部门和接入商页面为准。" url: "../../LICENSE" use_case: audience: "需要持续跟进 ICP、公安备案或安全评估的个人、企业和运维负责人" scenario: "备案状态巡检" tasks: - "读取权威订单或申请详情" - "比较并追加状态台账" - "按用户策略通知、保留 handoff 页面或暂停巡检" deployment: geography: mainland-china environments: - "lov-china-website-filing self-contained Skill Kit" requirements: credentials: "离线输出不需要凭据;实时页面使用用户自己的已登录会话。" dependencies: [] runtime: - "Portable Agent Skills" risks: - risk: "平台或地方规则变化导致旧字段和时限失效。" mitigation: "关键动作前重新读取政府或接入商权威页面并记录时间。" - risk: "敏感身份信息被复制到持久输出。" mitigation: "只保存公开标识和脱敏证据,不保存验证码、Cookie 或完整证件数据。" references: - title: "Module instructions" path: "SKILL.md" - title: "Composition record" path: "references/skill-composition.md" output: types: - "备案状态巡检" formats: - markdown parameters: - "subject, service, domain, authority evidence" validation: - "authoritative evidence and timestamp" - "stage-specific completion gate" description: "Markdown 追加式巡检记录与 JSON 差异结果,包含 changed 和 needs_user_action。" ethical_considerations: "不伪造材料、不规避监管、不把推断写成权威结论;关键提交保留用户授权。" dimensions: - id: correctness label: "正确性" description: "使用与本阶段匹配的权威来源和完成门。" evidence: "lovstudio.cn ICP 监控在权威页无法登录时没有沿用缓存状态;无变化时按用户要求静默记录。" score: null status: verified-with-one-real-case - id: safety label: "安全性" description: "保护敏感资料并保留人工提交门。" evidence: "模块遵循 Kit 的 authority-gates.md。" score: null status: policy-reviewed - id: traceability label: "可追溯性" description: "输出记录来源、时间、状态和下一动作。" evidence: "真实案例和统一台账字段已记录。" score: null status: locally-validated pricing: model: free currency: CNY list_price_cny: 0 basis: "减少重复人工查看和无效提醒,同时保留可审计的状态变化与人工接管点。" boundary: "不包含代办保证、政府/云费用或法律意见。" review_trigger: "法规、平台或人工服务边界重大变化时复评。" confidence: internal-verified distribution: paid: - channel: workbuddy status: not-published - channel: skillpay status: not-published free: - channel: github status: not-published - channel: lovstudio status: embedded-local -
SKILL.md 2.6 KB
--- name: lov-filing-monitor description: > 定期打开接入商、工信部或公安平台权威页面,比较备案状态并追加巡检台账;用户说“每天检查备案”“没变化就静默”或 "monitor the filing status" 时使用,只在变化或需人工动作时提醒。 license: MIT metadata: author: LovStudio version: "0.1.0" card_standard: lovstudio/skill-card/v1 tags: - filing-monitor - status-diff - audit-ledger - notification-policy compatibility: "Embedded module of lov-china-website-filing; scheduling and notification channels are supplied by the host." dependencies: [] --- # 备案哨兵 · Filing Monitor ## Input and output - 输入:权威入口、巡检频率、台账路径、上一状态、静默/通知策略和完成门。 - 输出:本次权威状态、差异、用户动作、追加记录,以及按策略触发的通知或完成信号。 ## Triggers ### Activate when - 用户说“每天检查备案状态”“没变化就不要通知”“审核通过后暂停巡检”。 - User asks to "monitor the filing status" or "notify me only when the filing changes". ### Do not activate when - 只需要一次性查询且不保留记录;直接使用对应 ICP 或公安模块。 - 用户要通用业务自动化而非备案状态机;交给宿主自动化或通用工作流能力。 ## Workflow (MANDATORY) 1. 读取 `$KIT_DIR/references/status-taxonomy.md`、`authority-gates.md`、本模块组合记录和用户明确的通知策略。 2. 运行 `python3 "$KIT_DIR/scripts/filing_record.py" check --path RECORD`,读取上一条状态。 3. 复用用户当前已登录的浏览器会话,优先打开具体订单/申请详情,其次列表;搜索结果和旧记录不能充当当前状态。 4. 核验适用字段:域名实名同步、接入商审核、补充材料、短信核验、管局结果、DNS/HTTPS/页脚、公安审核与安全评估。 5. 先用 `compare` 判断权威来源、阶段、状态、域名状态和动作是否变化,再用 `append` 追加本次观察。 6. 无变化且无需用户动作时只写记录并静默。变化、登录/验证码阻塞、补充材料、驳回、通过或完成门满足时才按用户指定渠道通知。 7. 浏览器收尾时保留需要接管的 handoff 页面,清理本轮新开的无关标签页。 ## Completion gate 监控只能在用户定义的最终权威门满足后暂停。ICP 中间状态、公安待审核、缓存备案号或无法登录都不能触发完成。 ## Dependencies Python 3.8+ for the ledger CLI. Scheduling, browser control and notifications are host-provided. -
skill.yaml 1.6 KB
schema: skill-manifest/v1 id: lov-filing-monitor version: "0.1.0" runtime: skill-runtime/v1 context: profile: schema: user-profile/v1 source: shared-profile read: - user - brand - workspace - preferences - skills.lov-filing-monitor persist: enabled: true namespace: skills.lov-filing-monitor records_path: skills.lov-filing-monitor.records write_policy: direct-user-statement atomic: true fields: - path: user.name aliases: - identity.name required: false question: 如果本次输出需要用户身份,请提供名称。 - path: user.language required: false question: 希望使用哪种语言输出? - path: user.timezone required: false question: 需要使用哪个时区处理日期和时间? - path: brand.name aliases: - identity.name required: false question: 如果本次输出需要品牌身份,请提供品牌名称。 - path: brand.site required: false question: 如果需要品牌官网,请提供地址。 - path: brand.tone required: false question: 如果已有品牌语气或审美关键词,请提供它们。 preferences: namespace: lov_filing_monitor fields: - path: user.language required: false question: 希望使用哪种语言输出? - path: user.timezone required: false question: 需要使用哪个时区处理日期和时间? interaction: ask_missing: true max_questions: 1
-
-
filing-readiness
-
cases
-
cases.json 815 B
[ { "type": "case", "title": "LovStudio 新增服务备案准备", "description": "为北京飞脑科技有限公司的 lovstudio.cn 新增服务备案确认域名、服务和接入场景,并把未解析识别为备案阶段的主动门控。", "input": { "items": [ "主办单位:北京飞脑科技有限公司", "服务:飞脑科技", "域名:lovstudio.cn", "场景:新增服务(原备案不在腾讯云)" ] }, "prompt": "先检查备案准备条件,确认域名和当前未开放状态是否正确。", "output": { "items": [ "场景分类为新增服务", "备案审核期间保持根域和 www 不开放", "材料和实名状态需要从接入商权威页逐项核验" ] } } ]
-
-
references
-
skill-card-standard.md 660 B
# LovStudio Skill Card standard `skill-card.yaml` follows the minimum release-record idea of NVIDIA Skill Cards: description, owner, license/terms, use case, deployment, requirements, risks/mitigations, references, output contract, version, and ethical considerations. LovStudio adds evidence that helps a user decide whether the Skill is credible: 1. A real user case with Input → Prompt → Output. 2. A dimension map with named evidence, not an unexplained score. 3. A pricing basis, including the free boundary and review trigger. 4. Explicit paid and free distribution states. Never claim a case, score, channel, or price that has not been verified. -
skill-composition.md 1 KB
# Skill Group Composition ## Nearby Skills Inspected | Skill or module | Classification | Decision | | --- | --- | --- | | `lov-china-website-filing` | owning Kit | 提供共享 Profile、权威规则、提交门、状态词表和统一台账。 | | `lov-fill-web-form` | optional external atom | 只接受明确的制品级交接,不作为隐藏依赖。 | | Kit sibling modules | upstream/downstream atoms | 通过域名、阶段状态、证据时间和下一动作交接。 | ## Atomic Handoffs - 上游:用户当前请求、品牌 Profile 或可选的 lov-fact-check 规则摘要 - 本模块:备案准备核验。 - 下游:将备案类型、材料清单、实名状态和阻塞项交给 icp-filing ## Overlap Decisions 不复制 lov-fill-web-form 的通用表单知识库检索,也不生成隐私政策或生产制品。 ## Composition Decision 本模块是 `lov-china-website-filing` 的**内嵌原子模块**。它可单独调用,但跨阶段时依赖 Kit 内共享的证据和完成门;不拆成外部安装依赖。 -
user-profile.md 509 B
# User Profile contract 本模块使用 Kit 根目录的 [user-profile/v1 contract](../../../references/user-profile.md) 和 `skills.lov-filing-readiness` 命名空间。 每次运行按当前请求、项目上下文、Skill 记录、共享偏好、品牌/用户 Profile、安全默认值的顺序解析。只有用户直接说明且希望长期复用的非敏感事实才可通过 `$KIT_DIR/scripts/profile_store.py` 持久化;证件、手机号、验证码、Cookie、密钥和扫描件不得保存。
-
-
scripts
-
profile_store.py 8.3 KB
#!/usr/bin/env python3 """Read and persist a Skill's user-owned profile context. The command keeps shared identity and brand facts in the profile root while putting Skill-specific durable records under ``skills.<skill_id>.records``. Writes require an explicit confirmation flag and use an atomic replacement. """ from __future__ import annotations import argparse import json import os import re import sys import tempfile from pathlib import Path from typing import Any, Iterable USER_PROFILE_SCHEMA = "user-profile/v1" SKILL_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") SAFE_ROOTS = {"user", "brand", "workspace", "preferences"} SENSITIVE_PARTS = {"token", "secret", "password", "credential", "cookie", "api_key", "apikey"} def config_dir() -> Path: configured = os.environ.get("SKILLS_CONFIG_DIR") if configured: return Path(os.path.expandvars(configured)).expanduser() xdg = os.environ.get("XDG_CONFIG_HOME") if xdg: return Path(os.path.expandvars(xdg)).expanduser() / "agent-skills" return Path.home() / ".config" / "agent-skills" def profile_path(explicit: Path | None = None) -> Path: if explicit: return explicit.expanduser() configured = ( os.environ.get("SKILL_PROFILE_PATH") or os.environ.get("SKILLS_PROFILE_PATH") ) if configured: return Path(os.path.expandvars(configured)).expanduser() candidates = ( Path.home() / ".lovstudio" / "skills" / "profile.json", Path.home() / ".skill-publisher" / "skills" / "profile.json", config_dir() / "profile.json", ) return next((candidate for candidate in candidates if candidate.exists()), candidates[-1]) def read_profile(path: Path) -> dict[str, Any]: if not path.exists(): return {} try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ValueError(f"profile cannot be read: {path}: {exc}") from exc if not isinstance(value, dict): raise ValueError("profile root must be an object") return value def profile_skeleton(skill_id: str) -> dict[str, Any]: return { "schema": "skill-profile/v1", "profile_id": f"profile-{skill_id}", "revision": 1, "status": "draft", "identity": {}, "purpose": {}, "brand": {}, "user": {}, "workspace": {}, "skills": {}, "extensions": {}, } def atomic_write(path: Path, value: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) mode = 0o600 if path.exists(): mode = path.stat().st_mode & 0o777 temporary_name: str | None = None try: with tempfile.NamedTemporaryFile( "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False, ) as handle: temporary_name = handle.name handle.write(json.dumps(value, ensure_ascii=False, indent=2) + "\n") handle.flush() os.fsync(handle.fileno()) os.chmod(temporary_name, mode) os.replace(temporary_name, path) finally: if temporary_name: temporary = Path(temporary_name) if temporary.exists(): temporary.unlink() def set_path(root: dict[str, Any], parts: list[str], value: Any) -> None: current: dict[str, Any] = root for part in parts[:-1]: child = current.get(part) if not isinstance(child, dict): child = {} current[part] = child current = child current[parts[-1]] = value def target_parts(skill_id: str, requested_path: str) -> tuple[list[str], str]: if not SKILL_ID_RE.fullmatch(skill_id): raise ValueError("skill_id must be kebab-case") path = requested_path.strip().strip(".") if not path: raise ValueError("path is required") parts = [part for part in path.split(".") if part] if any(not re.fullmatch(r"[A-Za-z0-9_-]+", part) for part in parts): raise ValueError("path contains an invalid segment") if any(part.casefold() in SENSITIVE_PARTS for part in parts): raise ValueError("profile records do not accept secret-like fields") if parts[0] == "records": target = ["skills", skill_id, "records", *parts[1:]] return target, ".".join(target) if parts[0] == "profile": target = ["skills", skill_id, "profile", *parts[1:]] return target, ".".join(target) if parts[:3] == ["skills", skill_id, "records"]: return parts, ".".join(parts) if parts[:3] == ["skills", skill_id, "profile"]: return parts, ".".join(parts) if parts[0] in SAFE_ROOTS: return parts, ".".join(parts) expected = f"records.<field> or one of: {', '.join(sorted(SAFE_ROOTS))}.<field>" raise ValueError(f"path must use {expected}") def parse_value(raw: str) -> Any: try: return json.loads(raw) except json.JSONDecodeError: return raw def read_command(args: argparse.Namespace) -> dict[str, Any]: path = profile_path(args.profile) profile = read_profile(path) skills = profile.get("skills", {}) if not isinstance(skills, dict): skills = {} skill = skills.get(args.skill_id, {}) if not isinstance(skill, dict): skill = {} user = profile.get("user") if not isinstance(user, dict) or not user: user = profile.get("identity", {}) if not isinstance(user, dict): user = {} brand = profile.get("brand", {}) if not isinstance(brand, dict): brand = {} workspace = profile.get("workspace", {}) if not isinstance(workspace, dict): workspace = {} preferences = profile.get("preferences", {}) if not isinstance(preferences, dict): preferences = {} records = skill.get("records", {}) if not isinstance(records, dict): records = {} return { "status": "ready", "schema": USER_PROFILE_SCHEMA, "skill_id": args.skill_id, "profile_path": str(path), "user": user, "brand": brand, "workspace": workspace, "preferences": preferences, "skill": skill, "records": records, } def record_command(args: argparse.Namespace) -> dict[str, Any]: if not args.confirm: raise PermissionError("record requires --confirm after the user has stated the value") parts, canonical_path = target_parts(args.skill_id, args.path) path = profile_path(args.profile) profile = read_profile(path) if not profile: profile = profile_skeleton(args.skill_id) set_path(profile, parts, parse_value(args.value)) revision = profile.get("revision") if isinstance(revision, int) and not isinstance(revision, bool): profile["revision"] = revision + 1 atomic_write(path, profile) return { "status": "saved", "schema": USER_PROFILE_SCHEMA, "skill_id": args.skill_id, "profile_path": str(path), "path": canonical_path, } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--profile", type=Path, default=None, help="Shared profile JSON path") subparsers = parser.add_subparsers(dest="command", required=True) read_parser = subparsers.add_parser("read", help="Read shared and Skill-specific profile context") read_parser.add_argument("--skill-id", required=True) read_parser.add_argument("--pretty", action="store_true") record_parser = subparsers.add_parser("record", help="Persist a user-stated profile value") record_parser.add_argument("--skill-id", required=True) record_parser.add_argument("--path", required=True, help="records.<field>, brand.<field>, or user.<field>") record_parser.add_argument("--value", required=True) record_parser.add_argument("--confirm", action="store_true") args = parser.parse_args() try: result = read_command(args) if args.command == "read" else record_command(args) except (OSError, PermissionError, ValueError) as exc: print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False), file=sys.stderr) return 2 indent = 2 if getattr(args, "pretty", False) else None print(json.dumps(result, ensure_ascii=False, indent=indent, sort_keys=bool(indent))) return 0 if __name__ == "__main__": raise SystemExit(main()) -
validate_skill.py 24.6 KB
#!/usr/bin/env python3 """Validate a portable local Skill Publisher Skill source directory.""" from __future__ import annotations import argparse import json import re import sys from pathlib import Path from typing import Any, Iterable try: import yaml except ImportError: print( "ERROR: PyYAML is required. Install it with: python3 -m pip install PyYAML", file=sys.stderr, ) raise SystemExit(2) FRONTMATTER_KEYS = {"name", "description", "license", "allowed-tools", "metadata"} TEXT_SUFFIXES = {".md", ".json", ".yaml", ".yml", ".txt", ".svg", ".py"} JUNK_NAMES = {"__pycache__", ".DS_Store"} JUNK_SUFFIXES = {".pyc", ".pyo"} SKIP_DIRS = {".git", "dist", ".venv", "venv", "node_modules"} SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$") NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*]\(([^)]+)\)") SKILL_PATH_RE = re.compile(r"\$(SKILL_DIR|KIT_DIR)/([A-Za-z0-9_./-]+)") CARD_STANDARD = "lovstudio/skill-card/v1" PRICING_CARD_SCHEMA = "lovstudio/pricing-card/v1" MANIFEST_SCHEMA = "skill-manifest/v1" RUNTIME_VERSION = "skill-runtime/v1" PROFILE_SCHEMA = "user-profile/v1" class ValidationFailure(Exception): """Raised when source metadata cannot be parsed.""" def read_text(path: Path) -> str: return path.read_text(encoding="utf-8") def compact_text(value: Any) -> str: return re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else "" def split_frontmatter(path: Path) -> tuple[dict[str, Any], str]: text = read_text(path) if not text.startswith("---\n"): raise ValidationFailure(f"{path}: missing YAML frontmatter") marker = text.find("\n---\n", 4) if marker < 0: raise ValidationFailure(f"{path}: frontmatter is not closed") try: data = yaml.safe_load(text[4:marker]) except yaml.YAMLError as exc: raise ValidationFailure( f"{path}: standard YAML parser rejected frontmatter: {exc}" ) from exc if not isinstance(data, dict): raise ValidationFailure(f"{path}: frontmatter must be a mapping") return data, text[marker + 5 :] def iter_files(root: Path) -> Iterable[Path]: for path in root.rglob("*"): if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue if path.is_file(): yield path def is_relative_to(path: Path, parent: Path) -> bool: try: path.relative_to(parent) return True except ValueError: return False def validate_skill_file(path: Path, errors: list[str]) -> dict[str, Any] | None: try: data, body = split_frontmatter(path) except ValidationFailure as exc: errors.append(str(exc)) return None unexpected = sorted(set(data) - FRONTMATTER_KEYS) if unexpected: errors.append(f"{path}: unsupported frontmatter keys: {', '.join(unexpected)}") name = compact_text(data.get("name")) if not NAME_RE.fullmatch(name) or len(name) > 64: errors.append(f"{path}: name must be kebab-case and at most 64 characters") description = compact_text(data.get("description")) if not 50 <= len(description) <= 200: errors.append( f"{path}: description must contain 50-200 characters " f"(found {len(description)})" ) metadata = data.get("metadata") if not isinstance(metadata, dict): errors.append(f"{path}: metadata must be a mapping") else: if not compact_text(metadata.get("author")): errors.append(f"{path}: metadata.author is required") if not SEMVER_RE.fullmatch(compact_text(metadata.get("version"))): errors.append(f"{path}: metadata.version must use SemVer") tags = metadata.get("tags") if not isinstance(tags, list) or not tags or not all( isinstance(tag, str) and tag.strip() for tag in tags ): errors.append(f"{path}: metadata.tags must be a non-empty list") dependencies = metadata.get("dependencies", []) if not isinstance(dependencies, list): errors.append(f"{path}: metadata.dependencies must be a list") if "card_standard" in metadata and metadata.get("card_standard") != CARD_STANDARD: errors.append(f"{path}: metadata.card_standard must be {CARD_STANDARD}") trigger_block = re.search( r"(?ms)^##\s+Triggers\s*$([\s\S]*?)(?=^##\s+|\Z)", body ) if not trigger_block: errors.append(f"{path}: add an explicit '## Triggers' section") else: block = trigger_block.group(1) if len(re.findall(r"(?m)^\s*-\s+\S", block)) < 3: errors.append(f"{path}: add two activation examples and one non-trigger") if not re.search(r"[\u3400-\u9fff]", block): errors.append(f"{path}: add a concrete Chinese trigger phrase") if not re.search(r"(?i)\b(?:the|a|an|create|build|help|publish|review|use)\b", block): errors.append(f"{path}: add a concrete English trigger phrase") if not re.search( r"(?mi)^###\s+(?:Do not activate when|Non-triggers?|不应触发|不要触发)\s*$", body, ): errors.append(f"{path}: add explicit non-trigger conditions") if len(read_text(path).splitlines()) >= 500: errors.append(f"{path}: keep SKILL.md below 500 lines") if not body.strip(): errors.append(f"{path}: body is empty") return data def load_yaml(path: Path, errors: list[str]) -> dict[str, Any] | None: try: data = yaml.safe_load(read_text(path)) except yaml.YAMLError as exc: errors.append(f"{path}: standard YAML parser rejected file: {exc}") return None if not isinstance(data, dict): errors.append(f"{path}: expected a YAML mapping") return None return data def validate_runtime_manifest( skill_root: Path, expected_skill_id: str, errors: list[str] ) -> None: manifest_path = skill_root / "skill.yaml" if not manifest_path.is_file(): errors.append(f"{manifest_path}: user-profile runtime manifest is required") return data = load_yaml(manifest_path, errors) if data is None: return if data.get("schema") != MANIFEST_SCHEMA: errors.append(f"{manifest_path}: schema must be {MANIFEST_SCHEMA}") if data.get("id") != expected_skill_id: errors.append(f"{manifest_path}: id must match {expected_skill_id}") version = compact_text(data.get("version")) if not SEMVER_RE.fullmatch(version): errors.append(f"{manifest_path}: version must use SemVer") if data.get("runtime") != RUNTIME_VERSION: errors.append(f"{manifest_path}: runtime must be {RUNTIME_VERSION}") context = data.get("context") if not isinstance(context, dict): errors.append(f"{manifest_path}: context must be a mapping") return profile = context.get("profile") preferences = context.get("preferences") interaction = context.get("interaction") if not isinstance(profile, dict): errors.append(f"{manifest_path}: context.profile must be a mapping") else: if profile.get("schema") != PROFILE_SCHEMA: errors.append(f"{manifest_path}: context.profile.schema must be {PROFILE_SCHEMA}") if profile.get("source") != "shared-profile": errors.append(f"{manifest_path}: context.profile.source must be shared-profile") reads = profile.get("read") if not isinstance(reads, list) or not reads or not all( isinstance(item, str) and item.strip() for item in reads ): errors.append(f"{manifest_path}: context.profile.read must be a non-empty list") persist = profile.get("persist") if not isinstance(persist, dict): errors.append(f"{manifest_path}: context.profile.persist must be a mapping") else: if persist.get("enabled") is not True: errors.append(f"{manifest_path}: profile persistence must be enabled") if persist.get("namespace") != f"skills.{expected_skill_id}": errors.append(f"{manifest_path}: persist.namespace must target skills.{expected_skill_id}") if persist.get("records_path") != f"skills.{expected_skill_id}.records": errors.append(f"{manifest_path}: persist.records_path must target Skill records") if persist.get("write_policy") != "direct-user-statement": errors.append(f"{manifest_path}: persist.write_policy must be direct-user-statement") if persist.get("atomic") is not True: errors.append(f"{manifest_path}: profile persistence must be atomic") fields = profile.get("fields") if not isinstance(fields, list) or not fields: errors.append(f"{manifest_path}: context.profile.fields must be a non-empty list") else: validate_manifest_fields(manifest_path, "profile", fields, errors) if not isinstance(preferences, dict): errors.append(f"{manifest_path}: context.preferences must be a mapping") else: fields = preferences.get("fields") if not isinstance(fields, list): errors.append(f"{manifest_path}: context.preferences.fields must be a list") else: validate_manifest_fields(manifest_path, "preferences", fields, errors) if not isinstance(interaction, dict): errors.append(f"{manifest_path}: context.interaction must be a mapping") else: if not isinstance(interaction.get("ask_missing"), bool): errors.append(f"{manifest_path}: interaction.ask_missing must be boolean") max_questions = interaction.get("max_questions") if not isinstance(max_questions, int) or isinstance(max_questions, bool) or not 1 <= max_questions <= 3: errors.append(f"{manifest_path}: interaction.max_questions must be 1-3") def validate_manifest_fields( manifest_path: Path, source_name: str, fields: list[Any], errors: list[str] ) -> None: for index, field in enumerate(fields): label = f"{manifest_path}: context.{source_name}.fields[{index}]" if not isinstance(field, dict): errors.append(f"{label} must be a mapping") continue if not compact_text(field.get("path")): errors.append(f"{label}.path is required") if not isinstance(field.get("required"), bool): errors.append(f"{label}.required must be boolean") if not compact_text(field.get("question")): errors.append(f"{label}.question is required") aliases = field.get("aliases", []) if isinstance(aliases, str): aliases = [aliases] if not isinstance(aliases, list) or not all( isinstance(alias, str) and alias.strip() for alias in aliases ): errors.append(f"{label}.aliases must be a list of paths") def has_content(value: Any) -> bool: if isinstance(value, str): return bool(value.strip()) if isinstance(value, list): return any(has_content(item) for item in value) if isinstance(value, dict): return any(has_content(item) for item in value.values()) return value is not None def contains_placeholder(value: Any) -> bool: if isinstance(value, str): return bool(re.search(r"\bTODO\b|\{[^}]+\}", value, re.I)) if isinstance(value, list): return any(contains_placeholder(item) for item in value) if isinstance(value, dict): return any(contains_placeholder(item) for item in value.values()) return False def validate_card_bundle(skill_root: Path, errors: list[str]) -> None: card_path = skill_root / "skill-card.yaml" card_doc_path = skill_root / "skill-card.md" cases_path = skill_root / "cases" / "cases.json" pricing_path = skill_root / "pricing-card.yaml" for path in (card_path, card_doc_path, cases_path, pricing_path): if not path.is_file(): errors.append(f"{path}: required Skill trust-bundle file is missing") card = load_yaml(card_path, errors) if card_path.is_file() else None if card is not None: if card.get("schema") != CARD_STANDARD: errors.append(f"{card_path}: schema must be {CARD_STANDARD}") required = ( "description", "owner", "license", "use_case", "deployment", "requirements", "risks", "references", "output", "version", "ethical_considerations", "dimensions", "pricing", "distribution", ) for key in required: if key not in card or not has_content(card.get(key)): errors.append(f"{card_path}: required field '{key}' is missing or empty") dimensions = card.get("dimensions") if not isinstance(dimensions, list) or len(dimensions) < 3: errors.append(f"{card_path}: dimensions must contain at least three named dimensions") else: ids: set[str] = set() for index, dimension in enumerate(dimensions): label = f"{card_path}: dimensions[{index}]" if not isinstance(dimension, dict): errors.append(f"{label}: expected a mapping") continue dimension_id = compact_text(dimension.get("id")) if not dimension_id or dimension_id in ids: errors.append(f"{label}: id is required and must be unique") ids.add(dimension_id) for key in ("label", "description", "evidence"): if not compact_text(dimension.get(key)): errors.append(f"{label}: '{key}' is required") risks = card.get("risks") if not isinstance(risks, list) or not risks: errors.append(f"{card_path}: risks must contain at least one risk and mitigation") else: for index, risk in enumerate(risks): if not isinstance(risk, dict) or not compact_text(risk.get("risk")) or not compact_text(risk.get("mitigation")): errors.append(f"{card_path}: risks[{index}] needs risk and mitigation") distribution = card.get("distribution") if not isinstance(distribution, dict) or not isinstance(distribution.get("paid"), list) or not isinstance(distribution.get("free"), list): errors.append(f"{card_path}: distribution must declare paid and free lists") if contains_placeholder(card): errors.append(f"{card_path}: replace unresolved TODO or template placeholders") if card_doc_path.is_file(): card_doc = read_text(card_doc_path) required_headings = ( "Description", "Owner", "License", "Use Case", "Deployment Geography", "Requirements", "Known Risks", "References", "Skill Output", "Skill Version", "Ethical Considerations", "User Cases", "Dimension Map", "Pricing Basis", "Distribution", ) for heading in required_headings: if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", card_doc): errors.append(f"{card_doc_path}: add the '{heading}' section") if re.search(r"\bTODO\b|\{[^}]+\}", card_doc, re.I): errors.append(f"{card_doc_path}: replace unresolved TODO or template placeholders") if cases_path.is_file(): try: cases = json.loads(read_text(cases_path)) except json.JSONDecodeError as exc: errors.append(f"{cases_path}: invalid JSON: {exc}") cases = [] if not isinstance(cases, list) or not cases: errors.append(f"{cases_path}: include at least one real user case") else: for index, case in enumerate(cases): label = f"{cases_path}: cases[{index}]" if not isinstance(case, dict): errors.append(f"{label}: expected a mapping") continue for key in ("title", "description", "input", "prompt", "output"): if not has_content(case.get(key)): errors.append(f"{label}: '{key}' is required") if contains_placeholder(case): errors.append(f"{label}: replace unresolved TODO or template placeholders") for image_key in ("cover", "gallery"): image_values = case.get(image_key, []) if isinstance(image_values, str): image_values = [image_values] if not isinstance(image_values, list): errors.append(f"{label}: '{image_key}' must be a string or list") continue for image in image_values: if not isinstance(image, str) or not image.strip(): errors.append(f"{label}: '{image_key}' contains an empty path") elif not re.match(r"^(?:https?:|/|data:)", image) and not (skill_root / image).is_file(): errors.append(f"{label}: case asset does not exist: {image}") pricing = load_yaml(pricing_path, errors) if pricing_path.is_file() else None if pricing is not None: if pricing.get("schema") != PRICING_CARD_SCHEMA: errors.append(f"{pricing_path}: schema must be {PRICING_CARD_SCHEMA}") for key in ("model", "currency", "list_price_cny", "basis", "boundary", "review_trigger", "confidence"): if key not in pricing or (key != "list_price_cny" and not has_content(pricing.get(key))): errors.append(f"{pricing_path}: required field '{key}' is missing or empty") if contains_placeholder(pricing): errors.append(f"{pricing_path}: replace unresolved TODO or template placeholders") def validate_composition_reference(skill_root: Path, errors: list[str]) -> None: path = skill_root / "references" / "skill-composition.md" if not path.is_file(): errors.append(f"{path}: required Skill group composition record is missing") return text = read_text(path) required_headings = ( "Nearby Skills Inspected", "Atomic Handoffs", "Overlap Decisions", "Composition Decision", ) for heading in required_headings: if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", text): errors.append(f"{path}: add the '{heading}' section") if re.search(r"\bTODO\b|\{[^}]+\}", text, re.I): errors.append(f"{path}: replace unresolved TODO or template placeholders") def validate_kit(root: Path, skill_names: set[str], errors: list[str]) -> None: manifest = root / "kit.yaml" if not manifest.exists(): return data = load_yaml(manifest, errors) if data is None: return modules = data.get("modules") if not isinstance(modules, list) or not modules: errors.append(f"{manifest}: modules must be a non-empty list") return module_ids: set[str] = set() for index, module in enumerate(modules): label = f"{manifest}: modules[{index}]" if not isinstance(module, dict): errors.append(f"{label}: expected a mapping") continue module_id = compact_text(module.get("id")) skill_name = compact_text(module.get("skill")) relative = compact_text(module.get("path")) if not module_id or module_id in module_ids: errors.append(f"{label}: id is required and must be unique") module_ids.add(module_id) module_path = (root / relative).resolve() if ( not relative or not is_relative_to(module_path, root.resolve()) or not (module_path / "SKILL.md").is_file() ): errors.append(f"{label}: missing module at '{relative}/SKILL.md'") if skill_name not in skill_names: errors.append(f"{label}: unresolved child skill '{skill_name}'") pipelines = data.get("pipelines") if not isinstance(pipelines, dict) or not pipelines: errors.append(f"{manifest}: pipelines must be a non-empty mapping") return for pipeline, sequence in pipelines.items(): if not isinstance(sequence, list) or not sequence: errors.append(f"{manifest}: pipeline '{pipeline}' must be a non-empty list") continue missing = [str(item) for item in sequence if item not in module_ids] if missing: errors.append( f"{manifest}: pipeline '{pipeline}' has unknown modules: " + ", ".join(missing) ) def validate_local_references(root: Path, errors: list[str]) -> None: for path in iter_files(root): if path.suffix.lower() != ".md": continue text = read_text(path) for raw in MARKDOWN_LINK_RE.findall(text): target = raw.strip().split(maxsplit=1)[0].strip("<>").split("#", 1)[0] if ( not target or re.match(r"^[a-z][a-z0-9+.-]*:", target, re.I) or any(token in target for token in ("TODO", "{", "}")) ): continue if not (path.parent / target).resolve().exists(): errors.append(f"{path}: broken local link '{target}'") skill_root = path.parent if path.name == "SKILL.md" else root for variable, target in SKILL_PATH_RE.findall(text): if "TODO" in target: continue base = skill_root if variable == "SKILL_DIR" else root resolved = (base / target.rstrip(".,;:)")).resolve() if not is_relative_to(resolved, root.resolve()) or not resolved.exists(): errors.append(f"{path}: missing required resource '${variable}/{target}'") def validate_hygiene(root: Path, errors: list[str]) -> None: private_path = re.compile(r"(?:/Users/[^/\s]+/|[A-Za-z]:\\\\Users\\\\[^\\\s]+\\\\)") for path in root.rglob("*"): if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue if path.name in JUNK_NAMES or path.suffix.lower() in JUNK_SUFFIXES: errors.append(f"{path}: generated/cache artifact must not ship") for path in iter_files(root): if path.suffix.lower() not in TEXT_SUFFIXES or path.name == "validate_skill.py": continue text = read_text(path) if private_path.search(text): errors.append(f"{path}: contains a private absolute user path") if path.name != "init_skill.py" and re.search(r"\bTODO\s*[::]", text): errors.append(f"{path}: unresolved TODO placeholder") for relative in ("workbuddy", "scripts/build_workbuddy.py"): if (root / relative).exists(): errors.append( f"{root / relative}: platform distribution artifacts belong to skill-publish" ) def validate_source(root: Path, errors: list[str]) -> None: root_skill = root / "SKILL.md" skill_files = [root_skill, *sorted((root / "skills").glob("*/SKILL.md"))] if not root_skill.is_file(): errors.append(f"{root_skill}: file is required") return parsed: list[tuple[Path, dict[str, Any]]] = [] for path in skill_files: data = validate_skill_file(path, errors) if data: parsed.append((path, data)) names = {compact_text(data.get("name")) for _, data in parsed} if len(names) != len(parsed): errors.append(f"{root}: every embedded Skill must have a unique name") for path, data in parsed: metadata = data.get("metadata") if isinstance(metadata, dict) and metadata.get("card_standard") == CARD_STANDARD: validate_card_bundle(path.parent, errors) validate_composition_reference(path.parent, errors) validate_runtime_manifest(path.parent, compact_text(data.get("name")), errors) validate_kit(root, names, errors) readme = root / "README.md" if not readme.is_file(): errors.append(f"{readme}: file is required") elif parsed: metadata = parsed[0][1].get("metadata") version = compact_text(metadata.get("version")) if isinstance(metadata, dict) else "" if version and f"version-{version}-" not in read_text(readme): errors.append(f"{readme}: version badge must match {version}") validate_hygiene(root, errors) validate_local_references(root, errors) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("path", type=Path, help="Local Skill source directory") args = parser.parse_args() root = args.path.expanduser().resolve() if not root.is_dir(): print(f"ERROR: directory does not exist: {root}", file=sys.stderr) return 2 errors: list[str] = [] validate_source(root, errors) if errors: print(f"FAILED: {len(errors)} issue(s)") for error in errors: print(f"- {error}") return 1 print(f"PASSED: source validation ({root})") return 0 if __name__ == "__main__": raise SystemExit(main())
-
-
pricing-card.yaml 527 B
schema: lovstudio/pricing-card/v1 version: "0.1.0" model: free currency: CNY list_price_cny: 0 value_anchor: "减少因备案类型、实名不一致、材料或接入资源错误导致的反复退回。" basis: "作为 lov-china-website-filing 的自包含模块免费提供。" boundary: "包含本阶段流程、证据门与输出契约;不包含代办保证、政府/云费用或法律意见。" review_trigger: "法规、平台、案例覆盖或人工服务边界重大变化时复评。" confidence: internal-verified -
skill-card.md 2.2 KB
# 备案预检 · Filing Readiness · Skill Card ## Description 在提交中国大陆网站备案前,核验备案场景、主体与域名实名、接入资源、材料和专项义务风险,输出带来源等级的可执行准备清单。 ## Owner LovStudio;由本地仓库维护者负责维护。 ## License / Terms MIT。可使用、修改和分发;实时监管结论以主管部门和接入商页面为准。 ## Use Case 面向准备在中国大陆服务器上线网站的企业、个人与技术负责人。核心任务:分类首次、新增服务、接入、变更或注销场景;核验主体、域名实名、接入资源和材料缺口;标记前置审批与安全评估风险。 ## Deployment Geography 中国大陆备案场景;作为 `lov-china-website-filing` 的自包含模块运行。 ## Requirements / Dependencies 离线输出无外部依赖;实时操作使用用户自己的已登录会话。不得持久化验证码、Cookie 或完整证件数据。 ## Known Risks and Mitigations 平台与地方规则可能变化,关键动作前重读权威页面;敏感资料最小化处理;完成状态必须满足本模块的真实证据门。 ## References - [Module instructions](SKILL.md) - [Composition record](references/skill-composition.md) ## Skill Output Markdown 准备报告,包含场景、verified/user-stated/inferred/unknown 证据标签、阻塞项与下一步。 ## Skill Version 0.1.0 ## Ethical Considerations 不伪造材料、不规避监管、不把推断写成权威结论;关键提交保留用户授权。 ## LovStudio Evidence ### User Cases 见 [cases/cases.json](cases/cases.json):LovStudio 新增服务备案准备。 ### Dimension Map - 正确性:单一真实案例已验证。 - 安全性:提交门与隐私边界已审阅。 - 可追溯性:来源、时间、状态和下一动作已结构化。 当前不设置缺少多地区基线的数字评分。 ### Pricing Basis 免费内嵌模块。减少因备案类型、实名不一致、材料或接入资源错误导致的反复退回。 不包含代办保证、政府/云费用或法律意见。 ### Distribution `lovstudio` 为本地 Kit 内嵌;`github`、`workbuddy`、`skillpay` 均未发布。 -
skill-card.yaml 3.3 KB
schema: lovstudio/skill-card/v1 version: "0.1.0" description: "在提交中国大陆网站备案前,核验备案场景、主体与域名实名、接入资源、材料和专项义务风险,输出带来源等级的可执行准备清单。" owner: team: LovStudio contact: local repository maintainers license: name: MIT terms: "可使用、修改和分发;实时监管结论以主管部门和接入商页面为准。" url: "../../LICENSE" use_case: audience: "准备在中国大陆服务器上线网站的企业、个人与技术负责人" scenario: "备案准备核验" tasks: - "分类首次、新增服务、接入、变更或注销场景" - "核验主体、域名实名、接入资源和材料缺口" - "标记前置审批与安全评估风险" deployment: geography: mainland-china environments: - "lov-china-website-filing self-contained Skill Kit" requirements: credentials: "离线输出不需要凭据;实时页面使用用户自己的已登录会话。" dependencies: [] runtime: - "Portable Agent Skills" risks: - risk: "平台或地方规则变化导致旧字段和时限失效。" mitigation: "关键动作前重新读取政府或接入商权威页面并记录时间。" - risk: "敏感身份信息被复制到持久输出。" mitigation: "只保存公开标识和脱敏证据,不保存验证码、Cookie 或完整证件数据。" references: - title: "Module instructions" path: "SKILL.md" - title: "Composition record" path: "references/skill-composition.md" output: types: - "备案准备核验" formats: - markdown parameters: - "subject, service, domain, authority evidence" validation: - "authoritative evidence and timestamp" - "stage-specific completion gate" description: "Markdown 准备报告,包含场景、verified/user-stated/inferred/unknown 证据标签、阻塞项与下一步。" ethical_considerations: "不伪造材料、不规避监管、不把推断写成权威结论;关键提交保留用户授权。" dimensions: - id: correctness label: "正确性" description: "使用与本阶段匹配的权威来源和完成门。" evidence: "LovStudio 案例在新增服务审核期间保持域名未解析,并先核验主体、服务和域名对应关系。" score: null status: verified-with-one-real-case - id: safety label: "安全性" description: "保护敏感资料并保留人工提交门。" evidence: "模块遵循 Kit 的 authority-gates.md。" score: null status: policy-reviewed - id: traceability label: "可追溯性" description: "输出记录来源、时间、状态和下一动作。" evidence: "真实案例和统一台账字段已记录。" score: null status: locally-validated pricing: model: free currency: CNY list_price_cny: 0 basis: "减少因备案类型、实名不一致、材料或接入资源错误导致的反复退回。" boundary: "不包含代办保证、政府/云费用或法律意见。" review_trigger: "法规、平台或人工服务边界重大变化时复评。" confidence: internal-verified distribution: paid: - channel: workbuddy status: not-published - channel: skillpay status: not-published free: - channel: github status: not-published - channel: lovstudio status: embedded-local -
SKILL.md 2.6 KB
--- name: lov-filing-readiness description: > 在提交中国大陆网站备案前核验主体、域名实名、服务类型、接入资源和材料缺口;用户说“备案前检查”“ICP 需要什么材料”或 "check ICP filing readiness" 时使用,并给出可执行准备清单。 license: MIT metadata: author: LovStudio version: "0.1.0" card_standard: lovstudio/skill-card/v1 tags: - icp-readiness - domain-real-name - filing-materials compatibility: "Embedded module of lov-china-website-filing; browser access is optional for live checks." dependencies: [] --- # 备案预检 · Filing Readiness ## Input and output - 输入:主办者、服务名称、域名、注册商、接入商/云资源、网站能力与目标地区。 - 输出:备案场景、已核验事实、材料清单、实名/资源缺口、专项许可风险和下一步。 ## Triggers ### Activate when - 用户说“备案前检查”“域名实名同步了吗”“ICP 需要什么材料”。 - User asks to "check ICP filing readiness" or "prepare a China website filing checklist". ### Do not activate when - 已有订单并只需查看审核状态;使用 `lov-icp-filing` 或 `lov-filing-monitor`。 - 只需生成隐私政策、部署网站或购买域名,不属于备案准备核验。 ## Workflow (MANDATORY) 1. 从 `$KIT_DIR/skill.yaml` 解析 Profile,敏感字段只在当前会话最小化使用;详见本模块的 `references/user-profile.md`。 2. 读取 `$KIT_DIR/references/official-rules.md`、`$KIT_DIR/references/authority-gates.md` 和本模块的 `references/skill-composition.md`。 3. 分类首次备案、新增服务、接入、变更或注销;识别网站、APP、小程序或仅 API。 4. 核验域名实名所有者、证件类型/有效期、域名后缀、接入资源资格、服务器地域和主体所在地要求。 5. 盘点主体证件、负责人信息、域名证明、真实性核验及可能的前置审批;不把接入商通用清单写成当地最终要求。 6. 检查论坛、评论、群组、直播、信息分享、算法、生成式 AI、新闻/出版/教育/医疗等能力,只标记可能的专项义务,不自行作法律结论。 7. 输出 `ready`、`ready-with-risks` 或 `blocked`,每项标注 `verified`、`user-stated`、`inferred` 或 `unknown`。 ## Completion gate 只有备案类型、主体/域名匹配、接入资源和必需材料都有可追溯依据时才可称 `ready`。准备就绪不等于已提交或已通过。 ## Dependencies None for offline planning. Live verification requires the relevant registrar and provider pages. -
skill.yaml 1.6 KB
schema: skill-manifest/v1 id: lov-filing-readiness version: "0.1.0" runtime: skill-runtime/v1 context: profile: schema: user-profile/v1 source: shared-profile read: - user - brand - workspace - preferences - skills.lov-filing-readiness persist: enabled: true namespace: skills.lov-filing-readiness records_path: skills.lov-filing-readiness.records write_policy: direct-user-statement atomic: true fields: - path: user.name aliases: - identity.name required: false question: 如果本次输出需要用户身份,请提供名称。 - path: user.language required: false question: 希望使用哪种语言输出? - path: user.timezone required: false question: 需要使用哪个时区处理日期和时间? - path: brand.name aliases: - identity.name required: false question: 如果本次输出需要品牌身份,请提供品牌名称。 - path: brand.site required: false question: 如果需要品牌官网,请提供地址。 - path: brand.tone required: false question: 如果已有品牌语气或审美关键词,请提供它们。 preferences: namespace: lov_filing_readiness fields: - path: user.language required: false question: 希望使用哪种语言输出? - path: user.timezone required: false question: 需要使用哪个时区处理日期和时间? interaction: ask_missing: true max_questions: 1
-
-
icp-filing
-
cases
-
cases.json 710 B
[ { "type": "case", "title": "lovstudio.cn 新增服务 ICP", "description": "跟进腾讯云新增服务订单,从接入商审核、登录阻塞和管局阶段直到取得服务备案号。", "input": { "items": [ "域名:lovstudio.cn", "订单:30178537319491747", "主办单位:北京飞脑科技有限公司" ] }, "prompt": "检查最新 ICP 状态,有变化或需要我操作时提醒,通过后给出备案号。", "output": { "items": [ "仅以权威订单页作为当前状态", "登录或验证码视为 blocked-user-action", "管局通过后记录京ICP备2025108424号-3" ] } } ]
-
-
references
-
skill-card-standard.md 660 B
# LovStudio Skill Card standard `skill-card.yaml` follows the minimum release-record idea of NVIDIA Skill Cards: description, owner, license/terms, use case, deployment, requirements, risks/mitigations, references, output contract, version, and ethical considerations. LovStudio adds evidence that helps a user decide whether the Skill is credible: 1. A real user case with Input → Prompt → Output. 2. A dimension map with named evidence, not an unexplained score. 3. A pricing basis, including the free boundary and review trigger. 4. Explicit paid and free distribution states. Never claim a case, score, channel, or price that has not been verified. -
skill-composition.md 1 KB
# Skill Group Composition ## Nearby Skills Inspected | Skill or module | Classification | Decision | | --- | --- | --- | | `lov-china-website-filing` | owning Kit | 提供共享 Profile、权威规则、提交门、状态词表和统一台账。 | | `lov-fact-check` | optional external atom | 只接受明确的制品级交接,不作为隐藏依赖。 | | Kit sibling modules | upstream/downstream atoms | 通过域名、阶段状态、证据时间和下一动作交接。 | ## Atomic Handoffs - 上游:filing-readiness 的备案类型、材料、实名与资源核验结果 - 本模块:ICP 申请与审核。 - 下游:将 ICP 通过证据、服务备案号、域名和接入资源交给 domain-cutover ## Overlap Decisions 通用表单填充只可提供草稿;本模块拥有备案状态机、短信核验和管局完成门。 ## Composition Decision 本模块是 `lov-china-website-filing` 的**内嵌原子模块**。它可单独调用,但跨阶段时依赖 Kit 内共享的证据和完成门;不拆成外部安装依赖。 -
user-profile.md 503 B
# User Profile contract 本模块使用 Kit 根目录的 [user-profile/v1 contract](../../../references/user-profile.md) 和 `skills.lov-icp-filing` 命名空间。 每次运行按当前请求、项目上下文、Skill 记录、共享偏好、品牌/用户 Profile、安全默认值的顺序解析。只有用户直接说明且希望长期复用的非敏感事实才可通过 `$KIT_DIR/scripts/profile_store.py` 持久化;证件、手机号、验证码、Cookie、密钥和扫描件不得保存。
-
-
scripts
-
profile_store.py 8.3 KB
#!/usr/bin/env python3 """Read and persist a Skill's user-owned profile context. The command keeps shared identity and brand facts in the profile root while putting Skill-specific durable records under ``skills.<skill_id>.records``. Writes require an explicit confirmation flag and use an atomic replacement. """ from __future__ import annotations import argparse import json import os import re import sys import tempfile from pathlib import Path from typing import Any, Iterable USER_PROFILE_SCHEMA = "user-profile/v1" SKILL_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") SAFE_ROOTS = {"user", "brand", "workspace", "preferences"} SENSITIVE_PARTS = {"token", "secret", "password", "credential", "cookie", "api_key", "apikey"} def config_dir() -> Path: configured = os.environ.get("SKILLS_CONFIG_DIR") if configured: return Path(os.path.expandvars(configured)).expanduser() xdg = os.environ.get("XDG_CONFIG_HOME") if xdg: return Path(os.path.expandvars(xdg)).expanduser() / "agent-skills" return Path.home() / ".config" / "agent-skills" def profile_path(explicit: Path | None = None) -> Path: if explicit: return explicit.expanduser() configured = ( os.environ.get("SKILL_PROFILE_PATH") or os.environ.get("SKILLS_PROFILE_PATH") ) if configured: return Path(os.path.expandvars(configured)).expanduser() candidates = ( Path.home() / ".lovstudio" / "skills" / "profile.json", Path.home() / ".skill-publisher" / "skills" / "profile.json", config_dir() / "profile.json", ) return next((candidate for candidate in candidates if candidate.exists()), candidates[-1]) def read_profile(path: Path) -> dict[str, Any]: if not path.exists(): return {} try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ValueError(f"profile cannot be read: {path}: {exc}") from exc if not isinstance(value, dict): raise ValueError("profile root must be an object") return value def profile_skeleton(skill_id: str) -> dict[str, Any]: return { "schema": "skill-profile/v1", "profile_id": f"profile-{skill_id}", "revision": 1, "status": "draft", "identity": {}, "purpose": {}, "brand": {}, "user": {}, "workspace": {}, "skills": {}, "extensions": {}, } def atomic_write(path: Path, value: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) mode = 0o600 if path.exists(): mode = path.stat().st_mode & 0o777 temporary_name: str | None = None try: with tempfile.NamedTemporaryFile( "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False, ) as handle: temporary_name = handle.name handle.write(json.dumps(value, ensure_ascii=False, indent=2) + "\n") handle.flush() os.fsync(handle.fileno()) os.chmod(temporary_name, mode) os.replace(temporary_name, path) finally: if temporary_name: temporary = Path(temporary_name) if temporary.exists(): temporary.unlink() def set_path(root: dict[str, Any], parts: list[str], value: Any) -> None: current: dict[str, Any] = root for part in parts[:-1]: child = current.get(part) if not isinstance(child, dict): child = {} current[part] = child current = child current[parts[-1]] = value def target_parts(skill_id: str, requested_path: str) -> tuple[list[str], str]: if not SKILL_ID_RE.fullmatch(skill_id): raise ValueError("skill_id must be kebab-case") path = requested_path.strip().strip(".") if not path: raise ValueError("path is required") parts = [part for part in path.split(".") if part] if any(not re.fullmatch(r"[A-Za-z0-9_-]+", part) for part in parts): raise ValueError("path contains an invalid segment") if any(part.casefold() in SENSITIVE_PARTS for part in parts): raise ValueError("profile records do not accept secret-like fields") if parts[0] == "records": target = ["skills", skill_id, "records", *parts[1:]] return target, ".".join(target) if parts[0] == "profile": target = ["skills", skill_id, "profile", *parts[1:]] return target, ".".join(target) if parts[:3] == ["skills", skill_id, "records"]: return parts, ".".join(parts) if parts[:3] == ["skills", skill_id, "profile"]: return parts, ".".join(parts) if parts[0] in SAFE_ROOTS: return parts, ".".join(parts) expected = f"records.<field> or one of: {', '.join(sorted(SAFE_ROOTS))}.<field>" raise ValueError(f"path must use {expected}") def parse_value(raw: str) -> Any: try: return json.loads(raw) except json.JSONDecodeError: return raw def read_command(args: argparse.Namespace) -> dict[str, Any]: path = profile_path(args.profile) profile = read_profile(path) skills = profile.get("skills", {}) if not isinstance(skills, dict): skills = {} skill = skills.get(args.skill_id, {}) if not isinstance(skill, dict): skill = {} user = profile.get("user") if not isinstance(user, dict) or not user: user = profile.get("identity", {}) if not isinstance(user, dict): user = {} brand = profile.get("brand", {}) if not isinstance(brand, dict): brand = {} workspace = profile.get("workspace", {}) if not isinstance(workspace, dict): workspace = {} preferences = profile.get("preferences", {}) if not isinstance(preferences, dict): preferences = {} records = skill.get("records", {}) if not isinstance(records, dict): records = {} return { "status": "ready", "schema": USER_PROFILE_SCHEMA, "skill_id": args.skill_id, "profile_path": str(path), "user": user, "brand": brand, "workspace": workspace, "preferences": preferences, "skill": skill, "records": records, } def record_command(args: argparse.Namespace) -> dict[str, Any]: if not args.confirm: raise PermissionError("record requires --confirm after the user has stated the value") parts, canonical_path = target_parts(args.skill_id, args.path) path = profile_path(args.profile) profile = read_profile(path) if not profile: profile = profile_skeleton(args.skill_id) set_path(profile, parts, parse_value(args.value)) revision = profile.get("revision") if isinstance(revision, int) and not isinstance(revision, bool): profile["revision"] = revision + 1 atomic_write(path, profile) return { "status": "saved", "schema": USER_PROFILE_SCHEMA, "skill_id": args.skill_id, "profile_path": str(path), "path": canonical_path, } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--profile", type=Path, default=None, help="Shared profile JSON path") subparsers = parser.add_subparsers(dest="command", required=True) read_parser = subparsers.add_parser("read", help="Read shared and Skill-specific profile context") read_parser.add_argument("--skill-id", required=True) read_parser.add_argument("--pretty", action="store_true") record_parser = subparsers.add_parser("record", help="Persist a user-stated profile value") record_parser.add_argument("--skill-id", required=True) record_parser.add_argument("--path", required=True, help="records.<field>, brand.<field>, or user.<field>") record_parser.add_argument("--value", required=True) record_parser.add_argument("--confirm", action="store_true") args = parser.parse_args() try: result = read_command(args) if args.command == "read" else record_command(args) except (OSError, PermissionError, ValueError) as exc: print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False), file=sys.stderr) return 2 indent = 2 if getattr(args, "pretty", False) else None print(json.dumps(result, ensure_ascii=False, indent=indent, sort_keys=bool(indent))) return 0 if __name__ == "__main__": raise SystemExit(main()) -
validate_skill.py 24.6 KB
#!/usr/bin/env python3 """Validate a portable local Skill Publisher Skill source directory.""" from __future__ import annotations import argparse import json import re import sys from pathlib import Path from typing import Any, Iterable try: import yaml except ImportError: print( "ERROR: PyYAML is required. Install it with: python3 -m pip install PyYAML", file=sys.stderr, ) raise SystemExit(2) FRONTMATTER_KEYS = {"name", "description", "license", "allowed-tools", "metadata"} TEXT_SUFFIXES = {".md", ".json", ".yaml", ".yml", ".txt", ".svg", ".py"} JUNK_NAMES = {"__pycache__", ".DS_Store"} JUNK_SUFFIXES = {".pyc", ".pyo"} SKIP_DIRS = {".git", "dist", ".venv", "venv", "node_modules"} SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$") NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*]\(([^)]+)\)") SKILL_PATH_RE = re.compile(r"\$(SKILL_DIR|KIT_DIR)/([A-Za-z0-9_./-]+)") CARD_STANDARD = "lovstudio/skill-card/v1" PRICING_CARD_SCHEMA = "lovstudio/pricing-card/v1" MANIFEST_SCHEMA = "skill-manifest/v1" RUNTIME_VERSION = "skill-runtime/v1" PROFILE_SCHEMA = "user-profile/v1" class ValidationFailure(Exception): """Raised when source metadata cannot be parsed.""" def read_text(path: Path) -> str: return path.read_text(encoding="utf-8") def compact_text(value: Any) -> str: return re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else "" def split_frontmatter(path: Path) -> tuple[dict[str, Any], str]: text = read_text(path) if not text.startswith("---\n"): raise ValidationFailure(f"{path}: missing YAML frontmatter") marker = text.find("\n---\n", 4) if marker < 0: raise ValidationFailure(f"{path}: frontmatter is not closed") try: data = yaml.safe_load(text[4:marker]) except yaml.YAMLError as exc: raise ValidationFailure( f"{path}: standard YAML parser rejected frontmatter: {exc}" ) from exc if not isinstance(data, dict): raise ValidationFailure(f"{path}: frontmatter must be a mapping") return data, text[marker + 5 :] def iter_files(root: Path) -> Iterable[Path]: for path in root.rglob("*"): if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue if path.is_file(): yield path def is_relative_to(path: Path, parent: Path) -> bool: try: path.relative_to(parent) return True except ValueError: return False def validate_skill_file(path: Path, errors: list[str]) -> dict[str, Any] | None: try: data, body = split_frontmatter(path) except ValidationFailure as exc: errors.append(str(exc)) return None unexpected = sorted(set(data) - FRONTMATTER_KEYS) if unexpected: errors.append(f"{path}: unsupported frontmatter keys: {', '.join(unexpected)}") name = compact_text(data.get("name")) if not NAME_RE.fullmatch(name) or len(name) > 64: errors.append(f"{path}: name must be kebab-case and at most 64 characters") description = compact_text(data.get("description")) if not 50 <= len(description) <= 200: errors.append( f"{path}: description must contain 50-200 characters " f"(found {len(description)})" ) metadata = data.get("metadata") if not isinstance(metadata, dict): errors.append(f"{path}: metadata must be a mapping") else: if not compact_text(metadata.get("author")): errors.append(f"{path}: metadata.author is required") if not SEMVER_RE.fullmatch(compact_text(metadata.get("version"))): errors.append(f"{path}: metadata.version must use SemVer") tags = metadata.get("tags") if not isinstance(tags, list) or not tags or not all( isinstance(tag, str) and tag.strip() for tag in tags ): errors.append(f"{path}: metadata.tags must be a non-empty list") dependencies = metadata.get("dependencies", []) if not isinstance(dependencies, list): errors.append(f"{path}: metadata.dependencies must be a list") if "card_standard" in metadata and metadata.get("card_standard") != CARD_STANDARD: errors.append(f"{path}: metadata.card_standard must be {CARD_STANDARD}") trigger_block = re.search( r"(?ms)^##\s+Triggers\s*$([\s\S]*?)(?=^##\s+|\Z)", body ) if not trigger_block: errors.append(f"{path}: add an explicit '## Triggers' section") else: block = trigger_block.group(1) if len(re.findall(r"(?m)^\s*-\s+\S", block)) < 3: errors.append(f"{path}: add two activation examples and one non-trigger") if not re.search(r"[\u3400-\u9fff]", block): errors.append(f"{path}: add a concrete Chinese trigger phrase") if not re.search(r"(?i)\b(?:the|a|an|create|build|help|publish|review|use)\b", block): errors.append(f"{path}: add a concrete English trigger phrase") if not re.search( r"(?mi)^###\s+(?:Do not activate when|Non-triggers?|不应触发|不要触发)\s*$", body, ): errors.append(f"{path}: add explicit non-trigger conditions") if len(read_text(path).splitlines()) >= 500: errors.append(f"{path}: keep SKILL.md below 500 lines") if not body.strip(): errors.append(f"{path}: body is empty") return data def load_yaml(path: Path, errors: list[str]) -> dict[str, Any] | None: try: data = yaml.safe_load(read_text(path)) except yaml.YAMLError as exc: errors.append(f"{path}: standard YAML parser rejected file: {exc}") return None if not isinstance(data, dict): errors.append(f"{path}: expected a YAML mapping") return None return data def validate_runtime_manifest( skill_root: Path, expected_skill_id: str, errors: list[str] ) -> None: manifest_path = skill_root / "skill.yaml" if not manifest_path.is_file(): errors.append(f"{manifest_path}: user-profile runtime manifest is required") return data = load_yaml(manifest_path, errors) if data is None: return if data.get("schema") != MANIFEST_SCHEMA: errors.append(f"{manifest_path}: schema must be {MANIFEST_SCHEMA}") if data.get("id") != expected_skill_id: errors.append(f"{manifest_path}: id must match {expected_skill_id}") version = compact_text(data.get("version")) if not SEMVER_RE.fullmatch(version): errors.append(f"{manifest_path}: version must use SemVer") if data.get("runtime") != RUNTIME_VERSION: errors.append(f"{manifest_path}: runtime must be {RUNTIME_VERSION}") context = data.get("context") if not isinstance(context, dict): errors.append(f"{manifest_path}: context must be a mapping") return profile = context.get("profile") preferences = context.get("preferences") interaction = context.get("interaction") if not isinstance(profile, dict): errors.append(f"{manifest_path}: context.profile must be a mapping") else: if profile.get("schema") != PROFILE_SCHEMA: errors.append(f"{manifest_path}: context.profile.schema must be {PROFILE_SCHEMA}") if profile.get("source") != "shared-profile": errors.append(f"{manifest_path}: context.profile.source must be shared-profile") reads = profile.get("read") if not isinstance(reads, list) or not reads or not all( isinstance(item, str) and item.strip() for item in reads ): errors.append(f"{manifest_path}: context.profile.read must be a non-empty list") persist = profile.get("persist") if not isinstance(persist, dict): errors.append(f"{manifest_path}: context.profile.persist must be a mapping") else: if persist.get("enabled") is not True: errors.append(f"{manifest_path}: profile persistence must be enabled") if persist.get("namespace") != f"skills.{expected_skill_id}": errors.append(f"{manifest_path}: persist.namespace must target skills.{expected_skill_id}") if persist.get("records_path") != f"skills.{expected_skill_id}.records": errors.append(f"{manifest_path}: persist.records_path must target Skill records") if persist.get("write_policy") != "direct-user-statement": errors.append(f"{manifest_path}: persist.write_policy must be direct-user-statement") if persist.get("atomic") is not True: errors.append(f"{manifest_path}: profile persistence must be atomic") fields = profile.get("fields") if not isinstance(fields, list) or not fields: errors.append(f"{manifest_path}: context.profile.fields must be a non-empty list") else: validate_manifest_fields(manifest_path, "profile", fields, errors) if not isinstance(preferences, dict): errors.append(f"{manifest_path}: context.preferences must be a mapping") else: fields = preferences.get("fields") if not isinstance(fields, list): errors.append(f"{manifest_path}: context.preferences.fields must be a list") else: validate_manifest_fields(manifest_path, "preferences", fields, errors) if not isinstance(interaction, dict): errors.append(f"{manifest_path}: context.interaction must be a mapping") else: if not isinstance(interaction.get("ask_missing"), bool): errors.append(f"{manifest_path}: interaction.ask_missing must be boolean") max_questions = interaction.get("max_questions") if not isinstance(max_questions, int) or isinstance(max_questions, bool) or not 1 <= max_questions <= 3: errors.append(f"{manifest_path}: interaction.max_questions must be 1-3") def validate_manifest_fields( manifest_path: Path, source_name: str, fields: list[Any], errors: list[str] ) -> None: for index, field in enumerate(fields): label = f"{manifest_path}: context.{source_name}.fields[{index}]" if not isinstance(field, dict): errors.append(f"{label} must be a mapping") continue if not compact_text(field.get("path")): errors.append(f"{label}.path is required") if not isinstance(field.get("required"), bool): errors.append(f"{label}.required must be boolean") if not compact_text(field.get("question")): errors.append(f"{label}.question is required") aliases = field.get("aliases", []) if isinstance(aliases, str): aliases = [aliases] if not isinstance(aliases, list) or not all( isinstance(alias, str) and alias.strip() for alias in aliases ): errors.append(f"{label}.aliases must be a list of paths") def has_content(value: Any) -> bool: if isinstance(value, str): return bool(value.strip()) if isinstance(value, list): return any(has_content(item) for item in value) if isinstance(value, dict): return any(has_content(item) for item in value.values()) return value is not None def contains_placeholder(value: Any) -> bool: if isinstance(value, str): return bool(re.search(r"\bTODO\b|\{[^}]+\}", value, re.I)) if isinstance(value, list): return any(contains_placeholder(item) for item in value) if isinstance(value, dict): return any(contains_placeholder(item) for item in value.values()) return False def validate_card_bundle(skill_root: Path, errors: list[str]) -> None: card_path = skill_root / "skill-card.yaml" card_doc_path = skill_root / "skill-card.md" cases_path = skill_root / "cases" / "cases.json" pricing_path = skill_root / "pricing-card.yaml" for path in (card_path, card_doc_path, cases_path, pricing_path): if not path.is_file(): errors.append(f"{path}: required Skill trust-bundle file is missing") card = load_yaml(card_path, errors) if card_path.is_file() else None if card is not None: if card.get("schema") != CARD_STANDARD: errors.append(f"{card_path}: schema must be {CARD_STANDARD}") required = ( "description", "owner", "license", "use_case", "deployment", "requirements", "risks", "references", "output", "version", "ethical_considerations", "dimensions", "pricing", "distribution", ) for key in required: if key not in card or not has_content(card.get(key)): errors.append(f"{card_path}: required field '{key}' is missing or empty") dimensions = card.get("dimensions") if not isinstance(dimensions, list) or len(dimensions) < 3: errors.append(f"{card_path}: dimensions must contain at least three named dimensions") else: ids: set[str] = set() for index, dimension in enumerate(dimensions): label = f"{card_path}: dimensions[{index}]" if not isinstance(dimension, dict): errors.append(f"{label}: expected a mapping") continue dimension_id = compact_text(dimension.get("id")) if not dimension_id or dimension_id in ids: errors.append(f"{label}: id is required and must be unique") ids.add(dimension_id) for key in ("label", "description", "evidence"): if not compact_text(dimension.get(key)): errors.append(f"{label}: '{key}' is required") risks = card.get("risks") if not isinstance(risks, list) or not risks: errors.append(f"{card_path}: risks must contain at least one risk and mitigation") else: for index, risk in enumerate(risks): if not isinstance(risk, dict) or not compact_text(risk.get("risk")) or not compact_text(risk.get("mitigation")): errors.append(f"{card_path}: risks[{index}] needs risk and mitigation") distribution = card.get("distribution") if not isinstance(distribution, dict) or not isinstance(distribution.get("paid"), list) or not isinstance(distribution.get("free"), list): errors.append(f"{card_path}: distribution must declare paid and free lists") if contains_placeholder(card): errors.append(f"{card_path}: replace unresolved TODO or template placeholders") if card_doc_path.is_file(): card_doc = read_text(card_doc_path) required_headings = ( "Description", "Owner", "License", "Use Case", "Deployment Geography", "Requirements", "Known Risks", "References", "Skill Output", "Skill Version", "Ethical Considerations", "User Cases", "Dimension Map", "Pricing Basis", "Distribution", ) for heading in required_headings: if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", card_doc): errors.append(f"{card_doc_path}: add the '{heading}' section") if re.search(r"\bTODO\b|\{[^}]+\}", card_doc, re.I): errors.append(f"{card_doc_path}: replace unresolved TODO or template placeholders") if cases_path.is_file(): try: cases = json.loads(read_text(cases_path)) except json.JSONDecodeError as exc: errors.append(f"{cases_path}: invalid JSON: {exc}") cases = [] if not isinstance(cases, list) or not cases: errors.append(f"{cases_path}: include at least one real user case") else: for index, case in enumerate(cases): label = f"{cases_path}: cases[{index}]" if not isinstance(case, dict): errors.append(f"{label}: expected a mapping") continue for key in ("title", "description", "input", "prompt", "output"): if not has_content(case.get(key)): errors.append(f"{label}: '{key}' is required") if contains_placeholder(case): errors.append(f"{label}: replace unresolved TODO or template placeholders") for image_key in ("cover", "gallery"): image_values = case.get(image_key, []) if isinstance(image_values, str): image_values = [image_values] if not isinstance(image_values, list): errors.append(f"{label}: '{image_key}' must be a string or list") continue for image in image_values: if not isinstance(image, str) or not image.strip(): errors.append(f"{label}: '{image_key}' contains an empty path") elif not re.match(r"^(?:https?:|/|data:)", image) and not (skill_root / image).is_file(): errors.append(f"{label}: case asset does not exist: {image}") pricing = load_yaml(pricing_path, errors) if pricing_path.is_file() else None if pricing is not None: if pricing.get("schema") != PRICING_CARD_SCHEMA: errors.append(f"{pricing_path}: schema must be {PRICING_CARD_SCHEMA}") for key in ("model", "currency", "list_price_cny", "basis", "boundary", "review_trigger", "confidence"): if key not in pricing or (key != "list_price_cny" and not has_content(pricing.get(key))): errors.append(f"{pricing_path}: required field '{key}' is missing or empty") if contains_placeholder(pricing): errors.append(f"{pricing_path}: replace unresolved TODO or template placeholders") def validate_composition_reference(skill_root: Path, errors: list[str]) -> None: path = skill_root / "references" / "skill-composition.md" if not path.is_file(): errors.append(f"{path}: required Skill group composition record is missing") return text = read_text(path) required_headings = ( "Nearby Skills Inspected", "Atomic Handoffs", "Overlap Decisions", "Composition Decision", ) for heading in required_headings: if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", text): errors.append(f"{path}: add the '{heading}' section") if re.search(r"\bTODO\b|\{[^}]+\}", text, re.I): errors.append(f"{path}: replace unresolved TODO or template placeholders") def validate_kit(root: Path, skill_names: set[str], errors: list[str]) -> None: manifest = root / "kit.yaml" if not manifest.exists(): return data = load_yaml(manifest, errors) if data is None: return modules = data.get("modules") if not isinstance(modules, list) or not modules: errors.append(f"{manifest}: modules must be a non-empty list") return module_ids: set[str] = set() for index, module in enumerate(modules): label = f"{manifest}: modules[{index}]" if not isinstance(module, dict): errors.append(f"{label}: expected a mapping") continue module_id = compact_text(module.get("id")) skill_name = compact_text(module.get("skill")) relative = compact_text(module.get("path")) if not module_id or module_id in module_ids: errors.append(f"{label}: id is required and must be unique") module_ids.add(module_id) module_path = (root / relative).resolve() if ( not relative or not is_relative_to(module_path, root.resolve()) or not (module_path / "SKILL.md").is_file() ): errors.append(f"{label}: missing module at '{relative}/SKILL.md'") if skill_name not in skill_names: errors.append(f"{label}: unresolved child skill '{skill_name}'") pipelines = data.get("pipelines") if not isinstance(pipelines, dict) or not pipelines: errors.append(f"{manifest}: pipelines must be a non-empty mapping") return for pipeline, sequence in pipelines.items(): if not isinstance(sequence, list) or not sequence: errors.append(f"{manifest}: pipeline '{pipeline}' must be a non-empty list") continue missing = [str(item) for item in sequence if item not in module_ids] if missing: errors.append( f"{manifest}: pipeline '{pipeline}' has unknown modules: " + ", ".join(missing) ) def validate_local_references(root: Path, errors: list[str]) -> None: for path in iter_files(root): if path.suffix.lower() != ".md": continue text = read_text(path) for raw in MARKDOWN_LINK_RE.findall(text): target = raw.strip().split(maxsplit=1)[0].strip("<>").split("#", 1)[0] if ( not target or re.match(r"^[a-z][a-z0-9+.-]*:", target, re.I) or any(token in target for token in ("TODO", "{", "}")) ): continue if not (path.parent / target).resolve().exists(): errors.append(f"{path}: broken local link '{target}'") skill_root = path.parent if path.name == "SKILL.md" else root for variable, target in SKILL_PATH_RE.findall(text): if "TODO" in target: continue base = skill_root if variable == "SKILL_DIR" else root resolved = (base / target.rstrip(".,;:)")).resolve() if not is_relative_to(resolved, root.resolve()) or not resolved.exists(): errors.append(f"{path}: missing required resource '${variable}/{target}'") def validate_hygiene(root: Path, errors: list[str]) -> None: private_path = re.compile(r"(?:/Users/[^/\s]+/|[A-Za-z]:\\\\Users\\\\[^\\\s]+\\\\)") for path in root.rglob("*"): if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue if path.name in JUNK_NAMES or path.suffix.lower() in JUNK_SUFFIXES: errors.append(f"{path}: generated/cache artifact must not ship") for path in iter_files(root): if path.suffix.lower() not in TEXT_SUFFIXES or path.name == "validate_skill.py": continue text = read_text(path) if private_path.search(text): errors.append(f"{path}: contains a private absolute user path") if path.name != "init_skill.py" and re.search(r"\bTODO\s*[::]", text): errors.append(f"{path}: unresolved TODO placeholder") for relative in ("workbuddy", "scripts/build_workbuddy.py"): if (root / relative).exists(): errors.append( f"{root / relative}: platform distribution artifacts belong to skill-publish" ) def validate_source(root: Path, errors: list[str]) -> None: root_skill = root / "SKILL.md" skill_files = [root_skill, *sorted((root / "skills").glob("*/SKILL.md"))] if not root_skill.is_file(): errors.append(f"{root_skill}: file is required") return parsed: list[tuple[Path, dict[str, Any]]] = [] for path in skill_files: data = validate_skill_file(path, errors) if data: parsed.append((path, data)) names = {compact_text(data.get("name")) for _, data in parsed} if len(names) != len(parsed): errors.append(f"{root}: every embedded Skill must have a unique name") for path, data in parsed: metadata = data.get("metadata") if isinstance(metadata, dict) and metadata.get("card_standard") == CARD_STANDARD: validate_card_bundle(path.parent, errors) validate_composition_reference(path.parent, errors) validate_runtime_manifest(path.parent, compact_text(data.get("name")), errors) validate_kit(root, names, errors) readme = root / "README.md" if not readme.is_file(): errors.append(f"{readme}: file is required") elif parsed: metadata = parsed[0][1].get("metadata") version = compact_text(metadata.get("version")) if isinstance(metadata, dict) else "" if version and f"version-{version}-" not in read_text(readme): errors.append(f"{readme}: version badge must match {version}") validate_hygiene(root, errors) validate_local_references(root, errors) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("path", type=Path, help="Local Skill source directory") args = parser.parse_args() root = args.path.expanduser().resolve() if not root.is_dir(): print(f"ERROR: directory does not exist: {root}", file=sys.stderr) return 2 errors: list[str] = [] validate_source(root, errors) if errors: print(f"FAILED: {len(errors)} issue(s)") for error in errors: print(f"- {error}") return 1 print(f"PASSED: source validation ({root})") return 0 if __name__ == "__main__": raise SystemExit(main())
-
-
pricing-card.yaml 518 B
schema: lovstudio/pricing-card/v1 version: "0.1.0" model: free currency: CNY list_price_cny: 0 value_anchor: "避免错过短信核验、遗漏补充材料或把中间状态当成备案完成。" basis: "作为 lov-china-website-filing 的自包含模块免费提供。" boundary: "包含本阶段流程、证据门与输出契约;不包含代办保证、政府/云费用或法律意见。" review_trigger: "法规、平台、案例覆盖或人工服务边界重大变化时复评。" confidence: internal-verified -
skill-card.md 2.1 KB
# ICP 备案助手 · ICP Filing Assistant · Skill Card ## Description 在接入商与工信部链路中协助填写、提交和跟进 ICP 备案,明确实名同步、材料补充、短信核验、管局审核和服务备案号。 ## Owner LovStudio;由本地仓库维护者负责维护。 ## License / Terms MIT。可使用、修改和分发;实时监管结论以主管部门和接入商页面为准。 ## Use Case 面向已有备案准备材料并需要办理或跟进 ICP 的网站负责人。核心任务:操作接入商备案订单;跟进实名、材料、短信核验和管局审核;仅在权威通过后记录服务备案号。 ## Deployment Geography 中国大陆备案场景;作为 `lov-china-website-filing` 的自包含模块运行。 ## Requirements / Dependencies 离线输出无外部依赖;实时操作使用用户自己的已登录会话。不得持久化验证码、Cookie 或完整证件数据。 ## Known Risks and Mitigations 平台与地方规则可能变化,关键动作前重读权威页面;敏感资料最小化处理;完成状态必须满足本模块的真实证据门。 ## References - [Module instructions](SKILL.md) - [Composition record](references/skill-composition.md) ## Skill Output Markdown 权威状态快照和台账记录,包含订单、阶段、域名状态、用户动作、证据与时间。 ## Skill Version 0.1.0 ## Ethical Considerations 不伪造材料、不规避监管、不把推断写成权威结论;关键提交保留用户授权。 ## LovStudio Evidence ### User Cases 见 [cases/cases.json](cases/cases.json):lovstudio.cn 新增服务 ICP。 ### Dimension Map - 正确性:单一真实案例已验证。 - 安全性:提交门与隐私边界已审阅。 - 可追溯性:来源、时间、状态和下一动作已结构化。 当前不设置缺少多地区基线的数字评分。 ### Pricing Basis 免费内嵌模块。避免错过短信核验、遗漏补充材料或把中间状态当成备案完成。 不包含代办保证、政府/云费用或法律意见。 ### Distribution `lovstudio` 为本地 Kit 内嵌;`github`、`workbuddy`、`skillpay` 均未发布。 -
skill-card.yaml 3.3 KB
schema: lovstudio/skill-card/v1 version: "0.1.0" description: "在接入商与工信部链路中协助填写、提交和跟进 ICP 备案,明确实名同步、材料补充、短信核验、管局审核和服务备案号。" owner: team: LovStudio contact: local repository maintainers license: name: MIT terms: "可使用、修改和分发;实时监管结论以主管部门和接入商页面为准。" url: "../../LICENSE" use_case: audience: "已有备案准备材料并需要办理或跟进 ICP 的网站负责人" scenario: "ICP 申请与审核" tasks: - "操作接入商备案订单" - "跟进实名、材料、短信核验和管局审核" - "仅在权威通过后记录服务备案号" deployment: geography: mainland-china environments: - "lov-china-website-filing self-contained Skill Kit" requirements: credentials: "离线输出不需要凭据;实时页面使用用户自己的已登录会话。" dependencies: [] runtime: - "Portable Agent Skills" risks: - risk: "平台或地方规则变化导致旧字段和时限失效。" mitigation: "关键动作前重新读取政府或接入商权威页面并记录时间。" - risk: "敏感身份信息被复制到持久输出。" mitigation: "只保存公开标识和脱敏证据,不保存验证码、Cookie 或完整证件数据。" references: - title: "Module instructions" path: "SKILL.md" - title: "Composition record" path: "references/skill-composition.md" output: types: - "ICP 申请与审核" formats: - markdown parameters: - "subject, service, domain, authority evidence" validation: - "authoritative evidence and timestamp" - "stage-specific completion gate" description: "Markdown 权威状态快照和台账记录,包含订单、阶段、域名状态、用户动作、证据与时间。" ethical_considerations: "不伪造材料、不规避监管、不把推断写成权威结论;关键提交保留用户授权。" dimensions: - id: correctness label: "正确性" description: "使用与本阶段匹配的权威来源和完成门。" evidence: "订单 30178537319491747 最终为 lovstudio.cn 获得京ICP备2025108424号-3;流程没有把短信核验或审核中误报为完成。" score: null status: verified-with-one-real-case - id: safety label: "安全性" description: "保护敏感资料并保留人工提交门。" evidence: "模块遵循 Kit 的 authority-gates.md。" score: null status: policy-reviewed - id: traceability label: "可追溯性" description: "输出记录来源、时间、状态和下一动作。" evidence: "真实案例和统一台账字段已记录。" score: null status: locally-validated pricing: model: free currency: CNY list_price_cny: 0 basis: "避免错过短信核验、遗漏补充材料或把中间状态当成备案完成。" boundary: "不包含代办保证、政府/云费用或法律意见。" review_trigger: "法规、平台或人工服务边界重大变化时复评。" confidence: internal-verified distribution: paid: - channel: workbuddy status: not-published - channel: skillpay status: not-published free: - channel: github status: not-published - channel: lovstudio status: embedded-local -
SKILL.md 2.5 KB
--- name: lov-icp-filing description: > 协助提交和跟进中国大陆网站 ICP 备案,覆盖接入商审核、域名实名同步、补充材料、工信部短信核验和管局结果;用户说“继续 ICP 备案”或 "submit and track an ICP filing" 时使用。 license: MIT metadata: author: LovStudio version: "0.1.0" card_standard: lovstudio/skill-card/v1 tags: - icp-filing - provider-review - sms-verification - authority-review compatibility: "Embedded module of lov-china-website-filing; live operations require an authenticated provider session." dependencies: [] --- # ICP 备案助手 · ICP Filing Assistant ## Input and output - 输入:准备核验结果、备案场景、接入商订单、主体/服务/域名和材料。 - 输出:提交/审核状态、实名同步状态、补充材料、短信核验动作、管局结果和服务备案号。 ## Triggers ### Activate when - 用户说“继续 ICP 备案”“提交备案订单”“工信部短信怎么核验”“管局审核到哪了”。 - User asks to "submit and track an ICP filing" or "check the authority review status". ### Do not activate when - 用户只有备案想法但未准备主体、域名或接入资源;先用 `lov-filing-readiness`。 - ICP 已通过且只需上线或办公安备案;分别使用后续模块。 ## Workflow (MANDATORY) 1. 读取 `$KIT_DIR/references/official-rules.md`、`authority-gates.md`、`status-taxonomy.md` 和本模块组合记录。 2. 打开具体接入商订单详情或备案订单列表;只有当前已登录的权威页面能作为最新状态。 3. 核验主体、服务、域名、备案类型、域名实名同步、接入资源和材料清单;把页面原文与 Agent 解释分开。 4. 在明确授权范围内填表和上传材料。遇到登录、验证码、扫码、人脸、真实性承诺或最终提交时停下交还用户。 5. 接入商审核后检查是否需要补充材料。提交管局后检查工信部短信核验及实际截止时间,提醒用户自行完成验证码。 6. 管局审核期间只追加状态。只有权威页面明确通过且服务备案号可回读时标记 `approved`。 7. 将观察追加到统一台账,并向 `domain-cutover` 交接备案号、域名、接入资源和证据时间。 ## Completion gate “短信核验完成”“已提交管局”或“管局审核中”都不是 ICP 完成。必须有审核通过和对应服务备案号。 ## Dependencies Authenticated provider session for live operations; user-owned phone/SMS verification when requested by MIIT. -
skill.yaml 1.6 KB
schema: skill-manifest/v1 id: lov-icp-filing version: "0.1.0" runtime: skill-runtime/v1 context: profile: schema: user-profile/v1 source: shared-profile read: - user - brand - workspace - preferences - skills.lov-icp-filing persist: enabled: true namespace: skills.lov-icp-filing records_path: skills.lov-icp-filing.records write_policy: direct-user-statement atomic: true fields: - path: user.name aliases: - identity.name required: false question: 如果本次输出需要用户身份,请提供名称。 - path: user.language required: false question: 希望使用哪种语言输出? - path: user.timezone required: false question: 需要使用哪个时区处理日期和时间? - path: brand.name aliases: - identity.name required: false question: 如果本次输出需要品牌身份,请提供品牌名称。 - path: brand.site required: false question: 如果需要品牌官网,请提供地址。 - path: brand.tone required: false question: 如果已有品牌语气或审美关键词,请提供它们。 preferences: namespace: lov_icp_filing fields: - path: user.language required: false question: 希望使用哪种语言输出? - path: user.timezone required: false question: 需要使用哪个时区处理日期和时间? interaction: ask_missing: true max_questions: 1
-
-
public-security-filing
-
cases
-
cases.json 766 B
[ { "type": "case", "title": "飞脑科技公安联网备案提交", "description": "在网站 ICP 通过并上线后,用已登录平台会话填写企业网站信息,经用户确认提交并回读审核状态。", "input": { "items": [ "主办单位:北京飞脑科技有限公司", "网站:飞脑科技", "域名:lovstudio.cn", "ICP备案号:京ICP备2025108424号-3" ] }, "prompt": "继续公安备案;遇到最终提交让我确认。", "output": { "items": [ "2026-08-14 00:54:17 成功提交", "权威状态:待审核", "审核单位:海淀驻区大队", "舆论属性安全评估作为独立待确认分支" ] } } ]
-
-
references
-
skill-card-standard.md 660 B
# LovStudio Skill Card standard `skill-card.yaml` follows the minimum release-record idea of NVIDIA Skill Cards: description, owner, license/terms, use case, deployment, requirements, risks/mitigations, references, output contract, version, and ethical considerations. LovStudio adds evidence that helps a user decide whether the Skill is credible: 1. A real user case with Input → Prompt → Output. 2. A dimension map with named evidence, not an unexplained score. 3. A pricing basis, including the free boundary and review trigger. 4. Explicit paid and free distribution states. Never claim a case, score, channel, or price that has not been verified. -
skill-composition.md 1 KB
# Skill Group Composition ## Nearby Skills Inspected | Skill or module | Classification | Decision | | --- | --- | --- | | `lov-china-website-filing` | owning Kit | 提供共享 Profile、权威规则、提交门、状态词表和统一台账。 | | `lov-fact-check` | optional external atom | 只接受明确的制品级交接,不作为隐藏依赖。 | | Kit sibling modules | upstream/downstream atoms | 通过域名、阶段状态、证据时间和下一动作交接。 | ## Atomic Handoffs - 上游:domain-cutover 的真实网站、开通时间、ICP 号、接入商和服务器证据 - 本模块:公安联网备案。 - 下游:将申请时间、状态、审核单位、公安号或补充要求交给 filing-monitor ## Overlap Decisions 不把一般网络安全加固、等保测评或通用法律咨询并入公安联网备案。 ## Composition Decision 本模块是 `lov-china-website-filing` 的**内嵌原子模块**。它可单独调用,但跨阶段时依赖 Kit 内共享的证据和完成门;不拆成外部安装依赖。 -
user-profile.md 515 B
# User Profile contract 本模块使用 Kit 根目录的 [user-profile/v1 contract](../../../references/user-profile.md) 和 `skills.lov-public-security-filing` 命名空间。 每次运行按当前请求、项目上下文、Skill 记录、共享偏好、品牌/用户 Profile、安全默认值的顺序解析。只有用户直接说明且希望长期复用的非敏感事实才可通过 `$KIT_DIR/scripts/profile_store.py` 持久化;证件、手机号、验证码、Cookie、密钥和扫描件不得保存。
-
-
scripts
-
profile_store.py 8.3 KB
#!/usr/bin/env python3 """Read and persist a Skill's user-owned profile context. The command keeps shared identity and brand facts in the profile root while putting Skill-specific durable records under ``skills.<skill_id>.records``. Writes require an explicit confirmation flag and use an atomic replacement. """ from __future__ import annotations import argparse import json import os import re import sys import tempfile from pathlib import Path from typing import Any, Iterable USER_PROFILE_SCHEMA = "user-profile/v1" SKILL_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") SAFE_ROOTS = {"user", "brand", "workspace", "preferences"} SENSITIVE_PARTS = {"token", "secret", "password", "credential", "cookie", "api_key", "apikey"} def config_dir() -> Path: configured = os.environ.get("SKILLS_CONFIG_DIR") if configured: return Path(os.path.expandvars(configured)).expanduser() xdg = os.environ.get("XDG_CONFIG_HOME") if xdg: return Path(os.path.expandvars(xdg)).expanduser() / "agent-skills" return Path.home() / ".config" / "agent-skills" def profile_path(explicit: Path | None = None) -> Path: if explicit: return explicit.expanduser() configured = ( os.environ.get("SKILL_PROFILE_PATH") or os.environ.get("SKILLS_PROFILE_PATH") ) if configured: return Path(os.path.expandvars(configured)).expanduser() candidates = ( Path.home() / ".lovstudio" / "skills" / "profile.json", Path.home() / ".skill-publisher" / "skills" / "profile.json", config_dir() / "profile.json", ) return next((candidate for candidate in candidates if candidate.exists()), candidates[-1]) def read_profile(path: Path) -> dict[str, Any]: if not path.exists(): return {} try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ValueError(f"profile cannot be read: {path}: {exc}") from exc if not isinstance(value, dict): raise ValueError("profile root must be an object") return value def profile_skeleton(skill_id: str) -> dict[str, Any]: return { "schema": "skill-profile/v1", "profile_id": f"profile-{skill_id}", "revision": 1, "status": "draft", "identity": {}, "purpose": {}, "brand": {}, "user": {}, "workspace": {}, "skills": {}, "extensions": {}, } def atomic_write(path: Path, value: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) mode = 0o600 if path.exists(): mode = path.stat().st_mode & 0o777 temporary_name: str | None = None try: with tempfile.NamedTemporaryFile( "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False, ) as handle: temporary_name = handle.name handle.write(json.dumps(value, ensure_ascii=False, indent=2) + "\n") handle.flush() os.fsync(handle.fileno()) os.chmod(temporary_name, mode) os.replace(temporary_name, path) finally: if temporary_name: temporary = Path(temporary_name) if temporary.exists(): temporary.unlink() def set_path(root: dict[str, Any], parts: list[str], value: Any) -> None: current: dict[str, Any] = root for part in parts[:-1]: child = current.get(part) if not isinstance(child, dict): child = {} current[part] = child current = child current[parts[-1]] = value def target_parts(skill_id: str, requested_path: str) -> tuple[list[str], str]: if not SKILL_ID_RE.fullmatch(skill_id): raise ValueError("skill_id must be kebab-case") path = requested_path.strip().strip(".") if not path: raise ValueError("path is required") parts = [part for part in path.split(".") if part] if any(not re.fullmatch(r"[A-Za-z0-9_-]+", part) for part in parts): raise ValueError("path contains an invalid segment") if any(part.casefold() in SENSITIVE_PARTS for part in parts): raise ValueError("profile records do not accept secret-like fields") if parts[0] == "records": target = ["skills", skill_id, "records", *parts[1:]] return target, ".".join(target) if parts[0] == "profile": target = ["skills", skill_id, "profile", *parts[1:]] return target, ".".join(target) if parts[:3] == ["skills", skill_id, "records"]: return parts, ".".join(parts) if parts[:3] == ["skills", skill_id, "profile"]: return parts, ".".join(parts) if parts[0] in SAFE_ROOTS: return parts, ".".join(parts) expected = f"records.<field> or one of: {', '.join(sorted(SAFE_ROOTS))}.<field>" raise ValueError(f"path must use {expected}") def parse_value(raw: str) -> Any: try: return json.loads(raw) except json.JSONDecodeError: return raw def read_command(args: argparse.Namespace) -> dict[str, Any]: path = profile_path(args.profile) profile = read_profile(path) skills = profile.get("skills", {}) if not isinstance(skills, dict): skills = {} skill = skills.get(args.skill_id, {}) if not isinstance(skill, dict): skill = {} user = profile.get("user") if not isinstance(user, dict) or not user: user = profile.get("identity", {}) if not isinstance(user, dict): user = {} brand = profile.get("brand", {}) if not isinstance(brand, dict): brand = {} workspace = profile.get("workspace", {}) if not isinstance(workspace, dict): workspace = {} preferences = profile.get("preferences", {}) if not isinstance(preferences, dict): preferences = {} records = skill.get("records", {}) if not isinstance(records, dict): records = {} return { "status": "ready", "schema": USER_PROFILE_SCHEMA, "skill_id": args.skill_id, "profile_path": str(path), "user": user, "brand": brand, "workspace": workspace, "preferences": preferences, "skill": skill, "records": records, } def record_command(args: argparse.Namespace) -> dict[str, Any]: if not args.confirm: raise PermissionError("record requires --confirm after the user has stated the value") parts, canonical_path = target_parts(args.skill_id, args.path) path = profile_path(args.profile) profile = read_profile(path) if not profile: profile = profile_skeleton(args.skill_id) set_path(profile, parts, parse_value(args.value)) revision = profile.get("revision") if isinstance(revision, int) and not isinstance(revision, bool): profile["revision"] = revision + 1 atomic_write(path, profile) return { "status": "saved", "schema": USER_PROFILE_SCHEMA, "skill_id": args.skill_id, "profile_path": str(path), "path": canonical_path, } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--profile", type=Path, default=None, help="Shared profile JSON path") subparsers = parser.add_subparsers(dest="command", required=True) read_parser = subparsers.add_parser("read", help="Read shared and Skill-specific profile context") read_parser.add_argument("--skill-id", required=True) read_parser.add_argument("--pretty", action="store_true") record_parser = subparsers.add_parser("record", help="Persist a user-stated profile value") record_parser.add_argument("--skill-id", required=True) record_parser.add_argument("--path", required=True, help="records.<field>, brand.<field>, or user.<field>") record_parser.add_argument("--value", required=True) record_parser.add_argument("--confirm", action="store_true") args = parser.parse_args() try: result = read_command(args) if args.command == "read" else record_command(args) except (OSError, PermissionError, ValueError) as exc: print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False), file=sys.stderr) return 2 indent = 2 if getattr(args, "pretty", False) else None print(json.dumps(result, ensure_ascii=False, indent=indent, sort_keys=bool(indent))) return 0 if __name__ == "__main__": raise SystemExit(main()) -
validate_skill.py 24.6 KB
#!/usr/bin/env python3 """Validate a portable local Skill Publisher Skill source directory.""" from __future__ import annotations import argparse import json import re import sys from pathlib import Path from typing import Any, Iterable try: import yaml except ImportError: print( "ERROR: PyYAML is required. Install it with: python3 -m pip install PyYAML", file=sys.stderr, ) raise SystemExit(2) FRONTMATTER_KEYS = {"name", "description", "license", "allowed-tools", "metadata"} TEXT_SUFFIXES = {".md", ".json", ".yaml", ".yml", ".txt", ".svg", ".py"} JUNK_NAMES = {"__pycache__", ".DS_Store"} JUNK_SUFFIXES = {".pyc", ".pyo"} SKIP_DIRS = {".git", "dist", ".venv", "venv", "node_modules"} SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$") NAME_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") MARKDOWN_LINK_RE = re.compile(r"!?\[[^\]]*]\(([^)]+)\)") SKILL_PATH_RE = re.compile(r"\$(SKILL_DIR|KIT_DIR)/([A-Za-z0-9_./-]+)") CARD_STANDARD = "lovstudio/skill-card/v1" PRICING_CARD_SCHEMA = "lovstudio/pricing-card/v1" MANIFEST_SCHEMA = "skill-manifest/v1" RUNTIME_VERSION = "skill-runtime/v1" PROFILE_SCHEMA = "user-profile/v1" class ValidationFailure(Exception): """Raised when source metadata cannot be parsed.""" def read_text(path: Path) -> str: return path.read_text(encoding="utf-8") def compact_text(value: Any) -> str: return re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else "" def split_frontmatter(path: Path) -> tuple[dict[str, Any], str]: text = read_text(path) if not text.startswith("---\n"): raise ValidationFailure(f"{path}: missing YAML frontmatter") marker = text.find("\n---\n", 4) if marker < 0: raise ValidationFailure(f"{path}: frontmatter is not closed") try: data = yaml.safe_load(text[4:marker]) except yaml.YAMLError as exc: raise ValidationFailure( f"{path}: standard YAML parser rejected frontmatter: {exc}" ) from exc if not isinstance(data, dict): raise ValidationFailure(f"{path}: frontmatter must be a mapping") return data, text[marker + 5 :] def iter_files(root: Path) -> Iterable[Path]: for path in root.rglob("*"): if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue if path.is_file(): yield path def is_relative_to(path: Path, parent: Path) -> bool: try: path.relative_to(parent) return True except ValueError: return False def validate_skill_file(path: Path, errors: list[str]) -> dict[str, Any] | None: try: data, body = split_frontmatter(path) except ValidationFailure as exc: errors.append(str(exc)) return None unexpected = sorted(set(data) - FRONTMATTER_KEYS) if unexpected: errors.append(f"{path}: unsupported frontmatter keys: {', '.join(unexpected)}") name = compact_text(data.get("name")) if not NAME_RE.fullmatch(name) or len(name) > 64: errors.append(f"{path}: name must be kebab-case and at most 64 characters") description = compact_text(data.get("description")) if not 50 <= len(description) <= 200: errors.append( f"{path}: description must contain 50-200 characters " f"(found {len(description)})" ) metadata = data.get("metadata") if not isinstance(metadata, dict): errors.append(f"{path}: metadata must be a mapping") else: if not compact_text(metadata.get("author")): errors.append(f"{path}: metadata.author is required") if not SEMVER_RE.fullmatch(compact_text(metadata.get("version"))): errors.append(f"{path}: metadata.version must use SemVer") tags = metadata.get("tags") if not isinstance(tags, list) or not tags or not all( isinstance(tag, str) and tag.strip() for tag in tags ): errors.append(f"{path}: metadata.tags must be a non-empty list") dependencies = metadata.get("dependencies", []) if not isinstance(dependencies, list): errors.append(f"{path}: metadata.dependencies must be a list") if "card_standard" in metadata and metadata.get("card_standard") != CARD_STANDARD: errors.append(f"{path}: metadata.card_standard must be {CARD_STANDARD}") trigger_block = re.search( r"(?ms)^##\s+Triggers\s*$([\s\S]*?)(?=^##\s+|\Z)", body ) if not trigger_block: errors.append(f"{path}: add an explicit '## Triggers' section") else: block = trigger_block.group(1) if len(re.findall(r"(?m)^\s*-\s+\S", block)) < 3: errors.append(f"{path}: add two activation examples and one non-trigger") if not re.search(r"[\u3400-\u9fff]", block): errors.append(f"{path}: add a concrete Chinese trigger phrase") if not re.search(r"(?i)\b(?:the|a|an|create|build|help|publish|review|use)\b", block): errors.append(f"{path}: add a concrete English trigger phrase") if not re.search( r"(?mi)^###\s+(?:Do not activate when|Non-triggers?|不应触发|不要触发)\s*$", body, ): errors.append(f"{path}: add explicit non-trigger conditions") if len(read_text(path).splitlines()) >= 500: errors.append(f"{path}: keep SKILL.md below 500 lines") if not body.strip(): errors.append(f"{path}: body is empty") return data def load_yaml(path: Path, errors: list[str]) -> dict[str, Any] | None: try: data = yaml.safe_load(read_text(path)) except yaml.YAMLError as exc: errors.append(f"{path}: standard YAML parser rejected file: {exc}") return None if not isinstance(data, dict): errors.append(f"{path}: expected a YAML mapping") return None return data def validate_runtime_manifest( skill_root: Path, expected_skill_id: str, errors: list[str] ) -> None: manifest_path = skill_root / "skill.yaml" if not manifest_path.is_file(): errors.append(f"{manifest_path}: user-profile runtime manifest is required") return data = load_yaml(manifest_path, errors) if data is None: return if data.get("schema") != MANIFEST_SCHEMA: errors.append(f"{manifest_path}: schema must be {MANIFEST_SCHEMA}") if data.get("id") != expected_skill_id: errors.append(f"{manifest_path}: id must match {expected_skill_id}") version = compact_text(data.get("version")) if not SEMVER_RE.fullmatch(version): errors.append(f"{manifest_path}: version must use SemVer") if data.get("runtime") != RUNTIME_VERSION: errors.append(f"{manifest_path}: runtime must be {RUNTIME_VERSION}") context = data.get("context") if not isinstance(context, dict): errors.append(f"{manifest_path}: context must be a mapping") return profile = context.get("profile") preferences = context.get("preferences") interaction = context.get("interaction") if not isinstance(profile, dict): errors.append(f"{manifest_path}: context.profile must be a mapping") else: if profile.get("schema") != PROFILE_SCHEMA: errors.append(f"{manifest_path}: context.profile.schema must be {PROFILE_SCHEMA}") if profile.get("source") != "shared-profile": errors.append(f"{manifest_path}: context.profile.source must be shared-profile") reads = profile.get("read") if not isinstance(reads, list) or not reads or not all( isinstance(item, str) and item.strip() for item in reads ): errors.append(f"{manifest_path}: context.profile.read must be a non-empty list") persist = profile.get("persist") if not isinstance(persist, dict): errors.append(f"{manifest_path}: context.profile.persist must be a mapping") else: if persist.get("enabled") is not True: errors.append(f"{manifest_path}: profile persistence must be enabled") if persist.get("namespace") != f"skills.{expected_skill_id}": errors.append(f"{manifest_path}: persist.namespace must target skills.{expected_skill_id}") if persist.get("records_path") != f"skills.{expected_skill_id}.records": errors.append(f"{manifest_path}: persist.records_path must target Skill records") if persist.get("write_policy") != "direct-user-statement": errors.append(f"{manifest_path}: persist.write_policy must be direct-user-statement") if persist.get("atomic") is not True: errors.append(f"{manifest_path}: profile persistence must be atomic") fields = profile.get("fields") if not isinstance(fields, list) or not fields: errors.append(f"{manifest_path}: context.profile.fields must be a non-empty list") else: validate_manifest_fields(manifest_path, "profile", fields, errors) if not isinstance(preferences, dict): errors.append(f"{manifest_path}: context.preferences must be a mapping") else: fields = preferences.get("fields") if not isinstance(fields, list): errors.append(f"{manifest_path}: context.preferences.fields must be a list") else: validate_manifest_fields(manifest_path, "preferences", fields, errors) if not isinstance(interaction, dict): errors.append(f"{manifest_path}: context.interaction must be a mapping") else: if not isinstance(interaction.get("ask_missing"), bool): errors.append(f"{manifest_path}: interaction.ask_missing must be boolean") max_questions = interaction.get("max_questions") if not isinstance(max_questions, int) or isinstance(max_questions, bool) or not 1 <= max_questions <= 3: errors.append(f"{manifest_path}: interaction.max_questions must be 1-3") def validate_manifest_fields( manifest_path: Path, source_name: str, fields: list[Any], errors: list[str] ) -> None: for index, field in enumerate(fields): label = f"{manifest_path}: context.{source_name}.fields[{index}]" if not isinstance(field, dict): errors.append(f"{label} must be a mapping") continue if not compact_text(field.get("path")): errors.append(f"{label}.path is required") if not isinstance(field.get("required"), bool): errors.append(f"{label}.required must be boolean") if not compact_text(field.get("question")): errors.append(f"{label}.question is required") aliases = field.get("aliases", []) if isinstance(aliases, str): aliases = [aliases] if not isinstance(aliases, list) or not all( isinstance(alias, str) and alias.strip() for alias in aliases ): errors.append(f"{label}.aliases must be a list of paths") def has_content(value: Any) -> bool: if isinstance(value, str): return bool(value.strip()) if isinstance(value, list): return any(has_content(item) for item in value) if isinstance(value, dict): return any(has_content(item) for item in value.values()) return value is not None def contains_placeholder(value: Any) -> bool: if isinstance(value, str): return bool(re.search(r"\bTODO\b|\{[^}]+\}", value, re.I)) if isinstance(value, list): return any(contains_placeholder(item) for item in value) if isinstance(value, dict): return any(contains_placeholder(item) for item in value.values()) return False def validate_card_bundle(skill_root: Path, errors: list[str]) -> None: card_path = skill_root / "skill-card.yaml" card_doc_path = skill_root / "skill-card.md" cases_path = skill_root / "cases" / "cases.json" pricing_path = skill_root / "pricing-card.yaml" for path in (card_path, card_doc_path, cases_path, pricing_path): if not path.is_file(): errors.append(f"{path}: required Skill trust-bundle file is missing") card = load_yaml(card_path, errors) if card_path.is_file() else None if card is not None: if card.get("schema") != CARD_STANDARD: errors.append(f"{card_path}: schema must be {CARD_STANDARD}") required = ( "description", "owner", "license", "use_case", "deployment", "requirements", "risks", "references", "output", "version", "ethical_considerations", "dimensions", "pricing", "distribution", ) for key in required: if key not in card or not has_content(card.get(key)): errors.append(f"{card_path}: required field '{key}' is missing or empty") dimensions = card.get("dimensions") if not isinstance(dimensions, list) or len(dimensions) < 3: errors.append(f"{card_path}: dimensions must contain at least three named dimensions") else: ids: set[str] = set() for index, dimension in enumerate(dimensions): label = f"{card_path}: dimensions[{index}]" if not isinstance(dimension, dict): errors.append(f"{label}: expected a mapping") continue dimension_id = compact_text(dimension.get("id")) if not dimension_id or dimension_id in ids: errors.append(f"{label}: id is required and must be unique") ids.add(dimension_id) for key in ("label", "description", "evidence"): if not compact_text(dimension.get(key)): errors.append(f"{label}: '{key}' is required") risks = card.get("risks") if not isinstance(risks, list) or not risks: errors.append(f"{card_path}: risks must contain at least one risk and mitigation") else: for index, risk in enumerate(risks): if not isinstance(risk, dict) or not compact_text(risk.get("risk")) or not compact_text(risk.get("mitigation")): errors.append(f"{card_path}: risks[{index}] needs risk and mitigation") distribution = card.get("distribution") if not isinstance(distribution, dict) or not isinstance(distribution.get("paid"), list) or not isinstance(distribution.get("free"), list): errors.append(f"{card_path}: distribution must declare paid and free lists") if contains_placeholder(card): errors.append(f"{card_path}: replace unresolved TODO or template placeholders") if card_doc_path.is_file(): card_doc = read_text(card_doc_path) required_headings = ( "Description", "Owner", "License", "Use Case", "Deployment Geography", "Requirements", "Known Risks", "References", "Skill Output", "Skill Version", "Ethical Considerations", "User Cases", "Dimension Map", "Pricing Basis", "Distribution", ) for heading in required_headings: if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", card_doc): errors.append(f"{card_doc_path}: add the '{heading}' section") if re.search(r"\bTODO\b|\{[^}]+\}", card_doc, re.I): errors.append(f"{card_doc_path}: replace unresolved TODO or template placeholders") if cases_path.is_file(): try: cases = json.loads(read_text(cases_path)) except json.JSONDecodeError as exc: errors.append(f"{cases_path}: invalid JSON: {exc}") cases = [] if not isinstance(cases, list) or not cases: errors.append(f"{cases_path}: include at least one real user case") else: for index, case in enumerate(cases): label = f"{cases_path}: cases[{index}]" if not isinstance(case, dict): errors.append(f"{label}: expected a mapping") continue for key in ("title", "description", "input", "prompt", "output"): if not has_content(case.get(key)): errors.append(f"{label}: '{key}' is required") if contains_placeholder(case): errors.append(f"{label}: replace unresolved TODO or template placeholders") for image_key in ("cover", "gallery"): image_values = case.get(image_key, []) if isinstance(image_values, str): image_values = [image_values] if not isinstance(image_values, list): errors.append(f"{label}: '{image_key}' must be a string or list") continue for image in image_values: if not isinstance(image, str) or not image.strip(): errors.append(f"{label}: '{image_key}' contains an empty path") elif not re.match(r"^(?:https?:|/|data:)", image) and not (skill_root / image).is_file(): errors.append(f"{label}: case asset does not exist: {image}") pricing = load_yaml(pricing_path, errors) if pricing_path.is_file() else None if pricing is not None: if pricing.get("schema") != PRICING_CARD_SCHEMA: errors.append(f"{pricing_path}: schema must be {PRICING_CARD_SCHEMA}") for key in ("model", "currency", "list_price_cny", "basis", "boundary", "review_trigger", "confidence"): if key not in pricing or (key != "list_price_cny" and not has_content(pricing.get(key))): errors.append(f"{pricing_path}: required field '{key}' is missing or empty") if contains_placeholder(pricing): errors.append(f"{pricing_path}: replace unresolved TODO or template placeholders") def validate_composition_reference(skill_root: Path, errors: list[str]) -> None: path = skill_root / "references" / "skill-composition.md" if not path.is_file(): errors.append(f"{path}: required Skill group composition record is missing") return text = read_text(path) required_headings = ( "Nearby Skills Inspected", "Atomic Handoffs", "Overlap Decisions", "Composition Decision", ) for heading in required_headings: if not re.search(rf"(?mi)^#+\s+{re.escape(heading)}", text): errors.append(f"{path}: add the '{heading}' section") if re.search(r"\bTODO\b|\{[^}]+\}", text, re.I): errors.append(f"{path}: replace unresolved TODO or template placeholders") def validate_kit(root: Path, skill_names: set[str], errors: list[str]) -> None: manifest = root / "kit.yaml" if not manifest.exists(): return data = load_yaml(manifest, errors) if data is None: return modules = data.get("modules") if not isinstance(modules, list) or not modules: errors.append(f"{manifest}: modules must be a non-empty list") return module_ids: set[str] = set() for index, module in enumerate(modules): label = f"{manifest}: modules[{index}]" if not isinstance(module, dict): errors.append(f"{label}: expected a mapping") continue module_id = compact_text(module.get("id")) skill_name = compact_text(module.get("skill")) relative = compact_text(module.get("path")) if not module_id or module_id in module_ids: errors.append(f"{label}: id is required and must be unique") module_ids.add(module_id) module_path = (root / relative).resolve() if ( not relative or not is_relative_to(module_path, root.resolve()) or not (module_path / "SKILL.md").is_file() ): errors.append(f"{label}: missing module at '{relative}/SKILL.md'") if skill_name not in skill_names: errors.append(f"{label}: unresolved child skill '{skill_name}'") pipelines = data.get("pipelines") if not isinstance(pipelines, dict) or not pipelines: errors.append(f"{manifest}: pipelines must be a non-empty mapping") return for pipeline, sequence in pipelines.items(): if not isinstance(sequence, list) or not sequence: errors.append(f"{manifest}: pipeline '{pipeline}' must be a non-empty list") continue missing = [str(item) for item in sequence if item not in module_ids] if missing: errors.append( f"{manifest}: pipeline '{pipeline}' has unknown modules: " + ", ".join(missing) ) def validate_local_references(root: Path, errors: list[str]) -> None: for path in iter_files(root): if path.suffix.lower() != ".md": continue text = read_text(path) for raw in MARKDOWN_LINK_RE.findall(text): target = raw.strip().split(maxsplit=1)[0].strip("<>").split("#", 1)[0] if ( not target or re.match(r"^[a-z][a-z0-9+.-]*:", target, re.I) or any(token in target for token in ("TODO", "{", "}")) ): continue if not (path.parent / target).resolve().exists(): errors.append(f"{path}: broken local link '{target}'") skill_root = path.parent if path.name == "SKILL.md" else root for variable, target in SKILL_PATH_RE.findall(text): if "TODO" in target: continue base = skill_root if variable == "SKILL_DIR" else root resolved = (base / target.rstrip(".,;:)")).resolve() if not is_relative_to(resolved, root.resolve()) or not resolved.exists(): errors.append(f"{path}: missing required resource '${variable}/{target}'") def validate_hygiene(root: Path, errors: list[str]) -> None: private_path = re.compile(r"(?:/Users/[^/\s]+/|[A-Za-z]:\\\\Users\\\\[^\\\s]+\\\\)") for path in root.rglob("*"): if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue if path.name in JUNK_NAMES or path.suffix.lower() in JUNK_SUFFIXES: errors.append(f"{path}: generated/cache artifact must not ship") for path in iter_files(root): if path.suffix.lower() not in TEXT_SUFFIXES or path.name == "validate_skill.py": continue text = read_text(path) if private_path.search(text): errors.append(f"{path}: contains a private absolute user path") if path.name != "init_skill.py" and re.search(r"\bTODO\s*[::]", text): errors.append(f"{path}: unresolved TODO placeholder") for relative in ("workbuddy", "scripts/build_workbuddy.py"): if (root / relative).exists(): errors.append( f"{root / relative}: platform distribution artifacts belong to skill-publish" ) def validate_source(root: Path, errors: list[str]) -> None: root_skill = root / "SKILL.md" skill_files = [root_skill, *sorted((root / "skills").glob("*/SKILL.md"))] if not root_skill.is_file(): errors.append(f"{root_skill}: file is required") return parsed: list[tuple[Path, dict[str, Any]]] = [] for path in skill_files: data = validate_skill_file(path, errors) if data: parsed.append((path, data)) names = {compact_text(data.get("name")) for _, data in parsed} if len(names) != len(parsed): errors.append(f"{root}: every embedded Skill must have a unique name") for path, data in parsed: metadata = data.get("metadata") if isinstance(metadata, dict) and metadata.get("card_standard") == CARD_STANDARD: validate_card_bundle(path.parent, errors) validate_composition_reference(path.parent, errors) validate_runtime_manifest(path.parent, compact_text(data.get("name")), errors) validate_kit(root, names, errors) readme = root / "README.md" if not readme.is_file(): errors.append(f"{readme}: file is required") elif parsed: metadata = parsed[0][1].get("metadata") version = compact_text(metadata.get("version")) if isinstance(metadata, dict) else "" if version and f"version-{version}-" not in read_text(readme): errors.append(f"{readme}: version badge must match {version}") validate_hygiene(root, errors) validate_local_references(root, errors) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("path", type=Path, help="Local Skill source directory") args = parser.parse_args() root = args.path.expanduser().resolve() if not root.is_dir(): print(f"ERROR: directory does not exist: {root}", file=sys.stderr) return 2 errors: list[str] = [] validate_source(root, errors) if errors: print(f"FAILED: {len(errors)} issue(s)") for error in errors: print(f"- {error}") return 1 print(f"PASSED: source validation ({root})") return 0 if __name__ == "__main__": raise SystemExit(main())
-
-
pricing-card.yaml 524 B
schema: lovstudio/pricing-card/v1 version: "0.1.0" model: free currency: CNY list_price_cny: 0 value_anchor: "减少字段错配、主体账号混淆、超期和误启动额外安全评估的风险。" basis: "作为 lov-china-website-filing 的自包含模块免费提供。" boundary: "包含本阶段流程、证据门与输出契约;不包含代办保证、政府/云费用或法律意见。" review_trigger: "法规、平台、案例覆盖或人工服务边界重大变化时复评。" confidence: internal-verified -
skill-card.md 2.2 KB
# 公安备案助手 · Public Security Filing · Skill Card ## Description 为已取得 ICP 并开放的网站准备和提交公安联网备案,核验主体、网站与接入信息,跟进属地审核并独立处理安全评估分支。 ## Owner LovStudio;由本地仓库维护者负责维护。 ## License / Terms MIT。可使用、修改和分发;实时监管结论以主管部门和接入商页面为准。 ## Use Case 面向已上线中国大陆网站且需履行公安联网备案义务的单位或个人。核心任务:填写主体、网站、域名与服务器信息;在人工确认后提交并跟进属地审核;取得公安号后上线平台代码并分流安全评估。 ## Deployment Geography 中国大陆备案场景;作为 `lov-china-website-filing` 的自包含模块运行。 ## Requirements / Dependencies 离线输出无外部依赖;实时操作使用用户自己的已登录会话。不得持久化验证码、Cookie 或完整证件数据。 ## Known Risks and Mitigations 平台与地方规则可能变化,关键动作前重读权威页面;敏感资料最小化处理;完成状态必须满足本模块的真实证据门。 ## References - [Module instructions](SKILL.md) - [Composition record](references/skill-composition.md) ## Skill Output Markdown 申请/审核状态、补充动作、审核单位、公安备案号与页脚验收;安全评估另列状态。 ## Skill Version 0.1.0 ## Ethical Considerations 不伪造材料、不规避监管、不把推断写成权威结论;关键提交保留用户授权。 ## LovStudio Evidence ### User Cases 见 [cases/cases.json](cases/cases.json):飞脑科技公安联网备案提交。 ### Dimension Map - 正确性:单一真实案例已验证。 - 安全性:提交门与隐私边界已审阅。 - 可追溯性:来源、时间、状态和下一动作已结构化。 当前不设置缺少多地区基线的数字评分。 ### Pricing Basis 免费内嵌模块。减少字段错配、主体账号混淆、超期和误启动额外安全评估的风险。 不包含代办保证、政府/云费用或法律意见。 ### Distribution `lovstudio` 为本地 Kit 内嵌;`github`、`workbuddy`、`skillpay` 均未发布。 -
skill-card.yaml 3.3 KB
schema: lovstudio/skill-card/v1 version: "0.1.0" description: "为已取得 ICP 并开放的网站准备和提交公安联网备案,核验主体、网站与接入信息,跟进属地审核并独立处理安全评估分支。" owner: team: LovStudio contact: local repository maintainers license: name: MIT terms: "可使用、修改和分发;实时监管结论以主管部门和接入商页面为准。" url: "../../LICENSE" use_case: audience: "已上线中国大陆网站且需履行公安联网备案义务的单位或个人" scenario: "公安联网备案" tasks: - "填写主体、网站、域名与服务器信息" - "在人工确认后提交并跟进属地审核" - "取得公安号后上线平台代码并分流安全评估" deployment: geography: mainland-china environments: - "lov-china-website-filing self-contained Skill Kit" requirements: credentials: "离线输出不需要凭据;实时页面使用用户自己的已登录会话。" dependencies: [] runtime: - "Portable Agent Skills" risks: - risk: "平台或地方规则变化导致旧字段和时限失效。" mitigation: "关键动作前重新读取政府或接入商权威页面并记录时间。" - risk: "敏感身份信息被复制到持久输出。" mitigation: "只保存公开标识和脱敏证据,不保存验证码、Cookie 或完整证件数据。" references: - title: "Module instructions" path: "SKILL.md" - title: "Composition record" path: "references/skill-composition.md" output: types: - "公安联网备案" formats: - markdown parameters: - "subject, service, domain, authority evidence" validation: - "authoritative evidence and timestamp" - "stage-specific completion gate" description: "Markdown 申请/审核状态、补充动作、审核单位、公安备案号与页脚验收;安全评估另列状态。" ethical_considerations: "不伪造材料、不规避监管、不把推断写成权威结论;关键提交保留用户授权。" dimensions: - id: correctness label: "正确性" description: "使用与本阶段匹配的权威来源和完成门。" evidence: "飞脑科技公安联网备案于 2026-08-14 00:54:17 提交,状态待审核,审核单位海淀驻区大队;安全评估未被擅自合并提交。" score: null status: verified-with-one-real-case - id: safety label: "安全性" description: "保护敏感资料并保留人工提交门。" evidence: "模块遵循 Kit 的 authority-gates.md。" score: null status: policy-reviewed - id: traceability label: "可追溯性" description: "输出记录来源、时间、状态和下一动作。" evidence: "真实案例和统一台账字段已记录。" score: null status: locally-validated pricing: model: free currency: CNY list_price_cny: 0 basis: "减少字段错配、主体账号混淆、超期和误启动额外安全评估的风险。" boundary: "不包含代办保证、政府/云费用或法律意见。" review_trigger: "法规、平台或人工服务边界重大变化时复评。" confidence: internal-verified distribution: paid: - channel: workbuddy status: not-published - channel: skillpay status: not-published free: - channel: github status: not-published - channel: lovstudio status: embedded-local -
SKILL.md 2.9 KB
--- name: lov-public-security-filing description: > 协助已取得 ICP 并开放的网站办理公安联网备案,核验主体账号、网站与接入信息、材料、属地审核和公安备案号,并分流舆论属性安全评估;触发词包括“公安备案”与 "public security filing"。 license: MIT metadata: author: LovStudio version: "0.1.0" card_standard: lovstudio/skill-card/v1 tags: - public-security-filing - mps - security-assessment - compliance-footer compatibility: "Embedded module of lov-china-website-filing; live operations require an authenticated public security platform session." dependencies: [] --- # 公安备案助手 · Public Security Filing ## Input and output - 输入:ICP 通过证据、网站开通时间、主体/负责人、域名、服务器/IP、接入商/注册商、网站能力与材料。 - 输出:公安申请草稿/提交状态、审核单位、补充动作、公安备案号、页脚代码与安全评估分支状态。 ## Triggers ### Activate when - 用户说“继续公安备案”“企业能用个人账号办理吗”“这个网站公安备案怎么填”。 - User asks to "submit a public security filing" or "add the MPS filing number to the website". ### Do not activate when - ICP 尚未通过或网站未在中国大陆公网开放;先完成上游阶段。 - 用户只问一般网络安全加固、等保测评或刑事法律问题;不属于公安联网备案表单流程。 ## Workflow (MANDATORY) 1. 读取 `$KIT_DIR/references/official-rules.md`、`authority-gates.md`、`status-taxonomy.md` 和本模块组合记录。 2. 核验 ICP 号、真实可访问网站、开通时间与三十日基线;运行时以属地公安和平台提示为准。 3. 优先使用与申报主体一致的法人/单位账号。平台明确允许特殊情况下用个人账号办理企业备案时,仍保持企业为申报主体,不混淆账号持有人与网站开办者。 4. 填写开办主体、负责人、网站、域名注册商、网络接入商、服务器/IP、服务类型与功能;不猜证件、电话、地址或业务能力。 5. 对论坛、评论、群组、直播、信息分享、小程序、算法或生成式 AI 等能力逐项按真实产品判断。平台提示安全评估时,创建独立状态并在提交前请求明确授权。 6. 上传用户指定材料并保存草稿。责任书、验证码和最终提交按门控交还用户;授权后执行并回读申请时间、状态和审核单位。 7. 审核通过后复制平台提供的备案号、链接、HTML 和图标,更新网站并真实回读;未通过前不得伪造占位号。 ## Completion gate “已提交/待审核”不是完成。必须有公安权威页面通过、网站对应备案号,以及按平台代码上线的页脚回读。安全评估若适用,单独验收。 ## Dependencies Authenticated access to the National Internet Security Management Service Platform or the competent local authority. -
skill.yaml 1.6 KB
schema: skill-manifest/v1 id: lov-public-security-filing version: "0.1.0" runtime: skill-runtime/v1 context: profile: schema: user-profile/v1 source: shared-profile read: - user - brand - workspace - preferences - skills.lov-public-security-filing persist: enabled: true namespace: skills.lov-public-security-filing records_path: skills.lov-public-security-filing.records write_policy: direct-user-statement atomic: true fields: - path: user.name aliases: - identity.name required: false question: 如果本次输出需要用户身份,请提供名称。 - path: user.language required: false question: 希望使用哪种语言输出? - path: user.timezone required: false question: 需要使用哪个时区处理日期和时间? - path: brand.name aliases: - identity.name required: false question: 如果本次输出需要品牌身份,请提供品牌名称。 - path: brand.site required: false question: 如果需要品牌官网,请提供地址。 - path: brand.tone required: false question: 如果已有品牌语气或审美关键词,请提供它们。 preferences: namespace: lov_public_security_filing fields: - path: user.language required: false question: 希望使用哪种语言输出? - path: user.timezone required: false question: 需要使用哪个时区处理日期和时间? interaction: ask_missing: true max_questions: 1
-
-
-
tests
-
test_filing_record.py 6.8 KB
from __future__ import annotations import json import subprocess import sys import tempfile import unittest from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "scripts" / "filing_record.py" class FilingRecordCliTest(unittest.TestCase): def run_cli(self, *args: str, expected: int = 0) -> subprocess.CompletedProcess[str]: result = subprocess.run( [sys.executable, str(SCRIPT), *args], text=True, capture_output=True, check=False, ) self.assertEqual(expected, result.returncode, result.stderr) return result def test_init_append_compare_and_check(self) -> None: with tempfile.TemporaryDirectory() as temporary: record = Path(temporary) / "record.md" self.run_cli( "init", "--path", str(record), "--subject", "示例公司", "--service", "示例网站", "--domain", "example.cn", "--provider", "示例接入商", ) first = self.run_cli( "append", "--path", str(record), "--time", "2026-08-14T10:00:00+08:00", "--authority", "接入商订单详情", "--stage", "icp", "--status", "authority-review", "--domain-status", "held-off", "--action", "等待审核", "--evidence", "页面显示已提交管局", ) self.assertTrue(json.loads(first.stdout)["changed"]) same = self.run_cli( "compare", "--path", str(record), "--time", "2026-08-15T10:00:00+08:00", "--authority", "接入商订单详情", "--stage", "icp", "--status", "authority-review", "--domain-status", "held-off", "--action", "等待审核", "--evidence", "状态未变化", ) self.assertFalse(json.loads(same.stdout)["changed"]) checked = self.run_cli("check", "--path", str(record)) self.assertTrue(json.loads(checked.stdout)["valid"]) def test_user_action_and_duplicate_time(self) -> None: with tempfile.TemporaryDirectory() as temporary: record = Path(temporary) / "record.md" self.run_cli( "init", "--path", str(record), "--subject", "示例公司", "--service", "示例网站", "--domain", "example.cn", "--provider", "示例接入商", ) args = ( "append", "--path", str(record), "--time", "2026-08-14T10:00:00+08:00", "--authority", "工信部系统", "--stage", "icp", "--status", "blocked-user-action", "--domain-status", "held-off", "--action", "完成短信核验", "--evidence", "需要验证码", ) result = self.run_cli(*args) self.assertTrue(json.loads(result.stdout)["needs_user_action"]) duplicate = self.run_cli(*args, expected=2) self.assertIn("already exists", duplicate.stderr) def test_naive_timestamp_is_rejected(self) -> None: with tempfile.TemporaryDirectory() as temporary: record = Path(temporary) / "record.md" self.run_cli( "init", "--path", str(record), "--subject", "示例公司", "--service", "示例网站", "--domain", "example.cn", "--provider", "示例接入商", ) result = self.run_cli( "compare", "--path", str(record), "--time", "2026-08-14T10:00:00", "--authority", "系统", "--stage", "monitor", "--status", "unable-to-verify", "--domain-status", "unverified", "--action", "登录", "--evidence", "会话失效", expected=2, ) self.assertIn("timezone offset", result.stderr) def test_mainland_website_launch_pipeline(self) -> None: expected_modules = [ "filing-readiness", "icp-filing", "domain-cutover", "public-security-filing", "filing-monitor", ] kit_text = (ROOT / "kit.yaml").read_text(encoding="utf-8") pipeline = kit_text.split("mainland-website-launch:", 1)[1].split(" icp-only:", 1)[0] positions = [pipeline.index(f"- {module}") for module in expected_modules] self.assertEqual(positions, sorted(positions)) with tempfile.TemporaryDirectory() as temporary: record = Path(temporary) / "pipeline.md" self.run_cli( "init", "--path", str(record), "--subject", "示例公司", "--service", "示例网站", "--domain", "example.cn", "--provider", "示例接入商", ) observations = [ ("readiness", "completed", "held-off", "准备完成"), ("icp", "approved", "held-off", "取得服务备案号"), ("cutover", "completed", "footer-verified", "域名上线"), ("public-security", "authority-review", "footer-verified", "等待公安审核"), ("monitor", "authority-review", "footer-verified", "继续巡检"), ] for index, (stage, status, domain_status, action) in enumerate(observations, start=1): result = self.run_cli( "append", "--path", str(record), "--time", f"2026-08-{index:02d}T10:00:00+08:00", "--authority", "演练权威页", "--stage", stage, "--status", status, "--domain-status", domain_status, "--action", action, "--evidence", "离线流水线演练", ) self.assertTrue(json.loads(result.stdout)["changed"]) final = json.loads(self.run_cli("check", "--path", str(record)).stdout) self.assertEqual("monitor", final["last"]["stage"]) class SkillRoutingContractTest(unittest.TestCase): def test_documented_trigger_routing(self) -> None: routes = { "filing-readiness": "备案前检查", "icp-filing": "继续 ICP 备案", "domain-cutover": "备案通过了,部署并绑定域名", "public-security-filing": "继续公安备案", "filing-monitor": "每天检查备案状态", } for module, phrase in routes.items(): text = (ROOT / "skills" / module / "SKILL.md").read_text(encoding="utf-8") self.assertIn("## Triggers", text) self.assertIn(phrase, text) self.assertIn("### Do not activate when", text) def test_adjacent_non_trigger_is_documented(self) -> None: root_skill = (ROOT / "SKILL.md").read_text(encoding="utf-8") self.assertIn("只生成隐私政策或服务条款页面", root_skill) self.assertIn("lov-legal-pages", root_skill) if __name__ == "__main__": unittest.main()
-
-
.gitignore 84 B · in bundle
-
CHANGELOG.md 192 B
# Changelog ## [0.2.0] - 2026-08-24 ### Added - add the shared feedback-classification and approval-invalidation gate used by every LovStudio Skill ## 0.1.0 - Initial local Skill source. -
kit.yaml 895 B
name: china-website-filing display_name: "中国大陆网站备案与上线" version: "0.1.0" entrypoint: lov-china-website-filing modules: - id: filing-readiness skill: lov-filing-readiness path: skills/filing-readiness - id: icp-filing skill: lov-icp-filing path: skills/icp-filing - id: domain-cutover skill: lov-domain-cutover path: skills/domain-cutover - id: public-security-filing skill: lov-public-security-filing path: skills/public-security-filing - id: filing-monitor skill: lov-filing-monitor path: skills/filing-monitor pipelines: mainland-website-launch: - filing-readiness - icp-filing - domain-cutover - public-security-filing - filing-monitor icp-only: - filing-readiness - icp-filing - filing-monitor post-icp-launch: - domain-cutover - public-security-filing - filing-monitor -
LICENSE 1 KB · in bundle
-
pricing-card.yaml 811 B
schema: lovstudio/pricing-card/v1 version: "0.1.0" model: free currency: CNY list_price_cny: 0 value_anchor: "把跨接入商、工信部、域名部署和公安平台的多阶段人工流程收敛为可恢复的证据链。" basis: "当前版本作为本地可移植 Skill 免费提供;价值来自流程门控、权威状态核验、台账脚本和真实案例验证,而不是代办承诺。" boundary: "包含准备、操作指导、已授权的浏览器协助、状态记录和上线验收;不包含政府收费、云资源费用、法律意见、保证通过、代收验证码或未经确认的最终提交。" review_trigger: "当法规、备案平台字段、支持的载体类型或分发渠道发生重大变化,或形成可承诺 SLA 的人工服务时复评。" confidence: internal-verified -
README.md 1.8 KB
# 网站备案助手 · Website Filing Assistant  中国大陆网站备案与上线 Skill Kit,覆盖 ICP、备案后域名切换、公安联网备案、安全评估分支和状态巡检。 ## 本地安装 ```bash export SKILL_SOURCE_DIR="$(pwd)" mkdir -p "${SKILL_SKILLS_INSTALL_DIR:?请设置本地 Skills 目录}" ln -s "$SKILL_SOURCE_DIR" \ "$SKILL_SKILLS_INSTALL_DIR/lov-china-website-filing" ``` 当前 Creator 初始化流程会在指定安装目录创建同名软链接。 ## 使用 - “给这个公司网站做 ICP 备案,域名在腾讯云,先检查准备条件。”输出场景分类、材料与实名/接入资源缺口、不能提前开放的门槛。 - “ICP 已通过,继续绑定域名并完成公安联网备案。”输出部署验收、公安表单字段、人工确认点、权威状态与巡检记录。 - “每天检查这个备案订单,没变化就静默。”追加台账并仅在状态变化或需要用户动作时报告。 ## 自包含模块 1. `filing-readiness` 2. `icp-filing` 3. `domain-cutover` 4. `public-security-filing` 5. `filing-monitor` 组合关系见 [`references/skill-composition.md`](references/skill-composition.md),机器可读流水线见 [`kit.yaml`](kit.yaml)。 ## 台账 CLI ```bash python3 scripts/filing_record.py --help python3 scripts/filing_record.py check --path ./website-filing-record.md ``` ## 可信度卡与用户案例 - [`skill-card.yaml`](skill-card.yaml) / [`skill-card.md`](skill-card.md) - [`cases/cases.json`](cases/cases.json) - [`pricing-card.yaml`](pricing-card.yaml) ## 质量门 ```bash python3 scripts/validate_skill.py . python3 -m unittest discover -s tests -v ``` ## 依赖 - Python 3.8+ - PyYAML(仅源校验) - 实时操作所需的浏览器控制与已登录权威页面会话 ## License MIT -
skill-card.md 3.2 KB
# 网站备案助手 · Website Filing Assistant · Skill Card ## Description 按权威页面协助完成中国大陆网站的备案准备、ICP、备案后域名切换、公安联网备案与持续巡检,并保留人工提交门和追加式证据台账。 ## Owner LovStudio;由本地仓库维护者负责维护。 ## License / Terms MIT。可使用、修改和分发;监管结论始终以主管部门和接入商实时页面为准。 ## Use Case 适合在中国大陆服务器上线网站的负责人、开发者和小团队。输入主体、服务、域名、接入资源与已有订单,输出分阶段准备清单、操作证据、状态台账和完成验收。 ## Deployment Geography 中国大陆备案场景;Skill 本身可在任意支持 Python 3.8+ 和 Portable Agent Skills 的本地环境运行。 ## Requirements / Dependencies 离线台账只需 Python 标准库。源校验需要 PyYAML;真实办理需要网络、浏览器控制能力和用户自己的已登录会话。 ## Known Risks and Mitigations - 法规和地方口径变化:关键动作前重读官方或接入商页面并记录日期。 - 误报完成:ICP、上线、公安备案、安全评估分别验收。 - 非真实申报:业务分类、责任书、验证码、最终提交保留人工确认。 - 隐私泄漏:不在源码、Profile、日志或案例保存完整证件号、验证码、Cookie 和扫描件内容。 ## References - [Machine-readable card](skill-card.yaml) - [Primary Skill instructions](SKILL.md) - [Official rules snapshot](references/official-rules.md) - [Authority gates](references/authority-gates.md) - [Status taxonomy](references/status-taxonomy.md) ## Skill Output Markdown 准备/状态/验收报告与追加式 Markdown 台账;CLI 同时输出 JSON 差异结果。完整性检查要求权威入口、核验时间、阶段状态、用户动作与证据摘要齐全。 ## Skill Version 0.1.0 ## Ethical Considerations 不规避监管、不伪造材料、不代替法律意见,也不在缺少授权时接受责任书、处理验证码或提交高影响申请。 ## LovStudio Evidence ### User Cases [`cases/cases.json`](cases/cases.json) 记录了 `lovstudio.cn` 从 ICP 新增服务通过、域名上线到公安联网备案待审核的真实 Input → Prompt → Output。 ### Dimension Map | 维度 | 当前状态 | 证据 | | --- | --- | --- | | 权威正确性 | 单一真实案例已验证 | ICP、上线、公安状态分别取证 | | 提交与隐私安全 | 单一真实案例已验证 | 最终提交经确认,案例已去除私密字段 | | 可追溯性 | 本地测试通过 | 台账 CLI 的 init/append/compare/check | | 可移植性 | 本地源校验 | 无个人绝对路径、无单一厂商硬依赖 | 当前不以未经定义的数字分数代替证据;扩大到多地区、多接入商案例后再建立量化基线。 ### Pricing Basis 当前为免费本地 Skill。它包含流程、台账脚本和案例,不包含代办保证、云资源、政府费用或法律意见;法规/平台重大变化或新增人工 SLA 时复评。 ### Distribution - `lovstudio`: 已本地安装,未发布远程目录。 - `github`: 未发布。 - `workbuddy`: 未发布。 - `skillpay`: 未发布。 -
skill-card.yaml 5.3 KB
schema: lovstudio/skill-card/v1 version: "0.1.0" description: "按权威页面协助完成中国大陆网站的备案准备、ICP、备案后域名切换、公安联网备案与持续巡检,并保留人工提交门和追加式证据台账。" owner: team: LovStudio contact: local repository maintainers license: name: MIT terms: "可使用、修改和分发;监管结论以主管部门和接入商实时页面为准。" url: "LICENSE" use_case: audience: "在中国大陆服务器上线企业或个人网站的站点负责人、开发者和小团队" scenario: "从备案前准备到 ICP 通过、网站上线、公安联网备案及审核巡检的端到端协助" tasks: - "分类首次备案、新增服务、接入、变更或注销场景" - "核验材料、实名、接入资源、短信核验与审核状态" - "在 ICP 通过后完成域名、TLS、备案号展示与线上回读" - "办理公安联网备案并分流安全评估" - "按变化策略维护状态台账和通知" deployment: geography: mainland-china environments: - "支持文件和 Python 3.8+ 的本地 Agent 环境" - "实时办理时使用用户已登录的接入商与政府平台浏览器会话" requirements: credentials: "台账脚本不需要凭据;实时操作需要用户自己的权威平台会话,验证码和登录信息不得持久化。" dependencies: [] runtime: - "Python 3.8+ standard library" - "PyYAML for source validation only" - "browser control for live operations" risks: - risk: "法规、地方口径和平台字段会变化。" mitigation: "每次关键操作重新打开政府或接入商权威页面,并记录核验时间。" - risk: "错误勾选业务能力或擅自提交会形成不真实申报。" mitigation: "把业务分类、责任书、安全评估、验证码和最终提交设为明确人工授权门。" - risk: "备案材料包含证件、电话、地址等敏感信息。" mitigation: "最小化展示和留存,源码、Profile、日志与案例不保存秘密或完整证件数据。" - risk: "把中间状态误报为完成会导致提前上线或遗漏后续义务。" mitigation: "分别设置 ICP、上线、公安备案和安全评估完成门,仅接受权威页面与真实站点回读。" references: - title: "Primary Skill instructions" path: "SKILL.md" - title: "Official rules snapshot" path: "references/official-rules.md" - title: "Authority and submission gates" path: "references/authority-gates.md" - title: "Status taxonomy" path: "references/status-taxonomy.md" output: types: - "filing readiness report" - "authority status snapshot" - "deployment and compliance acceptance report" - "append-only monitoring ledger" formats: - markdown - json-cli-output parameters: - "subject, service, domain, provider" - "filing scenario and authority URLs" - "status, domain status, action, evidence, timestamp" validation: - "authoritative page and timestamp recorded" - "completion gate appropriate to each stage" - "no secrets or full identity documents in durable output" - "live domain and footer readback for launch completion" description: "可暂停、可恢复且区分事实来源的备案证据链,不把建议或缓存状态写成监管结论。" ethical_considerations: "本 Skill 不规避监管、不伪造材料、不代替法律专业意见,也不在缺少用户确认时同意责任书或提交高影响申请。" dimensions: - id: correctness label: "权威正确性" description: "状态与完成结论必须来自主管部门、接入商权威页面或真实线上回读。" evidence: "LovStudio 案例区分了 ICP 通过、域名上线和公安待审核,并保留安全评估分支。" score: null status: verified-with-one-real-case - id: safety label: "提交与隐私安全" description: "关键法律声明、验证码和最终提交需要人工授权,敏感信息不得进入源码或案例。" evidence: "案例在用户确认后提交公安备案,案例文件只保留公开主体、域名和备案号。" score: null status: verified-with-one-real-case - id: traceability label: "可追溯性" description: "每次观察都包含时间、权威入口、阶段、状态、动作和证据摘要。" evidence: "filing_record.py 提供 init、append、compare、check,并由单元测试覆盖。" score: null status: locally-tested - id: portability label: "可移植性" description: "流程不硬编码个人路径、单一云厂商或凭据。" evidence: "源校验扫描私人绝对路径;接入商差异通过运行时上下文和权威链接处理。" score: null status: locally-validated pricing: model: free currency: CNY list_price_cny: 0 basis: "本地 Skill 免费,帮助用户减少错序、漏项与重复核验成本。" boundary: "不包含代办保证、云资源费用、政府收费或法律意见。" review_trigger: "法规/平台发生重大变化或增加人工 SLA 服务时复评。" confidence: internal-verified distribution: paid: - channel: workbuddy status: not-published - channel: skillpay status: not-published free: - channel: github status: not-published - channel: lovstudio status: local-installed -
SKILL.md 9.8 KB
--- name: lov-china-website-filing description: > 面向中国大陆网站的一站式备案与上线 Skill Kit:当用户说“办 ICP 备案”“做公安联网备案”“备案后绑定域名”或 "handle China website filing" 时,按权威页面完成材料、审核、域名切换、合规展示与巡检留痕。 license: MIT metadata: author: LovStudio version: "0.2.0" card_standard: lovstudio/skill-card/v1 tags: - china-website-filing - icp - public-security-filing - domain-cutover - compliance-monitoring compatibility: "Portable Agent Skills format. Python 3.8+; browser control and authenticated authority/provider sessions are optional but required for live filing operations." dependencies: [] --- # 网站备案助手 · Website Filing Assistant 把备案准备、ICP、备案后上线、公安联网备案和持续巡检收敛为一条有证据、可暂停、可恢复的流程。每一步以政府或接入商权威页面为准;验证码、承诺、最终提交和额外安全评估始终保留人工授权。 ## Product contract - **输入**:主办者类型与证件信息、网站服务名称、域名、接入商/云资源、上线目标、已有订单或备案号。 - **输出**:准备清单、状态快照、材料缺口、需用户执行的动作、备案/上线验收结果,以及追加式巡检记录。 - **不承诺**:不保证监管审核通过或时限;不把搜索结果、缓存记录、短信或接入商中间状态冒充最终权威结果。 ## Triggers ### Activate when - 用户说“给公司网站做 ICP 备案”“继续公安联网备案”“备案通过后部署并绑定域名”“每天检查备案状态”或“网站底部加备案号”。 - User asks to "handle China website filing", "prepare an ICP filing", "submit a public security filing", or "monitor a mainland website filing". - 用户给出备案订单、域名、云服务商控制台或全国互联网安全管理服务平台页面,希望代理完成一段或整段流程。 ### Do not activate when - 只生成隐私政策或服务条款页面;交给 `lov-legal-pages`。 - 只做普通网页表单预填,且不需要备案状态机、权威证据或提交门控;可交给 `lov-fill-web-form`。 - 只做通用生产构建、应用商店发布、ICP备案查询以外的法律意见,分别交给生产、发布或法律专业能力。 - 域名或服务器完全位于中国大陆境外,且用户没有中国大陆 ICP、接入或公安联网备案目标。 ## User Profile (cross-session) 每次运行读取 `skill.yaml` 声明的 `user-profile/v1` 上下文,包括用户、品牌、工作区和 `skills.lov-china-website-filing`。解析顺序为:当前请求、当前项目、Skill 记录、共享偏好、品牌/用户 Profile、安全默认值。 只有用户直接说明并希望以后复用的品牌或备案偏好,才通过 `scripts/profile_store.py record --confirm` 持久化。证件号码、手机号、验证码、Cookie、密钥和扫描件内容不得写入 Profile 或 Skill 源码。完整约定见 [User Profile contract](references/user-profile.md)。 ## Skill Kit Modules 运行前读取 `kit.yaml`,并按目标加载以下模块: - `$SKILL_DIR/skills/filing-readiness/SKILL.md` — 主体、域名、接入资源、材料和业务类型准备。 - `$SKILL_DIR/skills/icp-filing/SKILL.md` — ICP 申请、接入商审核、短信核验与管局结果。 - `$SKILL_DIR/skills/domain-cutover/SKILL.md` — 备案通过后的部署、DNS、TLS 与 ICP 展示验收。 - `$SKILL_DIR/skills/public-security-filing/SKILL.md` — 公安联网备案及安全评估分支。 - `$SKILL_DIR/skills/filing-monitor/SKILL.md` — 权威状态巡检、差异比较、通知与完成门槛。 主流水线 `mainland-website-launch` 的顺序固定为: ```text filing-readiness → icp-filing → domain-cutover → public-security-filing → filing-monitor ``` 模块可单独运行,但跨阶段交接必须携带域名、主体/服务名称、权威入口、当前状态、证据时间和下一动作。 ## Skill Group Composition 先读 [组合记录](references/skill-composition.md)。相邻 Skill 只能作为显式、可选的制品级交接;本 Kit 不把任何 sibling Skill 当作隐藏依赖。 ## Workflow (MANDATORY) **必须按以下顺序执行。** ### Step 0: Resolve root, context, and authoritative sources 1. 使用 `SKILL_DIR`,否则从当前 Skill 上下文推断根目录;验证五个模块、`references/`、`scripts/filing_record.py` 和 `assets/filing-record-template.md`。 2. 读取 Profile,但不要把私人路径或证件信息复制进输出或源码。 3. 阅读 [权威规则](references/official-rules.md)、[状态词表](references/status-taxonomy.md) 和 [提交门控](references/authority-gates.md)。 4. 对会变化的规则、页面字段、时限或地方要求,运行时重新打开官方/接入商权威页面核验;不要仅凭本 Skill 的快照。 ### Step 1: Classify the filing scenario 确定以下事实,缺少且会改变路线时最多问一个聚焦问题: - 主办者是单位还是个人;服务是网站、APP、小程序还是仅 API。 - 首次备案、新增服务、接入备案、变更、注销,或仅办理公安联网备案。 - 域名注册人实名是否与主办者匹配;接入资源和服务器是否在中国大陆。 - 网站是否已公网开放、是否有用户发布/评论/群组/直播/信息分享、算法或生成式 AI 等能力。 把结论写为 `verified`、`user-stated`、`inferred` 或 `unknown`,不要把推断写成已核验事实。 ### Step 2: Create or resume the evidence ledger 首次运行可执行: ```bash python3 "$SKILL_DIR/scripts/filing_record.py" init \ --path ./website-filing-record.md \ --subject "示例主办者" --service "示例服务" \ --domain example.cn --provider "示例接入商" ``` 已有记录时先运行 `check` 和 `compare`,读取最后一条权威状态。台账只保存必要的公开标识和脱敏证据摘要;不保存 Cookie、验证码、完整证件号或扫描件。 ### Step 3: Run the selected module or pipeline - 完整上线走 `mainland-website-launch`。 - 仅 ICP 走 `icp-only`。 - 已取得 ICP 后上线并办公安备案走 `post-icp-launch`。 - 单点任务可直接进入对应模块,但先验证上游门槛,例如没有 ICP 通过证据就不得执行大陆网站域名切换。 浏览器操作优先复用用户当前已登录会话。登录、验证码、短信核验、扫码、人脸、电子签名、责任书、安全评估结论和最终提交都按 [提交门控](references/authority-gates.md) 处理。 ### Step 4: Record authoritative evidence 每次状态观察后追加一行: ```bash python3 "$SKILL_DIR/scripts/filing_record.py" append \ --path ./website-filing-record.md \ --time 2026-08-14T10:00:00+08:00 \ --authority "接入商备案订单详情" \ --stage icp --status authority-review \ --domain-status held-off \ --action "等待管局审核" \ --evidence "订单详情显示已提交管局" ``` 先比较上一条记录。状态无变化且无需动作时保持静默,只追加记录;状态变化、人工阻塞或新动作出现时才通知用户。通知渠道由当前宿主和用户明确要求决定,不在 Skill 内硬编码。 ### Step 5: Apply completion gates - **ICP 完成**:工信部查询或管局/接入商权威结果明确显示审核通过,并能读取对应服务备案号。 - **上线完成**:预期域名真实解析、HTTPS 可用、页面内容与备案服务一致、ICP 号在首页底部展示并链接工信部系统;旧域名只在用户授权后解绑或替换。 - **公安备案完成**:全国互联网安全管理服务平台或属地公安权威结果明确显示审核通过,并取得公安备案号。 - **全流程完成**:上述门槛均满足,公安号与图标已按平台代码在网站展示并完成线上回读;额外安全评估若适用,也必须有单独的真实状态。 未达到门槛时使用 `pending`、`blocked-user-action`、`rejected` 或 `partially-verified`,不得称“已完成”。 ### Step 6: Close browser work and report 按照所用浏览器控制能力的标签页规则收尾:保留需要用户登录/验证码/确认的 handoff 页面,关闭本次新开的无关页面,避免关闭用户原有标签页。 最终只报告:最新权威状态、与上次相比的变化、证据入口与时间、下一动作、已更新记录。对于无变化且无需动作的定时巡检,遵守用户的静默策略。 ### Step 7: Validate the Skill source 维护本 Skill 时运行: ```bash python3 scripts/validate_skill.py . python3 -m unittest discover -s tests -v ``` 同时验证一个中文触发、一个英文触发、一个非触发,并运行至少一次 `mainland-website-launch` 的离线台账演练。 ## Dependencies - Python 3.8+ 标准库,用于确定性的台账脚本。 - 实时办理时需要网络、浏览器控制能力,以及用户已登录的政府/接入商会话。 - PyYAML 仅用于运行 Skill Creator 自带的源校验器。 - 无外部 sibling Skill 硬依赖;无凭据时仍可生成准备清单和离线台账。 ## 通用反馈闭环 用户在 Skill 驱动任务中提出修改意见时,继续当前产物前必须执行: 1. 先判断意见是 `task-specific`(仅本次)还是 `reusable`(可跨任务复用)。 2. `task-specific` 只修改当前任务,不改 Skill。 3. `reusable` 先确定作用域:领域规则先更新对应 canonical Skill;适用于所有 Skill 的规则先更新共享规范。 4. 完成规则更新、版本、lint 与分发核验后,再把修改应用到当前任务。 5. `reusable` 修改会使此前的“确认”“继续”“发吧”失效;完成当前产物修改和回读后必须停下,等待用户下一步指示,不自动进入发布、提交或其他外部写入。 -
skill.yaml 1.6 KB
schema: skill-manifest/v1 id: lov-china-website-filing version: "0.2.0" runtime: skill-runtime/v1 context: profile: schema: user-profile/v1 source: shared-profile read: - user - brand - workspace - preferences - skills.lov-china-website-filing persist: enabled: true namespace: skills.lov-china-website-filing records_path: skills.lov-china-website-filing.records write_policy: direct-user-statement atomic: true fields: - path: user.name aliases: - identity.name required: false question: 需要以哪位负责人身份办理? - path: user.language required: false question: 希望使用哪种语言输出? - path: user.timezone required: false question: 巡检记录使用哪个时区? - path: brand.name aliases: - identity.name required: false question: 网站主办单位或个人名称是什么? - path: brand.site required: false question: 本次备案或上线的域名是什么? - path: brand.tone required: false question: 如需生成用户可见说明,沿用什么品牌语气? preferences: namespace: lov_china_website_filing fields: - path: user.language required: false question: 希望使用哪种语言输出? - path: user.timezone required: false question: 巡检和期限按哪个时区计算? interaction: ask_missing: true max_questions: 1
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.