global-app-skill 0.1.2 → 0.1.5

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-dir` 参数,并在同一文件中记录 CLI 版本。已存在的 SKILL.md 会被原子替换,目录中的其他文件保留,符号链接入口拒绝覆盖。安装不下载 references,也不修改 MCP 或客户端配置。
13
+ 默认只安装 `~/.agents/skills/overseas-app-research/SKILL.md`。也可用 `--dir /absolute/skills/overseas-app-research` 指定当前客户端实际使用的 skill 目录。所有目录使用完全相同的薄入口,只记录 CLI 版本,不写入安装路径。安装位置记录在本机状态目录,供 CLI 后续自动发现;该记录不放进 skill。已存在的 SKILL.md 会被原子替换,目录中的其他文件保留,符号链接入口拒绝覆盖。安装不下载 references,也不修改 MCP 或客户端配置。
14
14
 
15
15
  ## 执行
16
16
 
@@ -18,11 +18,15 @@ npx -y --prefer-online global-app-skill@latest skill install
18
18
  npx -y --prefer-online global-app-skill@latest context resolve --mode research
19
19
  ```
20
20
 
21
- `--mode` 为 `download`、`research` 或 `ued`。CLI 每次查询 GitHub 的 `releases/latest.json`,验证清单、压缩包及逐文件 SHA-256,返回绝对 `resourceRoot` 和有序 `mustRead`。agent 读取这些文件后按原工作流编排任务。
21
+ `--mode` 为 `download`、`research` 或 `ued`。CLI 每次查询 GitHub 最新正式 Release 的 `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,不覆盖手工维护的其他实现或符号链接,也不降级更新的入口。
23
+ `context resolve` 和 `run` 执行前由 CLI 自动定位已安装的薄入口,更新为当前 CLI 捆绑的版本。入口只调用通用命令,不传安装路径。入口使用 `npx --prefer-online ...@latest`,因此发布新版 CLI 后,下一次执行会更新本地 SKILL.md。
24
24
 
25
- 自定义目录中的入口若未包含 `--skill-dir`,重新执行一次 `skill install --dir ...` 写入准确目录。`context resolve` 的 `skillUpdate` 单独报告入口同步结果;同步失败会明确显示,资源解析仍可独立进行。脚本 stdout 和退出码保持不变,同步日志写入 stderr。
25
+ CLI 检查以下位置中的 `overseas-app-research/SKILL.md`:用户目录下的 `.agents/skills`、`.codex/skills`、`.claude/skills`、`.cursor/skills`、`.gemini/skills`、`.kiro/skills`;当前目录至最近 Git 仓库根目录之间的相同项目级目录;`CODEX_HOME/skills`;以及 CLI 安装时登记的目录。不在 Git 仓库内时,项目级发现只检查当前目录,不遍历其他工作区。
26
+
27
+ 自定义安装目录通过 `skill install --dir ...` 登记在 `${XDG_STATE_HOME:-~/.local/state}/global-app-skill/installations/`,不需要把路径写进 SKILL.md。复制到另一台机器的标准 skill 目录后,CLI 会按该机器环境重新发现;移到标准目录以外的位置时,再运行一次安装命令登记新位置。`--skill-dir` 仅保留为手动指定本次更新目标的可选参数,薄入口无需使用。
28
+
29
+ 自动同步只更新已经存在且属于本 CLI 的入口,不创建其他客户端的 skill,不覆盖手工维护的其他实现或符号链接,也不降级更新的入口。`context resolve` 的 `skillUpdate` 单独报告同步结果;失败会明确显示,资源解析仍可独立进行。脚本 stdout 和退出码保持不变,同步日志写入 stderr。
26
30
 
27
31
  本地文件更新后,新入口在客户端重新加载 skill 时采用;已经读入当前任务的上下文不因磁盘替换自动改写。[Codex 官方文档](https://learn.chatgpt.com/docs/build-skills)说明会自动检测 skill 变更;如果没有显示更新,可重启 Codex。其他客户端遵循各自的重新加载规则。
28
32
 
@@ -44,9 +48,11 @@ npx -y --prefer-online global-app-skill@latest run preflight --mode research --r
44
48
  | `resources/overseas-app-research/scripts/` | 原有 Python 脚本及回归测试 |
45
49
  | `resources/overseas-app-research/examples/` | 业务报告模板 |
46
50
  | `scripts/build-resources.mjs` | 生成确定性的资源包与清单 |
47
- | `releases/` | GitHub 托管的最新清单与按哈希命名的资源包 |
51
+ | `.github/workflows/publish.yml` | tag 触发的构建、GitHub Release 和 npm 发布 |
52
+ | `scripts/release.mjs` | tag 校验、打包、校验和及发布资产核对 |
53
+ | `dist/release/` | 本次构建的 Release 资产,不提交到 Git |
48
54
 
49
- 分发源为 `https://raw.githubusercontent.com/shiguang-lab/global-app-skill/main/releases/latest.json`。npm 包仅包含 CLI 与薄 skill,工作流资源独立从 GitHub 更新,无需为文档或 Python 脚本修改发布 npm 新版本。
55
+ 分发源为 `https://github.com/shiguang-lab/global-app-skill/releases/latest/download/latest.json`。资源源码在仓库的 `resources/`,正式生成包由 Actions 上传到 GitHub Release。清单中的 `releaseTag` 决定资源包的固定下载地址,因此 latest 切换不会混用不同 Release 的清单和包。npm 包仅包含 CLI 与薄 skill;工作流资源可独立发布,不要求发布 npm 新版本。
50
56
 
51
57
  缓存默认位于 `~/.cache/global-app-skill/releases/<resourceHash>`,可用 `GLOBAL_APP_SKILL_CACHE` 或 `--cache-dir` 指定。下载先在临时目录完整校验,再发布快照。网络或完整性失败会报错,不静默使用旧资源;损坏的固定快照拒绝执行,重新 resolve 可修复。旧快照保留供现有任务使用。运行产物写入工作区,不写回资源缓存。
52
58
 
@@ -55,14 +61,44 @@ npx -y --prefer-online global-app-skill@latest run preflight --mode research --r
55
61
  在当前仓库编辑资源后:
56
62
 
57
63
  ```sh
58
- npm run resources:build
59
64
  npm test
60
65
  npm run test:python
61
- npm run resources:check
66
+ npm run release:prepare
62
67
  ```
63
68
 
64
- Python 回归测试中的长图部分需要 Pillow。CLI 无第三方 Node 依赖。`--feed` 可指定 HTTPS 清单,测试允许 loopback HTTP,用于在发布前验证同一协议。
69
+ Python 回归测试中的长图部分需要 Pillow。CLI 无第三方 Node 依赖。`--feed` 可指定 HTTPS 清单,测试允许 loopback HTTP。`release:prepare` 在本地预演与 Actions 相同的构建,默认 tag 为 `v<package.json.version>`,也可指定 `RELEASE_TAG`。构建结果进入忽略的 `dist/release/`,不手工上传。
70
+
71
+ ### 发布 CLI、薄入口与资源
72
+
73
+ 修改源码并更新 `package.json` 的版本,提交、push 后创建对应版本的 tag。例如版本为 `0.1.3` 时:
74
+
75
+ ```sh
76
+ git tag v0.1.3
77
+ git push origin v0.1.3
78
+ ```
79
+
80
+ `publish.yml` 校验 tag 与 package.json 一致,执行 Node/Python 测试,从 tag 源码生成以下资产:
81
+
82
+ - `latest.json`:资源清单、Release tag、最低 CLI 版本、逐文件哈希。
83
+ - `<resourceHash>.json.gz`:references、scripts、examples 的完整快照。
84
+ - `global-app-skill.tgz`:同一 tag 构建的 CLI 安装包。
85
+ - `SHA256SUMS`:上述三个资产的校验和。
86
+
87
+ Actions 先创建草稿 Release,上传所有已验证资产后发布为 latest,随后把同一个 CLI tgz 发布到 npm。npm 发布失败不会删除已经完整发布的 GitHub Release。重跑时先核对已发布资产;正式 Release 不覆盖不同字节的包。
88
+
89
+ ### 仅发布资源
90
+
91
+ 修改资源源码并提交,创建独立的资源 tag,例如:
92
+
93
+ ```sh
94
+ git tag resources-v0.1.4
95
+ git push origin resources-v0.1.4
96
+ ```
97
+
98
+ 该 tag 只发布 GitHub Release,跳过 npm。客户端下次 resolve 自动获取新资源。资源工作流仍包含完整快照,删除的源文件不会残留到新快照。已有任务继续使用其固定快照。不要删除历史 Release 的资源资产。
99
+
100
+ ### 一次性配置 npm 发布认证
65
101
 
66
- 将资源源文件、`releases/latest.json` 和新资源包一起提交并 push 到 GitHub 的 `main`。保留旧资源包,避免读到旧清单的客户端遇到 404。只修改资源时到此即可,后续任务自动获取最新快照;每个快照完整包含 references、scripts 和 examples,删除的源文件不会残留到新快照。
102
+ 在 GitHub 仓库 Actions secrets 中配置拥有 `global-app-skill` 发布权限的 `NPM_TOKEN`,工作流将其绑定到 npm 使用的 `NODE_AUTH_TOKEN`,不读取或复制本机 token。也可在 npm 包 Settings 中配置 GitHub Actions Trusted Publisher:组织 `shiguang-lab`、仓库 `global-app-skill`、工作流文件 `publish.yml`、允许 `npm publish`,不设置 environment。工作流保留 `id-token: write`,供 OIDC 与 provenance 使用。GitHub Release 使用 Actions 自带的 `GITHUB_TOKEN` 与 `contents: write`。
67
103
 
68
- 修改 CLI 或薄入口时更新 `package.json` 的版本,完成检查并推送代码后执行 `npm publish`。`prepublishOnly` 会执行 Node/Python 测试和资源一致性检查。初次发布需要 npm 登录和账号所要求的发布认证;GitHub 仓库需公开可读,才能使用默认免认证资源源。当前项目未授予开源许可证,npm 元数据使用 `UNLICENSED`。
104
+ 以后只推 tag,由 Actions 发布,不在本机执行 `npm publish`。当前项目未授予开源许可证,npm 元数据使用 `UNLICENSED`。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "global-app-skill",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "Thin skill bootstrap and GitHub resource runner for overseas Android app research",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -17,6 +17,7 @@
17
17
  "test:python": "python3 -m unittest discover -s resources/overseas-app-research/scripts -p 'test_*.py'",
18
18
  "resources:build": "node scripts/build-resources.mjs",
19
19
  "resources:check": "node scripts/build-resources.mjs --check",
20
- "prepublishOnly": "npm test && npm run test:python && npm run resources:check"
20
+ "release:prepare": "node scripts/release.mjs",
21
+ "prepublishOnly": "npm test && npm run test:python && npm run resources:build && npm run resources:check"
21
22
  }
22
23
  }
@@ -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 --skill-dir SKILL_DIRECTORY --mode MODE
13
+ npx -y --prefer-online global-app-skill@latest context resolve --mode MODE
14
14
  ```
15
15
 
16
- `SKILL_DIRECTORY` 为本 SKILL.md 所在绝对目录。CLI 同步本地入口,检查 `skillUpdate` 并报告失败;新入口在客户端重新加载后采用。依次读取全部 `mustRead`,以 `resourceRoot` 解析资源并按流程执行。新任务重新 resolve,同一任务沿用已读快照。资源更新或校验失败时停止依赖工作,不静默使用旧资源。
16
+ CLI 自动发现已安装的 skill 并同步入口,检查 `skillUpdate` 并报告失败;新入口在客户端重新加载后采用。依次读取全部 `mustRead`,以 `resourceRoot` 解析资源并按流程执行。新任务重新 resolve,同一任务沿用已读快照。资源更新或校验失败时停止依赖工作,不静默使用旧资源。
17
17
 
18
- 脚本通过 `npx -y --prefer-online global-app-skill@latest run SCRIPT --skill-dir SKILL_DIRECTORY --mode MODE --resource-root RESOURCE_ROOT -- 参数` 执行;参数见解析后的 CLI 文档。业务判断与页面操作由 agent 按文档编排,CLI 成功不代表调研完成;关键进度仍须输出到聊天正文。
18
+ 脚本通过 `npx -y --prefer-online global-app-skill@latest run SCRIPT --mode MODE --resource-root RESOURCE_ROOT -- 参数` 执行;参数见解析后的 CLI 文档。业务判断与页面操作由 agent 按文档编排,CLI 成功不代表调研完成;关键进度仍须输出到聊天正文。
package/src/cli.mjs CHANGED
@@ -13,6 +13,7 @@ const usage = `global-app-skill ${VERSION}
13
13
 
14
14
  skill install writes only SKILL.md; default: ~/.agents/skills/overseas-app-research.
15
15
  context resolve and run synchronize existing managed skill entries from this CLI bundle.
16
+ Skill locations are discovered from client user/project directories and CLI installation records.
16
17
  run uses a verified pinned snapshot when --resource-root is supplied; otherwise it resolves current resources.
17
18
  Script stdout/stderr and exit code are preserved. JSON command results go to stdout; progress goes to stderr.
18
19
  `;
@@ -42,6 +43,7 @@ export async function main(argv) {
42
43
  throw new Error('--resource-root cannot be combined with --feed or --cache-dir');
43
44
  }
44
45
  const skillUpdate = command === 'skill' ? undefined : await syncInstalledSkills({ directory: values['skill-dir'] });
46
+ if (skillUpdate?.error) process.stderr.write(`[global-app-skill] Skill discovery failed: ${skillUpdate.error}\n`);
45
47
  for (const entry of skillUpdate?.installations || []) {
46
48
  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
49
  if (entry.status === 'failed') process.stderr.write(`[global-app-skill] Skill entry update failed: ${entry.skillPath}: ${entry.error}\n`);
@@ -0,0 +1,64 @@
1
+ import { lstat, mkdir, readFile, readdir, 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
+ import { sha256 } from './protocol.mjs';
6
+
7
+ export const SKILL_ROOTS = ['.agents/skills', '.codex/skills', '.claude/skills', '.cursor/skills', '.gemini/skills', '.kiro/skills'];
8
+ const skillName = 'overseas-app-research';
9
+
10
+ function stateRoot({ home = homedir(), env = process.env, stateDirectory } = {}) {
11
+ return path.resolve(stateDirectory || path.join(env.XDG_STATE_HOME || path.join(home, '.local/state'), 'global-app-skill'));
12
+ }
13
+
14
+ async function exists(filename) {
15
+ try { await lstat(filename); return true; }
16
+ catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error.code)) return false; throw error; }
17
+ }
18
+
19
+ export async function rememberInstallation(directory, options = {}) {
20
+ directory = path.resolve(directory);
21
+ const registry = path.join(stateRoot(options), 'installations');
22
+ await mkdir(registry, { recursive: true });
23
+ // Independent records avoid lost registrations when installs run concurrently.
24
+ const target = path.join(registry, `${sha256(directory)}.json`);
25
+ const temporary = path.join(registry, `.${randomUUID()}.tmp`);
26
+ try {
27
+ await writeFile(temporary, JSON.stringify({ directory }) + '\n', { flag: 'wx' });
28
+ await rename(temporary, target);
29
+ } finally { await rm(temporary, { force: true }); }
30
+ }
31
+
32
+ async function registeredDirectories(options) {
33
+ const registry = path.join(stateRoot(options), 'installations');
34
+ let names;
35
+ try { names = await readdir(registry); }
36
+ catch (error) { if (error.code === 'ENOENT') return []; throw error; }
37
+ return Promise.all(names.filter(name => name.endsWith('.json')).sort().map(async name => {
38
+ const { directory } = JSON.parse(await readFile(path.join(registry, name), 'utf8'));
39
+ if (typeof directory !== 'string' || !path.isAbsolute(directory) || name !== `${sha256(directory)}.json`) {
40
+ throw new Error(`Invalid skill installation record: ${path.join(registry, name)}`);
41
+ }
42
+ return directory;
43
+ }));
44
+ }
45
+
46
+ async function projectDirectories(cwd) {
47
+ const directories = [];
48
+ for (let directory = path.resolve(cwd); ; directory = path.dirname(directory)) {
49
+ directories.push(directory);
50
+ if (await exists(path.join(directory, '.git'))) return directories;
51
+ if (path.dirname(directory) === directory) return [path.resolve(cwd)];
52
+ }
53
+ }
54
+
55
+ export async function discoverSkillDirectories(options = {}) {
56
+ const { home = homedir(), env = process.env, cwd = process.cwd() } = options;
57
+ const bases = [path.resolve(home), ...await projectDirectories(cwd)];
58
+ const candidates = bases.flatMap(base => SKILL_ROOTS.map(root => path.join(base, root, skillName)));
59
+ if (env.CODEX_HOME) candidates.push(path.resolve(env.CODEX_HOME, 'skills', skillName));
60
+ candidates.push(...await registeredDirectories(options));
61
+ const unique = [...new Set(candidates)];
62
+ const present = await Promise.all(unique.map(directory => exists(path.join(directory, 'SKILL.md'))));
63
+ return unique.filter((directory, index) => present[index]);
64
+ }
package/src/protocol.mjs CHANGED
@@ -2,7 +2,9 @@ import { createHash } from 'node:crypto';
2
2
  import { readFileSync } from 'node:fs';
3
3
 
4
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';
5
+ export const RELEASE_BASE = 'https://github.com/shiguang-lab/global-app-skill/releases';
6
+ export const FEED = `${RELEASE_BASE}/latest/download/latest.json`;
7
+ export const RELEASE_TAG = /^(?:v|resources-v)(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
6
8
  export const MAX_BYTES = 16 * 1024 * 1024;
7
9
  export const MODES = {
8
10
  download: 'references/download-workflow.md',
@@ -20,7 +22,7 @@ export function validateMode(mode) {
20
22
 
21
23
  export function validateManifest(manifest) {
22
24
  const hash = /^[a-f0-9]{64}$/;
23
- if (manifest?.schemaVersion !== 1 || !hash.test(manifest.resourceHash) ||
25
+ if (manifest?.schemaVersion !== 1 || !RELEASE_TAG.test(manifest.releaseTag) || !hash.test(manifest.resourceHash) ||
24
26
  !/^\d+\.\d+\.\d+$/.test(manifest.minCliVersion) ||
25
27
  !Array.isArray(manifest.files) || !manifest.files.length || manifest.files.length > 1024 ||
26
28
  manifest.bundle !== `${manifest.resourceHash}.json.gz` || !hash.test(manifest.sha256) ||
@@ -50,3 +52,13 @@ export function validateManifest(manifest) {
50
52
  }
51
53
  return manifest;
52
54
  }
55
+
56
+ export function bundleUrl(manifest, feed) {
57
+ const url = new URL(feed);
58
+ if (url.origin === 'https://github.com' && url.pathname.startsWith('/shiguang-lab/global-app-skill/releases/')) {
59
+ // Pin the bundle to the manifest's release, even if "latest" advances
60
+ // between fetching the manifest and fetching its archive.
61
+ return new URL(`${RELEASE_BASE}/download/${manifest.releaseTag}/${manifest.bundle}`);
62
+ }
63
+ return new URL(manifest.bundle, url);
64
+ }
package/src/resources.mjs CHANGED
@@ -3,7 +3,7 @@ import { homedir } from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { setTimeout as delay } from 'node:timers/promises';
5
5
  import { gunzipSync } from 'node:zlib';
6
- import { FEED, MAX_BYTES, MODES, sha256, validateManifest, validateMode } from './protocol.mjs';
6
+ import { bundleUrl, FEED, MAX_BYTES, MODES, sha256, validateManifest, validateMode } from './protocol.mjs';
7
7
 
8
8
  async function download(url, limit) {
9
9
  const response = await fetch(url, { cache: 'no-store', signal: AbortSignal.timeout(30000) });
@@ -61,7 +61,7 @@ export async function resolveContext({ mode, feed = FEED, cacheDir, progress = (
61
61
  let updated = false;
62
62
  if (!(await validSnapshot(resourceRoot, manifest.resourceHash))) {
63
63
  progress(`Downloading and verifying ${manifest.files.length} resource files`);
64
- const bytes = await download(new URL(manifest.bundle, url), MAX_BYTES);
64
+ const bytes = await download(bundleUrl(manifest, url), MAX_BYTES);
65
65
  if (bytes.length !== manifest.size || sha256(bytes) !== manifest.sha256) throw new Error('Resource bundle integrity check failed');
66
66
  const bundle = JSON.parse(gunzipSync(bytes, { maxOutputLength: MAX_BYTES * 2 }).toString('utf8'));
67
67
  if (!Array.isArray(bundle.files) || bundle.files.length !== manifest.files.length) throw new Error('Invalid resource bundle');
@@ -109,7 +109,7 @@ export async function resolveContext({ mode, feed = FEED, cacheDir, progress = (
109
109
  progress(updated ? 'Resource snapshot ready' : 'Verified cached snapshot is current');
110
110
  const mustRead = ['references/cli.md', 'references/guide.md', 'references/orchestration.md', MODES[mode]]
111
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,
112
+ return { ok: true, source: url.origin === 'https://github.com' ? 'github' : 'custom', feed: url.href,
113
+ mode, releaseTag: manifest.releaseTag, resourceHash: manifest.resourceHash, resourceRoot,
114
114
  referencePath: path.join(resourceRoot, 'references/guide.md'), mustRead, updated };
115
115
  }
package/src/skill.mjs CHANGED
@@ -4,6 +4,7 @@ import { homedir } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { setTimeout as delay } from 'node:timers/promises';
6
6
  import { VERSION } from './protocol.mjs';
7
+ import { discoverSkillDirectories, rememberInstallation } from './installations.mjs';
7
8
 
8
9
  async function stat(filename) {
9
10
  try { return await lstat(filename); }
@@ -46,8 +47,7 @@ async function writeSkill(target, existingOnly) {
46
47
  }
47
48
  }
48
49
  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`;
50
+ const content = bundled.trimEnd() + `\n\n<!-- global-app-skill-version: ${VERSION} -->\n`;
51
51
  if (previous === content) return { skillPath, status: 'unchanged' };
52
52
  const temporary = path.join(target, `.SKILL-${randomUUID()}.tmp`);
53
53
  try {
@@ -58,15 +58,18 @@ async function writeSkill(target, existingOnly) {
58
58
  } finally { await rm(lock, { recursive: true, force: true }); }
59
59
  }
60
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) };
61
+ export async function installSkill(directory, options = {}) {
62
+ const target = path.resolve(directory || path.join(options.home || homedir(), '.agents/skills/overseas-app-research'));
63
+ const result = await writeSkill(target, false);
64
+ await rememberInstallation(target, options);
65
+ return { ok: true, ...result };
63
66
  }
64
67
 
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)))];
68
+ export async function syncInstalledSkills({ directory, directories, ...options } = {}) {
69
+ let targets;
70
+ try {
71
+ targets = [...new Set((directory ? [directory] : directories || await discoverSkillDirectories(options)).map(target => path.resolve(target)))];
72
+ } catch (error) { return { ok: false, installations: [], error: error.message }; }
70
73
  const installations = [];
71
74
  for (const target of targets) {
72
75
  try { installations.push(await writeSkill(target, true)); }