global-app-skill 0.0.0-stage → 0.1.1
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 +61 -2
- package/package.json +20 -4
- package/skills/overseas-app-research/SKILL.md +18 -0
- package/src/cli.mjs +52 -0
- package/src/protocol.mjs +52 -0
- package/src/resources.mjs +115 -0
- package/src/run.mjs +21 -0
- package/src/skill.mjs +22 -0
package/README.md
CHANGED
|
@@ -1,3 +1,62 @@
|
|
|
1
|
-
#
|
|
1
|
+
# global-app-skill
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
海外 Android App 调研 skill 的薄入口、资源更新器和脚本 CLI。保持 `overseas-app-research` 的下载、业务调研与 UED 图册三个分支。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
要求 Node.js >=22.12;执行调研脚本需要 `python3` >=3.9,Android 设备、SDK 和其他工具由工作流按需检查。
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx -y --prefer-online global-app-skill@latest skill install
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
默认只安装 `~/.agents/skills/overseas-app-research/SKILL.md`。也可用 `--dir /absolute/skills/overseas-app-research` 指定当前客户端实际使用的 skill 目录。已存在的 SKILL.md 会被原子替换,目录中的其他文件保留,符号链接入口拒绝覆盖。安装不下载 references,也不修改 MCP 或客户端配置。安装后让客户端重新加载 skills。
|
|
14
|
+
|
|
15
|
+
## 执行
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npx -y --prefer-online global-app-skill@latest context resolve --mode research
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`--mode` 为 `download`、`research` 或 `ued`。CLI 每次查询 GitHub 的 `releases/latest.json`,验证清单、压缩包及逐文件 SHA-256,返回绝对 `resourceRoot` 和有序 `mustRead`。agent 读取这些文件后按原工作流编排任务。
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npx -y --prefer-online global-app-skill@latest run preflight --mode research --resource-root /absolute/resolved/hash -- --report /absolute/task/environment.json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`run` 支持 `preflight`、`download`、`export_device`、`verify`、`attempts`、`inspect_package`、`long_screenshot`,`--` 后参数原样传递。它保留当前工作目录、Python 输出和退出码;下载模式进度使用 `/4`,业务/UED 使用 `/10`。
|
|
28
|
+
|
|
29
|
+
同一任务固定在已读取的快照上执行,新任务重新 resolve。省略 `--resource-root` 的 `run` 会先获取当前资源。设备环境门槛、原包与证据规则保持在动态工作流内;CLI 不自动完成反编译、页面操作、Mock 或业务验收。
|
|
30
|
+
|
|
31
|
+
## 结构与更新机制
|
|
32
|
+
|
|
33
|
+
| 路径 | 职责 |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `skills/overseas-app-research/SKILL.md` | 安装到客户端的薄入口 |
|
|
36
|
+
| `src/` | npm CLI:安装、资源解析、校验、脚本运行 |
|
|
37
|
+
| `resources/overseas-app-research/references/` | 主流程和各阶段操作文档 |
|
|
38
|
+
| `resources/overseas-app-research/scripts/` | 原有 Python 脚本及回归测试 |
|
|
39
|
+
| `resources/overseas-app-research/examples/` | 业务报告模板 |
|
|
40
|
+
| `scripts/build-resources.mjs` | 生成确定性的资源包与清单 |
|
|
41
|
+
| `releases/` | GitHub 托管的最新清单与按哈希命名的资源包 |
|
|
42
|
+
|
|
43
|
+
分发源为 `https://raw.githubusercontent.com/shiguang-lab/global-app-skill/main/releases/latest.json`。npm 包仅包含 CLI 与薄 skill,工作流资源独立从 GitHub 更新,无需为文档或 Python 脚本修改发布 npm 新版本。
|
|
44
|
+
|
|
45
|
+
缓存默认位于 `~/.cache/global-app-skill/releases/<resourceHash>`,可用 `GLOBAL_APP_SKILL_CACHE` 或 `--cache-dir` 指定。下载先在临时目录完整校验,再发布快照。网络或完整性失败会报错,不静默使用旧资源;损坏的固定快照拒绝执行,重新 resolve 可修复。旧快照保留供现有任务使用。运行产物写入工作区,不写回资源缓存。
|
|
46
|
+
|
|
47
|
+
## 开发与发布
|
|
48
|
+
|
|
49
|
+
在当前仓库编辑资源后:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
npm run resources:build
|
|
53
|
+
npm test
|
|
54
|
+
npm run test:python
|
|
55
|
+
npm run resources:check
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Python 回归测试中的长图部分需要 Pillow。CLI 无第三方 Node 依赖。`--feed` 可指定 HTTPS 清单,测试允许 loopback HTTP,用于在发布前验证同一协议。
|
|
59
|
+
|
|
60
|
+
将资源源文件、`releases/latest.json` 和新资源包一起提交并 push 到 GitHub 的 `main`。保留旧资源包,避免读到旧清单的客户端遇到 404。只修改资源时到此即可,后续任务自动获取最新快照;每个快照完整包含 references、scripts 和 examples,删除的源文件不会残留到新快照。
|
|
61
|
+
|
|
62
|
+
修改 CLI 或薄入口时更新 `package.json` 的版本,完成检查并推送代码后执行 `npm publish`。`prepublishOnly` 会执行 Node/Python 测试和资源一致性检查。初次发布需要 npm 登录和账号所要求的发布认证;GitHub 仓库需公开可读,才能使用默认免认证资源源。当前项目未授予开源许可证,npm 元数据使用 `UNLICENSED`。
|
package/package.json
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "global-app-skill",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Thin skill bootstrap and GitHub resource runner for overseas Android app research",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/shiguang-lab/global-app-skill.git"
|
|
10
|
+
},
|
|
11
|
+
"engines": { "node": ">=22.12.0" },
|
|
12
|
+
"bin": { "global-app-skill": "src/cli.mjs" },
|
|
13
|
+
"files": ["src", "skills"],
|
|
14
|
+
"publishConfig": { "registry": "https://registry.npmjs.org/", "access": "public" },
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test tests/*.test.mjs",
|
|
17
|
+
"test:python": "python3 -m unittest discover -s resources/overseas-app-research/scripts -p 'test_*.py'",
|
|
18
|
+
"resources:build": "node scripts/build-resources.mjs",
|
|
19
|
+
"resources:check": "node scripts/build-resources.mjs --check",
|
|
20
|
+
"prepublishOnly": "npm test && npm run test:python && npm run resources:check"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: overseas-app-research
|
|
3
|
+
description: 调研海外 Android App 的业务流程、功能入口、字段权限,或为 UED 提取整款 App/指定范围的原始界面图册;也支持仅下载/导出 APK。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 海外 App 调研
|
|
7
|
+
|
|
8
|
+
需要 Node.js >=22.12、`python3` >=3.9;设备/AVD 门槛由解析后的工作流检查。
|
|
9
|
+
|
|
10
|
+
按目标选择 `download`(仅取包)、`research`(业务调研,含同时需要图册)或 `ued`(仅界面图册),执行:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npx -y --prefer-online global-app-skill@latest context resolve --mode MODE
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
依次读取返回的全部 `mustRead`,按其中流程执行;以返回的 `resourceRoot` 解析文档、模板和脚本。新任务重新 resolve,同一任务沿用已读快照。更新或校验失败时停止依赖工作并报告原因,不静默使用旧资源。
|
|
17
|
+
|
|
18
|
+
脚本通过 `npx -y --prefer-online global-app-skill@latest run SCRIPT --mode MODE --resource-root RESOURCE_ROOT -- 参数` 执行;可用脚本及参数见解析后的 CLI 文档。业务判断与页面操作由 agent 按文档编排,CLI 成功不代表调研完成;关键进度仍须输出到聊天正文。
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { installSkill } from './skill.mjs';
|
|
4
|
+
import { resolveContext } from './resources.mjs';
|
|
5
|
+
import { runScript } from './run.mjs';
|
|
6
|
+
import { validateMode, VERSION } from './protocol.mjs';
|
|
7
|
+
|
|
8
|
+
const usage = `global-app-skill ${VERSION}
|
|
9
|
+
skill install [--dir ABSOLUTE_SKILL_DIRECTORY]
|
|
10
|
+
context resolve --mode download|research|ued [--cache-dir PATH] [--feed HTTPS_MANIFEST_URL]
|
|
11
|
+
run SCRIPT [--mode download|research|ued] [--resource-root PATH] [--cache-dir PATH] [--feed URL] -- SCRIPT_ARGS
|
|
12
|
+
--version
|
|
13
|
+
|
|
14
|
+
skill install writes only SKILL.md; default: ~/.agents/skills/overseas-app-research.
|
|
15
|
+
run uses a verified pinned snapshot when --resource-root is supplied; otherwise it resolves current resources.
|
|
16
|
+
Script stdout/stderr and exit code are preserved. JSON command results go to stdout; progress goes to stderr.
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
export async function main(argv) {
|
|
20
|
+
const separator = argv.indexOf('--');
|
|
21
|
+
const { values, positionals } = parseArgs({
|
|
22
|
+
args: separator < 0 ? argv : argv.slice(0, separator), allowPositionals: true,
|
|
23
|
+
options: { dir: { type: 'string' }, mode: { type: 'string' }, 'resource-root': { type: 'string' },
|
|
24
|
+
'cache-dir': { type: 'string' }, feed: { type: 'string' }, help: { type: 'boolean' }, version: { type: 'boolean' } },
|
|
25
|
+
});
|
|
26
|
+
if (values.version) { console.log(VERSION); return; }
|
|
27
|
+
if (values.help || positionals.length === 0) { console.log(usage); return; }
|
|
28
|
+
const [command, action] = positionals;
|
|
29
|
+
if (positionals.length !== 2 || (separator >= 0 && command !== 'run')) throw new Error('Invalid command; use --help');
|
|
30
|
+
const allowed = command === 'skill' && action === 'install' ? ['dir'] :
|
|
31
|
+
command === 'context' && action === 'resolve' ? ['mode', 'cache-dir', 'feed'] :
|
|
32
|
+
command === 'run' ? ['mode', 'resource-root', 'cache-dir', 'feed'] : null;
|
|
33
|
+
if (!allowed) throw new Error('Unknown command; use --help');
|
|
34
|
+
for (const key of Object.keys(values)) {
|
|
35
|
+
if (!allowed.includes(key)) throw new Error(`--${key} is not supported by ${command} ${action}`);
|
|
36
|
+
}
|
|
37
|
+
const options = { mode: values.mode || 'research', resourceRoot: values['resource-root'], cacheDir: values['cache-dir'],
|
|
38
|
+
feed: values.feed, progress: message => process.stderr.write(`[global-app-skill] ${message}\n`) };
|
|
39
|
+
if (command !== 'skill') validateMode(command === 'context' ? values.mode : options.mode);
|
|
40
|
+
if (command === 'run') {
|
|
41
|
+
if (options.resourceRoot && (values.feed || values['cache-dir'])) throw new Error('--resource-root cannot be combined with --feed or --cache-dir');
|
|
42
|
+
process.exitCode = await runScript(action, separator < 0 ? [] : argv.slice(separator + 1), options);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const output = command === 'skill' ? await installSkill(values.dir) : await resolveContext(options);
|
|
46
|
+
console.log(JSON.stringify(output, null, 2));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
main(process.argv.slice(2)).catch(error => {
|
|
50
|
+
process.stderr.write(JSON.stringify({ ok: false, error: error.message }) + '\n');
|
|
51
|
+
process.exitCode = 1;
|
|
52
|
+
});
|
package/src/protocol.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url))).version;
|
|
5
|
+
export const FEED = 'https://raw.githubusercontent.com/shiguang-lab/global-app-skill/main/releases/latest.json';
|
|
6
|
+
export const MAX_BYTES = 16 * 1024 * 1024;
|
|
7
|
+
export const MODES = {
|
|
8
|
+
download: 'references/download-workflow.md',
|
|
9
|
+
research: 'references/research-workflow.md',
|
|
10
|
+
ued: 'references/ued-workflow.md',
|
|
11
|
+
};
|
|
12
|
+
export const SCRIPTS = ['preflight', 'download', 'export_device', 'verify', 'attempts', 'inspect_package', 'long_screenshot'];
|
|
13
|
+
export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
14
|
+
export const identity = (files) => sha256(JSON.stringify(files));
|
|
15
|
+
|
|
16
|
+
export function validateMode(mode) {
|
|
17
|
+
if (!Object.hasOwn(MODES, mode)) throw new Error('--mode must be download, research or ued');
|
|
18
|
+
return mode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function validateManifest(manifest) {
|
|
22
|
+
const hash = /^[a-f0-9]{64}$/;
|
|
23
|
+
if (manifest?.schemaVersion !== 1 || !hash.test(manifest.resourceHash) ||
|
|
24
|
+
!/^\d+\.\d+\.\d+$/.test(manifest.minCliVersion) ||
|
|
25
|
+
!Array.isArray(manifest.files) || !manifest.files.length || manifest.files.length > 1024 ||
|
|
26
|
+
manifest.bundle !== `${manifest.resourceHash}.json.gz` || !hash.test(manifest.sha256) ||
|
|
27
|
+
!Number.isSafeInteger(manifest.size) || manifest.size <= 0 || manifest.size > MAX_BYTES) {
|
|
28
|
+
throw new Error('Invalid resource manifest');
|
|
29
|
+
}
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
let total = 0;
|
|
32
|
+
for (const file of manifest.files) {
|
|
33
|
+
if (typeof file.path !== 'string' || !/^(references|scripts|examples)\//.test(file.path) ||
|
|
34
|
+
file.path.split('/').some(part => !/^[a-zA-Z0-9_.-]+$/.test(part) || part === '.' || part === '..') ||
|
|
35
|
+
seen.has(file.path) || !hash.test(file.sha256) ||
|
|
36
|
+
!Number.isSafeInteger(file.size) || file.size < 0 || (total += file.size) > MAX_BYTES) {
|
|
37
|
+
throw new Error('Invalid resource file entry');
|
|
38
|
+
}
|
|
39
|
+
seen.add(file.path);
|
|
40
|
+
}
|
|
41
|
+
if (identity(manifest.files) !== manifest.resourceHash) throw new Error('Resource manifest hash mismatch');
|
|
42
|
+
const required = ['references/guide.md', 'references/cli.md', 'references/orchestration.md',
|
|
43
|
+
...Object.values(MODES), ...SCRIPTS.map(name => `scripts/${name}.py`), 'scripts/apk_utils.py', 'scripts/progress.py'];
|
|
44
|
+
if (required.some(name => !seen.has(name))) throw new Error('Resource manifest is missing workflow files');
|
|
45
|
+
const installed = VERSION.split('.').map(Number);
|
|
46
|
+
const minimum = manifest.minCliVersion.split('.').map(Number);
|
|
47
|
+
for (let i = 0; i < 3; i++) {
|
|
48
|
+
if (installed[i] > minimum[i]) break;
|
|
49
|
+
if (installed[i] < minimum[i]) throw new Error(`Resources require CLI >=${manifest.minCliVersion}; rerun with global-app-skill@latest`);
|
|
50
|
+
}
|
|
51
|
+
return manifest;
|
|
52
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { gunzipSync } from 'node:zlib';
|
|
6
|
+
import { FEED, MAX_BYTES, MODES, sha256, validateManifest, validateMode } from './protocol.mjs';
|
|
7
|
+
|
|
8
|
+
async function download(url, limit) {
|
|
9
|
+
const response = await fetch(url, { cache: 'no-store', signal: AbortSignal.timeout(30000) });
|
|
10
|
+
if (!response.ok) throw new Error(`Cannot download ${url}: HTTP ${response.status}`);
|
|
11
|
+
const chunks = [];
|
|
12
|
+
let size = 0;
|
|
13
|
+
for await (const chunk of response.body) {
|
|
14
|
+
size += chunk.length;
|
|
15
|
+
if (size > limit) {
|
|
16
|
+
throw new Error('Resource download exceeds limit');
|
|
17
|
+
}
|
|
18
|
+
chunks.push(chunk);
|
|
19
|
+
}
|
|
20
|
+
return Buffer.concat(chunks);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function readSnapshot(resourceRoot) {
|
|
24
|
+
resourceRoot = path.resolve(resourceRoot);
|
|
25
|
+
if (!(await lstat(resourceRoot)).isDirectory()) throw new Error('Resource root must be a real directory');
|
|
26
|
+
const manifestPath = path.join(resourceRoot, 'manifest.json');
|
|
27
|
+
if (!(await lstat(manifestPath)).isFile()) throw new Error('Resource manifest must be a regular file');
|
|
28
|
+
const manifest = validateManifest(JSON.parse(await readFile(manifestPath, 'utf8')));
|
|
29
|
+
if (path.basename(resourceRoot) !== manifest.resourceHash) throw new Error('Resource root does not match its snapshot hash');
|
|
30
|
+
for (const file of manifest.files) {
|
|
31
|
+
let directory = resourceRoot;
|
|
32
|
+
for (const segment of file.path.split('/').slice(0, -1)) {
|
|
33
|
+
directory = path.join(directory, segment);
|
|
34
|
+
if (!(await lstat(directory)).isDirectory()) throw new Error(`Invalid resource directory: ${directory}`);
|
|
35
|
+
}
|
|
36
|
+
const filename = path.join(resourceRoot, file.path);
|
|
37
|
+
const stat = await lstat(filename);
|
|
38
|
+
if (!stat.isFile() || stat.size !== file.size || sha256(await readFile(filename)) !== file.sha256) {
|
|
39
|
+
throw new Error(`Resource integrity check failed: ${file.path}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return manifest;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function validSnapshot(root, resourceHash) {
|
|
46
|
+
try { return (await readSnapshot(root)).resourceHash === resourceHash; }
|
|
47
|
+
catch { return false; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function resolveContext({ mode, feed = FEED, cacheDir, progress = () => {} } = {}) {
|
|
51
|
+
validateMode(mode);
|
|
52
|
+
const url = new URL(feed);
|
|
53
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) || url.username || url.password) {
|
|
54
|
+
throw new Error('--feed must be HTTPS (HTTP is allowed only for loopback testing)');
|
|
55
|
+
}
|
|
56
|
+
progress('Checking GitHub resource manifest');
|
|
57
|
+
const manifest = validateManifest(JSON.parse((await download(url, 1024 * 1024)).toString('utf8')));
|
|
58
|
+
const root = path.resolve(cacheDir || process.env.GLOBAL_APP_SKILL_CACHE || path.join(homedir(), '.cache/global-app-skill'));
|
|
59
|
+
const releases = path.join(root, 'releases');
|
|
60
|
+
const resourceRoot = path.join(releases, manifest.resourceHash);
|
|
61
|
+
let updated = false;
|
|
62
|
+
if (!(await validSnapshot(resourceRoot, manifest.resourceHash))) {
|
|
63
|
+
progress(`Downloading and verifying ${manifest.files.length} resource files`);
|
|
64
|
+
const bytes = await download(new URL(manifest.bundle, url), MAX_BYTES);
|
|
65
|
+
if (bytes.length !== manifest.size || sha256(bytes) !== manifest.sha256) throw new Error('Resource bundle integrity check failed');
|
|
66
|
+
const bundle = JSON.parse(gunzipSync(bytes, { maxOutputLength: MAX_BYTES * 2 }).toString('utf8'));
|
|
67
|
+
if (!Array.isArray(bundle.files) || bundle.files.length !== manifest.files.length) throw new Error('Invalid resource bundle');
|
|
68
|
+
// Check the complete payload before writing anything into the snapshot.
|
|
69
|
+
const files = bundle.files.map((file, index) => {
|
|
70
|
+
const expected = manifest.files[index];
|
|
71
|
+
if (file.path !== expected.path || typeof file.content !== 'string') throw new Error('Resource bundle file mismatch');
|
|
72
|
+
const content = Buffer.from(file.content, 'base64');
|
|
73
|
+
if (content.length !== expected.size || sha256(content) !== expected.sha256) throw new Error(`Resource bundle integrity check failed: ${file.path}`);
|
|
74
|
+
return { path: expected.path, content };
|
|
75
|
+
});
|
|
76
|
+
await mkdir(releases, { recursive: true });
|
|
77
|
+
const staging = await mkdtemp(path.join(releases, '.prepare-'));
|
|
78
|
+
try {
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
const target = path.join(staging, file.path);
|
|
81
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
82
|
+
await writeFile(target, file.content, { flag: 'wx' });
|
|
83
|
+
}
|
|
84
|
+
await writeFile(path.join(staging, 'manifest.json'), JSON.stringify(manifest));
|
|
85
|
+
const lock = path.join(releases, `.${manifest.resourceHash}.lock`);
|
|
86
|
+
const deadline = Date.now() + 5000;
|
|
87
|
+
for (;;) {
|
|
88
|
+
try { await mkdir(lock); break; }
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error.code !== 'EEXIST') throw error;
|
|
91
|
+
if (Date.now() >= deadline) throw new Error(`Snapshot publication is locked: ${lock}; retry after the other updater finishes. Remove a stale lock only when no updater is running.`);
|
|
92
|
+
await delay(50);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
// Recheck under the lock so a concurrent resolver's completed snapshot
|
|
97
|
+
// cannot be deleted after an earlier ENOENT integrity check.
|
|
98
|
+
if (!(await validSnapshot(resourceRoot, manifest.resourceHash))) {
|
|
99
|
+
await rm(resourceRoot, { recursive: true, force: true });
|
|
100
|
+
await rename(staging, resourceRoot);
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
await rm(lock, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
updated = true;
|
|
106
|
+
} finally { await rm(staging, { recursive: true, force: true }); }
|
|
107
|
+
}
|
|
108
|
+
await readSnapshot(resourceRoot);
|
|
109
|
+
progress(updated ? 'Resource snapshot ready' : 'Verified cached snapshot is current');
|
|
110
|
+
const mustRead = ['references/cli.md', 'references/guide.md', 'references/orchestration.md', MODES[mode]]
|
|
111
|
+
.map(name => path.join(resourceRoot, name));
|
|
112
|
+
return { ok: true, source: url.hostname === 'raw.githubusercontent.com' ? 'github' : 'custom', feed: url.href,
|
|
113
|
+
mode, resourceHash: manifest.resourceHash, resourceRoot,
|
|
114
|
+
referencePath: path.join(resourceRoot, 'references/guide.md'), mustRead, updated };
|
|
115
|
+
}
|
package/src/run.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { SCRIPTS } from './protocol.mjs';
|
|
5
|
+
import { readSnapshot, resolveContext } from './resources.mjs';
|
|
6
|
+
|
|
7
|
+
export async function runScript(script, args, options = {}) {
|
|
8
|
+
if (!SCRIPTS.includes(script)) throw new Error(`Unknown script; choose ${SCRIPTS.join(', ')}`);
|
|
9
|
+
const resourceRoot = options.resourceRoot ? path.resolve(options.resourceRoot) : (await resolveContext(options)).resourceRoot;
|
|
10
|
+
await readSnapshot(resourceRoot);
|
|
11
|
+
await promisify(execFile)('python3', ['-c', 'import sys; sys.exit(0 if sys.version_info >= (3, 9) else 1)'])
|
|
12
|
+
.catch(() => { throw new Error('Python >=3.9 must be available as python3'); });
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const child = spawn('python3', [path.join(resourceRoot, 'scripts', `${script}.py`), ...args], {
|
|
15
|
+
stdio: 'inherit',
|
|
16
|
+
env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1', APP_RESEARCH_TOTAL_STAGES: options.mode === 'download' ? '4' : '10' },
|
|
17
|
+
});
|
|
18
|
+
child.once('error', reject);
|
|
19
|
+
child.once('exit', (code, signal) => resolve(code ?? (signal === 'SIGINT' ? 130 : 1)));
|
|
20
|
+
});
|
|
21
|
+
}
|
package/src/skill.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export async function installSkill(directory) {
|
|
7
|
+
const target = path.resolve(directory || path.join(homedir(), '.agents/skills/overseas-app-research'));
|
|
8
|
+
const skillPath = path.join(target, 'SKILL.md');
|
|
9
|
+
for (const filename of [target, skillPath]) {
|
|
10
|
+
try {
|
|
11
|
+
if ((await lstat(filename)).isSymbolicLink()) throw new Error(`Refusing to replace a symlinked skill: ${filename}`);
|
|
12
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
13
|
+
}
|
|
14
|
+
const content = await readFile(new URL('../skills/overseas-app-research/SKILL.md', import.meta.url), 'utf8');
|
|
15
|
+
await mkdir(target, { recursive: true });
|
|
16
|
+
const temporary = path.join(target, `.SKILL-${randomUUID()}.tmp`);
|
|
17
|
+
try {
|
|
18
|
+
await writeFile(temporary, content, { flag: 'wx' });
|
|
19
|
+
await rename(temporary, skillPath);
|
|
20
|
+
} finally { await rm(temporary, { force: true }); }
|
|
21
|
+
return { ok: true, skillPath };
|
|
22
|
+
}
|