@pzy560117/opentest 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -7
- package/assets/manifest.json +84 -27
- package/assets/skills/opentest/SKILL.md +48 -40
- package/assets/skills/opentest/references/codex-harness-coverage-heuristics.md +55 -55
- package/assets/skills/opentest/references/lifecycle.md +1 -1
- package/assets/skills/opentest/references/matrix-format.md +14 -14
- package/assets/skills/opentest/references/opentest-driven-development.md +32 -32
- package/assets/skills/opentest/templates/acceptance-template.md +1 -1
- package/assets/skills/opentest/templates/matrix-template.md +3 -3
- package/assets/skills/opentest/templates/plan-template.md +17 -17
- package/assets/skills/opentest/templates/report-template.md +1 -1
- package/assets/skills/opentest-accept/SKILL.md +13 -12
- package/assets/skills/opentest-archive/SKILL.md +2 -2
- package/assets/skills/opentest-author/SKILL.md +14 -13
- package/assets/skills/opentest-heal/SKILL.md +2 -2
- package/assets/skills/opentest-plan/SKILL.md +17 -16
- package/assets/skills/opentest-run/SKILL.md +16 -15
- package/assets/skills/opentest-verify/SKILL.md +11 -10
- package/assets/skills-zh/opentest/SKILL.md +93 -0
- package/assets/skills-zh/opentest/references/acceptance-evidence.md +27 -0
- package/assets/skills-zh/opentest/references/codex-harness-coverage-heuristics.md +83 -0
- package/assets/skills-zh/opentest/references/command-routing.md +9 -0
- package/assets/skills-zh/opentest/references/lifecycle.md +16 -0
- package/assets/skills-zh/opentest/references/matrix-format.md +27 -0
- package/assets/skills-zh/opentest/references/opentest-driven-development.md +48 -0
- package/assets/skills-zh/opentest/references/quality-gate.md +24 -0
- package/assets/skills-zh/opentest/templates/acceptance-template.md +32 -0
- package/assets/skills-zh/opentest/templates/archive-layout.md +14 -0
- package/assets/skills-zh/opentest/templates/matrix-template.md +6 -0
- package/assets/skills-zh/opentest/templates/plan-template.md +28 -0
- package/assets/skills-zh/opentest/templates/report-template.md +28 -0
- package/assets/skills-zh/opentest-accept/SKILL.md +25 -0
- package/assets/skills-zh/opentest-archive/SKILL.md +8 -0
- package/assets/skills-zh/opentest-author/SKILL.md +27 -0
- package/assets/skills-zh/opentest-heal/SKILL.md +8 -0
- package/assets/skills-zh/opentest-plan/SKILL.md +30 -0
- package/assets/skills-zh/opentest-run/SKILL.md +28 -0
- package/assets/skills-zh/opentest-verify/SKILL.md +24 -0
- package/bin/opentest.js +137 -29
- package/package.json +1 -1
- package/scripts/prepublish-check.js +105 -6
- package/scripts/smoke-test.js +456 -23
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# OpenTest 验收用例
|
|
2
|
+
|
|
3
|
+
## ACC-001
|
|
4
|
+
|
|
5
|
+
- 意图:
|
|
6
|
+
- 背景:
|
|
7
|
+
- 执行角色:
|
|
8
|
+
- 执行界面:
|
|
9
|
+
- 触发/输入:
|
|
10
|
+
- 期望反馈位置:
|
|
11
|
+
- status: pending
|
|
12
|
+
|
|
13
|
+
### 操作步骤
|
|
14
|
+
|
|
15
|
+
1.
|
|
16
|
+
|
|
17
|
+
### 期望结果
|
|
18
|
+
|
|
19
|
+
-
|
|
20
|
+
|
|
21
|
+
### 反馈契约
|
|
22
|
+
|
|
23
|
+
- 反馈位置:
|
|
24
|
+
- 视觉状态:
|
|
25
|
+
- 重试/恢复:
|
|
26
|
+
- 数据保留:
|
|
27
|
+
|
|
28
|
+
### 证据记录
|
|
29
|
+
|
|
30
|
+
- status:
|
|
31
|
+
- 备注:
|
|
32
|
+
- 产物:
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# OpenTest 测试策略
|
|
2
|
+
|
|
3
|
+
## 变更摘要
|
|
4
|
+
|
|
5
|
+
- 变更类型:
|
|
6
|
+
- 风险等级:
|
|
7
|
+
- 影响面:
|
|
8
|
+
|
|
9
|
+
## 适用覆盖面
|
|
10
|
+
|
|
11
|
+
- 必需:
|
|
12
|
+
- 不适用:
|
|
13
|
+
- gap:
|
|
14
|
+
|
|
15
|
+
## 隐性场景挖掘
|
|
16
|
+
|
|
17
|
+
- 空输入/非法输入:
|
|
18
|
+
- 网络/服务端失败:
|
|
19
|
+
- 权限/会话:
|
|
20
|
+
- 重复提交/并发/过期状态:
|
|
21
|
+
- 加载/空状态/错误/重试:
|
|
22
|
+
- 移动端/可访问性:
|
|
23
|
+
- 安全/敏感信息:
|
|
24
|
+
|
|
25
|
+
## 证据计划
|
|
26
|
+
|
|
27
|
+
| 证据层级 | 适用场景 | 命令或执行面 | 产物路径 | 状态 |
|
|
28
|
+
| --- | --- | --- | --- | --- |
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# OpenTest 验证报告
|
|
2
|
+
|
|
3
|
+
## 摘要
|
|
4
|
+
|
|
5
|
+
- result:
|
|
6
|
+
- 运行模式:
|
|
7
|
+
- 验证时间:
|
|
8
|
+
|
|
9
|
+
## 命令记录
|
|
10
|
+
|
|
11
|
+
| 命令 | 退出码 | result | 日志 |
|
|
12
|
+
| --- | --- | --- | --- |
|
|
13
|
+
|
|
14
|
+
## 验收证据
|
|
15
|
+
|
|
16
|
+
| ID | 是否必需 | result | 证据 | 备注 |
|
|
17
|
+
| --- | --- | --- | --- | --- |
|
|
18
|
+
|
|
19
|
+
## 矩阵覆盖
|
|
20
|
+
|
|
21
|
+
| ID | 证据层级 | 必需证据 | result | 缺口/风险 |
|
|
22
|
+
| --- | --- | --- | --- | --- |
|
|
23
|
+
|
|
24
|
+
## 质量门
|
|
25
|
+
|
|
26
|
+
- 阻塞项:
|
|
27
|
+
- 已接受风险:
|
|
28
|
+
- 非阻塞缺口:
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: opentest-accept
|
|
3
|
+
description: "OpenTest 阶段 4:执行自然语言验收、MCP 验收或真实链路验收,并回写证据。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# OpenTest Accept
|
|
7
|
+
|
|
8
|
+
## 目标
|
|
9
|
+
|
|
10
|
+
执行必需验收项,并把 PASS、FAIL 或 blocked evidence 回写到验收用例和矩阵。
|
|
11
|
+
|
|
12
|
+
## 步骤
|
|
13
|
+
|
|
14
|
+
1. 读取矩阵和 `docs/opentest/acceptance/`。
|
|
15
|
+
2. 对前端交互,优先用 Chrome DevTools MCP 执行真实页面验收。
|
|
16
|
+
3. 对 API 或后台链路,使用项目已有命令或直接 API 检查。
|
|
17
|
+
4. 对反馈类场景,必须观察实际呈现位置和形态;例如字段错误不能只记录“失败”,要记录是否在字段下方、表单顶部、轻提示、模态框或页面错误区显示。
|
|
18
|
+
5. 工具、环境或前置数据不可用时,记录 blocked evidence。
|
|
19
|
+
6. 更新验收记录。
|
|
20
|
+
7. 更新 `docs/loop-handoff/latest.md`(如果项目使用 Loop Handoff),记录验收结果、截图/步骤证据路径、blocked evidence 和下一步。
|
|
21
|
+
8. 运行 `bash "$OPENTEST_GUARD" accept --apply`。
|
|
22
|
+
|
|
23
|
+
## 现有技能路由
|
|
24
|
+
|
|
25
|
+
前端交互验收优先使用自然语言用例加 Chrome DevTools MCP。执行前先确认用例覆盖的是本次适用维度;执行后把 PASS、FAIL 或 blocked 写回对应 ACC ID。
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: opentest-author
|
|
3
|
+
description: "OpenTest 阶段 2:根据矩阵补齐测试资产和自然语言验收用例。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# OpenTest Author
|
|
7
|
+
|
|
8
|
+
## 目标
|
|
9
|
+
|
|
10
|
+
根据 acceptance-to-test matrix 创建或更新测试资产。
|
|
11
|
+
|
|
12
|
+
本阶段把矩阵变成可执行证据:能用项目测试框架证明的写测试;更适合真实链路、浏览器、API 或人工可复验步骤证明的,写自然语言验收用例。不要为了“像 TDD”而把所有交互反馈硬塞进 unit test。
|
|
13
|
+
|
|
14
|
+
## 步骤
|
|
15
|
+
|
|
16
|
+
1. 读取 `.opentest.yaml` 中的 `matrix`。
|
|
17
|
+
2. 对 unit/component/integration/contract 证据,按项目已有测试框架创建或更新测试文件。
|
|
18
|
+
3. 对 E2E、smoke、browser acceptance、真实 API 或跨页面流程,写入 `docs/opentest/acceptance/` 自然语言验收用例。
|
|
19
|
+
4. 对前端反馈类用例,写清反馈位置和形态,例如字段下方错误、表单顶部错误、轻提示、模态框、行内状态或页面错误态。
|
|
20
|
+
5. 对不适用或当前无法补齐的证据,记录原因和风险。
|
|
21
|
+
6. 写入 `.opentest.yaml` 的 `acceptance` 字段。
|
|
22
|
+
7. 更新 `docs/loop-handoff/latest.md`(如果项目使用 Loop Handoff),记录测试资产、自然语言验收用例、gap/risk 和下一步。
|
|
23
|
+
8. 运行 `bash "$OPENTEST_GUARD" author --apply`。
|
|
24
|
+
|
|
25
|
+
## 现有技能路由
|
|
26
|
+
|
|
27
|
+
当矩阵要求代码级测试证据时,优先加载现有 TDD guidance。若项目规则要求特定测试框架,遵循项目规则。若当前任务更适合真实验收而不是新增测试框架代码,记录原因并交给 `opentest-accept`。
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: opentest-plan
|
|
3
|
+
description: "OpenTest 阶段 1:分析变更、风险和项目事实,生成测试策略与 acceptance-to-test matrix。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# OpenTest Plan
|
|
7
|
+
|
|
8
|
+
## 目标
|
|
9
|
+
|
|
10
|
+
生成 `docs/opentest/plans/` 下的测试策略和 `docs/opentest/matrices/` 下的矩阵。
|
|
11
|
+
|
|
12
|
+
本阶段是 OpenTest-driven development 的入口。它必须在实现前把“需求没写但成熟产品默认应该处理”的场景显性化,避免只根据字面需求写少量 happy path 测试。
|
|
13
|
+
|
|
14
|
+
## 步骤
|
|
15
|
+
|
|
16
|
+
1. 读取项目规则、需求、设计、diff、现有测试命令和 `opentest/references/codex-harness-coverage-heuristics.md`。
|
|
17
|
+
2. 如果存在前端、表单、导航、CRUD、状态反馈或动效,优先读取项目内 `docs/frontend/DESIGN.md` 和可用的 `harness-frontend-design` / `product-ui-defaults` 规则。
|
|
18
|
+
3. 判断变更类型、风险等级和适用覆盖面。
|
|
19
|
+
4. 做隐性场景挖掘:空输入、非法输入、网络失败、权限不足、重复提交、长内容、空数据、错误映射、移动端、可访问性、跨页面返回和状态恢复。
|
|
20
|
+
5. 生成窄矩阵,至少包含 `ID`、`意图`、`触发/输入`、`期望行为`、`风险`、`证据层级`、`必需证据`、`状态`。
|
|
21
|
+
6. 对适用但未覆盖的证据面标记 `gap`。
|
|
22
|
+
7. 写入 `.opentest.yaml` 的 `plan` 和 `matrix` 字段。
|
|
23
|
+
8. 更新 `docs/loop-handoff/latest.md`(如果项目使用 Loop Handoff),记录 plan、matrix 路径、当前阶段和下一步。
|
|
24
|
+
9. 运行 `bash "$OPENTEST_GUARD" plan --apply`。
|
|
25
|
+
|
|
26
|
+
## 约束
|
|
27
|
+
|
|
28
|
+
不要把 Codex Harness 全量 checklist 展开成固定必需项。低风险变更保留轻量覆盖摘要,高风险闭环再展开详细验收维度。
|
|
29
|
+
|
|
30
|
+
不要把所有证据都写成 unit test。矩阵要明确哪些用 unit/component/integration/contract/E2E/smoke/browser acceptance/security review 证明。
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: opentest-run
|
|
3
|
+
description: "OpenTest 阶段 3:按 targeted、fast、full 或 ci-like 模式运行项目测试命令。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# OpenTest Run
|
|
7
|
+
|
|
8
|
+
## 目标
|
|
9
|
+
|
|
10
|
+
执行项目已有验证命令并写入运行报告。
|
|
11
|
+
|
|
12
|
+
## 模式
|
|
13
|
+
|
|
14
|
+
- `targeted`:只运行与矩阵相关的测试命令。
|
|
15
|
+
- `fast`:运行快速反馈命令,例如 type、lint、unit。
|
|
16
|
+
- `full`:运行项目完整测试命令。
|
|
17
|
+
- `ci-like`:尽量复现 CI 验证顺序。
|
|
18
|
+
|
|
19
|
+
证据层级由矩阵决定。不要因为存在 `npm test` 就只跑 unit test;如果矩阵要求 integration、contract、E2E、smoke 或安全检查,必须运行对应项目命令,或记录 missing command / blocked。
|
|
20
|
+
|
|
21
|
+
## 步骤
|
|
22
|
+
|
|
23
|
+
1. 读取 `.opentest.yaml` 的 `run_mode` 和矩阵。
|
|
24
|
+
2. 优先使用项目显式命令,按矩阵中的证据层级选择 targeted、fast、full 或 ci-like。
|
|
25
|
+
3. 记录命令、退出码、摘要和日志路径到 `docs/opentest/runs/`。
|
|
26
|
+
4. 写入 `.opentest.yaml` 的 `run_report` 字段。
|
|
27
|
+
5. 更新 `docs/loop-handoff/latest.md`(如果项目使用 Loop Handoff),记录已运行验证、未运行验证、失败/阻塞、run report 路径和下一步。
|
|
28
|
+
6. 运行 `bash "$OPENTEST_GUARD" run --apply`。
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: opentest-verify
|
|
3
|
+
description: "OpenTest 阶段 5:应用质量门并生成验证报告。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# OpenTest Verify
|
|
7
|
+
|
|
8
|
+
## 目标
|
|
9
|
+
|
|
10
|
+
应用质量门,输出结构化验证报告,并把结果写回状态文件。
|
|
11
|
+
|
|
12
|
+
## 验证顺序
|
|
13
|
+
|
|
14
|
+
验证报告按 build、type、lint、test、security、diff 组织。若项目没有某类命令,记录为 missing command 或 not applicable,不把未知状态写成 pass。
|
|
15
|
+
|
|
16
|
+
按 build、type、lint、test、security、diff 的顺序组织证据。必需测试、必需验收、构建、类型或 lint 失败时标记 `fail`。非必需缺口可记录为 `risk-accepted`。写入 `docs/opentest/reports/` 和 `.opentest.yaml` 的 `verification_report`、`verification_result`。
|
|
17
|
+
如果项目使用 Loop Handoff,同时把 OpenTest verification report 路径、verification result、已运行验证、未运行验证、失败/阻塞和下一步写入 `docs/loop-handoff/latest.md`。
|
|
18
|
+
|
|
19
|
+
验证报告还必须回看矩阵,确认:
|
|
20
|
+
|
|
21
|
+
- 每个 required evidence 都有 pass、fail、blocked 或 risk-accepted 结论。
|
|
22
|
+
- 高风险行为不能因为没有工具、没有测试框架或没有 seed data 就直接通过。
|
|
23
|
+
- blocked evidence 必须包含阻塞原因和后续恢复路径。
|
|
24
|
+
- 若产品行为失败,不进入 `heal` 修测试资产;应返回实现或需求修正。
|
package/bin/opentest.js
CHANGED
|
@@ -1,25 +1,57 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { copyFile, mkdir, readdir, rm, stat } from 'fs/promises';
|
|
4
5
|
import path from 'path';
|
|
5
6
|
import { fileURLToPath } from 'url';
|
|
6
7
|
|
|
7
8
|
const __filename = fileURLToPath(import.meta.url);
|
|
8
9
|
const __dirname = path.dirname(__filename);
|
|
9
10
|
const packageRoot = path.resolve(__dirname, '..');
|
|
10
|
-
const
|
|
11
|
+
const manifest = JSON.parse(readFileSync(path.join(packageRoot, 'assets', 'manifest.json'), 'utf8'));
|
|
12
|
+
|
|
13
|
+
function availableIds(entries) {
|
|
14
|
+
return entries.map((entry) => entry.id).join(', ');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function resolveLanguage(languageId) {
|
|
18
|
+
const language = manifest.languages.find((entry) => entry.id === languageId);
|
|
19
|
+
if (!language) {
|
|
20
|
+
throw new Error(`Invalid language: ${languageId}. Available languages: ${availableIds(manifest.languages)}`);
|
|
21
|
+
}
|
|
22
|
+
return language;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolvePlatform(platformId) {
|
|
26
|
+
const platform = manifest.platforms.find((entry) => entry.id === platformId);
|
|
27
|
+
if (!platform) {
|
|
28
|
+
throw new Error(`Invalid platform: ${platformId}. Available platforms: ${availableIds(manifest.platforms)}`);
|
|
29
|
+
}
|
|
30
|
+
return platform;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveScope(scope) {
|
|
34
|
+
const scopes = ['project', 'global'];
|
|
35
|
+
if (!scopes.includes(scope)) {
|
|
36
|
+
throw new Error(`Invalid scope: ${scope}. Available scopes: ${scopes.join(', ')}`);
|
|
37
|
+
}
|
|
38
|
+
return scope;
|
|
39
|
+
}
|
|
11
40
|
|
|
12
41
|
function usage(exitCode = 0) {
|
|
13
42
|
const text = `OpenTest skill installer
|
|
14
43
|
|
|
15
44
|
Usage:
|
|
16
|
-
opentest install [--global] [--
|
|
17
|
-
opentest list
|
|
45
|
+
opentest install [--scope <project|global>] [--platform <id>] [--language <id>] [--project <path>] [--global] [--force]
|
|
46
|
+
opentest list [--language <id>]
|
|
18
47
|
|
|
19
48
|
Options:
|
|
20
|
-
--
|
|
21
|
-
--
|
|
22
|
-
--
|
|
49
|
+
--scope SCOPE Install scope: project or global (default: project)
|
|
50
|
+
--platform ID Target platform: ${availableIds(manifest.platforms)} (default: codex)
|
|
51
|
+
--language ID Skill language: ${availableIds(manifest.languages)} (default: en)
|
|
52
|
+
--project PATH Project root for project scope (default: current directory)
|
|
53
|
+
--global Alias for --scope global --platform codex
|
|
54
|
+
--force Overwrite existing opentest skill directories
|
|
23
55
|
`;
|
|
24
56
|
(exitCode === 0 ? console.log : console.error)(text.trim());
|
|
25
57
|
process.exit(exitCode);
|
|
@@ -27,17 +59,41 @@ Options:
|
|
|
27
59
|
|
|
28
60
|
function parseArgs(argv) {
|
|
29
61
|
const [command, ...rest] = argv;
|
|
30
|
-
const options = {
|
|
62
|
+
const options = {
|
|
63
|
+
command,
|
|
64
|
+
scope: 'project',
|
|
65
|
+
platform: 'codex',
|
|
66
|
+
language: 'en',
|
|
67
|
+
force: false,
|
|
68
|
+
project: process.cwd(),
|
|
69
|
+
};
|
|
31
70
|
|
|
32
71
|
for (let i = 0; i < rest.length; i += 1) {
|
|
33
72
|
const arg = rest[i];
|
|
34
73
|
if (arg === '--global') {
|
|
35
|
-
options.
|
|
74
|
+
options.scope = 'global';
|
|
75
|
+
options.platform = 'codex';
|
|
36
76
|
} else if (arg === '--force') {
|
|
37
77
|
options.force = true;
|
|
78
|
+
} else if (arg === '--scope') {
|
|
79
|
+
const value = rest[i + 1];
|
|
80
|
+
if (!value) usage(1);
|
|
81
|
+
options.scope = value;
|
|
82
|
+
i += 1;
|
|
83
|
+
} else if (arg === '--platform') {
|
|
84
|
+
const value = rest[i + 1];
|
|
85
|
+
if (!value) usage(1);
|
|
86
|
+
options.platform = value;
|
|
87
|
+
i += 1;
|
|
88
|
+
} else if (arg === '--language') {
|
|
89
|
+
const value = rest[i + 1];
|
|
90
|
+
if (!value) usage(1);
|
|
91
|
+
options.language = value;
|
|
92
|
+
i += 1;
|
|
38
93
|
} else if (arg === '--project') {
|
|
39
94
|
const value = rest[i + 1];
|
|
40
95
|
if (!value) usage(1);
|
|
96
|
+
options.scope = 'project';
|
|
41
97
|
options.project = path.resolve(value);
|
|
42
98
|
i += 1;
|
|
43
99
|
} else {
|
|
@@ -56,44 +112,92 @@ function homeDir() {
|
|
|
56
112
|
return home;
|
|
57
113
|
}
|
|
58
114
|
|
|
59
|
-
function
|
|
60
|
-
|
|
61
|
-
|
|
115
|
+
function homeRelativePath(manifestPath) {
|
|
116
|
+
return manifestPath.replace(/^~[\\/]/, '');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function targetSkillsDir(options, platform) {
|
|
120
|
+
if (options.scope === 'global') {
|
|
121
|
+
return path.join(homeDir(), homeRelativePath(platform.globalSkillsDir));
|
|
62
122
|
}
|
|
63
|
-
return path.join(options.project,
|
|
123
|
+
return path.join(options.project, platform.projectSkillsDir);
|
|
64
124
|
}
|
|
65
125
|
|
|
66
|
-
async function listSkills() {
|
|
126
|
+
async function listSkills(language) {
|
|
127
|
+
const skillsSource = path.join(packageRoot, 'assets', language.skillsDir);
|
|
67
128
|
const entries = await readdir(skillsSource, { withFileTypes: true });
|
|
68
129
|
for (const entry of entries.filter((item) => item.isDirectory()).map((item) => item.name).sort()) {
|
|
69
130
|
console.log(entry);
|
|
70
131
|
}
|
|
71
132
|
}
|
|
72
133
|
|
|
73
|
-
|
|
74
|
-
|
|
134
|
+
function managedSkillDirs(currentManifest) {
|
|
135
|
+
return [...new Set([
|
|
136
|
+
...(currentManifest.managedFiles?.localized ?? []),
|
|
137
|
+
...(currentManifest.managedFiles?.shared ?? []),
|
|
138
|
+
].map((file) => file.split(/[\\/]+/)[0]).filter(Boolean))].sort();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function localizedSourceRoot(language) {
|
|
142
|
+
return path.join(packageRoot, 'assets', language.skillsDir);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function sharedSourceRoot() {
|
|
146
|
+
return path.join(packageRoot, 'assets', 'skills');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function exists(filePath) {
|
|
150
|
+
return stat(filePath).then(() => true, () => false);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function copyManifestFile(sourceRoot, targetRoot, relativePath) {
|
|
154
|
+
const src = path.join(sourceRoot, relativePath);
|
|
155
|
+
const dest = path.join(targetRoot, relativePath);
|
|
156
|
+
|
|
157
|
+
if (!(await exists(src))) {
|
|
158
|
+
throw new Error(`Manifest-managed source file is missing: ${src}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
162
|
+
await copyFile(src, dest);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function install(options, currentManifest) {
|
|
166
|
+
const language = resolveLanguage(options.language);
|
|
167
|
+
const platform = resolvePlatform(options.platform);
|
|
168
|
+
resolveScope(options.scope);
|
|
169
|
+
|
|
170
|
+
const target = targetSkillsDir(options, platform);
|
|
75
171
|
await mkdir(target, { recursive: true });
|
|
76
172
|
|
|
77
|
-
const
|
|
78
|
-
const skillDirs = entries.filter((entry) => entry.isDirectory() && entry.name.startsWith('opentest'));
|
|
173
|
+
const skillDirs = managedSkillDirs(currentManifest);
|
|
79
174
|
|
|
80
|
-
for (const
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
const exists = await stat(dest).then(() => true, () => false);
|
|
175
|
+
for (const skillDir of skillDirs) {
|
|
176
|
+
const dest = path.join(target, skillDir);
|
|
177
|
+
const directoryExists = await exists(dest);
|
|
84
178
|
|
|
85
|
-
if (
|
|
179
|
+
if (directoryExists && !options.force) {
|
|
86
180
|
throw new Error(`${dest} already exists. Re-run with --force to overwrite.`);
|
|
87
181
|
}
|
|
182
|
+
}
|
|
88
183
|
|
|
89
|
-
|
|
90
|
-
|
|
184
|
+
if (options.force) {
|
|
185
|
+
for (const skillDir of skillDirs) {
|
|
186
|
+
await rm(path.join(target, skillDir), { recursive: true, force: true });
|
|
91
187
|
}
|
|
92
|
-
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const file of currentManifest.managedFiles?.localized ?? []) {
|
|
191
|
+
await copyManifestFile(localizedSourceRoot(language), target, file);
|
|
192
|
+
}
|
|
193
|
+
for (const file of currentManifest.managedFiles?.shared ?? []) {
|
|
194
|
+
await copyManifestFile(sharedSourceRoot(), target, file);
|
|
93
195
|
}
|
|
94
196
|
|
|
95
197
|
console.log(`Installed ${skillDirs.length} OpenTest skills to ${target}`);
|
|
96
|
-
console.log(
|
|
198
|
+
console.log(`Platform: ${platform.name}`);
|
|
199
|
+
console.log(`Language: ${language.name}`);
|
|
200
|
+
console.log('Restart your AI coding tool or open a new session to pick up newly installed skills.');
|
|
97
201
|
}
|
|
98
202
|
|
|
99
203
|
async function main() {
|
|
@@ -103,13 +207,17 @@ async function main() {
|
|
|
103
207
|
usage(0);
|
|
104
208
|
}
|
|
105
209
|
|
|
210
|
+
const language = resolveLanguage(options.language);
|
|
211
|
+
const platform = resolvePlatform(options.platform);
|
|
212
|
+
resolveScope(options.scope);
|
|
213
|
+
|
|
106
214
|
if (options.command === 'list') {
|
|
107
|
-
await listSkills();
|
|
215
|
+
await listSkills(language);
|
|
108
216
|
return;
|
|
109
217
|
}
|
|
110
218
|
|
|
111
219
|
if (options.command === 'install') {
|
|
112
|
-
await install(options);
|
|
220
|
+
await install(options, manifest);
|
|
113
221
|
return;
|
|
114
222
|
}
|
|
115
223
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { readFileSync, readdirSync, statSync } from 'fs';
|
|
4
|
-
import { extname, join } from 'path';
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
4
|
+
import { basename, extname, join } from 'path';
|
|
5
|
+
import { TextDecoder } from 'util';
|
|
5
6
|
|
|
6
7
|
const SECRET_PATTERNS = [
|
|
7
8
|
{ pattern: /(?:api[_-]?key|apikey)\s*[:=]\s*['"][A-Za-z0-9_-]{20,}['"]/i, name: 'API key' },
|
|
@@ -14,7 +15,52 @@ const SECRET_PATTERNS = [
|
|
|
14
15
|
];
|
|
15
16
|
|
|
16
17
|
const SKIP_DIRS = new Set(['node_modules', '.git']);
|
|
17
|
-
const TEXT_EXTENSIONS = new Set([
|
|
18
|
+
const TEXT_EXTENSIONS = new Set([
|
|
19
|
+
'.js',
|
|
20
|
+
'.json',
|
|
21
|
+
'.md',
|
|
22
|
+
'.txt',
|
|
23
|
+
'.yml',
|
|
24
|
+
'.yaml',
|
|
25
|
+
'.toml',
|
|
26
|
+
'.sh',
|
|
27
|
+
'.env',
|
|
28
|
+
'.pem',
|
|
29
|
+
'.key',
|
|
30
|
+
'.crt',
|
|
31
|
+
'.cer',
|
|
32
|
+
]);
|
|
33
|
+
const DOT_TEXT_FILENAMES = new Set(['.env']);
|
|
34
|
+
const MAX_TEXT_SCAN_BYTES = 1024 * 1024;
|
|
35
|
+
const MAX_EXTENSIONLESS_TEXT_SCAN_BYTES = 64 * 1024;
|
|
36
|
+
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
|
37
|
+
|
|
38
|
+
function assertManifest(condition, message) {
|
|
39
|
+
if (!condition) {
|
|
40
|
+
console.error(`[MANIFEST] ${message}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function loadManifest() {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(readFileSync('assets/manifest.json', 'utf8'));
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error(`[MANIFEST] failed to load assets/manifest.json: ${error.message}`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validateManifestForPublish(manifest) {
|
|
55
|
+
assertManifest(Array.isArray(manifest.languages), 'languages must be an array');
|
|
56
|
+
assertManifest(
|
|
57
|
+
manifest.languages.some((entry) => entry.id === 'zh'),
|
|
58
|
+
'languages must include zh',
|
|
59
|
+
);
|
|
60
|
+
assertManifest(Array.isArray(manifest.managedFiles?.localized), 'managedFiles.localized must exist');
|
|
61
|
+
assertManifest(Array.isArray(manifest.managedFiles?.shared), 'managedFiles.shared must exist');
|
|
62
|
+
assertManifest(existsSync('assets/skills-zh') && statSync('assets/skills-zh').isDirectory(), 'assets/skills-zh must exist');
|
|
63
|
+
}
|
|
18
64
|
|
|
19
65
|
function* walkFiles(dir) {
|
|
20
66
|
for (const entry of readdirSync(dir)) {
|
|
@@ -30,17 +76,70 @@ function* walkFiles(dir) {
|
|
|
30
76
|
}
|
|
31
77
|
}
|
|
32
78
|
|
|
79
|
+
function scanByteLimit(filePath, fileStat) {
|
|
80
|
+
const filename = basename(filePath).toLowerCase();
|
|
81
|
+
const extension = extname(filename);
|
|
82
|
+
|
|
83
|
+
if (TEXT_EXTENSIONS.has(extension) || DOT_TEXT_FILENAMES.has(filename)) {
|
|
84
|
+
return MAX_TEXT_SCAN_BYTES;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (extension === '' && fileStat.size <= MAX_EXTENSIONLESS_TEXT_SCAN_BYTES) {
|
|
88
|
+
return MAX_EXTENSIONLESS_TEXT_SCAN_BYTES;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function looksLikeText(buffer) {
|
|
95
|
+
if (buffer.length === 0) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let controlBytes = 0;
|
|
100
|
+
for (const byte of buffer) {
|
|
101
|
+
if (byte === 0) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
if (byte < 9 || (byte > 13 && byte < 32)) {
|
|
105
|
+
controlBytes += 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return controlBytes / buffer.length < 0.05;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readScannableText(filePath) {
|
|
113
|
+
const fileStat = statSync(filePath);
|
|
114
|
+
const byteLimit = scanByteLimit(filePath, fileStat);
|
|
115
|
+
if (byteLimit === 0 || fileStat.size > byteLimit) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const content = readFileSync(filePath);
|
|
120
|
+
if (!looksLikeText(content)) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
return utf8Decoder.decode(content);
|
|
126
|
+
} catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
validateManifestForPublish(loadManifest());
|
|
132
|
+
|
|
33
133
|
let found = 0;
|
|
34
134
|
|
|
35
135
|
for (const filePath of walkFiles('.')) {
|
|
36
|
-
if (!TEXT_EXTENSIONS.has(extname(filePath))) continue;
|
|
37
|
-
|
|
38
136
|
let content;
|
|
39
137
|
try {
|
|
40
|
-
content =
|
|
138
|
+
content = readScannableText(filePath);
|
|
41
139
|
} catch {
|
|
42
140
|
continue;
|
|
43
141
|
}
|
|
142
|
+
if (content === undefined) continue;
|
|
44
143
|
|
|
45
144
|
for (const { pattern, name } of SECRET_PATTERNS) {
|
|
46
145
|
if (pattern.test(content)) {
|