@heihei0299/matt-skills 3.0.30 → 3.1.0

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.
@@ -0,0 +1,48 @@
1
+ import { rm } from 'node:fs/promises';
2
+
3
+ export async function runCheck(args) {
4
+ for (let i = 0; i < args.length; i++) {
5
+ const arg = args[i];
6
+ if (arg === '--all' || arg === '--json' || arg === '--help' || arg === '-h') continue;
7
+ if (arg === '--upstream' || arg === '--ref') {
8
+ if (i + 1 >= args.length || args[i + 1].startsWith('-')) {
9
+ process.stderr.write(`error: option '${arg}' for command 'check' requires a value\n`);
10
+ process.stderr.write(`Run 'matt-skills check --help' for usage.\n`);
11
+ process.exitCode = 2;
12
+ return;
13
+ }
14
+ i++;
15
+ continue;
16
+ }
17
+ if (arg.startsWith('--upstream=') || arg.startsWith('--ref=')) continue;
18
+ if (arg.startsWith('-')) {
19
+ process.stderr.write(`error: unknown option '${arg}' for command 'check'\n`);
20
+ process.stderr.write(`Run 'matt-skills check --help' for usage.\n`);
21
+ process.exitCode = 2;
22
+ return;
23
+ }
24
+ process.stderr.write(`error: unknown argument '${arg}' for command 'check'\n`);
25
+ process.stderr.write(`Run 'matt-skills check --help' for usage.\n`);
26
+ process.exitCode = 2;
27
+ return;
28
+ }
29
+
30
+ const { compare, formatComparison } = await import('../../scripts/sync-upstream.js');
31
+ const json = args.includes('--json');
32
+ const onlyProgramming = !args.includes('--all');
33
+ const upstreamIdx = args.indexOf('--upstream');
34
+ const upstreamEq = args.find((x) => x.startsWith('--upstream='));
35
+ const upstreamUrl = upstreamIdx !== -1 ? args[upstreamIdx + 1] : (upstreamEq ? upstreamEq.slice('--upstream='.length) : undefined);
36
+ const refIdx = args.indexOf('--ref');
37
+ const refEq = args.find((x) => x.startsWith('--ref='));
38
+ const ref = refIdx !== -1 ? args[refIdx + 1] : (refEq ? refEq.slice('--ref='.length) : undefined);
39
+ const cmp = await compare({ upstreamUrl, ref, onlyProgramming });
40
+ if (json) {
41
+ process.stdout.write(JSON.stringify({ head: cmp.head, counts: cmp.counts, result: cmp.result, onlyProgramming }, null, 2) + '\n');
42
+ } else {
43
+ process.stdout.write(formatComparison(cmp) + '\n');
44
+ }
45
+ await rm(cmp.dest, { recursive: true, force: true });
46
+ const hasDiff = cmp.result.added.length + cmp.result.updated.length + cmp.result.removed.length + cmp.result.renamed.length > 0;
47
+ if (hasDiff) process.exitCode = 1;
48
+ }
@@ -0,0 +1,63 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { PROPRIETARY_SKILLS, isRepoLocalSkill } from '../skills/boundaries.js';
4
+ import { listSkillNames, listSkills, SKILLS_DIR } from '../skills/discovery.js';
5
+ import { pathExists } from '../project/filesystem.js';
6
+ import { copyTemplate } from '../project/template.js';
7
+ import { distributeProjectSkills, PROJECT_SKILLS_DIR } from '../project/skills.js';
8
+
9
+ export function parseInitArgs(args) {
10
+ let dest;
11
+ let all = false;
12
+ for (let i = 0; i < args.length; i++) {
13
+ const arg = args[i];
14
+ if (arg === '--dest') {
15
+ if (i + 1 >= args.length || args[i + 1].startsWith('-')) throw new Error(`unknown option '--dest' requires a value`);
16
+ dest = args[++i];
17
+ } else if (arg.startsWith('--dest=')) dest = arg.slice('--dest='.length);
18
+ else if (arg === '--all') all = true;
19
+ else if (arg === '--help' || arg === '-h') {}
20
+ else if (arg.startsWith('-')) throw new Error(`unknown option '${arg}' for command 'init'`);
21
+ else throw new Error(`unknown argument '${arg}' for command 'init'`);
22
+ }
23
+ return { dest, all };
24
+ }
25
+
26
+ export async function runInit(args) {
27
+ const { dest, all } = parseInitArgs(args);
28
+ const target = dest ? path.resolve(process.cwd(), dest) : process.cwd();
29
+ const marker = path.join(target, 'AGENTS.md');
30
+ const onlyProgramming = !all;
31
+ if (await pathExists(marker)) {
32
+ process.stdout.write('模板已存在(AGENTS.md),跳过\n');
33
+ } else {
34
+ await copyTemplate(target);
35
+ const selectedSkills = await listSkillNames({ onlyProgramming });
36
+ await distributeProjectSkills({ target, sourceDir: SKILLS_DIR, skillNames: selectedSkills });
37
+ process.stdout.write(`模板:已复制(AGENTS.md、skills:${PROJECT_SKILLS_DIR})\n`);
38
+ }
39
+ // 统计(区分编程 vs 全量)
40
+ const skillsDir = path.join(target, '.agents', 'skills');
41
+ let installed = 0;
42
+ try {
43
+ const entries = await readdir(skillsDir, { withFileTypes: true });
44
+ installed = entries.filter((e) => e.isDirectory() && !e.name.endsWith('.bak') && e.name !== '.git' && e.name !== 'skill-creator' && !isRepoLocalSkill(e.name)).length;
45
+ } catch {}
46
+ const allSkillsFull = await listSkills({ onlyProgramming: false });
47
+ const defaultSkills = await listSkills({ onlyProgramming: true });
48
+ const defaultCount = defaultSkills.length;
49
+ const upstreamFull = allSkillsFull.filter((s) => !PROPRIETARY_SKILLS.has(s.name)).length;
50
+ const upstreamDefault = defaultSkills.filter((s) => !PROPRIETARY_SKILLS.has(s.name)).length;
51
+ const displayTotal = onlyProgramming ? defaultCount : allSkillsFull.length;
52
+ const displayUpstream = onlyProgramming ? upstreamDefault : upstreamFull;
53
+ if (path.resolve(skillsDir) === path.resolve(SKILLS_DIR)) {
54
+ process.stdout.write(`技能:已装 ${installed}、跳过 0(可分发 ${allSkillsFull.length},含上游 ${upstreamFull};默认 workflow ${defaultCount},含上游 ${upstreamDefault})\n`);
55
+ } else {
56
+ if (onlyProgramming) {
57
+ process.stdout.write(`技能:已装 ${installed}(默认 workflow ${displayTotal},含上游 ${displayUpstream};可分发 ${allSkillsFull.length},含上游 ${upstreamFull})\n`);
58
+ } else {
59
+ process.stdout.write(`技能:已装 ${installed}(可分发 ${displayTotal},含上游 ${displayUpstream})\n`);
60
+ }
61
+ }
62
+ process.stdout.write(`目标路径:${target}\n`);
63
+ }
@@ -0,0 +1,114 @@
1
+ import path from 'node:path';
2
+ import prompts from 'prompts';
3
+ import { isDistributableSkill } from '../skills/boundaries.js';
4
+ import { listSkillNames, listSkills, SKILLS_DIR } from '../skills/discovery.js';
5
+ import { TOOLS, copySkills, toolDir } from '../project/skills.js';
6
+
7
+ async function promptTools() {
8
+ const res = await prompts({
9
+ type: 'multiselect',
10
+ name: 'tools',
11
+ message: '选择要安装到的工具',
12
+ choices: TOOLS.map((t) => ({ title: t, value: t })),
13
+ instructions: '空格勾选,回车确认',
14
+ });
15
+ return Array.isArray(res?.tools) ? res.tools : [];
16
+ }
17
+
18
+ async function promptSkills(skills) {
19
+ const res = await prompts({
20
+ type: 'autocompleteMultiselect',
21
+ name: 'skills',
22
+ message: '选择要安装的技能',
23
+ choices: skills.map((s) => ({ title: s.name, value: s.name })),
24
+ instructions: '输入过滤,空格勾选,回车确认',
25
+ onRender() {
26
+ if (this.inputValue && this.filteredOptions.length === 0) {
27
+ this.filteredOptions = [{ title: '没有匹配的技能', value: '__no-match__', disabled: true }];
28
+ this.cursor = 0;
29
+ }
30
+ },
31
+ });
32
+ return Array.isArray(res?.skills) ? res.skills : [];
33
+ }
34
+
35
+ function parseInstallArgs(args) {
36
+ let dest;
37
+ let all = false;
38
+ let force = false;
39
+ let global = false;
40
+ let toolsArg;
41
+ for (let i = 0; i < args.length; i++) {
42
+ const arg = args[i];
43
+ if (arg === '--dest') {
44
+ if (i + 1 >= args.length || args[i + 1].startsWith('-')) throw new Error(`unknown option '--dest' requires a value`);
45
+ dest = args[++i];
46
+ } else if (arg.startsWith('--dest=')) dest = arg.slice('--dest='.length);
47
+ else if (arg === '--all') all = true;
48
+ else if (arg === '--force') force = true;
49
+ else if (arg === '--tools') {
50
+ if (i + 1 >= args.length || args[i + 1].startsWith('-')) throw new Error(`unknown option '--tools' requires a value`);
51
+ toolsArg = args[++i];
52
+ } else if (arg.startsWith('--tools=')) toolsArg = arg.slice('--tools='.length);
53
+ else if (arg === '--global') global = true;
54
+ else if (arg === '--project') global = false;
55
+ else if (arg === '--help' || arg === '-h') {}
56
+ else if (arg.startsWith('-')) throw new Error(`unknown option '${arg}' for command 'install'`);
57
+ else throw new Error(`unknown argument '${arg}' for command 'install'`);
58
+ }
59
+ const tools = toolsArg
60
+ ? toolsArg.split(',').map((t) => t.trim()).filter(Boolean)
61
+ : null;
62
+ return { dest, all, force, global, tools };
63
+ }
64
+
65
+ async function installCommand({ dest, all, force, tools, global }) {
66
+ const onlyProgramming = !all;
67
+ const skillNames = await listSkillNames({ onlyProgramming });
68
+ const skills = await listSkills({ onlyProgramming });
69
+ let targets;
70
+ if (dest) {
71
+ targets = [{ tool: null, dir: path.resolve(process.cwd(), dest) }];
72
+ } else {
73
+ const selectedTools = tools
74
+ ? tools.filter((t) => TOOLS.includes(t))
75
+ : await promptTools();
76
+ if (selectedTools.length === 0) {
77
+ process.stdout.write('未选择任何工具,未安装任何技能\n');
78
+ return;
79
+ }
80
+ targets = selectedTools.map((tool) => ({ tool, dir: toolDir(tool, global) }));
81
+ // 去重:保留对自定义 PROJECT_DIRS 映射的兼容。
82
+ const seen = new Map();
83
+ for (const t of targets) {
84
+ if (!seen.has(t.dir)) seen.set(t.dir, t);
85
+ }
86
+ targets = [...seen.values()];
87
+ }
88
+ const selected = all ? skillNames : await promptSkills(skills);
89
+ if (selected.length === 0) {
90
+ process.stdout.write('未选择任何技能,未安装任何技能\n');
91
+ return;
92
+ }
93
+ const knownDistributable = new Set(skillNames);
94
+ for (const name of selected) {
95
+ if (!isDistributableSkill(name, knownDistributable)) {
96
+ throw new Error(`${name} is repository-local or unavailable and cannot be distributed`);
97
+ }
98
+ }
99
+ for (const { tool, dir } of targets) {
100
+ const { installed, skipped } = await copySkills({
101
+ sourceDir: SKILLS_DIR,
102
+ targetDir: dir,
103
+ skillNames: selected,
104
+ force,
105
+ });
106
+ if (tool) process.stdout.write(`${tool}:已装 ${installed}、跳过 ${skipped}\n`);
107
+ else process.stdout.write(`已装 ${installed}、跳过 ${skipped}\n`);
108
+ process.stdout.write(`目标路径:${dir}\n`);
109
+ }
110
+ }
111
+
112
+ export async function runInstall(args) {
113
+ await installCommand(parseInstallArgs(args));
114
+ }
@@ -0,0 +1,26 @@
1
+ import { listSkills } from '../skills/discovery.js';
2
+
3
+ export async function runList(args) {
4
+ for (const arg of args) {
5
+ if (arg === '--all' || arg === '--json' || arg === '--help' || arg === '-h') continue;
6
+ if (arg.startsWith('-')) {
7
+ process.stderr.write(`error: unknown option '${arg}' for command 'list'\n`);
8
+ process.stderr.write(`Run 'matt-skills list --help' for usage.\n`);
9
+ process.exitCode = 2;
10
+ return;
11
+ }
12
+ process.stderr.write(`error: unknown argument '${arg}' for command 'list'\n`);
13
+ process.stderr.write(`Run 'matt-skills list --help' for usage.\n`);
14
+ process.exitCode = 2;
15
+ return;
16
+ }
17
+ const onlyProgramming = !args.includes('--all');
18
+ const skills = await listSkills({ onlyProgramming });
19
+ if (args.includes('--json')) {
20
+ process.stdout.write(`${JSON.stringify(skills, null, 2)}\n`);
21
+ } else {
22
+ for (const skill of skills) {
23
+ process.stdout.write(`${skill.name} — ${skill.description}\n`);
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,96 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { REPO_LOCAL_SKILLS } from '../skills/boundaries.js';
5
+ import { listSkillNames, SKILLS_DIR } from '../skills/discovery.js';
6
+ import { copyDryRunInputs, compareDryRunTrees, formatTargetComparison, syncTemplate } from '../project/template.js';
7
+ import { PROJECT_SKILLS_DIR, syncProjectSkills } from '../project/skills.js';
8
+
9
+ export function parseSyncArgs(args) {
10
+ let dest;
11
+ let all = false;
12
+ let dryRun = false;
13
+ let json = false;
14
+ let refreshAgents = false;
15
+ for (let i = 0; i < args.length; i++) {
16
+ const arg = args[i];
17
+ if (arg === '--dest') {
18
+ if (i + 1 >= args.length || args[i + 1].startsWith('-')) throw new Error(`unknown option '--dest' requires a value`);
19
+ dest = args[++i];
20
+ } else if (arg.startsWith('--dest=')) dest = arg.slice('--dest='.length);
21
+ else if (arg === '--all') all = true;
22
+ else if (arg === '--dry-run') dryRun = true;
23
+ else if (arg === '--json') json = true;
24
+ else if (arg === '--refresh-agents') refreshAgents = true;
25
+ else if (arg === '--help' || arg === '-h') {}
26
+ else if (arg === '--apply') {}
27
+ else if (arg.startsWith('-')) throw new Error(`unknown option '${arg}' for command 'sync'`);
28
+ else throw new Error(`unknown argument '${arg}' for command 'sync'`);
29
+ }
30
+ return { dest, all, dryRun, json, refreshAgents };
31
+ }
32
+
33
+ async function syncCommand({ dest, all, dryRun, json, refreshAgents, quiet = false }) {
34
+ const onlyProgramming = !all;
35
+ const output = (text) => {
36
+ if (!quiet) process.stdout.write(text);
37
+ };
38
+ if (!dryRun && json) throw new Error('--json 仅支持 sync --dry-run');
39
+ if (dryRun) {
40
+ const target = dest ? path.resolve(process.cwd(), dest) : process.cwd();
41
+ const stage = await mkdtemp(path.join(os.tmpdir(), 'matt-skills-sync-dry-run-'));
42
+ try {
43
+ await copyDryRunInputs(target, stage);
44
+ await syncCommand({ dest: stage, all, dryRun: false, json: false, refreshAgents, quiet: true });
45
+ const result = await compareDryRunTrees(target, stage);
46
+ const payload = { target, result, onlyProgramming, refreshAgents };
47
+ if (json) output(`${JSON.stringify(payload, null, 2)}\n`);
48
+ else output(`${formatTargetComparison(payload)}\n`);
49
+ const totalDiff = result.added.length + result.updated.length + result.removed.length;
50
+ if (totalDiff > 0) process.exitCode = 1;
51
+ } finally {
52
+ await rm(stage, { recursive: true, force: true });
53
+ }
54
+ return;
55
+ }
56
+
57
+ const target = dest ? path.resolve(process.cwd(), dest) : process.cwd();
58
+ const template = await syncTemplate({ target, refreshAgents });
59
+ if (template.initialized) {
60
+ output('未检测到现有项目(AGENTS.md 不存在),将执行全新初始化\n');
61
+ output(`模板:已复制(AGENTS.md、skills:${PROJECT_SKILLS_DIR})\n`);
62
+ } else {
63
+ output('同步:检测到现有项目,将增量更新\n');
64
+ if (template.agentsRefreshed && template.agentsRefreshMode === 'managed') {
65
+ output(`模板:已同步(AGENTS.md 受管区块已刷新、skills:${PROJECT_SKILLS_DIR},项目自定义内容已保留)\n`);
66
+ } else if (template.agentsRefreshed) {
67
+ output(`模板:已同步(AGENTS.md 已刷新,旧文件备份为 AGENTS.md.bak、skills:${PROJECT_SKILLS_DIR})\n`);
68
+ } else if (template.agentsManaged) {
69
+ output(`模板:已同步(AGENTS.md 受管区块已更新、skills:${PROJECT_SKILLS_DIR},项目自定义内容已保留)\n`);
70
+ } else {
71
+ output(`模板:已同步(AGENTS.md 未受管、skills:${PROJECT_SKILLS_DIR},已原样保留)\n`);
72
+ }
73
+ }
74
+ // --all 只扩大技能范围;同名覆盖、不存在新增,不删除目标中的额外技能。
75
+ const allSkills = await listSkillNames({ onlyProgramming });
76
+ const result = await syncProjectSkills({
77
+ target,
78
+ sourceDir: SKILLS_DIR,
79
+ skillNames: allSkills,
80
+ distributableSkillNames: (await listSkillNames({ onlyProgramming: false })).filter((name) => !REPO_LOCAL_SKILLS.has(name)),
81
+ repoLocalSkills: REPO_LOCAL_SKILLS,
82
+ });
83
+ if (result.cleanedLegacy.length) {
84
+ output(`迁移清理:${result.cleanedLegacy.join(', ')} 旧共享副本已移除\n`);
85
+ }
86
+ if (result.preservedRepoLocal.length) {
87
+ output(`迁移提示:${result.preservedRepoLocal.join(', ')} 已不再分发,现有副本已保留\n`);
88
+ }
89
+ const modeLabel = onlyProgramming ? '默认 workflow' : '全部可分发';
90
+ output(`技能:新增 ${result.installed}、更新 ${result.updated}(${modeLabel} ${allSkills.length})\n`);
91
+ output(`目标路径:${target}\n`);
92
+ }
93
+
94
+ export async function runSync(args) {
95
+ await syncCommand(parseSyncArgs(args));
96
+ }
@@ -0,0 +1,29 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+
3
+ export const AGENTS_MANAGED_START = '<!-- matt-skills:managed:start -->';
4
+ export const AGENTS_MANAGED_END = '<!-- matt-skills:managed:end -->';
5
+
6
+ export function findManagedBlock(content) {
7
+ const start = content.indexOf(AGENTS_MANAGED_START);
8
+ if (start < 0 || content.indexOf(AGENTS_MANAGED_START, start + AGENTS_MANAGED_START.length) >= 0) return null;
9
+ const endStart = content.indexOf(AGENTS_MANAGED_END, start + AGENTS_MANAGED_START.length);
10
+ if (endStart < 0 || content.indexOf(AGENTS_MANAGED_END, endStart + AGENTS_MANAGED_END.length) >= 0) return null;
11
+ return { start, end: endStart + AGENTS_MANAGED_END.length };
12
+ }
13
+
14
+ export function mergeManagedAgents(current, template) {
15
+ const currentBlock = findManagedBlock(current);
16
+ const templateBlock = findManagedBlock(template);
17
+ if (!currentBlock || !templateBlock) return null;
18
+ const managed = template.slice(templateBlock.start, templateBlock.end);
19
+ return current.slice(0, currentBlock.start) + managed + current.slice(currentBlock.end);
20
+ }
21
+
22
+ export async function syncManagedAgents(targetFile, templateFile) {
23
+ const current = await readFile(targetFile, 'utf8');
24
+ const template = await readFile(templateFile, 'utf8');
25
+ const merged = mergeManagedAgents(current, template);
26
+ if (merged === null) return false;
27
+ if (merged !== current) await writeFile(targetFile, merged);
28
+ return true;
29
+ }
@@ -0,0 +1,60 @@
1
+ import { readdir, readFile, lstat, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export async function pathExists(targetPath) {
5
+ try {
6
+ await stat(targetPath);
7
+ return true;
8
+ } catch {
9
+ return false;
10
+ }
11
+ }
12
+
13
+ export async function isSafeRealPath(targetPath) {
14
+ const absolute = path.resolve(targetPath);
15
+ const root = path.parse(absolute).root;
16
+ let current = root;
17
+ for (const segment of path.relative(root, absolute).split(path.sep).filter(Boolean)) {
18
+ current = path.join(current, segment);
19
+ try {
20
+ if ((await lstat(current)).isSymbolicLink()) return false;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+ return true;
26
+ }
27
+
28
+ function sameMetadata(left, right) {
29
+ return (left.mode & 0o7777) === (right.mode & 0o7777)
30
+ && left.uid === right.uid
31
+ && left.gid === right.gid;
32
+ }
33
+
34
+ export async function sameTree(left, right) {
35
+ try {
36
+ const [leftInfo, rightInfo] = await Promise.all([lstat(left), lstat(right)]);
37
+ if (leftInfo.isSymbolicLink() || rightInfo.isSymbolicLink()) return false;
38
+ if (leftInfo.isDirectory() !== rightInfo.isDirectory()) return false;
39
+ if (leftInfo.isDirectory()) {
40
+ const [leftEntries, rightEntries] = await Promise.all([
41
+ readdir(left, { withFileTypes: true }),
42
+ readdir(right, { withFileTypes: true }),
43
+ ]);
44
+ if (!sameMetadata(leftInfo, rightInfo) || leftEntries.length !== rightEntries.length) return false;
45
+ const rightNames = new Set(rightEntries.map((entry) => entry.name));
46
+ for (const entry of leftEntries) {
47
+ if (!rightNames.has(entry.name) || !await sameTree(path.join(left, entry.name), path.join(right, entry.name))) {
48
+ return false;
49
+ }
50
+ }
51
+ return true;
52
+ }
53
+ if (leftInfo.isFile() && rightInfo.isFile()) {
54
+ return (await readFile(left)).equals(await readFile(right)) && sameMetadata(leftInfo, rightInfo);
55
+ }
56
+ return false;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
@@ -0,0 +1,144 @@
1
+ import { cp, lstat, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { isSafeRealPath, pathExists, sameTree } from './filesystem.js';
5
+
6
+ export const TOOLS = ['codex', 'pi', 'opencode', 'claude'];
7
+
8
+ export const PROJECT_SKILLS_DIR = '.agents/skills';
9
+
10
+ export const PROJECT_DIRS = {
11
+ codex: PROJECT_SKILLS_DIR,
12
+ pi: PROJECT_SKILLS_DIR,
13
+ opencode: PROJECT_SKILLS_DIR,
14
+ claude: PROJECT_SKILLS_DIR,
15
+ };
16
+
17
+ export const LEGACY_PROJECT_SKILL_DIRS = ['.pi/skills', '.opencode/skills', '.claude/skills'];
18
+
19
+ const GLOBAL_DIRS = {
20
+ codex: '.codex/skills',
21
+ pi: '.pi/agent/skills',
22
+ opencode: '.config/opencode/skills',
23
+ claude: '.claude/skills',
24
+ };
25
+
26
+ export function projectSkillTargets(target) {
27
+ const targets = new Map();
28
+ for (const tool of TOOLS) {
29
+ const dir = path.resolve(target, PROJECT_DIRS[tool]);
30
+ if (!targets.has(dir)) targets.set(dir, { tool, dir });
31
+ }
32
+ return [...targets.values()];
33
+ }
34
+
35
+ export function toolDir(tool, global) {
36
+ if (global) return path.join(os.homedir(), GLOBAL_DIRS[tool]);
37
+ return path.resolve(process.cwd(), PROJECT_DIRS[tool]);
38
+ }
39
+
40
+ export async function copySkills({ sourceDir, targetDir, skillNames, force = true }) {
41
+ let installed = 0;
42
+ let skipped = 0;
43
+ for (const name of skillNames) {
44
+ const source = path.join(sourceDir, name);
45
+ const destination = path.join(targetDir, name);
46
+ if (path.resolve(source) === path.resolve(destination)) continue;
47
+ if (!force && await pathExists(destination)) {
48
+ skipped++;
49
+ continue;
50
+ }
51
+ await cp(source, destination, { recursive: true, force: true });
52
+ installed++;
53
+ }
54
+ return { installed, skipped };
55
+ }
56
+
57
+ export async function distributeProjectSkills({ target, sourceDir, skillNames }) {
58
+ for (const { dir } of projectSkillTargets(target)) {
59
+ await mkdir(dir, { recursive: true });
60
+ await copySkills({ sourceDir, targetDir: dir, skillNames });
61
+ }
62
+ }
63
+
64
+ export async function syncProjectSkills({ target, sourceDir, skillNames, distributableSkillNames, repoLocalSkills }) {
65
+ const skillTargets = projectSkillTargets(target);
66
+ const skillsDir = path.resolve(target, PROJECT_DIRS.codex);
67
+ const legacyLocations = LEGACY_PROJECT_SKILL_DIRS.map((relative) => ({
68
+ dir: path.resolve(target, relative),
69
+ label: relative,
70
+ isWorkspaceSource: false,
71
+ }));
72
+ const preservedRepoLocal = [];
73
+ const preserveLocations = [
74
+ ...skillTargets.map(({ tool, dir }) => ({
75
+ dir,
76
+ label: PROJECT_DIRS[tool],
77
+ isWorkspaceSource: path.resolve(dir) === path.resolve(sourceDir),
78
+ })),
79
+ ...legacyLocations,
80
+ ];
81
+ for (const { dir } of skillTargets) await mkdir(dir, { recursive: true });
82
+ for (const name of repoLocalSkills) {
83
+ for (const location of preserveLocations) {
84
+ if (!location.isWorkspaceSource && await pathExists(path.join(location.dir, name))) {
85
+ preservedRepoLocal.push(`${location.label}/${name}`);
86
+ }
87
+ }
88
+ }
89
+ let installed = 0;
90
+ let updated = 0;
91
+ for (const { dir } of skillTargets) {
92
+ for (const name of skillNames) {
93
+ const source = path.join(sourceDir, name);
94
+ const destination = path.join(dir, name);
95
+ if (path.resolve(source) === path.resolve(destination)) {
96
+ if (path.resolve(dir) === skillsDir) updated++;
97
+ continue;
98
+ }
99
+ const exists = await pathExists(destination);
100
+ if (exists) {
101
+ await rm(destination, { recursive: true, force: true });
102
+ await cp(source, destination, { recursive: true, force: true });
103
+ if (path.resolve(dir) === skillsDir) updated++;
104
+ } else {
105
+ await cp(source, destination, { recursive: true, force: true });
106
+ if (path.resolve(dir) === skillsDir) installed++;
107
+ }
108
+ }
109
+ }
110
+ const cleanedLegacy = [];
111
+ for (const location of legacyLocations) {
112
+ let entries;
113
+ try {
114
+ if (!await isSafeRealPath(location.dir)) continue;
115
+ const locationInfo = await lstat(location.dir);
116
+ if (!locationInfo.isDirectory()) continue;
117
+ entries = await readdir(location.dir, { withFileTypes: true });
118
+ } catch {
119
+ continue;
120
+ }
121
+ for (const entry of entries) {
122
+ if (!entry.isDirectory() || entry.name === '.git' || entry.name.endsWith('.bak')) continue;
123
+ if (!distributableSkillNames.includes(entry.name)) continue;
124
+ const source = path.join(sourceDir, entry.name);
125
+ const destination = path.join(location.dir, entry.name);
126
+ try {
127
+ if (await sameTree(source, destination)) {
128
+ await rm(destination, { recursive: true, force: true });
129
+ cleanedLegacy.push(`${location.label}/${entry.name}`);
130
+ }
131
+ } catch {}
132
+ }
133
+ }
134
+ try {
135
+ const piSettings = path.join(target, '.pi/settings.json');
136
+ if (await pathExists(piSettings)) {
137
+ const txt = await readFile(piSettings, 'utf8');
138
+ if (txt.includes('.opencode/skills') || txt.includes('../.opencode')) {
139
+ await writeFile(piSettings, '{}\n');
140
+ }
141
+ }
142
+ } catch {}
143
+ return { installed, updated, cleanedLegacy, preservedRepoLocal };
144
+ }