global-app-skill 0.1.0 → 0.1.2

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 CHANGED
@@ -10,7 +10,7 @@
10
10
  npx -y --prefer-online global-app-skill@latest skill install
11
11
  ```
12
12
 
13
- 默认只安装 `~/.agents/skills/overseas-app-research/SKILL.md`。也可用 `--dir /absolute/skills/overseas-app-research` 指定当前客户端实际使用的 skill 目录。已存在的 SKILL.md 会被原子替换,目录中的其他文件保留,符号链接入口拒绝覆盖。安装不下载 references,也不修改 MCP 或客户端配置。安装后让客户端重新加载 skills。
13
+ 默认只安装 `~/.agents/skills/overseas-app-research/SKILL.md`。也可用 `--dir /absolute/skills/overseas-app-research` 指定当前客户端实际使用的 skill 目录。安装器把该目录写入入口的 `--skill-dir` 参数,并在同一文件中记录 CLI 版本。已存在的 SKILL.md 会被原子替换,目录中的其他文件保留,符号链接入口拒绝覆盖。安装不下载 references,也不修改 MCP 或客户端配置。
14
14
 
15
15
  ## 执行
16
16
 
@@ -20,6 +20,12 @@ npx -y --prefer-online global-app-skill@latest context resolve --mode research
20
20
 
21
21
  `--mode` 为 `download`、`research` 或 `ued`。CLI 每次查询 GitHub 的 `releases/latest.json`,验证清单、压缩包及逐文件 SHA-256,返回绝对 `resourceRoot` 和有序 `mustRead`。agent 读取这些文件后按原工作流编排任务。
22
22
 
23
+ `context resolve` 和 `run` 执行前会将已安装的薄入口同步为当前 CLI 捆绑的版本。入口使用 `npx --prefer-online ...@latest`,因此发布新版 CLI 后,下一次执行会更新本地 SKILL.md。传入 `--skill-dir /absolute/skill-directory` 可精确定位安装目录;安装器已自动写入该参数。未传入时仅检查 `~/.agents/skills/overseas-app-research` 和 `${CODEX_HOME:-~/.codex}/skills/overseas-app-research` 中现有的 CLI 入口,不创建新 skill,不覆盖手工维护的其他实现或符号链接,也不降级更新的入口。
24
+
25
+ 自定义目录中的入口若未包含 `--skill-dir`,重新执行一次 `skill install --dir ...` 写入准确目录。`context resolve` 的 `skillUpdate` 单独报告入口同步结果;同步失败会明确显示,资源解析仍可独立进行。脚本 stdout 和退出码保持不变,同步日志写入 stderr。
26
+
27
+ 本地文件更新后,新入口在客户端重新加载 skill 时采用;已经读入当前任务的上下文不因磁盘替换自动改写。[Codex 官方文档](https://learn.chatgpt.com/docs/build-skills)说明会自动检测 skill 变更;如果没有显示更新,可重启 Codex。其他客户端遵循各自的重新加载规则。
28
+
23
29
  ```sh
24
30
  npx -y --prefer-online global-app-skill@latest run preflight --mode research --resource-root /absolute/resolved/hash -- --report /absolute/task/environment.json
25
31
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "global-app-skill",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Thin skill bootstrap and GitHub resource runner for overseas Android app research",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -10,9 +10,9 @@ description: 调研海外 Android App 的业务流程、功能入口、字段权
10
10
  按目标选择 `download`(仅取包)、`research`(业务调研,含同时需要图册)或 `ued`(仅界面图册),执行:
11
11
 
12
12
  ```sh
13
- npx -y --prefer-online global-app-skill@latest context resolve --mode MODE
13
+ npx -y --prefer-online global-app-skill@latest context resolve --skill-dir SKILL_DIRECTORY --mode MODE
14
14
  ```
15
15
 
16
- 依次读取返回的全部 `mustRead`,按其中流程执行;以返回的 `resourceRoot` 解析文档、模板和脚本。新任务重新 resolve,同一任务沿用已读快照。更新或校验失败时停止依赖工作并报告原因,不静默使用旧资源。
16
+ `SKILL_DIRECTORY` 为本 SKILL.md 所在绝对目录。CLI 同步本地入口,检查 `skillUpdate` 并报告失败;新入口在客户端重新加载后采用。依次读取全部 `mustRead`,以 `resourceRoot` 解析资源并按流程执行。新任务重新 resolve,同一任务沿用已读快照。资源更新或校验失败时停止依赖工作,不静默使用旧资源。
17
17
 
18
- 脚本通过 `npx -y --prefer-online global-app-skill@latest run SCRIPT --mode MODE --resource-root RESOURCE_ROOT -- 参数` 执行;可用脚本及参数见解析后的 CLI 文档。业务判断与页面操作由 agent 按文档编排,CLI 成功不代表调研完成;关键进度仍须输出到聊天正文。
18
+ 脚本通过 `npx -y --prefer-online global-app-skill@latest run SCRIPT --skill-dir SKILL_DIRECTORY --mode MODE --resource-root RESOURCE_ROOT -- 参数` 执行;参数见解析后的 CLI 文档。业务判断与页面操作由 agent 按文档编排,CLI 成功不代表调研完成;关键进度仍须输出到聊天正文。
package/src/cli.mjs CHANGED
@@ -1,17 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util';
3
- import { installSkill } from './skill.mjs';
3
+ import { installSkill, syncInstalledSkills } from './skill.mjs';
4
4
  import { resolveContext } from './resources.mjs';
5
5
  import { runScript } from './run.mjs';
6
6
  import { validateMode, VERSION } from './protocol.mjs';
7
7
 
8
8
  const usage = `global-app-skill ${VERSION}
9
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
10
+ context resolve --mode download|research|ued [--skill-dir PATH] [--cache-dir PATH] [--feed HTTPS_MANIFEST_URL]
11
+ run SCRIPT [--mode download|research|ued] [--skill-dir PATH] [--resource-root PATH] [--cache-dir PATH] [--feed URL] -- SCRIPT_ARGS
12
12
  --version
13
13
 
14
14
  skill install writes only SKILL.md; default: ~/.agents/skills/overseas-app-research.
15
+ context resolve and run synchronize existing managed skill entries from this CLI bundle.
15
16
  run uses a verified pinned snapshot when --resource-root is supplied; otherwise it resolves current resources.
16
17
  Script stdout/stderr and exit code are preserved. JSON command results go to stdout; progress goes to stderr.
17
18
  `;
@@ -20,7 +21,7 @@ export async function main(argv) {
20
21
  const separator = argv.indexOf('--');
21
22
  const { values, positionals } = parseArgs({
22
23
  args: separator < 0 ? argv : argv.slice(0, separator), allowPositionals: true,
23
- options: { dir: { type: 'string' }, mode: { type: 'string' }, 'resource-root': { type: 'string' },
24
+ options: { dir: { type: 'string' }, 'skill-dir': { type: 'string' }, mode: { type: 'string' }, 'resource-root': { type: 'string' },
24
25
  'cache-dir': { type: 'string' }, feed: { type: 'string' }, help: { type: 'boolean' }, version: { type: 'boolean' } },
25
26
  });
26
27
  if (values.version) { console.log(VERSION); return; }
@@ -28,8 +29,8 @@ export async function main(argv) {
28
29
  const [command, action] = positionals;
29
30
  if (positionals.length !== 2 || (separator >= 0 && command !== 'run')) throw new Error('Invalid command; use --help');
30
31
  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;
32
+ command === 'context' && action === 'resolve' ? ['mode', 'skill-dir', 'cache-dir', 'feed'] :
33
+ command === 'run' ? ['mode', 'skill-dir', 'resource-root', 'cache-dir', 'feed'] : null;
33
34
  if (!allowed) throw new Error('Unknown command; use --help');
34
35
  for (const key of Object.keys(values)) {
35
36
  if (!allowed.includes(key)) throw new Error(`--${key} is not supported by ${command} ${action}`);
@@ -37,16 +38,25 @@ export async function main(argv) {
37
38
  const options = { mode: values.mode || 'research', resourceRoot: values['resource-root'], cacheDir: values['cache-dir'],
38
39
  feed: values.feed, progress: message => process.stderr.write(`[global-app-skill] ${message}\n`) };
39
40
  if (command !== 'skill') validateMode(command === 'context' ? values.mode : options.mode);
41
+ if (command === 'run' && options.resourceRoot && (values.feed || values['cache-dir'])) {
42
+ throw new Error('--resource-root cannot be combined with --feed or --cache-dir');
43
+ }
44
+ const skillUpdate = command === 'skill' ? undefined : await syncInstalledSkills({ directory: values['skill-dir'] });
45
+ for (const entry of skillUpdate?.installations || []) {
46
+ if (entry.status === 'updated') process.stderr.write(`[global-app-skill] Updated local entry: ${entry.skillPath}; reload the skill to use new discovery instructions\n`);
47
+ if (entry.status === 'failed') process.stderr.write(`[global-app-skill] Skill entry update failed: ${entry.skillPath}: ${entry.error}\n`);
48
+ }
40
49
  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
50
  process.exitCode = await runScript(action, separator < 0 ? [] : argv.slice(separator + 1), options);
43
51
  return;
44
52
  }
45
- const output = command === 'skill' ? await installSkill(values.dir) : await resolveContext(options);
53
+ let output;
54
+ try { output = command === 'skill' ? await installSkill(values.dir) : { ...await resolveContext(options), skillUpdate }; }
55
+ catch (error) { error.skillUpdate = skillUpdate; throw error; }
46
56
  console.log(JSON.stringify(output, null, 2));
47
57
  }
48
58
 
49
59
  main(process.argv.slice(2)).catch(error => {
50
- process.stderr.write(JSON.stringify({ ok: false, error: error.message }) + '\n');
60
+ process.stderr.write(JSON.stringify({ ok: false, error: error.message, ...(error.skillUpdate ? { skillUpdate: error.skillUpdate } : {}) }) + '\n');
51
61
  process.exitCode = 1;
52
62
  });
package/src/resources.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import path from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
4
5
  import { gunzipSync } from 'node:zlib';
5
6
  import { FEED, MAX_BYTES, MODES, sha256, validateManifest, validateMode } from './protocol.mjs';
6
7
 
@@ -81,10 +82,25 @@ export async function resolveContext({ mode, feed = FEED, cacheDir, progress = (
81
82
  await writeFile(target, file.content, { flag: 'wx' });
82
83
  }
83
84
  await writeFile(path.join(staging, 'manifest.json'), JSON.stringify(manifest));
84
- if (!(await validSnapshot(resourceRoot, manifest.resourceHash))) await rm(resourceRoot, { recursive: true, force: true });
85
- try { await rename(staging, resourceRoot); }
86
- catch (error) {
87
- if (!['EEXIST', 'ENOTEMPTY'].includes(error.code) || !(await validSnapshot(resourceRoot, manifest.resourceHash))) throw error;
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 });
88
104
  }
89
105
  updated = true;
90
106
  } finally { await rm(staging, { recursive: true, force: true }); }
package/src/skill.mjs CHANGED
@@ -2,21 +2,75 @@ import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { homedir } from 'node:os';
4
4
  import path from 'node:path';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { VERSION } from './protocol.mjs';
5
7
 
6
- export async function installSkill(directory) {
7
- const target = path.resolve(directory || path.join(homedir(), '.agents/skills/overseas-app-research'));
8
+ async function stat(filename) {
9
+ try { return await lstat(filename); }
10
+ catch (error) { if (error.code === 'ENOENT') return undefined; throw error; }
11
+ }
12
+
13
+ async function writeSkill(target, existingOnly) {
8
14
  const skillPath = path.join(target, 'SKILL.md');
9
15
  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; }
16
+ if ((await stat(filename))?.isSymbolicLink()) {
17
+ if (existingOnly) return { skillPath, status: 'skipped', reason: 'symlink' };
18
+ throw new Error(`Refusing to replace a symlinked skill: ${filename}`);
19
+ }
20
+ }
21
+ if (existingOnly && !(await stat(skillPath))) return { skillPath, status: 'skipped', reason: 'not-installed' };
22
+ if (!existingOnly) await mkdir(target, { recursive: true });
23
+ const lock = path.join(target, '.global-app-skill-lock');
24
+ const deadline = Date.now() + 5000;
25
+ for (;;) {
26
+ try { await mkdir(lock); break; }
27
+ catch (error) {
28
+ if (error.code !== 'EEXIST') throw error;
29
+ if (Date.now() >= deadline) throw new Error(`Skill update is locked: ${lock}; retry after the other updater finishes`);
30
+ await delay(50);
31
+ }
13
32
  }
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
33
  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 };
34
+ let previous;
35
+ try { previous = await readFile(skillPath, 'utf8'); }
36
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
37
+ if (existingOnly && !previous?.includes('global-app-skill@latest context resolve')) {
38
+ return { skillPath, status: 'skipped', reason: 'not-managed' };
39
+ }
40
+ const installedVersion = previous?.match(/^<!-- global-app-skill-version: (\d+\.\d+\.\d+) -->$/m)?.[1];
41
+ if (installedVersion) {
42
+ const installed = installedVersion.split('.').map(Number), bundled = VERSION.split('.').map(Number);
43
+ for (let i = 0; i < 3; i++) {
44
+ if (installed[i] < bundled[i]) break;
45
+ if (installed[i] > bundled[i]) return { skillPath, status: 'skipped', reason: 'newer-installed', installedVersion };
46
+ }
47
+ }
48
+ const bundled = await readFile(new URL('../skills/overseas-app-research/SKILL.md', import.meta.url), 'utf8');
49
+ const quotedDirectory = "'" + target.replaceAll("'", "'\\''") + "'";
50
+ const content = bundled.replaceAll('SKILL_DIRECTORY', quotedDirectory).trimEnd() + `\n\n<!-- global-app-skill-version: ${VERSION} -->\n`;
51
+ if (previous === content) return { skillPath, status: 'unchanged' };
52
+ const temporary = path.join(target, `.SKILL-${randomUUID()}.tmp`);
53
+ try {
54
+ await writeFile(temporary, content, { flag: 'wx' });
55
+ await rename(temporary, skillPath);
56
+ } finally { await rm(temporary, { force: true }); }
57
+ return { skillPath, status: previous === undefined ? 'installed' : 'updated' };
58
+ } finally { await rm(lock, { recursive: true, force: true }); }
59
+ }
60
+
61
+ export async function installSkill(directory) {
62
+ return { ok: true, ...await writeSkill(path.resolve(directory || path.join(homedir(), '.agents/skills/overseas-app-research')), false) };
63
+ }
64
+
65
+ export async function syncInstalledSkills({ directory, directories = [
66
+ path.join(homedir(), '.agents/skills/overseas-app-research'),
67
+ path.join(process.env.CODEX_HOME || path.join(homedir(), '.codex'), 'skills/overseas-app-research'),
68
+ ] } = {}) {
69
+ const targets = [...new Set((directory ? [directory] : directories).map(target => path.resolve(target)))];
70
+ const installations = [];
71
+ for (const target of targets) {
72
+ try { installations.push(await writeSkill(target, true)); }
73
+ catch (error) { installations.push({ skillPath: path.join(target, 'SKILL.md'), status: 'failed', error: error.message }); }
74
+ }
75
+ return { ok: !installations.some(entry => entry.status === 'failed'), installations };
22
76
  }