diff --git a/docs/README.skills.md b/docs/README.skills.md index 24fd5baefd..879a3139b6 100644 --- a/docs/README.skills.md +++ b/docs/README.skills.md @@ -94,6 +94,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to | [breakdown-feature-prd](../skills/breakdown-feature-prd/SKILL.md)
`gh skills install github/awesome-copilot breakdown-feature-prd` | Prompt for creating Product Requirements Documents (PRDs) for new features, based on an Epic. | None | | [breakdown-plan](../skills/breakdown-plan/SKILL.md)
`gh skills install github/awesome-copilot breakdown-plan` | Issue Planning and Automation prompt that generates comprehensive project plans with Epic > Feature > Story/Enabler > Test hierarchy, dependencies, priorities, and automated tracking. | None | | [breakdown-test](../skills/breakdown-test/SKILL.md)
`gh skills install github/awesome-copilot breakdown-test` | Test Planning and Quality Assurance prompt that generates comprehensive test strategies, task breakdowns, and quality validation plans for GitHub projects. | None | +| [browser-extension-launch](../skills/browser-extension-launch/SKILL.md)
`gh skills install github/awesome-copilot browser-extension-launch` | 面向没有编程经验的用户,把想法做成可试用的浏览器插件,并完成检查、商店材料、审核提交和上线验证;也用于继续已有插件、排错和发布新版。用户说“帮我做个插件”“把插件上架”“继续我的插件”时使用。普通网站开发、仅查询插件知识不触发。 | `LICENSE`
`assets/starter/chrome-mv3/icons/icon-128.png`
`assets/starter/chrome-mv3/icons/icon-16.png`
`assets/starter/chrome-mv3/icons/icon-32.png`
`assets/starter/chrome-mv3/icons/icon-48.png`
`assets/starter/chrome-mv3/manifest.json`
`assets/starter/chrome-mv3/popup.css`
`assets/starter/chrome-mv3/popup.html`
`assets/starter/chrome-mv3/popup.js`
`references/acceptance.md`
`references/complex-workflow.md`
`references/dependencies.md`
`references/development.md`
`references/efficient-delivery.md`
`references/host-compatibility.md`
`references/playwright-mcp.md`
`references/product-loop.md`
`references/publishing.md`
`references/required-skills.json`
`references/workflow.md`
`scripts/acceptance_gate.py`
`scripts/project.py`
`scripts/release_bundle.py` | | [bug-receipt](../skills/bug-receipt/SKILL.md)
`gh skills install github/awesome-copilot bug-receipt` | Close defects and incidents with a BUG RECEIPT and VERIFIED, PARTIAL, or BLOCKED status after diagnosis, repair, or recovery. | `assets/receipt.template.json`
`references/receipt-contract.md`
`references/receipt.schema.json`
`scripts/validate-receipt.mjs` | | [bug-reproduction-brief](../skills/bug-reproduction-brief/SKILL.md)
`gh skills install github/awesome-copilot bug-reproduction-brief` | Turn a vague, intermittent, or environment-specific bug report into a minimal evidence-backed reproduction before proposing a fix. | None | | [build-evidence-map](../skills/build-evidence-map/SKILL.md)
`gh skills install github/awesome-copilot build-evidence-map` | Build an auditable evidence map for a contested technical choice, research synthesis, proposal review, or consequential decision. Use when Copilot must preserve supporting, contradicting, qualifying, and missing evidence with exact source regions instead of collapsing disagreement into prose. | `references/evidence-ladder.md`
`references/map-schema.md`
`scripts/contract.mjs`
`scripts/validate.mjs` | diff --git a/skills/browser-extension-launch/LICENSE b/skills/browser-extension-launch/LICENSE new file mode 100644 index 0000000000..74c9ecbbd0 --- /dev/null +++ b/skills/browser-extension-launch/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 xiehuan123 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/skills/browser-extension-launch/SKILL.md b/skills/browser-extension-launch/SKILL.md new file mode 100644 index 0000000000..7ef75cefc2 --- /dev/null +++ b/skills/browser-extension-launch/SKILL.md @@ -0,0 +1,100 @@ +--- +name: browser-extension-launch +description: '面向没有编程经验的用户,把想法做成可试用的浏览器插件,并完成检查、商店材料、审核提交和上线验证;也用于继续已有插件、排错和发布新版。用户说“帮我做个插件”“把插件上架”“继续我的插件”时使用。普通网站开发、仅查询插件知识不触发。' +--- + +# 浏览器插件一站式上线 + +你负责把插件做出来并推进交付,用户负责表达需要、体验结果及必要的本人操作。默认用户只经历:**说想法 → 试用第一版 → 说哪里要改 → 确认具体发布安排 → 获得安装入口**。 + +## When to Use + +- Use when a user wants to turn a plain-language idea into a browser extension. +- Use when continuing, debugging, packaging, submitting, or releasing an existing extension. +- Do not use for ordinary website development or browser-extension knowledge questions that require no implementation. + +本技能是宿主无关的共享 skill,负责编排子技能、受控浏览器工具和本地辅助工具。开始时按 [Agent 兼容说明](references/host-compatibility.md) 识别当前宿主并验证文件、终端、浏览器和子技能能力;不能把安装成功当成工具已可用。首次发布适配以 Chrome / Manifest V3 为基线。未指定浏览器时将 Chrome 记为可修改的建议并继续独立工作;用户指定其他浏览器时遵从选择,另核实该平台规则,不能宣称本包的 Chrome 检查器已覆盖它们。 + +## 执行约定 + +- 新项目默认正式本地任务,不要求 GitHub、服务器、付费域名或独立原型。已有项目先读其约定,保留现有任务系统和已确认设计。 +- 主动选择可逆技术方案、检查环境、创建任务、制作、排错和验证。已有答案与授权沿用,不让用户逐步批准常规动作。 +- 事实自己查;只有会改变用途、数据去向、费用或分发承诺的重要未知项才问。用日常场景解释影响,一轮只处理当前必要问题;等待时继续不依赖答案的工作。重要选择不以沉默当确认。 +- 对用户说“内容留在这台电脑”“安装包”“已经检查哪些操作”。工单、Manifest、E2E、构建工具等留在后台;平台按钮名称可准确引用。 +- 写代码、截图、静态检查、验收、上传、审核、上线各有独立证据。未运行的检查写“未验证”,不能以工具缺失、旧截图或用户没报错判通过。 +- **真实验收是交付门槛。** 首次可用版、每张实施票和最终发布候选包,必须使用当前宿主中已核实能加载扩展并操作原生入口的受控浏览器工具,完成端到端核心操作及适用的关闭重开验证,保存证据并通过验收门禁,才能称为可用、交付完成或关闭任务。Playwright MCP 是首选参考实现;模拟 Chrome API、静态检查、单独打开弹窗 HTML 均不能替代。具体适配和证据见 [Agent 兼容说明](references/host-compatibility.md) 与 [独立验收](references/acceptance.md)。 +- 当前宿主缺少可用浏览器通道或无法完成真实入口时,按 [依赖准备](references/dependencies.md) 给出具体原因,询问用户是否安装/启用或选择推荐的受控替代方案,等待选择;验收保持 `blocked`,不能静默改用日常浏览器或跳过此节点。已有明确选择沿用,不逐项重新请示。人工辅助后仍须接回真实验证。仅用户明确只要源码或要求中止时,可按该范围交出源码并标明未验收。 +- 缺权限、等待本人操作或商店审核时保存状态,说明下一步;暂停依赖该条件的工作。没有实际持续跟踪能力,不承诺后台监控。 + +## 产品使用闭环 + +新建或改变用户流程时,必须实际执行 `product-designer`,先按 [产品使用闭环](references/product-loop.md) 把进入、核心动作、结果处理、继续使用、退出和恢复写入现有规格。基本操作由 AI 主动补齐,重要体验取舍信息不足时才询问用户。最终真实验收必须包含 `repeat_use` 连续两轮使用,产品体验复核与技术结果分别记录;缺基本闭环不能因技术用例通过而交付。 + +## 效率与收尾 + +规划、最终审查或返工时读取 [高效交付](references/efficient-delivery.md):先验证开发环境和特殊浏览器能力,提前列出 AC 与硬规范;按风险拆票、按改动影响复验。同一候选的真实证据和双轴审查可明确覆盖多个就绪工单,最终跨模块验收仍必须实际执行。文档与归档未改变运行包时不重复产品测试;目标内验收、审查和交付文件齐全后一次收尾。用户要求独立 CLI 或完整会话时,按该参考保留实际输入、回复和原始记录。 + +## 必须执行的节点 + +“执行 skill”指定位并读取其真实入口及所需资源,按流程产生相应结果,记录来源、输入、输出和验证;只出现名字、文件存在或读过说明不算完成。优先执行下列命名子技能;宿主无法安装或调用时,只能采用 [Agent 兼容说明](references/host-compatibility.md) 中定义的等价实现,并明确记录 fallback,不能声称原技能已运行。 + +| 节点 | 必须执行 | 完成证据 | +| --- | --- | --- | +| 新建/改变用户流程及体验验收 | `product-designer` | 现有规格中的完整闭环、体验 AC、决策依据和真实连续使用复核 | +| 扩展设计、开发与发布 | `chrome-extensions` | 实际采用的 API/权限/发布规则及对应产物 | +| 新建插件 | `extension-create` | 已明确参数、实际脚手架和入口配置、基础构建结果;已有项目不重复初始化 | +| 发生故障或验收失败 | `diagnosing-bugs` | 可判定失败的复现、诊断、修复及原场景复验 | +| 每张实施票及版本审查 | `code-review` | 固定基线、实际变更、规格,以及规范/规格两个独立审查结果 | +| 首版、实施票、最终版本验收 | 受控浏览器工具;首选 Playwright MCP | 当前产物真实加载、原生入口、完整操作及重开证据;不支持时必须取得用户环境选择 | +| 复杂插件 | `setup-matt-pocock-skills` → `to-spec` → `to-tickets` → `implement` | 项目约定、已发布主工单、带依赖的子工单、按模块分工逐票实现;见 [复杂插件编排](references/complex-workflow.md) | + +缺少必需 skill 时按已核实来源和当前 Agent 目标通过 Skill CLI 或宿主安装器补齐后执行,不以名字相近的未知 skill 代替。只有兼容说明明确允许的 portable fallback 才能替代,并必须记录真实执行方式。安装与技术拆分尽量自动完成;浏览器工具不支持时的替代选择、无法推断的产品承诺和真实账号费用仍由用户决定。来源清单见 [依赖准备](references/dependencies.md)。 + +## 从当前项目继续 + +1. 确定项目目录;已有 `.extension-launch/` 时先运行 `project.py status`,再读规格、任务和证据。已有其他任务系统则从那里恢复。不要凭聊天回忆重建或覆盖任务。 +2. 按 [Agent 兼容说明](references/host-compatibility.md) 和 [依赖准备](references/dependencies.md) 检测必需技能、受控浏览器工具和开发软件,分别记录检测、读取及执行状态;缺什么补什么,不把文件存在当作能力已就绪。 +3. 主代理自行判断复杂度并写明理由。简单插件走最小本地记录;复杂插件必须走 [复杂插件编排](references/complex-workflow.md),由 AI 发布规格工单、拆票和分配独立模块。`project.py init --complexity simple|complex --complexity-reason <理由>` 建立调度记录,按 [任务与恢复](references/workflow.md) 关联真正的规格和工单。依据用户指定终点传 `--goal local`(先自己用)、`--goal materials`(仅材料)或 `--goal live`(上线);默认值不当用户已确认。范围外发布任务保持不适用,不列为本人待办。草拟内容必须补实,不是已确认需求。 +4. 按下表选择下一步;每阶段结束由主代理汇总进度、证据、未解决问题和下一动作。不要把整张表抛给新手。 + +| 当前需要 | 读取并执行 | 前进依据 | +| --- | --- | --- | +| 想法或需求变化 | [任务与恢复](references/workflow.md) | 范围、数据承诺、成功路径可实施;必要答案已记录 | +| 制作、修复第一版 | [开发与排错](references/development.md) | 当前版本真实安装、原生入口及逐票功能通过,有证据和可恢复版本 | +| 整体检查、准备新版本 | [独立验收](references/acceptance.md) | 当前候选包必需真实场景通过,失败已修复复验,验收门禁通过 | +| 上架、被拒、上线或更新 | [发布与维护](references/publishing.md) | 实际后台记录及商店安装结果 | + +## 本地辅助工具 + +路径相对本技能目录解析。以下供 AI 执行,替换占位参数并按宿主正确引用路径,不让用户拼命令。脚本需 Python 3.9+、标准库,无需第三方 Python 包。没有 Python 时依对应参考用宿主文件工具建立相同记录或检查,不能声称脚本已运行。 + +```text +python3 /scripts/project.py doctor +python3 /scripts/project.py init --name <项目名> --idea <用户原话> +python3 /scripts/project.py status +python3 /scripts/release_bundle.py check --report <新报告路径> +python3 /scripts/release_bundle.py pack --output <新ZIP路径> --report <新报告路径> +python3 /scripts/acceptance_gate.py fingerprint +python3 /scripts/acceptance_gate.py check --evidence <真实验收JSON路径> --report <新的gate-report.json路径> +``` + +`doctor` 只证明检测到文件或软件,不能证明浏览器已连接。`check` / `pack` 只检查 Chrome 包的静态结构及部分风险;警告需逐项复核,成功退出不等于真实验收或商店批准。用 `--previous-version` 检查更新版本;报告及 ZIP 放在构建目录外,不覆盖旧成果。 + +交付前先用 `fingerprint` 取得当前构建的 `candidate` 指纹,再据实际操作填写真实验收 JSON,最后执行门禁 `check` 并在任务中保存新生成的 `gate-report.json` 路径。只有 `gate_passed: true` 且有可核对的真实操作记录才可关闭依赖交付;指纹计算和机器检查本身不证明操作发生。证据字段与调用方式见 [验收参考](references/acceptance.md)。 + +内置 [参考模板](assets/starter/chrome-mv3/manifest.json) 用于阅读或接手已有样例,不替代新建项目必须执行的 `extension-create`。已有项目不复制覆盖。 + +## 按上下文触发发布协助 + +只有上下文已明确用户要商店发布/上线时,才提醒 Google 账号、开发者注册、首次 5 美元注册费和付款页支持的国际线上支付卡等准备事项;不是固定开场阶段,不因技能名称或默认 goal=live 推断用户要注册付款。按 [发布与维护](references/publishing.md) 在内置浏览器协助填写用户已提供或已明确采用的名称、描述、图片和宣传资料。素材生成不可用时停在素材步骤,请用户上传后再继续,不能用占位图通过。 + +## 对用户的交付 + +交付经真实验收通过的结果及短操作说明,说清做到哪里、依据什么、还需本人做什么。已授权工作持续推进到指定终点;必需真实验收未通过时继续修复或补齐能力,实际外部阻塞时留下 `blocked` 状态及可恢复记录,不宣称可用交付完成。 + +- 第一版:已实际安装并从原生入口验收的目录、核心操作结果、适用的重开持久化证据及限制。反馈直接改这一版,无默认原型评审。 +- 发布准备:最终 ZIP 解压后真实验收通过及门禁报告、真实截图和图标、可粘贴文案、权限数据说明、必要公开链接、具体分发及发布安排。材料做好再处理尚缺授权;不得以“想上线”代填未确认的身份声明或费用选择。 +- 审核期间:实际提交记录、当前状态、下次由谁以何种方式检查。不把上传说成提交或上线。 +- 上线完成:目标用户可安装的商店入口、匹配版本、从商店安装后首用通过的证据、使用说明与反馈入口。插件使用者不需要本技能或开发环境。 + +规则根据随需求提供的三份调研提炼,包内不依赖原作者电脑目录或 StyleSnip 私有工单。官方事实入口和核查日期见发布参考。 diff --git a/skills/browser-extension-launch/assets/starter/chrome-mv3/icons/icon-128.png b/skills/browser-extension-launch/assets/starter/chrome-mv3/icons/icon-128.png new file mode 100644 index 0000000000..81ea9f0f0e Binary files /dev/null and b/skills/browser-extension-launch/assets/starter/chrome-mv3/icons/icon-128.png differ diff --git a/skills/browser-extension-launch/assets/starter/chrome-mv3/icons/icon-16.png b/skills/browser-extension-launch/assets/starter/chrome-mv3/icons/icon-16.png new file mode 100644 index 0000000000..3649c8a3d1 Binary files /dev/null and b/skills/browser-extension-launch/assets/starter/chrome-mv3/icons/icon-16.png differ diff --git a/skills/browser-extension-launch/assets/starter/chrome-mv3/icons/icon-32.png b/skills/browser-extension-launch/assets/starter/chrome-mv3/icons/icon-32.png new file mode 100644 index 0000000000..3f5acc09e0 Binary files /dev/null and b/skills/browser-extension-launch/assets/starter/chrome-mv3/icons/icon-32.png differ diff --git a/skills/browser-extension-launch/assets/starter/chrome-mv3/icons/icon-48.png b/skills/browser-extension-launch/assets/starter/chrome-mv3/icons/icon-48.png new file mode 100644 index 0000000000..9ce84c3526 Binary files /dev/null and b/skills/browser-extension-launch/assets/starter/chrome-mv3/icons/icon-48.png differ diff --git a/skills/browser-extension-launch/assets/starter/chrome-mv3/manifest.json b/skills/browser-extension-launch/assets/starter/chrome-mv3/manifest.json new file mode 100644 index 0000000000..143970fd25 --- /dev/null +++ b/skills/browser-extension-launch/assets/starter/chrome-mv3/manifest.json @@ -0,0 +1,23 @@ +{ + "manifest_version": 3, + "name": "摘一句", + "version": "0.1.0", + "description": "手动记下喜欢的句子,保存在当前浏览器,随时复制或删除。", + "permissions": ["storage"], + "action": { + "default_title": "摘一句", + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } +} diff --git a/skills/browser-extension-launch/assets/starter/chrome-mv3/popup.css b/skills/browser-extension-launch/assets/starter/chrome-mv3/popup.css new file mode 100644 index 0000000000..479bb84e05 --- /dev/null +++ b/skills/browser-extension-launch/assets/starter/chrome-mv3/popup.css @@ -0,0 +1,58 @@ +:root { + color-scheme: light; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; + color: #26362e; + background: #fafaf6; + font-size: 14px; +} + +* { box-sizing: border-box; } +[hidden] { display: none !important; } + +body { width: 360px; margin: 0; } +main { padding: 22px 20px 16px; } +.page-header { display: flex; gap: 10px; align-items: center; margin-bottom: 24px; } +.page-header img { flex-shrink: 0; border-radius: 9px; } +h1 { margin: 0; font-size: 21px; font-weight: 700; line-height: 1.3; } +.subtitle { margin: 4px 0 0; font-size: 12px; color: #606b64; line-height: 1.5; } +.label-row, .section-heading { display: flex; justify-content: space-between; gap: 12px; align-items: center; } +.label-row { margin-bottom: 8px; } +label { font-weight: 600; } +#input-hint, #note-count { color: #626d65; font-size: 12px; } + +textarea { + width: 100%; + min-height: 112px; + max-height: 210px; + resize: vertical; + border: 1px solid #aeb9b0; + border-radius: 10px; + background: #fff; + padding: 11px 12px; + font: inherit; + line-height: 1.6; + color: inherit; +} +textarea::placeholder { color: #707970; } +button { font: inherit; cursor: pointer; touch-action: manipulation; } +button:disabled, textarea:disabled { cursor: wait; opacity: 0.65; } +button:focus-visible, textarea:focus-visible { outline: 3px solid #9c660d; outline-offset: 3px; } +.primary-button { width: 100%; min-height: 42px; margin-top: 10px; border: 0; border-radius: 10px; background: #24573f; color: #fff; font-weight: 600; } +.primary-button:hover:not(:disabled) { background: #1b4431; } +.primary-button:active:not(:disabled) { background: #123422; } + +.feedback { min-height: 48px; padding: 9px 0 12px; } +#status { margin: 0; color: #49634f; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; } +#status[data-tone="error"] { color: #a03525; } +.section-heading { border-bottom: 1px solid #dce2d8; padding-bottom: 11px; } +h2 { margin: 0; font-size: 14px; font-weight: 600; } +.empty-state { margin: 0; padding: 24px 0; color: #687369; text-align: center; font-size: 13px; line-height: 1.8; } +.note-list { list-style: none; padding: 0; margin: 0; max-height: 234px; overflow-y: auto; scrollbar-gutter: stable; } +.note { padding: 13px 0 10px; border-bottom: 1px solid #e2e7df; } +.note-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.65; user-select: text; } +.note-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 5px; } +.text-button { min-width: 44px; min-height: 32px; border: 0; border-radius: 6px; background: transparent; padding: 5px 8px; color: #315d44; font-size: 12px; } +.text-button:hover:not(:disabled) { background: #e8eee5; } +.delete-button { color: #875344; } +.delete-button:hover:not(:disabled) { background: #f4e8e3; } +footer { padding-top: 15px; color: #6a726b; font-size: 11px; line-height: 1.6; } diff --git a/skills/browser-extension-launch/assets/starter/chrome-mv3/popup.html b/skills/browser-extension-launch/assets/starter/chrome-mv3/popup.html new file mode 100644 index 0000000000..e05eb5d828 --- /dev/null +++ b/skills/browser-extension-launch/assets/starter/chrome-mv3/popup.html @@ -0,0 +1,47 @@ + + + + + + 摘一句 + + + + +
+ + +
+
+ + 最多 500 字 +
+ + +
+ + + +
+
+

已保存

+ 0 句 +
+ +
    +
    + +
    只保存在当前浏览器,不上传。
    卸载插件或清除插件数据后,句子会丢失。
    +
    + + diff --git a/skills/browser-extension-launch/assets/starter/chrome-mv3/popup.js b/skills/browser-extension-launch/assets/starter/chrome-mv3/popup.js new file mode 100644 index 0000000000..77835bceb8 --- /dev/null +++ b/skills/browser-extension-launch/assets/starter/chrome-mv3/popup.js @@ -0,0 +1,140 @@ +"use strict"; + +const STORAGE_KEY = "savedSentences"; +const form = document.querySelector("#note-form"); +const input = document.querySelector("#note-input"); +const saveButton = document.querySelector("#save-button"); +const status = document.querySelector("#status"); +const retryButton = document.querySelector("#retry-button"); +const list = document.querySelector("#note-list"); +const count = document.querySelector("#note-count"); +const emptyState = document.querySelector("#empty-state"); + +let notes = []; +let ready = false; +let busy = false; + +function showStatus(message, tone = "success") { + status.textContent = message; + status.dataset.tone = tone; +} + +function setBusy(value) { + busy = value; + input.disabled = value || !ready; + saveButton.disabled = value || !ready; + retryButton.disabled = value; + list.querySelectorAll("button").forEach((button) => { + button.disabled = value || !ready; + }); +} + +function renderNotes() { + list.replaceChildren(); + count.textContent = `${notes.length} 句`; + emptyState.hidden = notes.length > 0; + + notes.forEach((note, index) => { + const item = document.createElement("li"); + item.className = "note"; + + const content = document.createElement("p"); + content.className = "note-text"; + content.textContent = note.text; + + const actions = document.createElement("div"); + actions.className = "note-actions"; + const copyButton = document.createElement("button"); + copyButton.type = "button"; + copyButton.className = "text-button"; + copyButton.textContent = "复制"; + copyButton.setAttribute("aria-label", `复制第 ${index + 1} 句`); + copyButton.addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(note.text); + showStatus("已复制,可以粘贴到别处了。"); + } catch { + showStatus("复制失败,请选中文字后手动复制。", "error"); + } + }); + + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.className = "text-button delete-button"; + deleteButton.textContent = "删除"; + deleteButton.setAttribute("aria-label", `删除第 ${index + 1} 句`); + deleteButton.addEventListener("click", async () => { + if (busy || !ready) return; + const saved = await persistNotes(notes.filter((entry) => entry.id !== note.id)); + if (saved) { + showStatus("已删除这句话。"); + const nextButton = list.children[index]?.querySelector("button") + || list.lastElementChild?.querySelector("button"); + (nextButton || input).focus(); + } + }); + + actions.append(copyButton, deleteButton); + item.append(content, actions); + list.append(item); + }); +} + +async function persistNotes(nextNotes) { + setBusy(true); + try { + await chrome.storage.local.set({ [STORAGE_KEY]: nextNotes }); + notes = nextNotes; + renderNotes(); + return true; + } catch { + showStatus("保存失败,原有句子仍保留,请稍后重试。", "error"); + return false; + } finally { + setBusy(false); + } +} + +async function loadNotes() { + setBusy(true); + retryButton.hidden = true; + showStatus("正在读取已保存的句子……"); + try { + const stored = await chrome.storage.local.get(STORAGE_KEY); + const value = stored[STORAGE_KEY] ?? []; + if (!Array.isArray(value) || !value.every((note) => + note && typeof note.id === "string" && typeof note.text === "string")) { + throw new Error("Unrecognized saved data"); + } + notes = value; + ready = true; + renderNotes(); + showStatus(notes.length ? "句子都在,想用时可以复制。" : "保存后,下次打开还能看到。"); + } catch { + ready = false; + retryButton.hidden = false; + showStatus("暂时读不到已保存的句子,请重新读取。", "error"); + } finally { + setBusy(false); + } +} + +form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (busy || !ready) return; + const text = input.value.trim(); + if (!text) { + showStatus("先写下一句话再保存吧。", "error"); + input.focus(); + return; + } + const saved = await persistNotes([{ id: crypto.randomUUID(), text }, ...notes]); + if (saved) { + input.value = ""; + showStatus("已保存,下次打开还能看到。"); + } + input.focus(); +}); + +retryButton.addEventListener("click", loadNotes); +loadNotes(); diff --git a/skills/browser-extension-launch/references/acceptance.md b/skills/browser-extension-launch/references/acceptance.md new file mode 100644 index 0000000000..b1e8b9a2e7 --- /dev/null +++ b/skills/browser-extension-launch/references/acceptance.md @@ -0,0 +1,110 @@ +# 真实使用与独立验收 + +**未通过真实验收,不得称插件可用、交付完成或关闭实施票。** 这适用于首次可用版、每张实施票、每个实现示例和最终发布候选包;用户明确只要源码或中止工作时,才可按该范围交出标为未验收的源码。 + +首条功能实现后立即做票内真实检查,版本发布前有独立总验收票。独立指范围、记录与复核分开,不能用实施自述替代。支持代理时让另一代理拿规格、候选包及已验证的真实浏览器能力独立执行;否则主代理另起验收过程,如实记录执行者。 + +## 使用闭环也是必需场景 + +按 [产品使用闭环](product-loop.md) 实际执行 `repeat_use`:连续两轮任务,记录可发现的继续路径和退出/恢复。product-designer 复核实际体验,code-review 的 Spec 轴检查 AC 是否漏掉基本路径;复用同批真实操作,不另造重复验收。技术功能通过但使用闭环失败,不得称完整可用。 + +## 验收范围与证据复用 + +先按 [高效交付](efficient-delivery.md) 将 AC 映射到操作及结果,并据改动影响确定复验范围。同一候选、同一次真实操作可同时支持实施票和最终验收,记录引用关系;文档未改变运行包时保留原验收。代码变更后,当前候选必需场景仍须真实执行,历史深测只能单列引用,不改写为当前实测。 + +## 固定候选包 + +记录版本/提交、构建命令、目录及哈希。发布前将最终 ZIP 解压到新测试目录并加载这一份,按影响复验。测试后包变化需重新关联证据,版本号相同不证明文件相同。 + +**默认必须使用 Playwright MCP 加载当前实际构建产物,完成从安装到结果的端到端验证。** 先读 [Playwright MCP 验收执行说明](playwright-mcp.md),检查当前服务、浏览器、加载方式和原生入口能力。Playwright MCP 的 `--extension` 是连接已有浏览器的桥接方式,不是安装待测插件;不得虚构通用插件安装工具。未经用户选择不使用其日常浏览器。 + +Playwright MCP 缺失、不能加载实际插件或不能操作必需原生入口时,必须向用户说明具体缺口,询问是否安装/启用/修复 Playwright MCP,或选择已核实能够覆盖该缺口的受控替代。推荐的替代可包括具备扩展操作能力的 Chrome DevTools MCP、其他浏览器 MCP 或受控浏览器,但必须取得并保存用户的明确选择;等待期间关卡保持 `blocked`。不能静默换工具、跳过验收,或把人工步骤清单算成完成。已有明确选择和授权则沿用,不为同一环境反复询问。 + +从 [依赖准备](dependencies.md) 的路由取得 Playwright MCP 能力,或按用户已批准的替代完成准备后,按顺序执行:计算当前目录指纹 → 在独立测试配置中实际加载当前目录 → 核对扩展 ID、路径及版本 → 从工具栏 action、原生侧栏或实际菜单入口打开 → 执行核心动作并读取真实结果 → 关闭后重新从真实入口打开并核对状态 → 保存原始操作与结果证据 → 运行门禁 `check` 并保存 `gate-report.json`。原生入口、核心结果或重开步骤有一项缺失,门禁不得通过。跨浏览器重启等额外场景按产品承诺纳入。 + +| 验证层 | 证明什么 | 不能替代什么 | +| --- | --- | --- | +| 语法、类型、构建 | 编译及静态契约成立 | 真实操作 | +| 模拟 Chrome API 的逻辑测试 | 模拟条件下的业务逻辑 | 实际权限、浏览器事件、存储、剪贴板或原生入口 | +| 发布目录检查 | 检查器覆盖的结构、引用等 | 完整代码/数据审计、实际功能或商店审批 | +| 票内真实使用 | 当前功能从真实入口到结果可用 | 跨功能及线上安装 | +| 独立总验收 | 整合版本完整路径可用 | 商店实际分发状态 | +| 商店首用 | 目标用户安装线上版本并完成核心任务 | 所有网站/浏览器兼容承诺 | + +## 按功能选矩阵 + +| 场景 | 观察结果 | +| --- | --- | +| 安装和真实入口 | 来源、ID、版本正确;从工具栏、侧栏或菜单真正打开 | +| 核心成功路径 | 代表性页面完成任务,核对文本/产出;复制后粘贴,下载后打开 | +| 关闭、重开、重复 | 保留/取消符合规格,重开浏览器应保存内容仍在,不意外覆盖 | +| 权限和页面边界 | 拒绝授权、内部页及不支持网页有正确反馈 | +| 内容和网络异常 | 实际涉及的空/长内容、远程失败、断网可恢复,保留完成成果 | +| 删除和升级 | 删除正确;隔离测试数据验证旧版迁移及旧功能,不覆盖真实用户数据 | +| 界面和键盘 | 小窗口、滚动、空态、焦点、键盘;有动效再检查减少动效,有设计基准再对照 | + +把 popup 单独当普通页面打开、向普通标签页注入假的 `chrome` 对象、直接调用业务函数或直接写入存储,均不能作为原生核心路径通过证据。必须通过实际入口触发功能,核对真实产出;读取存储可补充诊断,不能代替界面保存操作。 + +有持久化承诺时,保存后须关掉原生弹窗再打开,核对同一内容;承诺跨浏览器重启保存时,在隔离测试配置中重启后再核对。复制后实际粘贴,标签页恢复后核对新标签页及网址,计时器核对真实后台恢复与到期状态。每项核心承诺均需当前文件的实际结果,不能只依据成功提示、截图或“挺好”。 + +缺少能力时先完成上述环境选择,按选择补齐受控环境,不能将人工步骤表交给新手后结束开发。只有已说明且无法自动化的原生环节确实必须本人操作时,才给当前一步并保留对应 `blocked` 状态;取得可核对的真实操作结果后由 AI 接回受控端到端验收。人工辅助不是工具选择或真实结果的豁免。沿用已有测试授权,不为同一测试环境内例行加载测试扩展、重载或检查反复请求确认;未经用户选择不切换到其日常浏览器,不为测试清空主浏览器数据。 + +## 单条证据 + +```text +ID: AC-01 +关联规格/实施票: +版本、提交或源码快照: +构建包及 SHA-256: +环境: 自动化服务、浏览器版本、测试页面/配置、扩展 ID +替代环境(适用时): 具体缺口、用户选择及原始选择证据路径 +真实安装与原生入口证据: +前置条件: +实际操作: +预期结果: +实际结果: +方式和执行者: 自动 / 人工 / 辅助人工 +证据: 截图、日志、导出结果等路径 +结论: 通过 / 失败 / 未验证 / 不适用(原因) +失败关联票及复验记录: +门禁报告 gate-report 路径: +``` + +截图是辅助,存储/复制/导出要检查真实结果。用代表性非私人数据,避免截图暴露账号信息。 + +## 机器门禁与真实证据 + +以下由 AI 在技能目录之外保存证据,并使用当前候选运行目录执行;输出报告必须使用新路径,不能覆盖旧证据。 + +```text +python3 /scripts/acceptance_gate.py fingerprint +python3 /scripts/acceptance_gate.py check --evidence <真实验收JSON路径> --report <新的gate-report.json路径> +``` + +`fingerprint` 输出对象原样放入真实验收 JSON 的 `candidate` 字段,只计算文件指纹,不生成通过记录。真实验收 JSON 使用以下契约,操作未发生时不得预填 `passed`: + +| 字段 | 内容 | +| --- | --- | +| `schema_version` | `1` | +| `candidate` | 当前目录的 `fingerprint` 输出对象,包含全部运行文件及 SHA-256 | +| `environment` | 实际 `automation_provider`、`browser_name`、`browser_version`、`extension_id`、`installation_type`;默认服务为 `playwright-mcp`,安装方式为 `unpacked`、`store` 或 `enterprise` | +| `browser_choice` | 使用替代服务时必填;`default_provider: playwright-mcp`、与实际环境一致的 `selected_provider`、具体 `reason`,以及 `user_decision: {status: approved, evidence_path: ...}`;路径必须指向用户明确选择的原始记录,不能以 AI 自述代替 | +| `required_scenarios` | 至少含 `install`、`native_entry`、`primary_flow`、`reopen`、`repeat_use`,并加入规格中的其他必需场景编号 | +| `scenarios` | 实际场景列表;每项有唯一 `id`、与必需清单一致的布尔 `required`、`status`、`method`、非空步骤数组 `steps`、可观察的 `expected` 和 `actual`、证据数组 `artifactPaths` | +| 通过场景 | `status: passed`,`method` 必须为 `real_browser_tool` 或 `witnessed_manual`;后者仍须记录实际自动化服务和可核对的原生操作结果,不能豁免服务选择;模拟、静态检查不能使用这些方法值 | +| `native_entry.entry_kind` | 对应场景对象的 `entry_kind` 为实际入口:`native_action`、`context_menu`、`keyboard_command`、`native_side_panel` 或 `browser_extension_entry` | +| `artifactPaths` | 相对于真实验收 JSON 所在目录的真实证据文件路径;文件必须存在、非空且位于该目录内,不能指向验收汇总 JSON 自己 | + +`repeat_use` 还须包含 `rounds`(至少两项,每项非空 `entry` 和 `outcome`)、`continuation`、`exit_or_recovery`,全部描述真实操作。旧报告缺该场景时不能通过新门禁,但不自动修改旧记录或重测已停止的例子。字段完整不证明体验良好,仍需核对实际 UI 与操作记录。 + +`reopen` 必须实际关闭并重新打开适用的插件入口,核对按规格应保留或清除的状态;有本地保存承诺则必须核对真实保存内容。跨浏览器重启作为额外必需场景记录,不能用简单重开弹窗替代。 + +`automation_provider` 默认只填 `playwright-mcp`。用户明确选择的替代值限于 `chrome-devtools-mcp`、`browser-mcp`、`controlled-browser`,并要求 `browser_choice.selected_provider` 与之相同。`user_decision.evidence_path` 相对于验收 JSON 所在目录,须指向该目录内真实、非空的原始用户选择记录;不得指向验收 JSON 自己,不得预写同意或以等待超时代替选择。可从已有会话复制用户实际选择并记录来源,不得改写为未发生的批准。 + +工具能力、受控浏览器配置和实际执行方式一并留证。加载或入口失败时记录失败并保持关卡 `blocked`,按已批准环境修复;需要更换服务时走用户选择流程。只有受控环境中无法自动化的原生环节才辅以实际原生操作,随后接回真实验证;不默认切换日常浏览器。门禁不能识别虚构证据,主代理仍须核对实际浏览器操作、产出及原始记录。 + +失败留记录 → 回实施票或新缺陷票 → **读取并执行 `diagnosing-bugs`**,复现并定位原因 → 修复 → 原失败和受影响路径复验 → 新证据回填。验收票不夹带新功能、不降低预期。 + +必需场景有当前包真实通过证据,并经上述门禁 `check` 返回 `gate_passed: true` 后才关闭;将生成的 `gate-report.json` 路径回填任务。缺报告、候选文件指纹已变化、门禁拒绝或未执行均不能关闭。门禁只核验证据契约,不能把虚构或模拟记录变成真实通过。 + +无关场景写不适用并说明与规格的关系;安装、原生入口、核心路径和重开不得以工具不足标不适用。必需场景失败先修复;能力不足必须完成环境选择并继续补齐,等待用户选择或确实依赖本人操作时交付状态保持 `blocked`。用户拒绝安装且未选可用替代时,如实保留未验收状态。可以继续无依赖的材料工作,但不能用材料完成冲抵验收。反馈精确说检查了哪些、还剩哪一步,不报笼统全部通过。 diff --git a/skills/browser-extension-launch/references/complex-workflow.md b/skills/browser-extension-launch/references/complex-workflow.md new file mode 100644 index 0000000000..910b4f0f56 --- /dev/null +++ b/skills/browser-extension-launch/references/complex-workflow.md @@ -0,0 +1,46 @@ +# 复杂插件:自动发布工单、拆分和独立实施 + +先自行评估范围;命中复杂条件时必须执行本文件的真实子技能链。用户只处理目标、重要取舍和结果反馈,不维护工单或决定技术拆分。 + +## 复杂度由主代理决定 + +命中任一明显条件就走复杂链,并将具体需求和理由写入 `state.workflow.complexity`: + +- 三个以上可分别验收、彼此有依赖的用户功能。 +- 登录、账号、云同步、付费服务或后端 API 形成独立模块与跨端状态。 +- 多网站适配、采集/处理/导出流水线,或多个浏览器上下文之间的复杂消息和状态同步。 +- 旧数据迁移、恢复、后台异步任务等,需要独立模块契约及跨功能回归。 +- 现有项目改动跨多个模块,不能在一张小范围实施票里清楚验收。 + +单一 popup + 本机保存不因为文件多就算复杂。需要升级路线时在原记录上继续,不重复创建规格或工单。开始实施前不得把 `undetermined` 当成默认简单。 + +## 显式执行技能链 + +本机已验证这套技能与 `diagnosing-bugs` 同级安装,部分带 `disable-model-invocation` 或关闭自动触发。这不是可以省略它们的理由:按用户本次对主编排的明确授权显式定位、读取并执行,不修改它们全局的触发设置。其上游默认的“用户触发技能不再调用用户触发技能”偏好,在本主技能范围内采用本次授权的自动编排适配。 + +1. **`setup-matt-pocock-skills`:首次或配置缺失时执行。** 读取已有项目约定;没有 tracker 时选该技能原生 Local Markdown 模板。准备 `docs/agents/issue-tracker.md` 及实际需要的 domain/triage 约定,原位维护既有 AGENTS/CLAUDE 区块。已有正确配置不重建。 +2. **`to-spec`:形成并发布规格主工单。** 先执行 product-designer,将完整使用闭环和体验 AC 写入输入规格,不能只以技术功能列表拆票。 输入已有需求、决定和代码背景;用户已说清的内容不重复采访。主代理决定可逆的实现与测试方式,将规格发布至已配置 tracker。Local Markdown 的正式路径为 `.scratch//spec.md`;真实写入即完成本地发布,不需要 GitHub。保留主票编号/路径及 `ready-for-agent` 状态。 +3. **`to-tickets`:发布子工单及依赖。** 拆票遵循 [高效交付](efficient-delivery.md):同一机制的多站适配可放在一票,按风险与独立契约划分,不为站点数或文件数重复流程。 输入真实主票。按可演示、可验收的纵向功能切片拆分,每票同时覆盖所需界面、逻辑、数据和检查;不能只拆 UI/API/测试三层。Local Markdown 每票单独保存为 `.scratch//issues/-.md`,写 `Blocked by`、范围、验收、必要技能、模块和文件所有权。 +4. **`implement`:按前置依赖逐票独立实施。** 主代理自动选择前置完成的票;每个执行者先读该技能、原票和契约,再实现。执行技能要求的 `tdd` 等条件依赖,先在真实行为边界定义检查。派发子代理时明确模块/文件所有权、输入输出、禁止回滚他人改动。只有无依赖且写入范围不冲突的票并行;公共 manifest、共享协议和整合由主代理协调。 +5. **`code-review` + Playwright MCP:逐票收口。** 必须执行真实双轴审查及当前版本的端到端检查。同一候选可将就绪票批量审查和验收,逐票关联实际覆盖的 AC、证据与结论;不跳过依赖。故障立即进入 `diagnosing-bugs`,修复后复验。把技能调用记录、变更版本、浏览器证据及 gate 报告回填同一票,才关闭该票并释放依赖。 +6. **独立总验收与发布工单。** 模块全部整合后保留单独总验收票,实际执行跨模块路径和必要回归;同一最终候选已有的相同整体操作可引用,不按票号重复执行,不能用分模块测试替代缺失的整体路径。随后按交付目标建立/更新发布准备及上线跟踪票,主任务依自己的承诺范围关闭。 + +`to-spec` 产生的就绪新票不必再调用 `triage`。只有外部反馈、旧票或新故障需要分流时执行 `triage`,主代理在已有授权内判断可实施性,必要产品信息缺失才询问。`wayfinder` 默认是规划和 HITL 决策流程,不作为普通复杂插件的默认前置;确有未决重要决策时才按其适用范围用。 + +## 权威记录与调用证据 + +复杂项目的真实规格和实现票来自 `to-spec` / `to-tickets`,按 tracker 规定存储。`.extension-launch/state.json` 只索引这些文件或远程 ID;初始化的 `.extension-launch/tasks/` 模板不能与技能原生票竞争权威,也不能填一张占位票就称已执行子技能。将真实产物链接写入 `workflow.authoritative_artifacts`,主代理统一更新状态。 + +每个必需调用记录:skill 名称、解析到的真实入口、文件哈希/来源修订、输入规格或票、实际输出、检查结果、状态、采用的授权适配。`read_evidence` 与 `execution_evidence` 分开;只有读取入口、没有执行产物时保持未执行。已读且版本未变的内容可复用,不重复读文件充当更多执行次数。 + +## 工单决策由 AI 处理 + +用户本次已授权自动管理本地工单和独立模块开发。以下机械决定由主代理完成并记录“按用户授权由编排决定”:tracker 默认值、标签/目录、技术框架、规格发布、拆分粒度、依赖、选票顺序、模块归属、测试 seam 和技术审查处理。将这些输入显式交给子技能,不再次向用户收集相同选择,也不伪造用户逐项说过“同意”。 + +这份授权不自动包含未知收费、身份声明、新的数据外发承诺、未选定远程仓库或 Playwright 环境不支持时的替代决定。确需用户选择时仅暂停依赖答案的工作;工单技术细节仍由主代理维护。 + +## 审查不能漏掉未提交文件 + +调用 `code-review` 前由主代理准备固定基线、真实变更和规格路径,不让新手挑 Git 提交。原技能的 `...HEAD` 只覆盖相应提交;待审实现必须实际进入审查差异,包含新文件。使用临时审查快照或工作区差异适配时明确记录,禁止空 diff 报通过。复杂项目可用本地 Git 保存可恢复版本,不因此要求 GitHub。 + +双轴结果分别保留 Standards / Spec;某一轴失败就先修复。浏览器验收及缺少 Playwright MCP 的处理严格按 [验收参考](acceptance.md),不能因项目复杂省掉真实入口、整合验收或用户环境选择。 diff --git a/skills/browser-extension-launch/references/dependencies.md b/skills/browser-extension-launch/references/dependencies.md new file mode 100644 index 0000000000..17f1da104a --- /dev/null +++ b/skills/browser-extension-launch/references/dependencies.md @@ -0,0 +1,62 @@ +# 必需能力、子技能来源和浏览器工具 + +先读 [Agent 兼容说明](host-compatibility.md)。这是跨宿主依赖编排:优先执行下面的命名技能;缺失时按核实来源用 Skill CLI 或宿主安装器补齐。只有兼容说明明确允许的 portable fallback 或经核对覆盖同一能力的宿主原生技能可以替代,必须记录真实来源与差异,不能冒充命名技能已执行。 + +## 哪些节点必须用什么 + +| 节点 | 必需依赖 | 适用条件 | +| --- | --- | --- | +| 新建/改变用户流程及体验验收 | product-designer | 拆票前定义闭环与体验 AC,验收时复核连续使用和恢复路径 | +| 扩展设计、开发、修改和商店准备 | chrome-extensions | 对应阶段必须读取并执行相关部分 | +| 初始化插件 | extension-create | 每个新项目;已有项目记录已有脚手架,不重复初始化 | +| 可复现故障、运行或验收失败 | diagnosing-bugs | 一旦发生就进入完整诊断循环,不能用短排错段落替代 | +| 实施票与候选版本审查 | code-review | 提供真实比较基线与规格,执行两个独立审查维度 | +| 真实端到端验收 | 受控浏览器工具;首选 Playwright MCP | 必须加载实际插件并操作原生入口;不支持时必须询问安装/启用或替代选择 | +| 复杂项目配置及工单 | setup-matt-pocock-skills、to-spec、to-tickets、implement | 主代理自行判定复杂后强制,完整顺序见 complex-workflow.md | +| 工程链的条件依赖 | tdd、triage、domain-modeling 等 | 按实际子技能触发条件执行;triage 不加在每张新就绪票前 | + +product-designer 在上表节点必需执行,具体见 [产品使用闭环](product-loop.md);ui-designer 在界面节点按需调用,有动效再用 motion-designer、有新图像再用 imagegen。它们不替代上述必需技能。现代 Web API 等确实需要 modern-web-guidance 时按相关技能引用补齐,不一次安装无关技能。 + +## 已维护的来源清单 + +机器可读的 [required-skills.json](required-skills.json) 保存已核实仓库、精确子目录、固定修订、许可及入口哈希。先复用用户当前版本;本地文件与上游不相同并不意味着应该升级,先读真实内容,保留定制并记录兼容性。 + +- chrome-extensions:GoogleChrome/modern-web-guidance,skills/chrome-extensions,Apache-2.0。 +- extension-create:quangpl/browser-extension-skills,skills/extension-create,MIT。 +- 六个工程主链技能:mattpocock/skills,skills/engineering/,MIT。本地部分技能与所记录上游版本不同,分别保存本地核查哈希与上游哈希,不伪称本地安装于该提交。 +- product-designer:已核实部分宿主安装的实际入口及哈希,来源未核实为公开仓库,不能臆造下载地址。优先复用宿主版本;缺失时按兼容说明执行 `product-loop.md` portable fallback,并明确记录没有运行命名技能。 +- Playwright MCP:microsoft/playwright-mcp / @playwright/mcp,固定参考版本及配置见 [Playwright MCP](playwright-mcp.md)。它是执行工具,不是安装一个同名 SKILL.md 就可用。 + +## 查找、安装、读取和执行分开 + +1. 获取宿主公开技能与工具清单,再检查其明确的技能目录及必需技能位置。用户提到与 diagnosing-bugs 同级时检查该目录,包括显式调用型技能、大小写入口和有权限读取的链接;不能因为未出现在自动列表就说没安装。不扫描凭据配置。 +2. 运行 `project.py doctor PROJECT --skills-dir DIR` 查看必需技能的 detected/missing;它不证明已激活或已执行。阶段进入前解析真实入口、读取 SKILL.md 及该阶段引用文件,按宿主的显式调用机制执行。宿主没有专门调用命令时,显式读取并逐步执行真实技能,而非声称调用了不存在的命令。 +3. 缺失时读取 skill-installer 并用其公开仓库安装器按清单固定来源补齐;宿主另有安装器时使用相应等效安装接口。参数形状为 `install-skill-from-github.py --repo --ref --path `。安装的是完整技能目录及引用资源,不只下载入口。自动完成当前任务已授权的必要安装,不覆盖旧技能或把自己的私人目录当下载源。 +4. 核对入口、资源和来源;需要宿主重载时保存 pending_reload 及恢复动作,重载后再查可用性。无法安装或来源无法核实时明确具体缺项并保持对应步骤阻塞,不用包内文字替代必需依赖。 +5. 执行前准备输入,执行后保存真实输出及验证结果。`source_path` / `source_revision` / `read_evidence` / `execution_evidence` / `status` 写入 workflow.required_skill_stages。存在、已读取、已执行三个状态分开;子技能阶段有产物且核对完成才更新状态。 + +用户已经明确授权这条自动主链。对关闭自动触发的 to-spec/to-tickets/implement 等进行显式编排,不修改其全局触发配置;新手不必手动输入每个技能。技术选型、拆分、依赖和本地发布等机械确认由主代理按授权提供给子技能,具体适配见 [复杂流程](complex-workflow.md)。 + +必需调用不等于照抄样例。extension-create 的框架问题由 AI 根据目标预先选定并传入;必须真正执行所选脚手架、配置入口和检查构建。已有项目不强行重建。示例的广泛网站权限不自动继承;遇技能样例与真实需求、当前官方 API 或用户明确约束冲突时,保留技能执行,记录有证据的局部适配,不能借此取消真实验收或跳过整项技能。付费推广或相关技能推荐不是强制购买/全量安装要求。 + +## Playwright MCP 是必须出现的决策节点 + +默认首先检查 Playwright MCP 的真实工具和当前支持能力:浏览器是否可启动、能否加载当前扩展、是否能观察并操作真实入口。必须在正式开发早期发现环境缺项,不能到交付时才把整张验收表给用户。 + +Playwright MCP 当前没有通用的“安装任意扩展”工具,不得捏造 extension_install。按照 [专用配置说明](playwright-mcp.md) 将实际构建加载到独立的持久 Chromium 上下文,再由 MCP 操作。普通页面能打开、桥接扩展已连接或直接打开 popup.html 都不足以说明端到端通道成立。 + +出现 MCP 未安装、未启用、浏览器不兼容、加载失败或原生入口无法受控时:先记录已查明原因和可行方案,**询问用户是否安装/启用 Playwright MCP,或选择推荐的其他受控方案**。可推荐 Chrome DevTools MCP 的扩展专用工具;若用户暂不处理则保持 waiting_user_environment_choice / blocked。等待期间继续独立工作,不能静默切换、跳过检查或标为可用。此前对相同环境的明确选择沿用,不重复询问。 + +推荐提问的内容应类似:“当前环境还不能通过 Playwright MCP 完成插件加载/入口操作。可安装或启用它,或使用已核实能真实加载插件的 Chrome DevTools MCP;请决定采用哪一种。”选项按当时事实提供,不能把尚未核实的替代说成已可用。实际用户选择和原因保存为原始证据。 + +选定后才配置和启用对应通道。优先项目专用配置和独立 profile,保留用户已配置服务;不得擅自切换日常浏览器。重新加载宿主后再次检查实际调用。只有不可自动化的原生步骤才由用户辅助,随后 AI 必须接回端到端检查。 + +验收 JSON 的 environment.automation_provider 默认必须为 playwright-mcp。若选择 chrome-devtools-mcp / browser-mcp / controlled-browser,按验收参考保存 browser_choice 和用户批准证据;门禁会拒绝缺失选择、遗漏 provider、模拟方法或过期文件指纹。门禁只检查记录,不负责安装或驱动浏览器。 + +## 失败和恢复 + +- 技能缺失与 MCP 不可用分别处理,不重复安装方法技能解决浏览器连接。 +- 同一失败查明原因后至多原样重试一次,再按支持路径处理;不关闭工具限制。工具明确允许临时目录时可复制已授权构建并核对哈希,不能假定各 MCP 的目录策略相同。 +- 软件版本按所选脚手架和官方文档核实;安装 extension-create 不代表其 Node/包管理器或项目构建已可运行。在实际执行 shell、工作目录和项目缓存下跑最小正式构建,并早测所需原生入口及特殊能力,按 [高效交付](efficient-delivery.md) 记录可复用环境结果。 +- 固定来源失效先保留已有成果,再核实维护者的新修订。未知来源不能替代清单,已知定制不静默覆盖。 +- 用户取消工作时按范围停止,保留未验收状态;不能因必需节点存在就继续运行用户已停止的例子。 diff --git a/skills/browser-extension-launch/references/development.md b/skills/browser-extension-launch/references/development.md new file mode 100644 index 0000000000..a5d82813f9 --- /dev/null +++ b/skills/browser-extension-launch/references/development.md @@ -0,0 +1,42 @@ +# 制作第一版、审查与排错 + +实现或修复时读取。先读规格、前置票、既有规范和待保护的旧功能;了解代码时遵从项目索引约定。首条路径完成实现后,必须在真实 Chrome 安装并从原生入口验收通过,才能交付为可用扩展。 + +## 先执行必需技能,再选择起点 + +每次进入扩展开发先读取并执行 chrome-extensions 的相关规则和引用资源。新项目必须执行 extension-create:主代理把已知名称、用途、功能、框架选择作为明确输入,实际运行所选脚手架、配置入口和最小权限,再构建核实;不能只读一次说明就复制包内模板而称初始化完成。 + +技术选择由 AI 在用户授权内完成,不再问新手 React/Vue/WXT 等。extension-create 当前支持 WXT/CRXJS;按真实版本和官方文档准备 Node、包管理器及命令,简单功能也可选轻量模板。已有项目保留既有结构和锁文件,不为了满足初始化节点再重建,记录该节点因接手已有项目不适用。 + +复杂度由主代理自行识别,命中条件必须走 [复杂插件编排](complex-workflow.md),每张实施票显式调用 implement;该技能要求 tdd 时按行为边界执行。原生参考模板和 examples 只作为代码阅读素材,不替代必需调用。用户已停止的例子不因更新编排而继续运行。 + +实现前按 [高效交付](efficient-delivery.md) 提取当前必需技能的硬规范并尽早检查;涉及共享设置时,先验证并发读写和异常后继续操作。特殊浏览器能力尽早探测,不在最终验收才发现环境不支持。 + +## Chrome 实现要点 + +- 每项权限对应功能。点击后临时读当前网页可评估 activeTab 与 scripting;持久网站访问才选窄化 host permissions,并处理临时授权生命周期。不要默认所有网站、tabs、debugger 等权限。[activeTab](https://developer.chrome.com/docs/extensions/develop/concepts/activeTab) +- popup 会关闭,service worker 会休眠;需要保留的内容写入适合存储,不靠后台全局变量永存。读写失败反馈给用户,迁移不能每次启动清空旧内容。[后台生命周期](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle) +- 网页输入和消息按不可信内容处理,校验消息类型、发送方及参数。展示文本优先 textContent,不把网页文字拼成可执行 HTML。注入和清理不得破坏原网页。 +- 执行逻辑随包提供,避免 CDN 脚本、下载代码后执行、字符串 eval。远程 API、图片、普通数据依行为判断,不一概当违规代码。[MV3 要求](https://developer.chrome.com/docs/webstore/program-policies/mv3-requirements) +- 扩展文件用户可查看,不能放服务端私钥或开发者长期密钥。付费 AI/服务先明确数据接收方及费用承担方式,再做服务和授权,不套用开发者个人凭证。 +- 不支持的内部页、未获站点权限、断网、空内容、重复点击分别给实际提示。确定原因后才指导刷新或授权,不把所有错误归因于用户环境。 + +## 界面与逐票循环 + +按 product-designer 已定义的 [使用闭环](product-loop.md) 实现可发现的再次操作、结果处理、退出与恢复;基本路径遗漏时先补 AC,不以用户未逐项指定为由省略。围绕主要动作组织入口,结果可见,成功后说明保存在哪里。空态说下一步,错误说明恢复方法;处理中避免重复提交,取消保留约定成果。适配实际 popup/侧栏尺寸,长文可滚动;表单有标签、键盘可达、焦点可见。有确认参考图才对照,有动效才检查减少动效。 + +不把安装脚本、进度字段、开发说明放进插件日常界面。插件使用者只需自己的操作,开发 skills 和任务记录属于制作者。 + +循环:读取真实实施票 → 执行 chrome-extensions(复杂项目同时执行 implement)→ 实现完整小功能并正式构建 → 默认 Playwright MCP 加载当前产物并核对 ID/版本 → 原生入口端到端核心操作及适用关闭重开 → 故障执行 diagnosing-bugs 并复验 → 执行 code-review 双轴审查 → 保存版本和调用证据 → 验收门禁通过 → 关闭实施票。Playwright MCP 不支持时必须先取得用户安装/启用或替代选择,不能自行换工具。 + +按真实风险验证存储、权限、异步消息和迁移,不为微小可逆文案写镜像测试。逐票真实使用检查不推迟到发布前;模拟 API、单独打开弹窗页面及静态检查不能替代。新示例遵循同一循环。必需真实检查尚未通过时,实施票保持进行中或因明确外部依赖而 `blocked`。按 `acceptance.md` 先执行 `acceptance_gate.py fingerprint BUILD_DIR`,将实际证据写成 JSON,再执行 `check BUILD_DIR --evidence JSON --report NEW_PATH`,保存 `gate_passed: true` 的报告及路径。 + +## 首次试用及排错 + +由 AI 默认使用 Playwright MCP 完成首次真实端到端试用。按 `dependencies.md` 探测扩展加载和原生入口能力;缺失或不支持时保持 blocked,明确询问安装/启用或推荐受控替代,并等待选择。已获相同环境选择则沿用;不自行换 MCP,不默认交回整份人工验收表,也不使用未被选定的日常 Chrome。 + +只有受控环境中无法自动化的原生环节确实需本人操作时,才参考 Chrome 当前[本地加载教程](https://developer.chrome.com/docs/extensions/get-started/tutorial/hello-world):扩展管理页 → 开发者模式 → “加载已解压的扩展程序” → 确切运行目录 → 工具栏入口。每次只给当前所需一步和确切测试配置/目录,核实实际结果后由 AI 接回端到端验证;依赖该步的交付保持 `blocked`,不让用户替 AI 做完整验收。 + +更新本地版先核对扩展 ID、加载路径、版本、扩展是否重载、网页内容脚本是否需要刷新。不为测试清空或重启用户主浏览器配置;通过已授权的浏览器通道管理测试环境。 + +发生“没反应”、报错、构建失败或验收失败时,必须实际读取并执行 diagnosing-bugs。先建立能判定这次具体故障的失败循环、保存最小复现,再提出可证伪原因并逐项验证;在正确的行为边界保留回归检查,修复后复跑原场景及受影响路径。能自行查到的环境事实不问用户,技术排查由 AI 决策;缺少真正影响复现的场景信息时才问。日志与结果按普通语言说明,不以包内这段摘要替代技能阶段,不改预期来制造通过。新增承诺回规格流程。 diff --git a/skills/browser-extension-launch/references/efficient-delivery.md b/skills/browser-extension-launch/references/efficient-delivery.md new file mode 100644 index 0000000000..75350c48e0 --- /dev/null +++ b/skills/browser-extension-launch/references/efficient-delivery.md @@ -0,0 +1,52 @@ +# 高效实施、复验与收尾 + +开始规划、准备最终审查或发生返工时读取。保留所有必需技能和真实验收;优化的是重复工作,不以时间预算豁免失败场景。 + +## 开发前准备 + +- 在实际执行环境中确认工作目录、Node/包管理器、所选脚手架版本及项目缓存可用;先运行最小正式构建。记录可重用命令与目录,后续不凭记忆重写路径。不固定某台机器的版本、端口或绝对路径。 +- 从实际读取的必需技能提取会影响实现的硬约束,例如异步消息响应、权限、存储和代码规范;能用现有 lint/静态检查发现的,在第一次浏览器验收前检查。规范来自当前技能,不能误说成浏览器平台强制规则。 +- 先验证所选 MCP 能真实安装扩展、触发原生入口并读取结果;复制、截图或重启等特殊能力仅在需求涉及时早测。替代默认 Playwright MCP 仍须按依赖规则取得用户选择。版本和环境未变时复用已核实结果。 +- 新建或改变使用流程先实际执行 product-designer,按 [产品使用闭环](product-loop.md) 定义继续使用与恢复,验收包含连续两轮;效率优化不得省略这一体验关卡。 +- 将 AC 写成“前置状态 → 实际动作 → 预期 → 证据”,提前覆盖核心路径、故障恢复及产品承诺的持久化。涉及设置时用非默认值;涉及清空、并发保存或跨浏览器重开时明确列项。不要等最终审查才发明测试范围,也不为简单功能增加无关矩阵。 + +## 拆票与批次 + +复杂插件仍执行 setup → to-spec → to-tickets → implement。按用户功能、独立风险和模块契约拆票,不按文件数量或同一种机制支持几个网站拆出重复流水线。 + +主代理可合并尚未关闭、同一共享实现且需要一起验证的票,保留原 AC 映射与模块所有权,更新依赖,不删需求。每张实际实施票仍需真实验收和 code-review。无依赖且写入范围不冲突的模块可独立开发;有依赖的工作不得因合批绕过前置条件。 + +同一固定候选的一次双轴审查可以覆盖多个就绪票及最终版本,前提是明确列全基线、差异、规格和 AC,并分别回填结论。最终跨模块验收票仍保留,但可直接引用同一候选、同一实际操作的证据,不为工单编号重复安装和操作。不能用分模块检查代替缺失的整体路径。 + +## 按影响复验 + +| 改动 | 必需工作 | +| --- | --- | +| 仅文档、工单状态、会话归档,运行包字节未变 | 校核记录、链接与原指纹;不重跑产品测试、浏览器或已通过的代码审查 | +| 局部界面或独立逻辑 | 正常构建与针对性检查;加载新候选,验证实际原生核心路径和受影响操作;涉及保存则重开验证 | +| 权限、入口、消息、共享状态、存储、过滤规则或依赖变化 | 对新候选验证相关跨模块路径、失败恢复及适用持久化;影响无法界定时扩大回归 | +| 仅打包,ZIP 内相对路径和字节均与已验收目录相同 | 本地交付核对文件清单与哈希;有商店发布需求时仍执行发布参考要求的 ZIP 解压加载验证 | + +每次运行包变化都生成新指纹,最终候选始终必须有实际加载、原生入口、核心流程和适用重开的当前证据。复用未变模块的历史深测时,记录旧候选、覆盖场景、未变的依赖及影响判断,作为历史证据单列;不得修改旧指纹或把未重跑场景标为新候选实测。属于当前最终验收必需场景的,仍须在当前候选实际执行。现有门禁不支持用历史通过记录冒充当前通过。 + +## 审查前备齐,失败后只修缺口 + +准备固定基线、全部差异(含新文件)、AC、当前成品、真实证据和门禁报告,以及本次交付要求的 ZIP 与 FINAL_REPORT 草稿。报告写明待审查,不预填通过。用机器检查先发现文件缺失、JSON 无效、必需场景 ID 缺失和哈希不符,再交 code-review;静态检查单列,不能填成必需真实浏览器场景。 + +双轴审查必需执行。功能/安全/数据问题必须修复复验;明确的项目硬规范也必须满足,但应在前置检查中发现。建议性重构或纯偏好不自动扩大交付范围。整改复审聚焦原 findings、修复差异及受影响 AC,不因状态文档变化重启整个审查周期。 + +遇到已知工作目录或证据格式错误,diagnosing-bugs 可复用已确认根因,修正调用并验证结果,不重做无关产品实验。连续出现同类返工时,主代理应检查编排、模板和依赖是否有问题,合并重复动作;不得降低预期来结束任务。 + +## 一次收尾与时间记录 + +只有目标内 AC、当前验收门禁和双轴审查通过且交付文件齐全,才进入收尾。一次更新权威工单、简短状态索引、最终报告和本地版本记录后交付;不再增加全文日志、形式化清理、新审查或产品测试。发现实质缺陷则返回对应节点。 + +规格和工单为权威正文,state 仅索引,报告链接证据;不要手工维护多份相同状态。已有状态工具可用时调用它更新索引;没有工具则一次性更新必要字段,不临时开发一套任务平台。结束后不为“再确认一次”循环运行同一套检查。 + +记录首次开始、各阶段结束及中断/恢复时间。多项目并行时区分整体历时和各项目历时,不能相加冒充用户等待时间。预计明显超出计划或用户催促时,立即报告已完成成品、剩余具体问题,并缩减无关范围与重复步骤;不凭空承诺剩余分钟数。 + +## 用户要求独立 CLI 或完整会话时 + +仅在本次有此要求时启用。每个项目保留独立目录与原会话 ID,按用户指定模型和强度运行;说明配置/历史/profile 隔离与操作系统隔离的区别。记录控制消息是否真正进入会话;排队不等于已送达,不把中断重启当作常规消息通道。恢复时只传缺口和下一动作,不重复初始化或读取全部技能。 + +从原始公开消息生成 conversation.md,明确标注用户原话、主线程委派、恢复指令和 AI 回复;保留原始主/子会话、工具事件及校验清单,不导出认证文件或把内部推理展开成公开对话。无法取得原始内容时标明缺失,不能补写。运行中采用增量或按需快照,结束后做一次完整导出和一致性核对;避免多写入者覆盖同一个 Markdown。该记录不自动代表包含桌面主线程全部聊天。 diff --git a/skills/browser-extension-launch/references/host-compatibility.md b/skills/browser-extension-launch/references/host-compatibility.md new file mode 100644 index 0000000000..2cffe313e1 --- /dev/null +++ b/skills/browser-extension-launch/references/host-compatibility.md @@ -0,0 +1,58 @@ +# Agent host compatibility + +Read this when installing, resuming on a different agent, or resolving a missing tool or child skill. The shared workflow is host-neutral; installation paths, tool names, permission prompts, and browser automation vary by host. + +## Verified Skill CLI targets + +The public repository root contains one valid `SKILL.md`. Skill CLI 1.7.0 discovered it and installed the complete folder for these targets in an isolated project: + +| Agent target | Skill CLI id | Installed through | +| --- | --- | --- | +| Codex | `codex` | shared `.agents/skills` | +| Claude Code | `claude-code` | `.claude/skills` plus shared skill files | +| Cursor | `cursor` | shared `.agents/skills` | +| GitHub Copilot | `github-copilot` | shared `.agents/skills` | +| OpenCode | `opencode` | shared `.agents/skills` | + +Install for all five: + +```bash +npx skills add xiehuan123/browser-extension-launch \ + --skill browser-extension-launch \ + --agent codex claude-code cursor github-copilot opencode +``` + +Use `-g` for user-level installation. Current Skill CLI 1.7.0 requires Node.js 22.20+. Other supported agents can use `--agent '*'`; do not claim a host is verified until its installation and required runtime capabilities have been exercised. + +## Capability contract + +Before doing project work, identify the host and verify these capabilities separately: + +1. Read and write the project and skill resources. +2. Run local commands in the project directory. +3. Load or control a real browser environment capable of exercising an unpacked extension and its native entry point. +4. Preserve project state and evidence across turns or sessions. +5. Invoke installed child skills, or execute an explicitly documented portable fallback. + +Installing this skill does not grant those tools. A host lacking file or shell access can only provide guidance. A host lacking controlled extension-capable browser automation cannot certify a working delivery; keep acceptance blocked or deliver source only when the user explicitly chooses that scope. + +## Child skill adapters + +Prefer the named child skills because their reviewed workflows are the reference implementation. Discover installed skills first. When missing and the host supports Skill CLI, install from the pinned public sources in `required-skills.json`, targeting the current agent. + +- `chrome-extensions`, `extension-create`, `diagnosing-bugs`, `code-review`, and the complex-project chain have public sources recorded in the dependency manifest. +- `product-designer` has no verified portable public source in the current manifest. On hosts where it is absent, `product-loop.md` is the approved portable fallback. Record that fallback instead of claiming the named skill ran. +- A host-native skill may replace a named child only when its actual instructions cover the same outcome and constraints. Record its source, what was read, produced artifacts, and any gaps. + +Never install by guessing a similarly named repository. Do not overwrite a customized skill. If installation requires the host to restart, save progress and verify discovery after restart. + +## Browser tool adapters + +The acceptance contract is provider-neutral even though `playwright-mcp.md` is the reference setup: + +- Prefer Playwright MCP when it can load the current extension and operate the native entry point. +- Chrome DevTools MCP, another browser MCP, or a controlled browser harness is acceptable only after capability probing shows it covers the required actions. +- Record the selected provider, browser version, package fingerprint, native entry evidence, core flow, reopen behavior, and repeat use. +- Webpage screenshots, a directly opened popup HTML file, mocked Chrome APIs, and static package checks do not replace native extension acceptance. + +Use the acceptance gate's provider and user-choice fields. If no provider can exercise the needed native browser surface, explain the concrete gap and keep the affected delivery state blocked. diff --git a/skills/browser-extension-launch/references/playwright-mcp.md b/skills/browser-extension-launch/references/playwright-mcp.md new file mode 100644 index 0000000000..23f658724a --- /dev/null +++ b/skills/browser-extension-launch/references/playwright-mcp.md @@ -0,0 +1,102 @@ +# Playwright MCP:真实插件端到端验收 + +## 必需路径和环境选择 + +本编排默认必须使用 **Microsoft Playwright MCP** 对实际插件构建做端到端验证。先发现并读取当前可调用工具和服务信息,再核实浏览器、插件加载与原生入口能力。仅有 Playwright npm 包、浏览器截图、普通页面自动化或桥接扩展,不代表此关卡已具备全部能力。 + +1. 已有 Playwright MCP 且能力足够:沿用已经授权的独立测试环境,准备当前构建和原始操作记录。 +2. 缺少服务/浏览器、启动失败、不能加载插件或不能触发必需原生入口:解释具体缺口,询问用户是否安装、启用或修复 Playwright MCP,或采用能够覆盖缺口的受控替代。 +3. 等待用户明确选择。可推荐经核实具有扩展操作能力的 Chrome DevTools MCP、其他浏览器 MCP 或受控浏览器,并说明可覆盖的操作与限制。不得静默切换服务、默认使用用户日常浏览器,或改用人工清单来宣告完成。 +4. 取得选择后记录实际用户原话、来源和时间,沿用该选择及授权。不得为同一环境内的例行加载、重载和检查反复询问;换服务、换到日常浏览器等超出选择范围的变化需要重新确认。 +5. 用户拒绝安装且未选择可用替代时,保持 `blocked` 和未验收状态,可继续不依赖该环境的准备工作。 + +此说明仅规定后续插件项目的执行方式。更新编排 skill 时不要因此自行启动浏览器、验证历史例子或改动用户现有全局 MCP 服务。 + +## 已核实的来源 + +- 维护者:[Microsoft / playwright-mcp](https://github.com/microsoft/playwright-mcp)。核实日期:2026-09-11;源码提交 `8a13ef8e9f7385a0f89477922127f31cbfde9761`。 +- 已发布包:`@playwright/mcp@0.0.80`,Apache-2.0,Node.js ≥18。优先复用现有兼容的 Node 20 或更高版本,不另行降级。 +- 该版本依赖 `playwright` 和 `playwright-core` 的 `1.63.0-alpha-2026-08-31`。浏览器二进制必须与实际安装的 Playwright 版本匹配;具体安装操作在用户选择准备环境后按该版本官方说明执行。 +- 0.0.80 是本次核实的固定参考,不代表对任何宿主都完成了兼容测试。保留已可用的版本;升级前核实差异并记录实际版本。 + +依据:[固定版本 package.json](https://github.com/microsoft/playwright-mcp/blob/8a13ef8e9f7385a0f89477922127f31cbfde9761/package.json)、[许可证](https://github.com/microsoft/playwright-mcp/blob/8a13ef8e9f7385a0f89477922127f31cbfde9761/LICENSE)。 + +## 区分服务、库和桥接扩展 + +| 能力 | 作用 | 验收边界 | +| --- | --- | --- | +| Playwright MCP | AI 通过当前服务的真实工具操作浏览器并观察页面 | 当前官方清单没有通用的“安装任意插件”工具;不能虚构工具名。若某版本提供 `browser_install`,也须读工具说明,它不能据名字被当成插件安装器 | +| Playwright 库 | 使用 Chromium 持久上下文和启动参数加载未打包扩展 | 库脚本不等于 MCP 验收;可用于已授权环境准备或补充验证,不能静默取代默认 MCP | +| Playwright Bridge / Extension | `--extension` 连接已有 Chrome/Edge 页面及其登录状态 | 它是连接器,不是待测插件安装器;连接成功不证明 popup、侧栏或工具栏入口可操作 | + +官方扩展测试文档要求使用持久上下文;Chrome/Edge 已移除相关命令行侧载参数,采用此加载方式时应使用 Playwright 随附的 Chromium。Chromium 支持有头或受支持的无头扩展测试,不能写成“任何插件都不支持无头”。本编排优先有头独立环境,以便检查真实入口和收集证据;仍需核实 MCP 是否能够操作具体入口。 + +依据:[官方扩展测试文档](https://playwright.dev/docs/chrome-extensions)、[Playwright MCP 配置](https://github.com/microsoft/playwright-mcp/blob/8a13ef8e9f7385a0f89477922127f31cbfde9761/README.md)、[官方桥接扩展说明](https://github.com/microsoft/playwright/blob/main/packages/extension/README.md)。 + +## 实际加载方案 + +下面是根据官方 MCP `browser.launchOptions` 转发配置与 Playwright 扩展测试文档组合出的配置示意,**本次仅核实文档和来源,未实机验证该组合**。执行者须对实际宿主完成能力预检,不能把配置示意当成功记录。 + +用户选择准备该环境后,在待测项目中生成专用配置,填入最终解包产物的绝对路径。不要覆盖既有全局服务;优先使用宿主支持的项目级服务或另命名的专用服务,并按宿主真实配置能力执行。配置文件保存于项目记录目录,浏览器 profile 必须独立且不能被并行会话共享。 + +```json +{ + "browser": { + "browserName": "chromium", + "isolated": false, + "userDataDir": "/ABS/PROJECT/.extension-launch/browser-profile", + "launchOptions": { + "channel": "chromium", + "headless": false, + "args": [ + "--disable-extensions-except=/ABS/PROJECT/FINAL_UNPACKED_EXTENSION", + "--load-extension=/ABS/PROJECT/FINAL_UNPACKED_EXTENSION" + ] + } + }, + "saveSession": true, + "outputDir": "/ABS/PROJECT/.extension-launch/evidence/playwright" +} +``` + +参考服务启动参数如下。占位路径必须替换,不要原样执行;执行前确认 Node、相匹配的 Chromium、宿主服务重载方式及用户选择均已就绪。 + +```text +npx -y @playwright/mcp@0.0.80 --config /ABS/PROJECT/playwright-mcp.json +``` + +这里通过浏览器启动配置加载待测产物,随后通过 MCP 工具执行测试;不声称 MCP 存在专用插件安装工具。不要同时设置 `--extension` 来完成侧载,桥接模式会忽略上述 `browser` 配置。ZIP 须先解压为当前候选目录,并校验指纹;不要拿开发服务器的页面代替实际构建。 + +加载后必须观察到正确的扩展 ID、版本和当前构建来源。Manifest V3 有 service worker 时,可用实际上下文的 worker URL 辅助确定 ID;没有 worker 的插件应通过真实扩展管理信息取得 ID,不能为了取 ID 改动插件。只声明了参数、浏览器启动成功或打开了空白页都不算安装通过。 + +## 从真实入口验证完整路径 + +按 [真实验收契约](acceptance.md) 记录候选 SHA-256,以及至少五个必需场景:`install`、`native_entry`、`primary_flow`、`reopen`、`repeat_use`。最后一项按产品闭环从正常用户路径连续完成两轮,记录继续与退出/恢复,不通过脚本调用内部处理器替代再次操作。 + +使用当前 MCP 的真实工具逐步执行:加载并核对插件 → 从实际工具栏、原生侧栏、菜单或已定义的命令入口触发 → 完成核心操作 → 观察真实产出 → 关闭并重开适用入口 → 核对状态。保存原始工具结果、截图、导出内容或可复查的其他证据。 + +MCP 能操作网页不等于能点击浏览器工具栏。直接导航到 `chrome-extension:///popup.html` 可作扩展页面辅助检查,不能替代工具栏 popup 的 `native_entry`。模拟 Chrome API、注入假的 `chrome` 对象、直接调用业务函数或直接写入存储,都不能证明用户完成了真实流程。 + +遇到原生入口能力不足,返回环境选择步骤。只有已说明且确实无法自动化的原生环节才安排用户辅助操作,并保留 `blocked` 直到取得可核对的真实结果;AI 随后接回受控验证。人工辅助不免除自动化服务、用户选择和核心结果证据。 + +## 服务选择证据 + +默认环境记录 `environment.automation_provider: "playwright-mcp"`。用户明确选替代时,服务值限于 `chrome-devtools-mcp`、`browser-mcp` 或 `controlled-browser`,同时提供: + +```json +{ + "browser_choice": { + "default_provider": "playwright-mcp", + "selected_provider": "chrome-devtools-mcp", + "reason": "填写实际核实的能力缺口与替代覆盖方式", + "user_decision": { + "status": "approved", + "evidence_path": "browser-choice/user-decision.md" + } + } +} +``` + +这是字段示意,不能预填用户已批准。`selected_provider` 必须与实际环境一致;选择证据必须是验收 JSON 同目录范围内的非空原始记录,保留用户实际选择及可追溯来源,不能是 AI 写出的同意,也不能指向验收 JSON 本身。 + +场景方式仍使用 `real_browser_tool` 或有真实见证记录的 `witnessed_manual`。人工辅助不是第四种服务,也不是绕过选择记录的办法。任何必需场景失败、未测、候选哈希变化或门禁拒绝,都不能宣布验收完成。 diff --git a/skills/browser-extension-launch/references/product-loop.md b/skills/browser-extension-launch/references/product-loop.md new file mode 100644 index 0000000000..2e834c3c2c --- /dev/null +++ b/skills/browser-extension-launch/references/product-loop.md @@ -0,0 +1,35 @@ +# 产品使用闭环 + +新建插件、改变用户流程或处理体验缺陷时,必须实际执行 `product-designer`。输入用户原话和已有决定,输出写入现有规格,不另建一套产品文档。小需求用短段落或表格即可;只读技能名字、列功能清单不算完成。 + +## 从开始到下一次使用 + +规格在拆票前必须回答:用户从哪里进入、做什么、如何知道成功、如何处理结果、如何继续下一次、如何结束,以及取消/失败后如何恢复。逐状态写出可见反馈、可发现的下一动作与数据保留方式,并形成体验 AC。 + +结果页不能只有结果而无后续动作。例如取色应从结果面板“重新取色”进入下一轮,保留已选结果并支持取消返回;不能默认要求用户关面板再去浏览器工具栏重启。按钮、快捷键和连续模式只是实现选项,选符合任务的最短可发现路径,不强制所有插件套相同界面。 + +自动型插件没有结果面板时,闭环是后续网页/事件继续自动生效,并且用户可发现状态、暂停、恢复和退出方式;不要为去广告或自动跳转硬加每次确认。确属一次性操作则第二轮用下一项输入或再次访问验证,不能把“没有再次按钮”直接判失败,也不能用“不适用”豁免整个闭环检查。 + +## AI 决策与用户决策 + +- AI 主动补齐再次操作、取消、关闭、成功反馈、错误恢复等基本路径,记录为设计决定,不要求用户逐按钮批准。 +- 连续采集还是单次确认、自动执行还是先预览、覆盖还是追加等明显影响用途/数据或打断程度的选择,先从上下文判断;信息不足时向用户用日常场景说明推荐和取舍,等待必要答案,再实施依赖部分。技术拆分、框架、测试方法仍由 AI 决定。 +- 不把“用户没提”当作缺少基本闭环的理由,也不以补闭环扩成账号、云同步、批量管理等无关功能。 + +## 真实连续使用验收 + +最终候选必须增加 `repeat_use` 必需场景:从已验证的真实入口开始,连续完成两轮任务,第二轮通过用户可发现的正常路径继续;记录每轮入口/触发、实际结果,以及第二轮如何开始。再实际验证结束、取消或失败后的可恢复状态。 + +交互型插件优先使用真实 MCP 点击、键盘或指针操作。不得通过脚本调用内部处理器、强设存储、隐藏快捷方式或重新初始化页面来替代用户继续操作;读取 DOM/存储和确定性测试数据可作核对。浏览器原生入口本来就必须使用时,记录平台约束及为什么该路径合理,不机械禁止工具栏操作。 + +例如取色连续选 A、复制、在面板再次取 B、取消或退出;自动跳转连续访问两个受支持外链;广告过滤在动态插入或下一网页继续工作,并实际暂停/恢复。以上仅为场景设计参考,不自动触发修改或重测已有例子。 + +code-review 的 Spec 轴须核对这条闭环本身是否完整,不能只看已有 AC 全打勾。`product-designer` 对实际页面的反馈、继续操作和恢复结果做体验复核,复用同一批浏览器证据,不新开整套流水线。若规格遗漏必要基本动作,先补体验 AC,再修复,不以实现“符合不完整规格”关闭问题。 + +最终报告分列“技术功能”与“使用闭环”,分别说明通过/未通过及证据。缺再次操作或只能靠绕路完成任务时,技术测试通过也不能称完整可用。验收门禁只验证证据结构与版本,不能自动判断入口是否易发现;需保留实际界面、连续操作与上述复核结论。 + +## 证据字段与旧记录 + +`repeat_use` 沿用场景的 steps、expected、actual、artifactPaths;另含 `rounds` 数组,至少两项,每项填写实际非空 `entry`(用户入口/自动触发)和 `outcome`;`continuation` 描述两轮如何衔接,`exit_or_recovery` 描述实际退出或恢复结果。不得预填通过或用字段齐全冒充操作发生。 + +新门禁要求此场景;旧报告缺失时将不能通过新检查。保留旧报告和历史结论,不自动重写、补造证据或启动用户已停止的例子。继续该项目时再补齐实际验收,并标明原有技术检查与新体验检查的区别。 diff --git a/skills/browser-extension-launch/references/publishing.md b/skills/browser-extension-launch/references/publishing.md new file mode 100644 index 0000000000..cf1bad2955 --- /dev/null +++ b/skills/browser-extension-launch/references/publishing.md @@ -0,0 +1,103 @@ +# 发布、审核跟进与维护 + +准备上架、提交、拒审、上线或更新时读取,同时必须执行 chrome-extensions 的发布部分,按实际实现维护其要求的 CHROMEWEBSTORE.md 和引用检查清单;本文件补充编排,不替代子技能。Chrome 官方基线核查于 2026-09-11;正式提交当天重查政策、后台必填项和实际账号。其他浏览器单独核实对应官方规则。 + +## 上下文明确发布意图后才提醒 + +这不是每个项目的固定前置阶段。只有用户说要上架、公开发布、上线到商店,或当前会话已明确包含商店发布目标时,才触发下列提醒。单说“做个插件”、只想本地使用、技能本身名字含“上线”,或者 init 的默认 goal=live,都不能触发开通账号或付款提示。只是“给朋友用”而分发渠道未定时,先在需要时确认分发目标,不先要求开发者注册。 + +将真实上下文依据写入 `release.publishing_intent.context_evidence`,标记意图已成立再给一次当前缺项的简短说明;既有账户或已完成事项不重复提示。账号准备可以与已授权开发并行,不能变成阻止第一版制作的统一关卡。 + +| 需要告知/核查的事项 | 用户能理解的说明 | +| --- | --- | +| Google 账号 | 需要能正常登录、长期使用并接收通知的账号;已有账号可用,不一律要求另建 | +| Chrome Web Store 开发者身份 | 首次自己创建发布者需完成开发者注册;已有发布者或团队授权先检查实际权限,不重复开通 | +| 注册费 | 首次注册的参考金额为一次性 **5 美元**,不是每个插件都再收一次;支付时以当时页面金额和币种为准 | +| 付款方式 | 提前准备付款页支持、能完成国际线上支付的银行卡,常见是国际信用卡;支持哪些卡种、是否支持借记卡及地区要求须在实际付款页核实,不承诺任何一张卡必定可用 | +| 验证和资料 | 两步验证、发布者名称和已验证联系邮箱;Trader/Non-Trader 等声明按实际用途由发布者决定 | + +Chrome 官方注册说明要求注册及一次性费用;2026-04-28 的官方公告明确新发布者仍需 5 美元注册费。当前官方注册文档没有给出适用于所有地区的完整银行卡清单,不能拿 Google Play 开发者的付款规则代替 Chrome Web Store,也不能把“只支持国际信用卡”当已核实的普遍规定。来源:[注册](https://developer.chrome.com/docs/webstore/register)、[5 美元官方说明](https://developer.chrome.com/blog/cws-role-expansion-developer-dashboard)、[账号设置](https://developer.chrome.com/docs/webstore/set-up-account)。 + +只保存完成/待办、非敏感页面结果及必要提交证据。用户在官方页面完成账号验证、银行卡信息、付款确认及真实身份声明,不在对话或项目中收集密码、验证码、完整卡号和证件内容。 + +## 先形成可交付材料 + +准备票关联独立验收票。AI 生成以下内容放在 `release//` 等明确位置,不让新手拼装;ZIP 和检查报告须在构建目录外。 + +| 交付 | 内容 | +| --- | --- | +| ZIP 和报告 | manifest 根目录、正式文件、版本、哈希、静态检查及警告处理 | +| 可粘贴文案 | 名称、简述、详细功能、用法、分类、语言、实际限制;对应后台字段 | +| 素材 | 最终图标、当前候选版本真实功能截图、小宣传图;标注用途 | +| 权限与数据清单 | 权限→功能;读什么→在哪处理→保存多久→给谁→如何清除,涵盖依赖和第三方服务 | +| 隐私及支持 | 按实现生成;适用公开链接未登录可访问,不指向私有仓库 | +| 发布安排 | 人群、地区、自动/手动发布、发布者待办及实际授权依据 | +| 发布记录和教程 | 候选版本、验收、提交/审核证据、下一步、最终入口及首条操作 | + +只要求材料时交付到此范围;要求上线则持续推进,不把材料完成当目标完成。 + +## 包和素材 + +`release_bundle.py check` 后处理错误及警告,`pack` 生成新 ZIP;更新传 `--previous-version`。静态脚本覆盖有限,结合代码、依赖、实际浏览器检查补全,解压最终 ZIP 加载并让证据关联同一哈希。 + +Chrome 基线为 Manifest V3,ZIP 根直接有 manifest.json;版本依官方整数格式递增,改显示版名不足以升级。正式目录排除开发服务器依赖、依赖源码目录、私钥和环境文件,核对运行引用。[准备上传](https://developer.chrome.com/docs/webstore/prepare)、[版本规则](https://developer.chrome.com/docs/extensions/reference/manifest/version) + +远程代码扫描只能给线索,复核依赖及动态加载;网络 URL 不自动等于远程执行,关键词无命中也不证明完全没有。[MV3 政策](https://developer.chrome.com/docs/webstore/program-policies/mv3-requirements) + +素材基线:包内 128×128 PNG 图标,1–5 张 1280×800 或 640×400 真实截图,440×280 小宣传图;1400×560 大宣传图可选。功能截图不以概念生图冒充。视频字段若官方页面措辞不一致,核实当前后台必填性并记录,不臆造硬阻塞。[图片规范](https://developer.chrome.com/docs/webstore/images)、[商店字段](https://developer.chrome.com/docs/webstore/cws-dashboard-listing) + +## 账号及数据披露 + +发布者需开发者注册和费用支付、两步验证、发布者和联系资料、适用 Trader/Non-Trader 声明及验证。注册费按上文给出 5 美元参考,实际金额及付款方式再看当前页;身份声明按实际情况由发布者决定。官网验证、地址等按业务区分必须/条件要求/可选,不一律要求购买域名。[注册](https://developer.chrome.com/docs/webstore/register)、[账号设置](https://developer.chrome.com/docs/webstore/set-up-account)、[两步验证](https://developer.chrome.com/docs/webstore/program-policies/two-step-verification)、[身份声明](https://developer.chrome.com/docs/webstore/program-policies/trader-verification-faq) + +账号准备与开发可并行。本人登录、验证码、付款、身份操作在官方界面逐步完成并核实;只记完成/待办,不保存密码、验证码、证件内容,不提前索取全部凭证。 + +用途清楚聚焦、最小权限且逐项解释。网页文字、网址、截图仅在本机处理也可能是用户数据处理,不能自动填无数据收集。涉及用户数据处理时核实并满足公开隐私政策等要求,真正不处理数据时区别官方义务和自愿说明。[隐私字段](https://developer.chrome.com/docs/webstore/cws-dashboard-privacy)、[用户数据 FAQ](https://developer.chrome.com/docs/webstore/program-policies/user-data-faq) + +从代码及依赖形成数据清单,对照实际网络行为、后台勾选和公开政策保持一致;适用的披露、同意、安全处理和 Limited Use 依当前官方要求落实,不照搬通用政策。[开发者政策](https://developer.chrome.com/docs/webstore/program-policies/policies#protecting-user-privacy) + +## 内置浏览器协助填写与素材等待 + +发布意图明确且用户已提供相关信息后,由 AI 使用内置浏览器或当前获准的发布浏览器会话进入官方开发者后台,准备并填写名称、简述、详细介绍、语言、分类、支持/隐私链接、权限理由、图标、截图及宣传图。能根据真实实现生成的文案由 AI 准备,缺少真实发布者信息或重要分发决定才询问;不让用户重新逐字段输入已经提供的内容。 + +测试通道与发布通道区分:插件 E2E 仍默认 Playwright MCP;登录商店、填写资料、上传用户授权素材可以使用内置浏览器,不把“发布表单能打开”记作插件验收。复用用户已有合法会话,按工具当时的登录、付款和协议操作规则处理;不修改现有全局浏览器服务来填一个表单。 + +先在项目里准备可审阅的字段和素材,再按已有授权填写、保存草稿或提交。读取实际后台页面以确定字段和按钮,上传后核对预览、名称和保存状态,记录当前停留的草稿/表单位置。用户已经授权并提供相同范围资料时不重复询问常规填写、选图或保存动作;实际提交/公开发布仍对应明确的发布安排与当时工具权限。 + +素材按以下分支执行: + +1. 用户已有图片:先检查内容、尺寸及用途,对应到图标、截图、小宣传图等字段;只做所需裁切或格式调整。 +2. 需要生成图标或宣传图:读取并执行 imagegen 或用户明确选用的实际生成技能,检查输出可用性。功能截图必须取自当前真实插件,不能生成一张假界面替代。 +3. 生成能力不存在、调用失败或生成结果不可用:保存 `release.assets.status=waiting_user_assets`、`waiting_for_user_upload=true` 和具体缺项,**停在素材这一步,直接请用户上传需要的图片文件**,同时说明字段、尺寸及数量。不要使用仅能接收文字的提问工具索要文件,不放占位图、旧图或空白图,不擅自改用其他付费服务。 +4. 等待期间保留已填字段、已生成文件和当前后台位置;可以完成独立的本地文字整理,但不能将缺图资料标为可提交,也不能跳过上传完成发布。收到用户文件后检查并填回对应字段,再恢复到原来的步骤,不重新开始整个流程。 + +默认需要的图片和尺寸见上文“包和素材”;例如缺小宣传图时明确要求 440×280,而不是泛泛说“自己准备图片”。素材来源、实际生成/上传结果、失败原因和用户补充文件均记录在发布票及 CHROMEWEBSTORE.md。 + +## 提交及状态 + +先做好候选包、文案、隐私及发布安排,再处理尚缺授权。已授权动作继续执行,不重复确认;未获授权的外部写入不能因材料已好就代办。缺重要决定时展示这些可审阅成果及当前影响。 + +Public 面向所选地区公开展示;Unlisted 不公开列出但持链接者都可安装,不能称私密;Private 限定测试者/群组等。各方式仍遵守政策及审核。[分发设置](https://developer.chrome.com/docs/webstore/cws-dashboard-distribution) + +通过可用官方界面或连接器依授权上传、填写、提交并读回结果;没有操作通道则给可粘贴内容及当前一步。不要猜后台 API,不把上传成功当已提交。受限功能按需给审核说明,不将私人长期凭证打入包。[测试说明](https://developer.chrome.com/docs/webstore/cws-dashboard-test-instructions) + +```text +待提交 → 已提交审核 → 被拒待修复 → 修复、相关复验后重提 + → 审核通过待发布 → 已发布待安装验证 + → 自动发布后:已发布待安装验证 +已发布待安装验证 → 目标用户从商店安装并完成首用 → 已验证可安装 +``` + +支持审核后自动或手动发布;手动发布窗口基线 30 天,执行时核实后台截止时间。不承诺审核固定天数或保证通过。[首次发布](https://developer.chrome.com/docs/webstore/publish)、[审核流程](https://developer.chrome.com/docs/webstore/review-process) + +记录包、商店项目 ID、提交时间/凭据、观察时间、审核反馈、下一步及负责人。跨会话监控仅在宿主确实支持且已授权时安排,无变化时安静;否则明确保存供下次检查。等待期不是失败也不是完成。 + +商店出现后以目标受众入口安装,核对版本、权限提示、核心操作才报告可安装。受限分发用目标授权身份核查,不强求未登录安装;公开素材和政策仍检查未登录访问。 + +## 拒审、更新和交接 + +保存拒审理由摘要及官方引用,关联修复票或有依据申诉;代码/权限/数据改变后相关复验并同步材料再提交,不反复提交同一被拒包。 + +更新沿用同一商店项目,交完整新包并递增版本,检查旧数据、原功能、新权限和介绍;审核中不能说线上已更新。线上问题按当时官方回退流程和授权处理,不把旧 ZIP 重传当必然降级。[更新说明](https://developer.chrome.com/docs/webstore/update) + +保留源码可恢复版本、已发布包哈希、商店项目/版本、真实入口、短教程和反馈渠道。后续问题回到复现及修复票,不跳过记录直接更改线上承诺。 diff --git a/skills/browser-extension-launch/references/required-skills.json b/skills/browser-extension-launch/references/required-skills.json new file mode 100644 index 0000000000..26315dc0ff --- /dev/null +++ b/skills/browser-extension-launch/references/required-skills.json @@ -0,0 +1,153 @@ +{ + "schema_version": 1, + "verified_at": "2026-09-11", + "skills": [ + { + "name": "chrome-extensions", + "required_when": "extension-development-and-publishing", + "bundled_substitute_allowed": false, + "source": { + "repo": "GoogleChrome/modern-web-guidance", + "path": "skills/chrome-extensions", + "ref": "bfd8c8dded770f3ba07a518e28991a32df40f902", + "license": "Apache-2.0", + "entry_sha256": "c67d9e4ba9da93ed0da8b0147235e5890aa24e659548f65b7765c5af0a470c3f" + }, + "verified_local_entry_sha256": "c67d9e4ba9da93ed0da8b0147235e5890aa24e659548f65b7765c5af0a470c3f", + "local_version_policy": "reuse compatible installed copy; do not assume local copy equals upstream revision" + }, + { + "name": "extension-create", + "required_when": "new-project", + "bundled_substitute_allowed": false, + "source": { + "repo": "quangpl/browser-extension-skills", + "path": "skills/extension-create", + "ref": "249886cf8137086792b6c3a5a2f7adce117a9f8d", + "license": "MIT", + "entry_sha256": "579b05e875ae6b63ce98aad54e4e0a4ad0b55afb3779e77f3d9cc511e99fc1f2" + }, + "verified_local_entry_sha256": "579b05e875ae6b63ce98aad54e4e0a4ad0b55afb3779e77f3d9cc511e99fc1f2", + "local_version_policy": "reuse compatible installed copy; do not assume local copy equals upstream revision" + }, + { + "name": "setup-matt-pocock-skills", + "required_when": "complex-project-setup", + "bundled_substitute_allowed": false, + "source": { + "repo": "mattpocock/skills", + "path": "skills/engineering/setup-matt-pocock-skills", + "ref": "3cca18b368ae95cdbdebbff572ccafa662551015", + "license": "MIT", + "entry_sha256": "2bcd89e97777cdb705914424e39c97d5db524c8eb4eafac8120778a07774f0ec" + }, + "verified_local_entry_sha256": "310fb1a73c0467e617e17d7c41d4a2278b5405c4a27f36d7cd22bb4599aee6bb", + "local_version_policy": "reuse compatible installed copy; do not assume local copy equals upstream revision" + }, + { + "name": "to-spec", + "required_when": "complex-spec-and-parent-ticket", + "bundled_substitute_allowed": false, + "source": { + "repo": "mattpocock/skills", + "path": "skills/engineering/to-spec", + "ref": "3cca18b368ae95cdbdebbff572ccafa662551015", + "license": "MIT", + "entry_sha256": "43ad9cf318e5e7d3d1fa360253a37021796dc87a0c2e595ad262661a10f85088" + }, + "verified_local_entry_sha256": "5d26479544b08048d3a8f79d937b39bc613a617f026b3fd083bafc1e99a7b811", + "local_version_policy": "reuse compatible installed copy; do not assume local copy equals upstream revision" + }, + { + "name": "to-tickets", + "required_when": "complex-ticket-splitting", + "bundled_substitute_allowed": false, + "source": { + "repo": "mattpocock/skills", + "path": "skills/engineering/to-tickets", + "ref": "3cca18b368ae95cdbdebbff572ccafa662551015", + "license": "MIT", + "entry_sha256": "5c9fba69845c2519b9b35b9af42ae5142c21f8ca15ac2123dc2722002c8058ae" + }, + "verified_local_entry_sha256": "5ecdf1d4df8a360ed39df21a2347f97ba177afd449a577da4f6b6ea8e1ebb808", + "local_version_policy": "reuse compatible installed copy; do not assume local copy equals upstream revision" + }, + { + "name": "implement", + "required_when": "complex-ticket-implementation", + "bundled_substitute_allowed": false, + "source": { + "repo": "mattpocock/skills", + "path": "skills/engineering/implement", + "ref": "3cca18b368ae95cdbdebbff572ccafa662551015", + "license": "MIT", + "entry_sha256": "6d3fd9e83b8f36e5213854779db49b256a457a7ebb4a503e53fa7dcff696adc3" + }, + "verified_local_entry_sha256": "6d3fd9e83b8f36e5213854779db49b256a457a7ebb4a503e53fa7dcff696adc3", + "local_version_policy": "reuse compatible installed copy; do not assume local copy equals upstream revision" + }, + { + "name": "diagnosing-bugs", + "required_when": "on-failure", + "bundled_substitute_allowed": false, + "source": { + "repo": "mattpocock/skills", + "path": "skills/engineering/diagnosing-bugs", + "ref": "3cca18b368ae95cdbdebbff572ccafa662551015", + "license": "MIT", + "entry_sha256": "77f3cf31bc99b2f49af943222526531fcc9fc41d047626d3640e875e85af3e84" + }, + "verified_local_entry_sha256": "b9339b09ee3980808d8c5a35c7251b891b8b1e0036ec4ca37812b976ebddf6b6", + "local_version_policy": "reuse compatible installed copy; do not assume local copy equals upstream revision" + }, + { + "name": "code-review", + "required_when": "implementation-and-release-review", + "bundled_substitute_allowed": false, + "source": { + "repo": "mattpocock/skills", + "path": "skills/engineering/code-review", + "ref": "3cca18b368ae95cdbdebbff572ccafa662551015", + "license": "MIT", + "entry_sha256": "47f4e52c21694def9c7c11cbfbf891ca35eac7a93e395797515be3c8a409ae50" + }, + "verified_local_entry_sha256": "9cf46653dd9c710ea1e6c22423caf31a794c88773bc94bdaa23140277f470442", + "local_version_policy": "reuse compatible installed copy; do not assume local copy equals upstream revision" + } + ], + "conditional_host_skills": [ + "tdd", + "triage", + "domain-modeling", + "ui-designer", + "motion-designer", + "imagegen", + "modern-web-guidance" + ], + "mcp": { + "preferred_provider": "playwright-mcp", + "package": "@playwright/mcp", + "version": "0.0.80", + "repo": "microsoft/playwright-mcp", + "ref": "8a13ef8e9f7385a0f89477922127f31cbfde9761", + "license": "Apache-2.0", + "node_requirement": ">=18", + "environment_fallback_requires_user_decision": true, + "configuration_reference": "playwright-mcp.md" + }, + "required_host_skills": [ + { + "name": "product-designer", + "required_when": "new_or_changed_user_flow_and_experience_acceptance", + "bundled_substitute_allowed": true, + "source": { + "kind": "host-installed", + "entry": "product-designer/SKILL.md", + "upstream_status": "not_verified" + }, + "verified_local_entry_sha256": "d0995fcc2a4accdb7df4a47d01a7263af6423298db95b76f8eb186a7b95caf74", + "portable_fallback": "product-loop.md", + "missing_policy": "prefer verified host skill; otherwise execute and record the bundled product-loop portable fallback, never invent a repository" + } + ] +} diff --git a/skills/browser-extension-launch/references/workflow.md b/skills/browser-extension-launch/references/workflow.md new file mode 100644 index 0000000000..d7888ea720 --- /dev/null +++ b/skills/browser-extension-launch/references/workflow.md @@ -0,0 +1,71 @@ +# 任务、决定和恢复 + +新想法、需求变化、任务调度及继续项目时读取。先由主代理判断复杂度并记录理由;复杂项目必须执行 [复杂插件编排](complex-workflow.md) 的 setup → to-spec → to-tickets → implement 链。新项目默认本地工单,不连接 GitHub;已有仓库的任务规范优先。 + +## 必要问题 + +从原话、项目和已有决定查明:谁用、在哪使用、做什么得到什么、数据如何保存及流出、第一版边界。事实自行检查,可逆实现自行决定,不把技术选型列成问卷。 + +“保存句子”可建议“选一句话保存,关掉再打开还能找到,先放这台电脑”;跨电脑使用影响范围时才进一步问同步。不得悄悄上传网页内容,也不要开场索要发布账号。 + +重要问题先给具体建议和代价,再问实际选择。决定记录:编号、问题、回答或暂定方案、依据(用户原话/项目约定/AI假设)、影响的规格和验收、替代哪个旧决定。明确答案直接采用,AI 建议保留暂定身份。 + +新建/改变使用流程先执行 product-designer,按 [产品使用闭环](product-loop.md) 将首次到再次使用、退出和恢复写入同一规格,形成体验 AC,再进行工单拆分。基本动作由 AI 决策,重要体验取舍才询问;不把所有设计问题交给新手。 + +规格包含一句话用途、用户故事、范围内外、入口到结果、失败时保留什么、数据及费用、验收、未决事项。信息足够就开始,不制造额外确认关卡。新功能先更新范围和依赖再实现。 + +## 文件和权威来源 + +`project.py init` 建立 `state.json`(机器索引)、`progress.md`(简短进度)、`spec.md`、`decisions.md`、`tasks/`、`evidence/`。按目标传 `--goal local|materials|live`;先自用或只要材料时,其余发布模板标为范围外,不阻止当前主任务收口。模板须细化后执行;本地任务本身是正式依据,无需转成远程工单。 + +简单项目的任务正文承载范围和逐条证据。复杂项目由 to-spec/to-tickets 在 tracker 中生成真正的规格和单票文件,`.extension-launch/` 仅记录调度索引,不能把初始化模板当成已发布的技能产物。`state.json` 不能覆盖权威正文;规格保留产品承诺。主代理汇总状态,并行代理仅写各自任务、实现及证据,避免并发覆盖。 + +更新状态和关键记录先保留上次有效内容,再用同目录临时文件原子替换。备份不进入运行包。结束一轮时同步进度摘要,不能只靠对话。 + +## 任务最小字段及关闭条件 + +每票有稳定编号、类型、父票、前置依赖、状态、范围内外、所需能力及状态、验收编号、候选版本/变更起点、证据、审查、未完成事项、关闭条件和下一步。已有票能涵盖的小修改复用,不重复制造全套任务。 + +| 类型 | 最少内容 | 关闭条件 | +| --- | --- | --- | +| 主任务 | 问题、范围、决定、子票和整体承诺 | 承诺内实施及独立真实验收通过,有当前文件的 `gate-report`;若目标上线,还需商店首用证据 | +| 实施 | 可体验功能、依赖、改动边界、验收 | 当前文件真实安装、原生入口的核心操作及适用的关闭重开通过;审查、可恢复版本与 `gate-report` 齐全 | +| 独立验收 | 当前候选包、依赖、跨功能矩阵、环境和证据 | 必需真实场景通过,回归修复复验,验收门禁通过;待测不能关闭 | +| 发布准备 | 包、素材、文案、权限数据、公开链接、账号事项 | 最终 ZIP 解压后真实验收及门禁通过,约定准备交付完成;尚待本人事项继续跟踪 | +| 上线跟踪 | 分发安排、提交记录、反馈、商店入口 | 目标人群实际安装并完成首用 | + +按完整用户功能拆分,如“保存并重开找回”;前置依赖完成才能实施。文件不重叠且契约稳定可并行,冲突实现串行。分流原因(缺信息/可执行/需本人)和进度(实现中/审核中)分开,不混成一组顺序关卡。 + +模拟测试或包检查通过不能把实施票推进为完成。必须默认使用 Playwright MCP 加载实际产物并完成原生入口端到端验证;环境不支持时按依赖参考询问安装/启用或替代选择,未取得选择不能跳过或静默换工具。真实功能失败则必须执行 diagnosing-bugs 复现、诊断、修复及复验;受控加载工具未就绪则将验收关卡及依赖交付设为 `blocked` 并继续修通,未经用户选择不切换日常 Chrome。本人仅处理确实无法自动化的原生环节,给出当前一步后核实结果并接回验证,不能仅转交人工清单就关闭主任务。已有测试授权持续有效,不增加常规批准票。 + +任务证据须链接当前 `candidate` 指纹、真实验收 JSON 和新的 `gate-report.json`。验收 JSON 至少覆盖 `install`、`native_entry`、`primary_flow`、`reopen`、`repeat_use`;按 [验收参考](acceptance.md) 执行 `acceptance_gate.py check BUILD_DIR --evidence JSON --report NEW_PATH`。仅返回 `gate_passed: true` 可满足门禁条件,静态包报告不充当该报告;该命令不代替主代理回填任务状态。 + +## 版本与审查 + +验收条目用“前置条件 → 操作 → 预期结果 → 验证及证据”,以 AC-01 等稳定编号关联规格、实施和验证,不只有勾选框。 + +改动前记录旧功能和版本基线。已有本地 Git 时沿用;没有时建立源码快照及恢复说明,不需要远程账号。快照包含恢复所需源码和依赖锁定信息,排除账号秘密、缓存和无关私人数据。运行包 ZIP 不能替代复杂项目源码备份。 + +每张实施票和候选版本必须真正执行 code-review,分别回答“符合项目规范吗”“实现规格承诺了吗”,保留两个独立审查结果。主代理预先提供固定基线、覆盖新文件的真实差异和本地规格,原生 Local Markdown tracker 无需 GitHub。确需工作区或快照差异适配时写明方法,不能用空 diff 或包内简短审查代替该技能。审查通过也不等于真实使用验收。 + +审查前按 [高效交付](efficient-delivery.md) 备齐交付文件、当前证据和标为待审查的报告;同一候选的批量审查须明确覆盖各票,不因归档更新重审产品。验收和双轴审查通过后一次更新必要记录并交付,禁止为收尾文档重新运行全部检查。 + +## 恢复及可选同步 + +读取索引后核实项目、版本、改动、票和证据存在性、包哈希、待答问题、能力是否仍可用。文件存在不证明内容正确;源代码或包变化后按影响范围重验,矛盾以实际证据纠正。 + +保存下一条具体动作和阻塞条件。重新加载宿主后验证能力生效再继续;换工具需打开同一项目,不承诺自动共享对话。 + +只有用户选择 GitHub 协作后才按授权同步具体仓库和范围,维护本地/远程编号映射、去重及失败状态,并明确权威任务系统。上传范围不默认含私人测试数据或全部项目文件。 + +## 用户要求保存示例时 + +为每场演练建立独立 `examples/<编号-名称>/`,至少包含两个部分:完整的 `extension/`,以及 `conversation.md`。相关需求、决定、测试、独立审查、失败和验收证据放入 `records/`,与运行文件分开。不能只列几个选题或复制同一插件换名充数。 + +用户要求独立 CLI 或完整会话时,按 [高效交付](efficient-delivery.md) 的专门约定保存主线程实际输入、公开 AI 回复及原始文件;完整记录要求不能用关键往返摘录替代。 + +会话记录注明日期、来源和完整度。能取得原始消息则保留实际需求及关键往返;只有可见摘录时写“整理稿”,不得补写成逐字原文。为说明流程而写的对话明确标“演示”,场景角色的回答不能当作真实用户授权或实测结论。记录实际实现选择、检查结果、修复以及下一步,并链接证据。 + +把临时产物迁入例子目录,核对源文件哈希,保留有用原始证据;原始未回填进度原样存档并另写当前状态。更新索引和包内链接后,默认使用 Playwright MCP(或用户在环境不支持时明确选择的受控替代)加载迁入的当前实际产物,完成原生入口端到端核心操作、适用关闭重开及验收门禁,把 `gate-report` 和实际证据收入 `records/`,才将例子标为可用并关闭交付。 + +已有例子缺真实验收时,如实归档旧状态并继续补做;不能把“未验证”作为正常完成的交付出口,也不能补写对话让演练显示完成。确有本人操作依赖则保留 `blocked`。仅用户明确只要源码或要求中止时,按该范围交出标为未验收的源码。商店验证只在要求的发布范围内执行,不把本地实测写成商店通过。 diff --git a/skills/browser-extension-launch/scripts/acceptance_gate.py b/skills/browser-extension-launch/scripts/acceptance_gate.py new file mode 100644 index 0000000000..1c1ecd1f5a --- /dev/null +++ b/skills/browser-extension-launch/scripts/acceptance_gate.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +"""Check real-browser acceptance records against the exact current extension. + +This gate validates record completeness, artifact locations, and file hashes. +It cannot certify that a person or browser tool actually performed an operation. +Produce the report during the real browser workflow; never fabricate passed +records from this checker, a mock, a static review, or an unexecuted template. + +Artifact paths are relative to the evidence JSON's directory and must stay +inside it. Put browser screenshots, recordings and logs beneath that directory. +The build directory must be a clean runnable extension containing manifest.json; +every file, including hidden files, participates in its fingerprint. +""" + +import argparse +import hashlib +import json +import os +from pathlib import Path, PurePosixPath, PureWindowsPath +import stat +import sys + + +SCHEMA_VERSION = 1 +REQUIRED_SCENARIOS = frozenset(("install", "native_entry", "primary_flow", "reopen", "repeat_use")) +ALLOWED_METHODS = frozenset(("real_browser_tool", "witnessed_manual")) +ALLOWED_ENTRIES = frozenset(( + "native_action", "context_menu", "keyboard_command", + "native_side_panel", "browser_extension_entry", +)) +ALLOWED_INSTALLATIONS = frozenset(("unpacked", "store", "enterprise")) +DEFAULT_E2E_PROVIDER = "playwright-mcp" +ALLOWED_PROVIDERS = frozenset((DEFAULT_E2E_PROVIDER, "chrome-devtools-mcp", "browser-mcp", "controlled-browser")) + + +def json_text(value): + return json.dumps(value, ensure_ascii=False, indent=2) + "\n" + + +def nonempty_text(value): + return isinstance(value, str) and bool(value.strip()) + + +def safe_relative_path(value): + if not nonempty_text(value) or "\\" in value or "\x00" in value: + return False + path = PurePosixPath(value) + return (not path.is_absolute() and not PureWindowsPath(value).drive + and value == path.as_posix() and all(part not in (".", "..") for part in path.parts) + and bool(path.parts)) + + +def sha256_file(path): + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def fingerprint_build(build_directory): + """Return the candidate object to copy into a real acceptance report.""" + supplied = Path(build_directory).expanduser() + if supplied.is_symlink(): + raise ValueError("候选运行目录不能是符号链接。") + root = supplied.resolve(strict=True) + if not root.is_dir(): + raise ValueError("候选运行目录不存在或不是目录。") + manifest = root / "manifest.json" + if not manifest.is_file() or manifest.is_symlink(): + raise ValueError("候选运行目录根部必须有非符号链接的 manifest.json。") + files = [] + + def fail_walk(error): + raise error + + for current, directories, filenames in os.walk(root, followlinks=False, onerror=fail_walk): + for name in directories: + path = Path(current) / name + if path.is_symlink(): + raise ValueError(f"候选目录包含符号链接:{path.relative_to(root).as_posix()}") + for name in filenames: + path = Path(current) / name + if path.is_symlink() or not stat.S_ISREG(path.stat().st_mode): + raise ValueError(f"候选目录含非普通文件:{path.relative_to(root).as_posix()}") + path.resolve(strict=True).relative_to(root) + files.append({"path": path.relative_to(root).as_posix(), "sha256": sha256_file(path)}) + files.sort(key=lambda item: item["path"]) + canonical = json.dumps(files, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return {"fingerprint_sha256": hashlib.sha256(canonical.encode("utf-8")).hexdigest(), "files": files} + + +def check_artifact(root, relative): + """Check location and nonempty regular file only; do not read its contents.""" + if not safe_relative_path(relative): + raise ValueError("证据路径必须是 JSON 同目录内的规范子路径,不允许绝对路径或 ../。") + path = root + for part in PurePosixPath(relative).parts: + path = path / part + if path.is_symlink(): + raise ValueError("证据路径不允许符号链接。") + path.resolve(strict=True).relative_to(root) + details = path.stat() + if not stat.S_ISREG(details.st_mode) or details.st_size == 0: + raise ValueError("证据必须是非空的普通文件。") + + +def validate_candidate(recorded, current, errors): + if not isinstance(recorded, dict): + errors.append("candidate 缺失或不是对象;请从 fingerprint 命令取得真实候选目录指纹。") + return + entries = recorded.get("files") + if not isinstance(entries, list) or not entries: + errors.append("candidate.files 必须记录全部候选运行文件及 SHA-256。") + return + recorded_files = {} + for entry in entries: + if not isinstance(entry, dict) or not safe_relative_path(entry.get("path")): + errors.append("candidate.files 中存在无效的文件路径。") + return + name, digest = entry["path"], entry.get("sha256") + if name in recorded_files: + errors.append(f"candidate.files 重复记录文件:{name}") + return + if not isinstance(digest, str) or len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest): + errors.append(f"candidate.files 文件指纹无效:{name}") + return + recorded_files[name] = digest + current_files = {item["path"]: item["sha256"] for item in current["files"]} + if recorded_files != current_files: + added = sorted(set(current_files) - set(recorded_files)) + removed = sorted(set(recorded_files) - set(current_files)) + changed = sorted(name for name in current_files.keys() & recorded_files.keys() + if current_files[name] != recorded_files[name]) + errors.append(f"候选运行文件与验收记录不一致;新增={added},缺少={removed},变化={changed}。请重验受影响场景并记录当前版本。") + if recorded.get("fingerprint_sha256") != current["fingerprint_sha256"]: + errors.append("候选目录 fingerprint_sha256 已改变或缺失,不能沿用旧验收记录。") + + +def validate_browser_choice(report, evidence_path, errors): + """Require the default MCP or an evidenced user choice of a real alternative.""" + environment = report.get("environment") + provider = environment.get("automation_provider") if isinstance(environment, dict) else None + if not isinstance(provider, str) or provider not in ALLOWED_PROVIDERS: + errors.append("environment.automation_provider 必须为 playwright-mcp、chrome-devtools-mcp、browser-mcp 或 controlled-browser;缺失、模拟和纯手工供应商不能通过。") + return None + choice = report.get("browser_choice") + if provider == DEFAULT_E2E_PROVIDER and choice is None: + return None + if not isinstance(choice, dict): + errors.append("非默认验收工具必须记录 browser_choice:先说明 Playwright MCP 不支持的原因并请用户选择,不能静默切换。") + return None + if choice.get("default_provider") != DEFAULT_E2E_PROVIDER: + errors.append("browser_choice.default_provider 必须为 playwright-mcp。") + if choice.get("selected_provider") != provider: + errors.append("browser_choice.selected_provider 必须与 environment.automation_provider 一致。") + if not nonempty_text(choice.get("reason")): + errors.append("browser_choice.reason 必须说明默认 Playwright MCP 不支持的具体原因。") + decision = choice.get("user_decision") + if not isinstance(decision, dict) or decision.get("status") != "approved": + errors.append("browser_choice.user_decision 必须记录用户已同意的选择 status=approved;沉默或 AI 自行决定不算授权。") + return None + relative = decision.get("evidence_path") + try: + check_artifact(evidence_path.parent, relative) + if (evidence_path.parent / relative).resolve() == evidence_path: + raise ValueError("验收汇总 JSON 不能充当用户选择的原始证据。") + except (OSError, ValueError, TypeError) as error: + errors.append(f"browser_choice.user_decision.evidence_path 无效:{error}") + return None + return relative + + +def check_acceptance(build_directory, evidence_file): + errors = [] + result = { + "schema_version": SCHEMA_VERSION, + "gate_passed": False, + "candidate_fingerprint_sha256": None, + "required_scenarios": [], + "checked_artifacts": 0, + "state_updated": False, + "claims_authenticated": False, + "errors": errors, + "message": "仅核对真实验收报告的完整性、证据可核查性和版本对应;不公证操作真实性,不修改项目状态。", + } + try: + current = fingerprint_build(build_directory) + result["candidate_fingerprint_sha256"] = current["fingerprint_sha256"] + except (OSError, ValueError) as error: + errors.append(str(error)) + return result + supplied = Path(evidence_file).expanduser() + if supplied.is_symlink(): + errors.append("验收 JSON 不能是符号链接。") + return result + try: + evidence_path = supplied.resolve(strict=True) + report = json.loads(evidence_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + errors.append(f"无法读取验收 JSON:{error}") + return result + if not isinstance(report, dict) or report.get("schema_version") != SCHEMA_VERSION: + errors.append("验收 JSON 必须是 schema_version=1 的对象。") + return result + validate_candidate(report.get("candidate"), current, errors) + choice_artifact = validate_browser_choice(report, evidence_path, errors) + + environment = report.get("environment") + if not isinstance(environment, dict): + errors.append("缺少 environment:需要真实浏览器名称、版本、扩展 ID 和安装方式。") + else: + for field in ("browser_name", "browser_version", "extension_id"): + if not nonempty_text(environment.get(field)): + errors.append(f"environment.{field} 为空,不能证明对应浏览器与扩展。") + if environment.get("installation_type") not in ALLOWED_INSTALLATIONS: + errors.append("environment.installation_type 必须是 unpacked、store 或 enterprise。") + + required = report.get("required_scenarios") + if not isinstance(required, list) or not required or not all(nonempty_text(item) for item in required): + errors.append("required_scenarios 必须是非空的场景编号列表。") + required = [] + if len(required) != len(set(required)): + errors.append("required_scenarios 不允许重复编号。") + required_set = set(required) + result["required_scenarios"] = sorted(required_set) + missing_minimum = REQUIRED_SCENARIOS - required_set + if missing_minimum: + errors.append(f"必需场景清单缺少:{', '.join(sorted(missing_minimum))}。") + scenarios = report.get("scenarios") + if not isinstance(scenarios, list): + errors.append("scenarios 必须是实际执行场景记录列表。") + scenarios = [] + seen = set() + checked_paths = {choice_artifact} if choice_artifact else set() + for scenario in scenarios: + if not isinstance(scenario, dict) or not nonempty_text(scenario.get("id")): + errors.append("scenarios 中有缺少 id 的记录。") + continue + scenario_id = scenario["id"] + if scenario_id in seen: + errors.append(f"重复场景编号:{scenario_id}。") + continue + seen.add(scenario_id) + is_required = scenario_id in required_set + if scenario.get("required") is not is_required: + errors.append(f"{scenario_id} 的 required 与 required_scenarios 清单不一致。") + # Optional unexecuted scenarios are allowed; all claimed passes are checked. + if not is_required and scenario.get("status") != "passed": + continue + if scenario.get("status") != "passed": + errors.append(f"必需场景 {scenario_id} 尚未 passed。") + if scenario.get("method") not in ALLOWED_METHODS: + errors.append(f"{scenario_id} 必须来自 real_browser_tool 或 witnessed_manual;模拟、静态检查或未执行模板不能替代。") + steps = scenario.get("steps") + if not isinstance(steps, list) or not steps or not all(nonempty_text(step) for step in steps): + errors.append(f"{scenario_id} 缺少实际执行步骤 steps。") + for field in ("expected", "actual"): + if not nonempty_text(scenario.get(field)): + errors.append(f"{scenario_id} 缺少 {field} 的可观察结果。") + if scenario_id == "native_entry" and scenario.get("entry_kind") not in ALLOWED_ENTRIES: + errors.append("native_entry 缺少真实入口类型 entry_kind;直接打开 popup.html 或普通标签页不算原生入口。") + if scenario_id == "repeat_use": + rounds = scenario.get("rounds") + if (not isinstance(rounds, list) or len(rounds) < 2 + or any(not isinstance(item, dict) + or not nonempty_text(item.get("entry")) + or not nonempty_text(item.get("outcome")) for item in rounds)): + errors.append("repeat_use 必须记录至少两轮实际使用,每轮包含 entry 和 outcome。") + for field in ("continuation", "exit_or_recovery"): + if not nonempty_text(scenario.get(field)): + errors.append(f"repeat_use 缺少 {field}:需记录可发现的继续路径与实际退出或恢复结果。") + artifacts = scenario.get("artifactPaths") + if not isinstance(artifacts, list) or not artifacts: + errors.append(f"{scenario_id} 缺少浏览器证据 artifactPaths。") + continue + for relative in artifacts: + try: + check_artifact(evidence_path.parent, relative) + if (evidence_path.parent / relative).resolve() == evidence_path: + raise ValueError("验收汇总 JSON 不能充当自己的浏览器原始证据。") + checked_paths.add(relative) + except (OSError, ValueError) as error: + errors.append(f"{scenario_id} 证据 {relative!r} 无效:{error}") + missing_records = required_set - seen + if missing_records: + errors.append(f"缺少必需场景实际记录:{', '.join(sorted(missing_records))}。") + result["checked_artifacts"] = len(checked_paths) + result["gate_passed"] = not errors + return result + + +def main(argv=None): + parser = argparse.ArgumentParser(description="检查当前插件构建是否具有完整且对应版本的真实浏览器验收记录。") + commands = parser.add_subparsers(dest="command", required=True) + fingerprint = commands.add_parser("fingerprint", help="输出 candidate 对象;只计算文件指纹,不生成 passed 记录") + fingerprint.add_argument("build_directory") + check = commands.add_parser("check", help="缺少真实验收记录或版本变化时返回非零状态") + check.add_argument("build_directory") + check.add_argument("--evidence", required=True, type=Path, help="真实浏览器流程生成的 JSON;证据文件放在其目录下") + check.add_argument("--report", type=Path, help="将校验结果写入新 JSON 文件;不会覆盖或修改主状态") + args = parser.parse_args(argv) + try: + if args.command == "fingerprint": + print(json_text(fingerprint_build(args.build_directory)), end="") + return 0 + result = check_acceptance(args.build_directory, args.evidence) + if args.report: + with args.report.expanduser().open("x", encoding="utf-8") as handle: + handle.write(json_text(result)) + print(json_text(result), end="") + return 0 if result["gate_passed"] else 1 + except (OSError, ValueError) as error: + print(json_text({"gate_passed": False, "errors": [str(error)], "state_updated": False}), + file=sys.stderr, end="") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/browser-extension-launch/scripts/project.py b/skills/browser-extension-launch/scripts/project.py new file mode 100644 index 0000000000..8a06bb7837 --- /dev/null +++ b/skills/browser-extension-launch/scripts/project.py @@ -0,0 +1,656 @@ +#!/usr/bin/env python3 +"""Create formal local records and inspect capabilities without changing a project. + +Python standard library only. Detection never proves host activation, a browser +connection, a passing acceptance check, or a live store release. +""" + +import argparse +import json +import os +from pathlib import Path +import platform +import re +import shutil +import sys +from datetime import datetime, timezone + + +SCHEMA_VERSION = 1 +RECORD_DIR = ".extension-launch" +DEFAULT_E2E_PROVIDER = "playwright-mcp" +REQUIRED_SKILLS = ( + ("product-designer", "product_experience", "new_or_changed_user_flow_and_experience_acceptance"), + ("chrome-extensions", "architecture", "all_projects"), + ("extension-create", "creation", "new_extension"), + ("diagnosing-bugs", "debugging", "failure_or_regression"), + ("code-review", "review", "each_implementation_and_final_candidate"), + ("setup-matt-pocock-skills", "project_setup", "complex_project_first_use_or_missing_configuration"), + ("to-spec", "specification", "complex_project"), + ("to-tickets", "ticket_decomposition", "complex_project"), + ("triage", "feedback_triage", "external_feedback_or_existing_tickets_need_triage"), + ("implement", "ticket_implementation", "each_complex_project_ticket"), +) +PORTABLE_FALLBACKS = { + "product-designer": "references/product-loop.md", +} +COMPLEX_CHAIN = ("setup-matt-pocock-skills", "to-spec", "publish-parent-ticket", "to-tickets", "implement") +METHODS = { + "dependencies": "references/dependencies.md", + "workflow": "references/workflow.md", + "development": "references/development.md", + "acceptance": "references/acceptance.md", + "publishing": "references/publishing.md", +} +BROWSER_COMMANDS = { + "chrome": ("google-chrome", "google-chrome-stable", "chrome", "chromium", "chromium-browser"), + "edge": ("microsoft-edge", "microsoft-edge-stable", "msedge"), + "firefox": ("firefox",), +} +TASK_DEFINITIONS = ( + ("T-001", "scope", "目标与首版范围", [], "needs_scope", "workflow", + "记录用户、网页场景、首版范围、范围外事项、已确认决定和验收标准。", + "范围内实施、独立验收、发布准备和上线跟踪全部满足各自关闭条件;未完成事项有明确处置。"), + ("T-002", "implementation", "首条完整用户功能", [], "needs_scope", "development", + "按 spec.md 做出从真实插件入口到可观察结果的一条完整路径。", + "功能实现、相关测试、真实浏览器路径及规范/规格审查通过;保存可追溯且可恢复的版本。"), + ("T-003", "acceptance", "独立完整体验检查", ["T-002"], "blocked", "acceptance", + "用最终候选包独立检查跨功能主路径、异常恢复和适用的旧数据、界面及权限场景。", + "所有必需场景均通过,失败已修复并复验;未验证不能算通过;证据与候选版本一致。"), + ("T-004", "release-preparation", "发布材料准备", ["T-003"], "blocked", "publishing", + "整理候选包、图标截图、商店文案、权限用途、数据说明、公开链接和具体账号待办。", + "本票约定材料完整并对应已验收版本;清楚列出尚待提交或本人操作的项目。准备好不等于已上线。"), + ("T-005", "launch-tracking", "提交与上线验证", ["T-004"], "blocked", "publishing", + "记录分发决定、具体发布授权、提交记录、审核反馈、安装入口及发布后首次使用证据。", + "目标用户可从所选渠道安装实际发布版本,核对版本并完成首条用户任务;有对应证据。"), +) +DELIVERY_GOALS = { + "local": { + "label": "在本机使用", + "required_tasks": ["T-002", "T-003"], + "outside_scope": ["T-004", "T-005"], + "close": "实施 T-002 和独立验收 T-003 满足各自关闭条件,能够在本机安装并使用;发布准备与上线跟踪不属于本次交付。", + }, + "materials": { + "label": "做好插件和发布材料", + "required_tasks": ["T-002", "T-003", "T-004"], + "outside_scope": ["T-005"], + "close": "实施 T-002、独立验收 T-003 和发布准备 T-004 满足各自关闭条件;提交与上线跟踪不属于本次交付。", + }, + "live": { + "label": "完成发布并验证可安装", + "required_tasks": ["T-002", "T-003", "T-004", "T-005"], + "outside_scope": [], + "close": "实施 T-002、独立验收 T-003、发布准备 T-004 和上线跟踪 T-005 全部满足各自关闭条件;目标渠道可安装且首用验证有证据。", + }, +} + + +def now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def dump(value): + return json.dumps(value, ensure_ascii=False, indent=2) + "\n" + + +def bundled_methods(): + skill_root = Path(__file__).resolve().parents[1] + return { + name: { + "kind": "bundled_method", + "source": relative_path, + "source_base": "skill_root", + "status": "available" if (skill_root / relative_path).is_file() else "missing", + "host_activation": "not_applicable", + "execution_proven": False, + } + for name, relative_path in METHODS.items() + } + + +def make_task(definition, goal="live", complexity="undetermined"): + task_id, task_type, title, dependencies, status, method, scope, close = definition + goal_settings = DELIVERY_GOALS[goal] + in_scope = task_id not in goal_settings["outside_scope"] + if task_id == "T-001": + close = goal_settings["close"] + elif not in_scope: + status = "not_in_scope" + scope_note = "属于本次交付范围。" if in_scope else "本次范围外,未执行。保留模板供以后明确扩展目标时使用,不是当前阻塞项。" + remaining = "本票范围尚未交付。" if in_scope else "本票不在本次范围内,无当前交付待办。" + next_action = "核对前置条件,并替换与本票实际范围有关的待填项。" if in_scope else "无需执行;以后扩大目标时先更新规格、依赖和交付目标。" + parent = "none" if task_id == "T-001" else "T-001" + children = "T-002, T-003, T-004, T-005" if task_id == "T-001" else "none" + precondition = ( + "读取用户原话和已有约定,明确当前阻塞范围的问题。" + if task_id == "T-001" else + "spec.md 中首版范围、首条成功路径及当前重要决定已明确。" + if task_id == "T-002" else + "前置任务已满足各自关闭条件,证据与当前候选版本一致。" + ) + required_skills = { + "scope": "优先由 product-designer 定义进入、操作、结果、再次使用、退出与恢复的体验 AC;当前宿主没有该技能时执行 references/product-loop.md portable fallback 并记录。使用 chrome-extensions;新建插件使用 extension-create;复杂项目首次或配置缺失使用 setup-matt-pocock-skills,使用 to-spec 发布正式主票后再使用 to-tickets;仅外部反馈或旧票待分流时使用 triage。", + "implementation": "chrome-extensions;新建插件使用 extension-create;故障时使用 diagnosing-bugs;完成后使用 code-review;复杂项目每张实施票必须使用 implement。", + "acceptance": "优先由 product-designer 复核真实连续两轮使用的闭环;缺失时执行 product-loop portable fallback。code-review 的 Spec 轴检查体验 AC 是否完整;验收发现故障先使用 diagnosing-bugs,修复后重验;必须通过已核实的受控浏览器工具实际加载插件完成端到端验证并记录 repeat_use。", + "release-preparation": "code-review 审查最终候选;若构建文件变化,使用 Playwright MCP 重验受影响的真实插件路径。", + "launch-tracking": "若发布或首用检查失败,使用 diagnosing-bugs;实际安装版本仍须完成端到端验证。", + }[task_type] + complexity_note = ( + "当前为复杂插件:主执行者自行使用 setup-matt-pocock-skills(首次/缺少配置)→ to-spec → 发布正式主票 → to-tickets → implement," + "按明确文件/模块所有权逐票开发和独立验收;未完成拆分与依赖检查不得开始实施。" + "本目录任务仅为调度索引,须链接这些 skills 产生的 .scratch//spec.md 和 issues/*.md;不得维护第二套权威规格或工单。" + if complexity == "complex" else + "当前为简单插件,保留精简本地工单;必须执行对应 skills、code-review 与真实端到端验收。" + if complexity == "simple" else + "复杂度尚未判定;主执行者根据需求自行记录 simple/complex 与理由,实施前必须完成判断;复杂时走正式主票和拆票流程。" + ) + text = f"""# {task_id} {title} + +本文件{'仅为复杂项目调度索引,正式规格和工单由对应 skills 生成并在此链接' if complexity == 'complex' else '是正式本地任务记录'};下列待填项不代表功能已完成。由主执行者汇总更新,避免多个代理并发覆盖。 + +## 任务字段 + +- 编号:{task_id} +- 类型:{task_type} +- 状态:{status} +- 本次交付目标:{goal_settings['label']}({goal}) +- 范围说明:{scope_note} +- 父任务:{parent} +- 子任务:{children} +- 前置依赖:{', '.join(dependencies) or 'none'} +- 开始条件:{precondition} +- 修改范围:待根据首版规格填写具体文件/模块;范围重叠时串行执行。 +- 对应规格:../spec.md +- 已确认决定:../decisions.md(仅引用实际已确认条目) +- 所需方法:技能包中的 {METHODS[method]} +- 所需外部 skills:{required_skills} +- skills 来源、修订与实际可用证据:待填写;必须实际读取适用 SKILL.md 并执行,保存读取及执行证据。文件存在、同名方法或包内说明不能代替技能调用。 +- 真实验收工具:默认 Playwright MCP;须在真实浏览器安装/加载插件并从原生入口完成用户路径。环境不支持时先记录原因并询问用户安装/启用或采用推荐替代,未获选择不得静默切换。 + +{complexity_note} + +## 本票范围 + +{scope} + +## 验收条目 + +| 编号 | 前置条件 | 用户操作 | 预期结果 | 验证方法 | 实际结果 | 证据 | 状态 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| {task_id}-AC-01 | 待从规格填写 | 待填写 | 可观察结果待填写 | 真实环境或适用检查待填写 | 未执行 | 无 | 未验证 | + +按功能增加失败、取消、重复操作、重开与升级场景;不适用项说明理由,不删除失败项。 + +## 版本与检查证据 + +- 候选版本/本地提交/可恢复快照:未保存 +- 实际构建包及校验值:无 +- 测试环境、浏览器版本、测试网页:待记录 +- 用户路径证据:无 +- 规范审查结论及证据:未审查 +- 规格审查结论及证据:未审查 +- 已执行 skills、源文件/修订、读取证据及按技能完成的产出:无 +- Playwright MCP 加载/安装及端到端操作证据:无 +- 工具不支持原因、推荐替代与用户选择证据(仅适用时填写):无 +- 失败复现、关联修复任务及复验结果:无 +- 人工检查与尚待本人操作:按实际需要填写 + +## 关闭条件 + +{close} + +{scope_note} + +勾选或改变状态前核对原始证据;编写说明、检测到工具或生成包均不能代替真实检查。 + +## 未完成事项与下一步 + +- 未完成事项:{remaining} +- 下一步:{next_action} +- 发布准备状态:未开始 +- 提交记录、审核反馈、商店/分发入口、安装验证:无(适用发布任务时逐项记录) + +## 变更记录 + +- 初始化:建立正式本地任务,未执行开发、审查、验收或发布。 +""" + index = { + "id": task_id, "type": task_type, "path": f"tasks/{task_id}.md", + "parent": None if task_id == "T-001" else "T-001", + "children": ["T-002", "T-003", "T-004", "T-005"] if task_id == "T-001" else [], + "depends_on": dependencies, "status": status, + "in_scope": in_scope, + "closing_dependencies": goal_settings["required_tasks"] if task_id == "T-001" else [], + "scope_ready": False, "version": None, "evidence": [], + } + return text, index + + +def initialize(project, name=None, idea="", browser=None, goal=None, + complexity=None, complexity_reason=""): + project = Path(project).expanduser().resolve() + record = project / RECORD_DIR + if record.exists() or record.is_symlink(): + raise ValueError(f"已有 {RECORD_DIR},未覆盖任何记录。请运行 status 后读取已有任务继续。") + name = (name or project.name).strip() + if not name: + raise ValueError("项目名称不能为空。") + delivery_goal = goal or "live" + if delivery_goal not in DELIVERY_GOALS: + raise ValueError("交付目标须为 local、materials 或 live。") + complexity_value = complexity or "undetermined" + if complexity_value not in ("simple", "complex", "undetermined"): + raise ValueError("复杂度须为 simple 或 complex,省略时待主执行者判断。") + goal_settings = DELIVERY_GOALS[delivery_goal] + goal_status = "provided" if goal else "assumption" + project.mkdir(parents=True, exist_ok=True) + task_files, tasks = {}, [] + for definition in TASK_DEFINITIONS: + content, entry = make_task(definition, delivery_goal, complexity_value) + task_files[entry["path"]] = content + tasks.append(entry) + target_browser = None if browser == "undecided" else (browser or "chrome") + browser_status = "provided" if browser and browser != "undecided" else "undecided" if browser else "assumption" + state = { + "schema_version": SCHEMA_VERSION, + "created_at": now(), "updated_at": now(), + "project": { + "name": name, "root": "..", "idea": idea, + "target_browser": {"value": target_browser, "status": browser_status, + "source": "explicit_cli_argument" if browser else "reversible_first_iteration_default"}, + "distribution": {"channel": None, "audience": None, "status": "undecided"}, + "delivery_goal": { + "value": delivery_goal, "status": goal_status, + "source": "explicit_cli_argument" if goal else "skill_default", + }, + }, + "record_policy": { + "authority": (["workflow.authoritative_artifacts", "decisions.md"] if complexity_value == "complex" + else ["spec.md", "decisions.md", "tasks/*.md"]), + "state_role": "index_only", "paths_relative_to": RECORD_DIR, + "updates": "single_writer; preserve last valid state before atomic replacement", + "resume": "read authoritative documents; compare actual version and evidence before resuming", + }, + "phase": "discovery", + "workflow": { + "complexity": { + "value": complexity_value, "reason": complexity_reason, + "status": "recorded" if complexity else "requires_agent_assessment", + "source": "explicit_cli_argument" if complexity else "not_assessed", + "decision_owner": "lead_agent", + }, + "default_e2e_provider": DEFAULT_E2E_PROVIDER, + "browser_choice": None, + "required_skill_stages": [ + {"skill": skill, "stage": stage, "applies_when": condition, + "status": "not_executed", "source_path": None, "source_revision": None, + "read_evidence": [], "execution_evidence": []} + for skill, stage, condition in REQUIRED_SKILLS + ], + "complex_chain": list(COMPLEX_CHAIN) if complexity_value == "complex" else [], + "complex_chain_required_when": "complex_project", + "parent_ticket": {"status": "not_published", "path_or_url": None, "evidence": []}, + "authoritative_artifacts": {"spec": None, "parent_ticket": None, "implementation_tickets": [], + "paths_relative_to": "project_root", "produced_by_required_skills": True}, + "ticket_policy": "lead_agent_decides; native skill artifacts are authoritative; publish parent before decomposition; explicit file/module ownership; dependencies before implement; triage only for external feedback or existing tickets; independent review and real E2E", + }, + "spec": {"path": "spec.md", "status": "awaiting_to_spec" if complexity_value == "complex" else "draft", + "role": "coordination_pointer" if complexity_value == "complex" else "authoritative_local_spec"}, + "decisions_path": "decisions.md", "progress_path": "progress.md", + "tasks": tasks, + "next_actions": [ + "读取已有要求与项目约定,补齐首版范围和可观察验收,沿用已明确的答案。", + "运行 doctor 并用宿主工具验证当前步骤需要的实际执行能力。", + "自行记录需求复杂度及理由;实际读取并执行适用的必需 skills,复杂时按项目配置、正式主票、拆票、implement 顺序推进。", + "连接 Playwright MCP 并实测安装/加载插件能力;不支持时记录原因,请用户选择安装/启用或推荐的真实浏览器替代。", + "仅对会影响当前实现的未定重要事项询问用户,再执行 T-002。", + ], + "needs_user": [], + "capabilities": { + "checked_at": None, "bundled_methods": bundled_methods(), + "external_skills": [], "execution_tools": {"status": "unverified"}, + "development_software": {"status": "unverified"}, "doctor_report": None, + }, + "milestones": { + milestone: {"status": "not_started", "version": None, "evidence": []} + for milestone in ("built", "accepted", "submitted", "live") + }, + "release": { + "status": "not_prepared", "candidate_version": None, "package": None, + "publishing_intent": {"status": "not_established", "context_evidence": [], + "readiness_notice": "not_shown"}, + "registration": {"status": "not_checked", "fee": None, + "payment_method_status": "not_checked"}, + "assets": {"status": "not_started", "required_missing": [], + "generation_status": "not_attempted", "waiting_for_user_upload": False}, + "preparation_in_scope": delivery_goal in ("materials", "live"), + "submission_in_scope": delivery_goal == "live", + "acceptance_task": "T-003", "preparation_task": "T-004", "tracking_task": "T-005", + "channel": None, "submission": None, "store_url": None, + "authorization_evidence": [], "first_use_evidence": [], + }, + } + browser_text = target_browser or "待确定" + distribution_text = "本次仅本机使用,商店分发不在范围内" if delivery_goal == "local" else "未定,不自动公开发布" + preparation_progress = "本次范围外(未执行)" if delivery_goal == "local" else "未准备" + submission_progress = "未提交" if delivery_goal == "live" else "本次范围外(未执行)" + live_progress = "未验证" if delivery_goal == "live" else "本次范围外(未执行)" + documents = { + "state.json": dump(state), + "spec.md": f"""# {name}:首版说明 + +规格状态:draft。用户原话作为需求来源保存;初始化不会自动确认尚未明确的范围。此文件与正式任务共同作为依据。 + +## 用户原话 + +{idea or '尚未提供;先读取当前对话或请用户用一句话说明用途。'} + +## 谁会用、在哪用、希望得到什么 + +- 使用者:待从用户已说明的要求填写 +- 代表性网页/场景:待填写 +- 首条完整用户故事:待填写 +- 目标浏览器:{browser_text}({browser_status};默认只用于可逆的第一版建议) +- 本次交付目标:{goal_settings['label']}({delivery_goal};{goal_status}) +- 分发范围和渠道:{distribution_text} +- 复杂度:{complexity_value};判断理由:{complexity_reason or '待主执行者根据需求自行记录'} + +未明示的交付目标是 AI 暂定,不能当作用户已确认或发布授权;应沿用用户实际要求调整。保留未来发布任务模板不扩大本次范围。 + +## 首版范围与范围外事项 + +- 必须完成:待明确 +- 暂缓事项:待明确 +- 关键术语的实际含义:待明确 +- 内容放在哪里、会不会发送出去、费用:按真实需要明确,不假定用户已同意 + +## 可观察验收 + +| 编号 | 前置条件 | 用户操作 | 预期结果 | 验证方法与证据 | +| --- | --- | --- | --- | --- | +| AC-01 | 待填写 | 待填写 | 待填写 | 待填写 | + +## 重要决定及未决事项 + +决定和来源见 decisions.md;已由用户明确的要求直接沿用。只暂停依赖尚未回答的重要选择的步骤。 + +## 任务 + +主任务 T-001;实施 T-002;独立验收 T-003;发布准备 T-004;上线跟踪 T-005。 +实施任务按完整用户功能继续拆分;本次主任务关闭条件:{goal_settings['close']} +范围外的任务保留并标为 not_in_scope,不要求执行或关闭它们。 +复杂插件必须按 setup-matt-pocock-skills(首次/缺少配置)→ to-spec → 发布正式主票 → to-tickets → implement 推进,由主执行者自行安排独立模块与工单;triage 仅用于外部反馈或旧票分流。 +本文件及初始任务仅为待完善记录,不证明已执行上述 skills、已发布正式主票或已完成拆分。 +""", + "decisions.md": """# 项目决定 + +按用户原话、已有记录或实际证据填写;区分用户已确认、AI 暂定、待决定。沉默不等于同意。 + +| 编号 | 要决定什么 | 答案 | 来源与日期 | 状态 | 影响规格/验收 | 替代旧决定 | +| --- | --- | --- | --- | --- | --- | --- | + +初始化未添加任何用户确认。浏览器默认值仅记录在 state.json 与 spec.md 中作为暂定方案。 +""", + "progress.md": f"""# {name}:项目进度 + +当前:已建立项目记录,正在明确第一版。尚未开发、验收、提交或上线。 + +本次做到:{goal_settings['label']}({goal_status};暂定值不能替代用户已说明的目标或发布授权)。 + +接下来:从已有要求整理一条有用的功能和完成标准,再准备所需能力并开始制作。 + +待你处理:目前没有自动认定的待办;仅在实际遇到必要决定或本人操作时填写。 + +## 恢复说明 + +state.json 仅用于索引。继续前先阅读 spec.md、decisions.md 和当前任务,检查对应版本、构建包与证据是否还在。 +文件存在仅证明有记录,须核对内容、版本和实际检查结果;不可仅凭旧进度文字报告完成。 +每次变更由主执行者汇总,先保留上一次有效记录,再替换状态;不要存密码、验证码或密钥。 + +## 阶段结果 + +- 本地可试用:未开始 +- 独立验收:未开始 +- 发布材料:{preparation_progress} +- 提交审核:{submission_progress} +- 正式可安装及首用验证:{live_progress} +""", + "evidence/README.md": """# 检查证据 + +这里只存本项目的检查结果和必要样例,不存密码、验证码、密钥或无关私人网页内容。 +每份证据写明对应任务、候选版本/构建包、环境、操作、预期和实际结果、检查方式、时间和原始产出位置。 +状态只能按事实填写:通过、失败、未验证、不适用(注明原因)。普通网页截图不能证明原生插件入口已打开。 +在任务及 state.json 中使用相对 .extension-launch 的文件路径引用,例如 evidence/T-002-main-path.md。 +""", + **task_files, + } + if complexity_value == "complex": + documents["spec.md"] = f"""# {name}:复杂项目规格入口 + +本文件仅为调度索引,不是另一份权威规格。尚未执行 to-spec,也未发布主票或创建独立模块工单。 + +- 需求来源:{idea or '读取当前对话中的用户原话'} +- 复杂度理由:{complexity_reason or '待主执行者记录'} +- 本次交付目标:{goal_settings['label']}({delivery_goal};{goal_status}) +- 正式规格:待 to-spec 生成 .scratch//spec.md 后填入实际路径。 +- 正式主票:待 to-spec 发布后记录实际路径/链接和发布证据。 +- 子票:待 to-tickets 生成后记录 issues/*.md 的实际路径和模块所有权。 + +先使用 setup-matt-pocock-skills 完成首次/缺失配置,再执行 to-spec → 发布正式主票 → to-tickets → implement。 +默认沿用这些 skills 的 Local Markdown 工作流,由主执行者自行做技术判断和拆分;更新 state.workflow.authoritative_artifacts 指向实际产物。 +tasks/T-*.md 仅跟踪阶段依赖和验收证据,实施范围与验收标准以正式规格和子票为准。外部反馈或旧票需要分流时才使用 triage。 +每票必须使用 code-review,并通过 Playwright MCP 实际加载插件完成端到端验证;发现问题必须使用 diagnosing-bugs。 +""" + # The exclusive directory creation also rejects concurrent initializers. + record.mkdir(exist_ok=False) + for relative, content in documents.items(): + destination = record / relative + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open("x", encoding="utf-8") as handle: + handle.write(content) + return {"status": "initialized", "record_directory": RECORD_DIR, + "phase": "discovery", "spec_status": state["spec"]["status"], "created_files": list(documents), + "delivery_goal": state["project"]["delivery_goal"], + "message": "项目记录已建立;请核对要求后继续。未执行开发、验收或发布。"} + + +def detect_browser(name): + candidates = [shutil.which(command) for command in BROWSER_COMMANDS[name]] + system = platform.system() + if system == "Darwin": + app_names = {"chrome": "Google Chrome", "edge": "Microsoft Edge", "firefox": "Firefox"} + executable = "firefox" if name == "firefox" else app_names[name] + for root in (Path("/Applications"), Path.home() / "Applications"): + candidates.append(str(root / f"{app_names[name]}.app" / "Contents" / "MacOS" / executable)) + elif system == "Windows": + suffixes = {"chrome": "Google/Chrome/Application/chrome.exe", + "edge": "Microsoft/Edge/Application/msedge.exe", + "firefox": "Mozilla Firefox/firefox.exe"} + for key in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"): + directory = os.environ.get(key) + if directory: + candidates.append(str(Path(directory) / suffixes[name])) + detected = next((path for path in candidates if path and Path(path).is_file() and os.access(path, os.X_OK)), None) + return {"status": "detected" if detected else "missing", "executable": detected, + "version": None, "connection": "unverified", "extension_loading": "unverified"} + + +def inspect_skills(directories): + """Inspect only case-insensitive SKILL.md names, never host configuration.""" + result = [] + for supplied in directories: + directory = Path(supplied).expanduser() + entry = {"directory": str(directory), "status": "missing", "skills": []} + if directory.is_dir(): + entry["status"] = "detected" + # Standard root/name/SKILL.md plus a directly supplied skill folder. + paths = [path for pattern in ("*", "*/*", "*/*/*") + for path in directory.glob(pattern) if path.name.lower() == "skill.md"] + for path in sorted(set(paths)): + if path.is_file(): + name = None + try: + with path.open(encoding="utf-8") as handle: + if handle.readline().strip() == "---": + for _ in range(100): + line = handle.readline() + if not line or line.strip() == "---": + break + match = re.fullmatch(r"name:\s*(.+?)\s*", line.rstrip("\n")) + if match: + name = match[1].strip().strip("\"'") + except (OSError, UnicodeError): + pass + entry["skills"].append({ + "directory_name": path.parent.name, + "name": name, "source_path": str(path.resolve()), + "metadata": str(path.relative_to(directory)), + "status": "detected", "host_activation": "unverified", + "compatibility": "unverified", "source_revision": None, + "invocation": "unverified", "execution_evidence": [], + }) + result.append(entry) + return result + + +def doctor(project, skills_dirs=()): + software = {"python": {"status": "detected", "version": platform.python_version(), + "minimum_version_satisfied": sys.version_info >= (3, 9)}} + for program in ("node", "npm", "git"): + executable = shutil.which(program) + software[program] = {"status": "detected" if executable else "missing", + "executable": executable, "version": None, "execution": "unverified"} + browsers = {name: detect_browser(name) for name in BROWSER_COMMANDS} + external_skills = inspect_skills(skills_dirs) + found = [skill for directory in external_skills for skill in directory["skills"]] + required_skills = [] + for name, stage, condition in REQUIRED_SKILLS: + matches = [item for item in found if name.lower() in { + item["directory_name"].lower(), (item["name"] or "").lower()}] + required_skills.append({ + "skill": name, "stage": stage, "required_when": condition, + "status": "detected" if matches else "missing", + "source_paths": [item["source_path"] for item in matches], + "host_activation": "unverified", "invocation": "unverified", + "read_evidence": [], "execution_evidence": [], + "bundled_substitute_allowed": name in PORTABLE_FALLBACKS, + "portable_fallback": PORTABLE_FALLBACKS.get(name), + }) + return { + "schema_version": SCHEMA_VERSION, "checked_at": now(), "mode": "read_only_detection", + "project_exists": Path(project).expanduser().is_dir(), + "os": {"name": platform.system(), "release": platform.release(), "architecture": platform.machine()}, + "software": software, "browsers": browsers, + "bundled_methods": bundled_methods(), + "external_skills": external_skills, + "required_skills": required_skills, + "skill_discovery": "explicit_directories_only" if skills_dirs else "not_requested; use --skills-dir from the host", + "execution_tools": {"default_e2e_provider": DEFAULT_E2E_PROVIDER, + "playwright_mcp": "unverified", "browser_connection": "unverified", "screenshot": "unverified", + "extension_installation": "unverified", "host_file_and_shell_tools": "unverified"}, + "overall": "requires_runtime_verification", "ready_to_develop": None, + "notes": [ + "检测到命令或 SKILL.md 不代表软件可运行、技能已生效或浏览器已连接。", + "适用的必需 skills 缺失时先通过当前宿主或 Skill CLI 补齐;只有 portable_fallback 非空时才能执行包内等价流程,并须记录。", + "受控浏览器工具加载插件和操作原生入口的能力必须实测;Playwright MCP 不支持时请用户选择安装/启用或经核实的替代,不能静默换工具。", + "Node/npm 是否必需取决于项目模板;纯 JavaScript 模板可以不需要。", + "下一步由宿主验证当前任务所需能力;本检查不安装软件、不读取凭证、不改项目状态。", + ], + } + + +def inspect_status(project): + record = Path(project).expanduser().resolve() / RECORD_DIR + state = json.loads((record / "state.json").read_text(encoding="utf-8")) + if not isinstance(state, dict) or state.get("schema_version") != SCHEMA_VERSION: + raise ValueError("不支持或缺少 state.json schema_version;请先审阅记录,不自动迁移。") + errors, warnings, file_checks = [], [], [] + + def check(relative, kind): + if not isinstance(relative, str) or not relative: + errors.append(f"{kind} 路径为空或格式错误") + return + path = (record / relative).resolve() + try: + path.relative_to(record.parent) + except ValueError: + errors.append(f"{kind} 路径超出项目:{relative}") + return + exists = path.is_file() + file_checks.append({"path": relative, "kind": kind, "exists": exists, "content_verified": False}) + if not exists: + errors.append(f"缺少 {kind}:{relative}") + + check(state.get("spec", {}).get("path"), "specification") + check(state.get("decisions_path"), "decisions") + check(state.get("progress_path"), "progress") + for task in state.get("tasks", []): + check(task.get("path"), "task") + for evidence in task.get("evidence", []): + check(evidence.get("path") if isinstance(evidence, dict) else evidence, "task_evidence") + if task.get("status") in ("complete", "completed", "done", "closed") and not task.get("evidence"): + warnings.append(f"{task.get('id')} 索引标为完成但没有证据索引;需阅读正式任务并核实关闭条件。") + for milestone, data in state.get("milestones", {}).items(): + evidence = data.get("evidence", []) + for pointer in evidence: + check(pointer.get("path") if isinstance(pointer, dict) else pointer, f"{milestone}_evidence") + if data.get("status") not in ("not_started", "pending", "unverified", "blocked") and not evidence: + warnings.append(f"{milestone} 状态已推进但没有证据索引,不可据此宣称完成。") + release = state.get("release", {}) + package = release.get("package") + if package: + check(package.get("path") if isinstance(package, dict) else package, "release_package") + for field in ("authorization_evidence", "first_use_evidence"): + for pointer in release.get(field, []): + check(pointer.get("path") if isinstance(pointer, dict) else pointer, field) + return { + "schema_version": SCHEMA_VERSION, "phase_index": state.get("phase"), + "release_status_index": state.get("release", {}).get("status"), + "files": file_checks, "errors": errors, "warnings": warnings, + "next_actions_index": state.get("next_actions", []), "needs_user_index": state.get("needs_user", []), + "resume_requires_document_and_version_review": True, + "message": "这是索引及文件存在性检查。继续前须阅读正式任务、核对当前版本与证据内容;本命令不会确认完成或推进阶段。", + } + + +def main(argv=None): + parser = argparse.ArgumentParser(description="建立并检查浏览器插件项目的正式本地记录。") + commands = parser.add_subparsers(dest="command", required=True) + initialize_parser = commands.add_parser("init", help="创建记录;拒绝覆盖已有 .extension-launch") + initialize_parser.add_argument("project") + initialize_parser.add_argument("--name") + initialize_parser.add_argument("--idea", default="") + initialize_parser.add_argument("--browser", choices=("chrome", "edge", "firefox", "undecided")) + initialize_parser.add_argument("--goal", choices=("local", "materials", "live"), + help="本机使用、完成发布材料或上线;省略时 live 仅为未确认的暂定目标") + initialize_parser.add_argument("--complexity", choices=("simple", "complex"), + help="由主执行者判定需求复杂度;省略时记录 undetermined,实施前须判断") + initialize_parser.add_argument("--complexity-reason", default="", help="主执行者判断需求复杂度的具体理由") + doctor_parser = commands.add_parser("doctor", help="只读检测;不会宣称能力已连接") + doctor_parser.add_argument("project") + doctor_parser.add_argument("--skills-dir", action="append", default=[], help="宿主提供的技能目录;可重复") + doctor_parser.add_argument("--report", type=Path, help="另存 JSON 报告(新文件;不会覆盖或更新 state)") + status_parser = commands.add_parser("status", help="读取状态索引并核对文件是否存在") + status_parser.add_argument("project") + args = parser.parse_args(argv) + try: + if args.command == "init": + result = initialize(args.project, args.name, args.idea, args.browser, args.goal, + args.complexity, args.complexity_reason) + elif args.command == "doctor": + result = doctor(args.project, args.skills_dir) + if args.report: + with args.report.expanduser().open("x", encoding="utf-8") as handle: + handle.write(dump(result)) + else: + result = inspect_status(args.project) + print(dump(result), end="") + return 1 if result.get("errors") else 0 + except (OSError, ValueError, TypeError, AttributeError) as error: + print(dump({"status": "error", "message": str(error)}), file=sys.stderr, end="") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/browser-extension-launch/scripts/release_bundle.py b/skills/browser-extension-launch/scripts/release_bundle.py new file mode 100644 index 0000000000..af8d016b72 --- /dev/null +++ b/skills/browser-extension-launch/scripts/release_bundle.py @@ -0,0 +1,540 @@ +#!/usr/bin/env python3 +"""Check a Chrome MV3 build and make a deterministic, root-level release ZIP. + +Only Python's standard library is used. This is a conservative release-directory +check, not a complete Chrome manifest validator, security audit or browser test. +Run ``release_bundle.py --help`` for the command-line interface. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import stat +import struct +import sys +import tempfile +import zipfile +from html.parser import HTMLParser +from pathlib import Path, PurePosixPath +from urllib.parse import unquote, urlsplit + + +BLOCKED_DIRS = {"node_modules", ".git", ".hg", ".svn", ".ssh"} +BLOCKED_NAMES = { + "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", ".npmrc", ".pypirc", + ".envrc", ".netrc", "credentials.json", "service-account.json", "service_account.json", +} +PRIVATE_HEADER = re.compile(rb"-----BEGIN (?:[A-Z0-9 ]*PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----") +MESSAGE = re.compile(r"__MSG_([A-Za-z0-9_@]+)__") +BUILTIN_MESSAGES = { + "@@extension_id", "@@ui_locale", "@@bidi_dir", "@@bidi_reversed_dir", + "@@bidi_start_edge", "@@bidi_end_edge", +} +TEXT_SUFFIXES = {".js", ".mjs", ".cjs", ".html", ".htm", ".css", ".json", ".ts", ".tsx", ".jsx"} +SCAN_LIMIT = 8 * 1024 * 1024 +FIXED_ZIP_TIME = (1980, 1, 1, 0, 0, 0) + + +def version_tuple(value: object) -> tuple[int, int, int, int] | None: + """Chrome: 1–4 decimal components, <=65535, no leading zeros, not all zero.""" + if not isinstance(value, str) or not re.fullmatch(r"(?:0|[1-9][0-9]{0,4})(?:\.(?:0|[1-9][0-9]{0,4})){0,3}", value): + return None + parts = tuple(int(part) for part in value.split(".")) + if max(parts) > 65535 or not any(parts): + return None + return parts + (0,) * (4 - len(parts)) + + +def issue(report: dict, level: str, code: str, path: str, message: str) -> None: + item = {"code": code, "path": path, "message": message} + if item not in report[level]: + report[level].append(item) + + +def read_regular(root: Path, relative: str) -> bytes: + """Refuse symlinks and non-regular files, including symlinked parent folders.""" + if root.is_symlink(): + raise ValueError("构建目录已变为符号链接,请重新检查。") + path = root + for part in PurePosixPath(relative).parts: + path = path / part + if path.is_symlink(): + raise ValueError("资源已变为符号链接,请重新检查构建目录。") + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) + if os.open in os.supports_dir_fd and hasattr(os, "O_NOFOLLOW") and hasattr(os, "O_DIRECTORY"): + # On POSIX, anchor each lookup to a directory descriptor to prevent + # a parent directory being swapped for a symlink between path checks. + directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + parts = PurePosixPath(relative).parts + for part in parts[:-1]: + following = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory) + os.close(directory) + directory = following + descriptor = os.open(parts[-1], flags, dir_fd=directory) + finally: + os.close(directory) + else: + descriptor = os.open(path, flags) + with os.fdopen(descriptor, "rb") as handle: + if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode): + raise ValueError("资源不是普通文件。") + return handle.read() + + +def read_json(data: bytes, report: dict, path: str) -> object | None: + def reject_constant(value): + raise ValueError("JSON 不能包含 NaN 或 Infinity。") + + def unique_object(pairs: list) -> dict: + result = {} + for key, value in pairs: + if key in result: + raise ValueError("JSON 含重复字段,无法确定实际生效值。") + result[key] = value + return result + + try: + return json.loads(data.decode("utf-8-sig"), object_pairs_hook=unique_object, parse_constant=reject_constant) + except (UnicodeDecodeError, ValueError): + issue(report, "errors", "invalid_json", path, "文件必须是有效的 UTF-8 JSON,且不能包含重复字段。") + return None + + +def iter_strings(value: object, at: str = "manifest"): + if isinstance(value, str): + yield at, value + elif isinstance(value, list): + for index, child in enumerate(value): + yield from iter_strings(child, f"{at}[{index}]") + elif isinstance(value, dict): + for key, child in value.items(): + yield from iter_strings(child, f"{at}.{key}") + + +class PageResources(HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=True) + self.resources: list[tuple[str, str]] = [] + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag in {"script", "img", "iframe", "source", "audio", "video"} and attrs.get("src"): + self.resources.append(("script" if tag == "script" else "asset", attrs["src"])) + if tag == "link" and attrs.get("href"): + rel = (attrs.get("rel") or "").lower().split() + if any(part in {"stylesheet", "icon", "modulepreload", "preload"} for part in rel): + self.resources.append(("script" if "modulepreload" in rel or attrs.get("as") == "script" else "asset", attrs["href"])) + + +def check_bundle(build_dir: str | Path, previous_version: str | None = None) -> dict: + original = Path(build_dir).expanduser() + root = original.resolve() + report = { + "schema_version": 1, + "status": "blocked", + "build_dir": str(root), + "manifest": {}, + "errors": [], + "warnings": [], + "inventory": [], + "checks_scope": { + "performed": ["MV3 基础字段与版本", "manifest 声明的本地资源", "HTML 常见静态资源", "默认语言消息", "图标存在与 PNG 尺寸", "敏感文件与路径", "部分远程执行和开发地址启发式"], + "not_performed": ["完整 Chrome manifest schema 校验", "全部动态依赖分析", "完整安全与隐私审计", "浏览器安装和功能测试", "商店后台检查", "审核或发布"], + "warning_policy": "警告必须由开发者逐项复核;零警告也不代表没有远程代码、隐私问题或功能故障。", + }, + } + if original.is_symlink() or not root.is_dir(): + issue(report, "errors", "invalid_build_dir", str(original), "构建目录必须是实际目录,不能是符号链接。") + return report + + files: dict[str, bytes] = {} + + def walk_error(error): + issue(report, "errors", "unreadable_directory", str(error.filename or root), "目录无法读取;检查不会静默忽略它。") + + for current, directories, filenames in os.walk(root, followlinks=False, onerror=walk_error): + current_path = Path(current) + for name in sorted(directories): + child = current_path / name + relative = child.relative_to(root).as_posix() + if "\\" in relative or ":" in relative: + issue(report, "errors", "unsafe_archive_path", relative, "文件名含反斜杠或冒号,无法安全地跨平台解压。") + if child.is_symlink(): + issue(report, "errors", "symlink", relative, "发布目录不能包含符号链接。") + directories.remove(name) + elif name.lower() in BLOCKED_DIRS: + issue(report, "errors", "development_directory", relative, "发布目录含依赖、版本控制或凭证目录;请重新输出干净的正式构建。") + directories.remove(name) + directories.sort() + for name in sorted(filenames): + child = current_path / name + relative = child.relative_to(root).as_posix() + if "\\" in relative or ":" in relative: + issue(report, "errors", "unsafe_archive_path", relative, "文件名含反斜杠或冒号,无法安全地跨平台解压。") + if child.is_symlink(): + issue(report, "errors", "symlink", relative, "发布目录不能包含符号链接。") + continue + try: + data = read_regular(root, relative) + except (OSError, ValueError): + issue(report, "errors", "unreadable_file", relative, "文件无法读取或不是普通文件。") + continue + files[relative] = data + report["inventory"].append({"path": relative, "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}) + lowered = name.lower() + if lowered == ".env" or lowered.startswith(".env.") or lowered in BLOCKED_NAMES or child.suffix.lower() in {".p12", ".pfx", ".keystore", ".jks"}: + issue(report, "errors", "sensitive_file", relative, "发现可能含密钥或凭证的文件;不要把环境配置、凭证或私钥放入扩展包。") + if PRIVATE_HEADER.search(data): + issue(report, "errors", "private_key", relative, "发现私钥标记;请移出发布目录,并检查是否需要更换已暴露的密钥。") + if lowered == ".ds_store" or lowered in {"package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb", "bun.lock"} or child.suffix.lower() in {".map", ".ts", ".tsx", ".jsx"}: + issue(report, "warnings", "development_artifact", relative, "包含常见开发文件;请确认确实需要随正式扩展分发。") + + report["inventory"].sort(key=lambda item: item["path"]) + if "manifest.json" not in files: + issue(report, "errors", "missing_manifest", "manifest.json", "正式构建目录根部必须有 manifest.json。") + return report + manifest = read_json(files["manifest.json"], report, "manifest.json") + if not isinstance(manifest, dict): + issue(report, "errors", "manifest_object", "manifest.json", "manifest.json 顶层必须是对象。") + return report + if type(manifest.get("manifest_version")) is not int or manifest.get("manifest_version") != 3: + issue(report, "errors", "manifest_version", "manifest.manifest_version", "此工具面向 Chrome Manifest V3,manifest_version 必须为 3。") + + locales: dict[str, dict] = {} + for path, data in files.items(): + parts = PurePosixPath(path).parts + if len(parts) == 3 and parts[0] == "_locales" and parts[2] == "messages.json": + messages = read_json(data, report, path) + if not isinstance(messages, dict): + issue(report, "errors", "locale_object", path, "语言消息文件顶层必须是对象。") + continue + normalized = {} + for key, value in messages.items(): + if not isinstance(value, dict) or not isinstance(value.get("message"), str): + issue(report, "errors", "locale_message", path, "每个语言条目必须包含字符串 message。") + continue + if key.lower() in normalized: + issue(report, "errors", "locale_duplicate", path, "语言消息名称不能只靠大小写区分。") + normalized[key.lower()] = value["message"] + locales[parts[1]] = normalized + message_refs = [(at, key) for at, value in iter_strings(manifest) for key in MESSAGE.findall(value)] + locale = manifest.get("default_locale") + if locale is not None and (not isinstance(locale, str) or not re.fullmatch(r"[A-Za-z0-9_@-]+", locale)): + issue(report, "errors", "default_locale", "manifest.default_locale", "default_locale 必须是合法的语言目录名称。") + locale = None + if (any(path.startswith("_locales/") for path in files) or any(key.lower() not in BUILTIN_MESSAGES for _, key in message_refs)) and not locale: + issue(report, "errors", "missing_default_locale", "manifest.default_locale", "存在本地化消息时必须设置 default_locale 并提供对应 messages.json。") + if locale and locale not in locales: + issue(report, "errors", "missing_locale", f"_locales/{locale}/messages.json", "默认语言消息文件不存在。") + default_messages = locales.get(locale, {}) + for at, key in message_refs: + if key.lower() not in BUILTIN_MESSAGES and key.lower() not in default_messages: + issue(report, "errors", "missing_message", at, f"默认语言缺少消息 {key}。") + + def localize(value): + if isinstance(value, str): + return MESSAGE.sub(lambda match: default_messages.get(match.group(1).lower(), match.group(0)), value) + if isinstance(value, list): + return [localize(child) for child in value] + if isinstance(value, dict): + return {key: localize(child) for key, child in value.items()} + return value + + manifest = localize(manifest) + for key, limit in (("name", 75), ("description", 132)): + value = manifest.get(key) + if not isinstance(value, str) or not value.strip() or len(value) > limit: + issue(report, "errors", f"invalid_{key}", f"manifest.{key}", f"{key} 必须是非空字符串,且不能超过 {limit} 个字符。") + version = manifest.get("version") + current_version = version_tuple(version) + if current_version is None: + issue(report, "errors", "invalid_version", "manifest.version", "版本须为 1–4 段整数,每段 0–65535、无前导零,且不能全为零,例如 1.0.0。") + if previous_version is not None: + previous = version_tuple(previous_version) + if previous is None: + issue(report, "errors", "invalid_previous_version", "--previous-version", "上个发布版本不符合 Chrome 版本格式。") + elif current_version is not None and current_version <= previous: + issue(report, "errors", "version_not_increased", "manifest.version", "更新版本必须大于上个发布版本;末尾补零视为同一版本。") + report["previous_version"] = previous_version + report["manifest"] = {key: manifest.get(key) for key in ("name", "version", "description", "manifest_version")} + + def resolve_resource(value, at, *, glob=False): + if not isinstance(value, str) or not value: + issue(report, "errors", "resource_type", at, "资源路径必须是非空字符串。") + return [] + decoded = unquote(value) + if "\\" in decoded or "\x00" in decoded or decoded.startswith("/") or re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", decoded) or ".." in PurePosixPath(decoded).parts: + issue(report, "errors", "unsafe_resource_path", at, "资源必须使用构建目录内的相对路径,不能含外部地址、反斜杠或上级目录。") + return [] + relative = PurePosixPath(decoded).as_posix() + if glob: + # Chrome's resources field uses '*' as a wildcard, not shell '?' or '[...]'. + pattern = re.compile("^" + re.escape(relative).replace(r"\*", ".*") + "$") + matches = [path for path in files if pattern.fullmatch(path)] + else: + matches = [relative] if relative in files else [] + if not matches: + issue(report, "errors", "missing_resource", at, f"资源不存在或没有匹配文件:{value}") + return matches + + def icon_resources(value, at, require128=False): + if isinstance(value, str) and not require128: + resolve_resource(value, at) + return + if not isinstance(value, dict) or not value: + issue(report, "errors", "icons_object", at, "图标配置必须是尺寸到本地图片路径的非空对象。") + return + if require128 and "128" not in value: + issue(report, "errors", "missing_icon_128", at, "商店发布包需在 icons 中提供 128 × 128 图标。") + for size, path in value.items(): + for match in resolve_resource(path, f"{at}.{size}"): + data = files[match] + if data.startswith(b"\x89PNG\r\n\x1a\n"): + if len(data) < 33 or data[12:16] != b"IHDR" or struct.unpack(">I", data[8:12])[0] != 13: + issue(report, "errors", "invalid_png", match, "PNG 文件头损坏,无法读取图标尺寸。") + else: + width, height = struct.unpack(">II", data[16:24]) + if not size.isdigit() or width != int(size) or height != int(size): + level = "errors" if require128 and size == "128" else "warnings" + issue(report, level, "icon_dimensions", match, f"PNG 为 {width} × {height},与声明尺寸 {size} 不符。") + else: + if require128 and size == "128": + issue(report, "errors", "icon_128_requires_png", match, "Chrome Web Store 发布包中的 128 × 128 图标必须为 PNG;请转换图片并更新 icons.128 引用。") + else: + issue(report, "warnings", "icon_format_review", match, "非 PNG 图标:请核实 Chrome 支持此图片格式、图片有效且尺寸与声明一致;本工具不验证其尺寸。") + + icon_resources(manifest.get("icons"), "manifest.icons", require128=True) + for key in ("options_page", "devtools_page"): + if key in manifest: + resolve_resource(manifest[key], f"manifest.{key}") + for parent, resource_key in (("action", "default_popup"), ("options_ui", "page"), ("side_panel", "default_path"), ("background", "service_worker"), ("storage", "managed_schema")): + if parent not in manifest: + continue + value = manifest[parent] + if not isinstance(value, dict): + issue(report, "errors", "field_type", f"manifest.{parent}", "此字段必须是对象。") + continue + if resource_key in value and not (parent == "action" and value[resource_key] == ""): + resolve_resource(value[resource_key], f"manifest.{parent}.{resource_key}") + if parent == "action" and "default_icon" in value: + icon_resources(value["default_icon"], "manifest.action.default_icon") + if parent == "background" and ("scripts" in value or "page" in value): + issue(report, "errors", "mv2_background", "manifest.background", "MV3 后台应使用 service_worker;请移除 MV2 的 scripts/page 配置。") + + overrides = manifest.get("chrome_url_overrides", {}) + if not isinstance(overrides, dict): + issue(report, "errors", "field_type", "manifest.chrome_url_overrides", "此字段必须是对象。") + else: + for key, path in overrides.items(): + resolve_resource(path, f"manifest.chrome_url_overrides.{key}") + + def records(value, at): + if not isinstance(value, list): + issue(report, "errors", "field_type", at, "此字段必须是数组。") + return [] + for index, item in enumerate(value): + if not isinstance(item, dict): + issue(report, "errors", "field_type", f"{at}[{index}]", "此条目必须是对象。") + return [(index, item) for index, item in enumerate(value) if isinstance(item, dict)] + + def resource_array(value, at, *, glob=False): + if not isinstance(value, list): + issue(report, "errors", "field_type", at, "资源列表必须是数组。") + return + for index, path in enumerate(value): + resolve_resource(path, f"{at}[{index}]", glob=glob) + + for index, entry in records(manifest.get("content_scripts", []), "manifest.content_scripts"): + for kind in ("js", "css"): + if kind in entry: + resource_array(entry[kind], f"manifest.content_scripts[{index}].{kind}") + for index, entry in records(manifest.get("web_accessible_resources", []), "manifest.web_accessible_resources"): + resource_array(entry.get("resources"), f"manifest.web_accessible_resources[{index}].resources", glob=True) + sandbox = manifest.get("sandbox", {}) + if isinstance(sandbox, dict): + if "pages" in sandbox: + resource_array(sandbox["pages"], "manifest.sandbox.pages") + else: + issue(report, "errors", "field_type", "manifest.sandbox", "此字段必须是对象。") + dnr = manifest.get("declarative_net_request", {}) + if isinstance(dnr, dict): + for index, entry in records(dnr.get("rule_resources", []), "manifest.declarative_net_request.rule_resources"): + resolve_resource(entry.get("path"), f"manifest.declarative_net_request.rule_resources[{index}].path") + else: + issue(report, "errors", "field_type", "manifest.declarative_net_request", "此字段必须是对象。") + + # Heuristics report file/line and category, never source excerpts or secrets. + remote_import = re.compile(r"(?:\bimport\s*(?:\(|[^;\n]*?\bfrom\s*)?|\bexport\s+[^;\n]*?\bfrom\s*|\bimportScripts\s*\()\s*['\"](?:https?:)?//", re.I) + execution = re.compile(r"\beval\s*\(|\bnew\s+Function\s*\(") + development = re.compile(r"(?:https?|wss?)://(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?=[:/\s'\"]|$)|/@vite/|@vite/client", re.I) + for path, data in files.items(): + suffix = PurePosixPath(path).suffix.lower() + if suffix not in TEXT_SUFFIXES: + continue + if len(data) > SCAN_LIMIT: + issue(report, "warnings", "static_scan_limit", path, "文件大于 8 MiB,启发式代码扫描未执行;请单独复核此文件。") + continue + try: + source = data.decode("utf-8-sig") + except UnicodeDecodeError: + issue(report, "warnings", "text_encoding_review", path, "无法按 UTF-8 扫描文本,请单独复核文件内容及编码。") + continue + patterns = [(development, "development_endpoint", "发现本机服务或开发工具地址;确认正式功能不依赖开发环境。")] + if suffix in {".js", ".mjs", ".cjs", ".html", ".htm", ".ts", ".tsx", ".jsx"}: + patterns.extend([(remote_import, "remote_code_review", "发现疑似远程脚本导入;核实 MV3 要求并把执行代码随包提供。"), (execution, "dynamic_execution_review", "发现 eval/new Function 的静态特征;请复核真实执行路径、CSP 和 MV3 要求。")]) + for pattern, code, message in patterns: + match = pattern.search(source) + if match: + line = source.count("\n", 0, match.start()) + 1 + issue(report, "warnings", code, f"{path}:{line}", message + " 此项是启发式线索,可能命中注释或字符串。") + if suffix in {".html", ".htm"}: + parser = PageResources() + try: + parser.feed(source) + except (ValueError, AssertionError): + issue(report, "warnings", "html_parse_review", path, "HTML 解析未完成,请在浏览器中检查实际资源加载。") + continue + for kind, reference in parser.resources: + try: + url = urlsplit(reference) + except ValueError: + issue(report, "warnings", "html_url_review", path, "HTML 含无法解析的资源 URL,请手动检查。") + continue + if url.scheme or url.netloc: + if kind == "script" and url.scheme not in {"chrome-extension"}: + issue(report, "warnings", "remote_code_review", path, "HTML 引用了包外脚本或特殊 URL 脚本;请核实代码来源及 MV3 要求。") + continue + if not url.path: + continue + decoded = unquote(url.path) + if "\\" in decoded or "\x00" in decoded: + issue(report, "errors", "unsafe_resource_path", path, "HTML 资源路径含反斜杠或空字符。") + continue + # HTML uses URL resolution: ../ inside the extension is legitimate. + relative = (root / decoded.lstrip("/")) if decoded.startswith("/") else (root / path).parent / decoded + resolved = relative.resolve() + try: + normalized = resolved.relative_to(root).as_posix() + except ValueError: + issue(report, "errors", "unsafe_resource_path", path, "HTML 资源路径超出构建目录。") + continue + if normalized not in files: + issue(report, "errors", "missing_html_resource", path, f"HTML 引用的本地资源不存在:{reference}") + + if not report["errors"]: + report["status"] = "checks_passed" + return report + + +def safe_output(path: Path, root: Path, overwrite: bool) -> Path: + if path.is_symlink(): + raise ValueError("输出位置不能是符号链接。") + resolved = path.expanduser().resolve() + if resolved == root or root in resolved.parents: + raise ValueError("ZIP 和报告必须保存到构建目录外,避免把旧报告或 ZIP 装入新发布包。") + if resolved.exists() and (not overwrite or not resolved.is_file()): + raise ValueError("输出已存在;请选择新路径,或明确添加 --overwrite 覆盖普通文件。") + return resolved + + +def commit_temp(temporary: Path, destination: Path, overwrite: bool): + if overwrite: + if destination.is_symlink() or (destination.exists() and not destination.is_file()): + raise ValueError("输出位置已经变化,不允许覆盖链接或非普通文件。") + os.replace(temporary, destination) + else: + # Hard linking atomically avoids replacing a file created after preflight. + os.link(temporary, destination) + temporary.unlink() + + +def write_report(report: dict, destination: Path, overwrite: bool): + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = None + try: + with tempfile.NamedTemporaryFile(dir=destination.parent, prefix=".extension-report-", suffix=".tmp", delete=False) as handle: + temporary = Path(handle.name) + handle.write((json.dumps(report, ensure_ascii=False, indent=2) + "\n").encode("utf-8")) + commit_temp(temporary, destination, overwrite) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def pack_bundle(report: dict, destination: Path, overwrite: bool = False): + if report["errors"]: + raise ValueError("检查存在错误,不能生成发布 ZIP。") + root = Path(report["build_dir"]) + destination = safe_output(destination, root, overwrite) + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = None + try: + with tempfile.NamedTemporaryFile(dir=destination.parent, prefix=".extension-release-", suffix=".tmp", delete=False) as handle: + temporary = Path(handle.name) + with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for entry in report["inventory"]: + data = read_regular(root, entry["path"]) + if hashlib.sha256(data).hexdigest() != entry["sha256"]: + raise ValueError("构建文件在检查后发生变化;请重新执行打包。") + info = zipfile.ZipInfo(entry["path"], date_time=FIXED_ZIP_TIME) + info.create_system = 3 + info.external_attr = (stat.S_IFREG | 0o644) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + archive.writestr(info, data, compress_type=zipfile.ZIP_DEFLATED, compresslevel=9) + digest = hashlib.sha256(temporary.read_bytes()).hexdigest() + size = temporary.stat().st_size + commit_temp(temporary, destination, overwrite) + report["status"] = "packed" + report["archive"] = {"path": str(destination), "bytes": size, "sha256": digest, "file_count": len(report["inventory"]), "manifest_at_root": True} + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description="检查 Chrome MV3 正式构建并生成可追溯的 ZIP。此工具不代替浏览器实测、隐私审计或商店审核。") + subparsers = parser.add_subparsers(dest="command", required=True) + for command in ("check", "pack"): + child = subparsers.add_parser(command, help="只做静态检查" if command == "check" else "检查通过后打包") + child.add_argument("build_dir", type=Path, help="包含根 manifest.json 的正式构建目录") + child.add_argument("--report", type=Path, help="JSON 报告路径,必须在构建目录外") + child.add_argument("--previous-version", help="上个发布版本;提供后要求本次版本严格递增") + child.add_argument("--overwrite", action="store_true", help="明确允许覆盖已有 ZIP / 报告普通文件") + if command == "pack": + child.add_argument("--output", type=Path, required=True, help="新 ZIP 路径,必须在构建目录外") + args = parser.parse_args(argv) + root = args.build_dir.expanduser().resolve() + try: + report_path = safe_output(args.report, root, args.overwrite) if args.report else None + output = safe_output(args.output, root, args.overwrite) if args.command == "pack" else None + if output is not None and output == report_path: + raise ValueError("ZIP 和 JSON 报告必须使用不同路径。") + if output is not None and report_path is not None and (output in report_path.parents or report_path in output.parents): + raise ValueError("ZIP 和报告路径不能互为父目录。") + except (OSError, ValueError) as error: + print(f"无法准备输出:{error}", file=sys.stderr) + return 2 + try: + report = check_bundle(args.build_dir, args.previous_version) + if output is not None and not report["errors"]: + try: + pack_bundle(report, output, args.overwrite) + except (OSError, ValueError, zipfile.BadZipFile) as error: + issue(report, "errors", "pack_failed", str(output), f"打包未完成:{error}") + report["status"] = "blocked" + if report_path is not None: + write_report(report, report_path, args.overwrite) + except (OSError, ValueError) as error: + print(f"检查或写入失败:{error}", file=sys.stderr) + return 2 + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 1 if report["errors"] else 0 + + +if __name__ == "__main__": + raise SystemExit(main())