@oneezy/skills-sync 0.2.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.
- package/README.md +84 -0
- package/dist/src/cli.js +369 -0
- package/dist/src/config.js +15 -0
- package/dist/src/fs.js +146 -0
- package/dist/src/harnesses.js +58 -0
- package/dist/src/library.js +244 -0
- package/dist/src/plan.js +79 -0
- package/dist/src/steps.js +184 -0
- package/dist/src/wsl.js +32 -0
- package/package.json +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# @oneezy/skills-sync
|
|
2
|
+
|
|
3
|
+
One skills library. Every agent harness. Every project on the machine.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npx @oneezy/skills-sync
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Run it anywhere and it works out the rest:
|
|
10
|
+
|
|
11
|
+
- **No library on this machine?** It clones one into `~/.skills-sync` (default `oneezy/skills`; `--library owner/repo` for another) and asks nothing.
|
|
12
|
+
- **Library present?** It pulls it, restores whatever the lock file has that is missing, rebuilds the library's harness layers, and links every skill into each harness's user folder. Every step is skipped when its result is already right, so a no-op run is silent and fast.
|
|
13
|
+
- **On another machine or in a cloud session?** Run the same command there (or ask the agent to run the `oneezy-skills` skill, which does exactly that). Nothing is installed into any harness's settings.
|
|
14
|
+
|
|
15
|
+
First run on a machine with a terminal asks which harnesses, whether to link the user folders, which projects (if any) should carry copies, and (on Windows) which WSL distros. Answers are remembered in `skills-sync.json` beside the lock; `--ask` prompts again. Node 20+, git. Windows uses junctions (no admin), everything else symlinks.
|
|
16
|
+
|
|
17
|
+
Edit a skill in the library and every harness sees the change immediately: the user-folder entries are links. `git push` from the library is how it reaches other machines; their next session start pulls it.
|
|
18
|
+
|
|
19
|
+
## The library
|
|
20
|
+
|
|
21
|
+
A skills library is a folder with two committed things:
|
|
22
|
+
|
|
23
|
+
| path | holds | written by |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| `skills/<name>/` | your own skills, bare Agent Skills form (`SKILL.md`, optional `scripts/`, `references/`, `agents/openai.yaml`) | you |
|
|
26
|
+
| `skills-lock.json` | pins for third-party skills: source repo, path, hash | `npx skills add <owner>/<repo>` and `npx skills update` |
|
|
27
|
+
|
|
28
|
+
Everything else in it is generated and should be gitignored:
|
|
29
|
+
|
|
30
|
+
| path | holds |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `.agents/skills/<name>` | the working set: third-party skills restored from the lock, plus one link per own skill. Codex reads this folder directly. |
|
|
33
|
+
| `.claude/skills/<name>`, `.goose/skills/<name>`, `.hermes/skills/<name>` | one link per working-set entry, for each harness that does not read `.agents/skills` |
|
|
34
|
+
|
|
35
|
+
`oneezy/skills` is one such library; `npx skills add oneezy/skills` installs its own skills anywhere, and cloning it plus one `npx @oneezy/skills-sync` gives a new machine the whole set.
|
|
36
|
+
|
|
37
|
+
## What a run does
|
|
38
|
+
|
|
39
|
+
1. **Find the library.** `--repo`, else `$SKILLS_REPO`, else `~/.skills-sync` (the clone, or a link to wherever the library really lives), else a library folder above the current one (both `skills/` and `skills-lock.json`; never a dot-folder). Found somewhere else than `~/.skills-sync`? A link is left there so the next run finds it from anywhere. Nothing found? Clone one.
|
|
40
|
+
1. **Pull.** Fast-forward the library from its remote, at most every 30 minutes, only when its tree is clean (`--pull` forces, `--no-pull` skips).
|
|
41
|
+
2. **Restore.** Lock entries with no folder in `.agents/skills` are fetched: one shallow clone per source, each skill copied from its recorded path, or found by folder name when upstream moved it. Skills upstream deleted are reported, remembered, and skipped until `--retry`. (`npx skills experimental_install` clones once per skill and stops at the first stale path, so this is done natively.)
|
|
42
|
+
3. **Layers.** `.agents/skills/<name>` links to `skills/<name>` for own skills, listed in a generated `.agents/skills/.gitignore`. Each selected harness that has its own project folder gets one link per working-set entry. With `--sidecars`, own skills that lack `agents/openai.yaml` get one generated from their frontmatter, so Codex sees the same policy; it writes into `skills/`, so it is opt-in.
|
|
43
|
+
4. **User folders.** One link per skill in each selected harness's user skills folder (`~/.claude/skills`, `~/.agents/skills`, `~/.config/goose/skills`, `~/.hermes/skills`), pointing at the real folder. Every project on the machine now sees the set, and an edit in the library is live everywhere.
|
|
44
|
+
5. **Projects.** Optional. Git repos under the dev folder that you check get their skills too: **link** mode makes the same links inside the repo and hides them from git through `.git/info/exclude`; **copy** mode writes real folders meant to be committed, for repos that must carry their own (cloud sessions, other people).
|
|
45
|
+
6. **WSL.** Windows only, optional. Each checked distro runs the same sync for its own user folders through `/mnt/<drive>/…`. The distro needs Node.
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
Then it prints one line per change and a summary. `--plan` prints the same without touching anything.
|
|
49
|
+
|
|
50
|
+
## Rules it never breaks
|
|
51
|
+
|
|
52
|
+
- A link is created, retargeted or removed only when its target is inside the library. A real folder in the way, or a link pointing elsewhere, is reported as a conflict and left alone.
|
|
53
|
+
- `synced/` (Claude's account skills) and `.system/` are never touched.
|
|
54
|
+
- Third-party copies in `.agents/skills` are never modified; `npx skills update` owns them.
|
|
55
|
+
- Inside WSL the library's own layers are left to Windows.
|
|
56
|
+
|
|
57
|
+
## Prompts and flags
|
|
58
|
+
|
|
59
|
+
Interactive (a terminal, no `-y`): library path, harnesses to sync (detected ones pre-checked), where (user folders, projects), which projects and link or copy, machines (Windows plus WSL distros). Answers are saved in `skills-sync.json` beside the lock; `--ask` prompts again.
|
|
60
|
+
|
|
61
|
+
Every answer is also a flag, so scripts and agents never see a prompt:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
--repo <path> --agents claude-code,codex,goose,hermes
|
|
65
|
+
--global | --no-global
|
|
66
|
+
--projects a,b | --projects '*' | --no-projects --dev <dir> --copy
|
|
67
|
+
--wsl Ubuntu | --wsl '*' | --no-wsl
|
|
68
|
+
--library owner/repo --pull | --no-pull
|
|
69
|
+
--no-restore --retry --sidecars --no-layers
|
|
70
|
+
--watch --plan --quiet --json -y --ask
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Commands: `sync` (default), `status`, `unlink` (remove every link this tool made in the user folders), `projects` (only step 5).
|
|
74
|
+
|
|
75
|
+
`--watch` keeps running and redoes layers and user folders when `skills/` or the lock changes, so a skill you add is linked the moment its folder appears.
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
cd clis/skills-sync
|
|
81
|
+
pnpm install && pnpm build
|
|
82
|
+
pnpm test # node:test on temp folders; junctions on Windows, symlinks elsewhere
|
|
83
|
+
node dist/src/cli.js --repo <library> --plan
|
|
84
|
+
```
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// skills-sync: one skills library, every harness, every project on the machine. One command that works out where it is.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import * as p from "@clack/prompts";
|
|
7
|
+
import { readConfig, writeConfig } from "./config.js";
|
|
8
|
+
import { gitExclude, isDir, isLink, lexists, linkTarget, real, samePath } from "./fs.js";
|
|
9
|
+
import { detected, harnessTable } from "./harnesses.js";
|
|
10
|
+
import { cloneLibrary, DEFAULT_LIBRARY, findLibrary, homeLibrary, Library, looksLikeLibrary, pullLibrary } from "./library.js";
|
|
11
|
+
import { apply, line, Report } from "./plan.js";
|
|
12
|
+
import { findProjects, home, isRepo, layers, projects, status, unlink } from "./steps.js";
|
|
13
|
+
import { runInWsl, wslDistros } from "./wsl.js";
|
|
14
|
+
const VERSION = "0.2.0";
|
|
15
|
+
const HELP = `skills-sync ${VERSION}
|
|
16
|
+
One skills library, every harness, every project on this machine. Run it anywhere; it works out the rest.
|
|
17
|
+
|
|
18
|
+
no library on this machine clone one into ~/.skills-sync (default: ${DEFAULT_LIBRARY}, or --library owner/repo)
|
|
19
|
+
library present pull it, restore what the lock has, rebuild its layers, link the user folders
|
|
20
|
+
|
|
21
|
+
Usage: skills-sync [command] [options]
|
|
22
|
+
|
|
23
|
+
Commands
|
|
24
|
+
sync (default) everything above
|
|
25
|
+
status what is linked and what is missing
|
|
26
|
+
unlink remove every link this tool made in the user folders
|
|
27
|
+
projects only the project step
|
|
28
|
+
|
|
29
|
+
Options
|
|
30
|
+
--repo <path> the skills library (default: $SKILLS_REPO, ~/.skills-sync, a library folder above here)
|
|
31
|
+
--library <src> what to clone when there is no library yet (owner/repo or URL; default ${DEFAULT_LIBRARY})
|
|
32
|
+
--agents <ids> harnesses: claude-code,codex,goose,hermes (default: detected)
|
|
33
|
+
--global / --no-global link into the harnesses' user skills folders (default: yes)
|
|
34
|
+
--projects <names|*> repos under --dev to sync; "*" for all; --no-projects for none
|
|
35
|
+
--dev <dir> folder whose git repos are offered (default: here, or the parent when here is a repo)
|
|
36
|
+
--copy projects get real copies instead of links
|
|
37
|
+
--wsl <distros|*> Windows: also sync the user folders inside these WSL distros; --no-wsl for none
|
|
38
|
+
--no-pull / --pull skip, or force, the library pull (default: at most every 30 minutes)
|
|
39
|
+
--no-restore do not restore missing lock entries from their sources
|
|
40
|
+
--retry retry lock entries an earlier run reported as gone upstream
|
|
41
|
+
--sidecars generate agents/openai.yaml for own skills that lack one (writes into skills/, so opt-in)
|
|
42
|
+
--no-layers leave the library's own layers alone (used inside WSL, where Windows owns them)
|
|
43
|
+
--watch stay running; redo layers and user folders when skills/ or the lock changes
|
|
44
|
+
--plan show what would change, touch nothing
|
|
45
|
+
--quiet for scripts: no prompts, no WSL fan-out, print only changes and problems
|
|
46
|
+
--json machine output
|
|
47
|
+
-y, --yes no prompts: flags, then remembered answers, then defaults
|
|
48
|
+
--ask prompt even when answers are remembered
|
|
49
|
+
-h, --help
|
|
50
|
+
`;
|
|
51
|
+
function parseArgs(argv) {
|
|
52
|
+
const a = { command: "sync", pull: true, restore: true, retry: false, sidecars: false, layers: true, watch: false, plan: false, quiet: false, json: false, yes: false, ask: false };
|
|
53
|
+
const list = (v) => v.split(",").map((s) => s.trim()).filter(Boolean);
|
|
54
|
+
for (let i = 0; i < argv.length; i++) {
|
|
55
|
+
const x = argv[i];
|
|
56
|
+
const next = () => argv[++i];
|
|
57
|
+
if (x === "-h" || x === "--help") {
|
|
58
|
+
process.stdout.write(HELP);
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
else if (x === "--repo")
|
|
62
|
+
a.repo = next();
|
|
63
|
+
else if (x === "--library")
|
|
64
|
+
a.library = next();
|
|
65
|
+
else if (x === "--agents")
|
|
66
|
+
a.agents = list(next());
|
|
67
|
+
else if (x === "--global")
|
|
68
|
+
a.global = true;
|
|
69
|
+
else if (x === "--no-global")
|
|
70
|
+
a.global = false;
|
|
71
|
+
else if (x === "--projects") {
|
|
72
|
+
const v = next();
|
|
73
|
+
a.projects = v === "*" ? "*" : list(v);
|
|
74
|
+
}
|
|
75
|
+
else if (x === "--no-projects")
|
|
76
|
+
a.projects = false;
|
|
77
|
+
else if (x === "--dev")
|
|
78
|
+
a.dev = next();
|
|
79
|
+
else if (x === "--copy")
|
|
80
|
+
a.copy = true;
|
|
81
|
+
else if (x === "--wsl") {
|
|
82
|
+
const v = next();
|
|
83
|
+
a.wsl = v === "*" ? "*" : list(v);
|
|
84
|
+
}
|
|
85
|
+
else if (x === "--no-wsl")
|
|
86
|
+
a.wsl = false;
|
|
87
|
+
else if (x === "--no-pull")
|
|
88
|
+
a.pull = false;
|
|
89
|
+
else if (x === "--pull")
|
|
90
|
+
a.pull = "force";
|
|
91
|
+
else if (x === "--no-restore")
|
|
92
|
+
a.restore = false;
|
|
93
|
+
else if (x === "--retry")
|
|
94
|
+
a.retry = true;
|
|
95
|
+
else if (x === "--sidecars")
|
|
96
|
+
a.sidecars = true;
|
|
97
|
+
else if (x === "--no-layers")
|
|
98
|
+
a.layers = false;
|
|
99
|
+
else if (x === "--watch")
|
|
100
|
+
a.watch = true;
|
|
101
|
+
else if (x === "--plan")
|
|
102
|
+
a.plan = true;
|
|
103
|
+
else if (x === "--quiet")
|
|
104
|
+
a.quiet = true;
|
|
105
|
+
else if (x === "--json")
|
|
106
|
+
a.json = true;
|
|
107
|
+
else if (x === "-y" || x === "--yes")
|
|
108
|
+
a.yes = true;
|
|
109
|
+
else if (x === "--ask")
|
|
110
|
+
a.ask = true;
|
|
111
|
+
else if (x.startsWith("-")) {
|
|
112
|
+
process.stderr.write(`unknown option ${x}\n${HELP}`);
|
|
113
|
+
process.exit(2);
|
|
114
|
+
}
|
|
115
|
+
else
|
|
116
|
+
a.command = x;
|
|
117
|
+
}
|
|
118
|
+
if (a.quiet)
|
|
119
|
+
a.yes = true;
|
|
120
|
+
return a;
|
|
121
|
+
}
|
|
122
|
+
function bail(msg) {
|
|
123
|
+
process.stderr.write(msg + "\n");
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
async function main() {
|
|
127
|
+
const args = parseArgs(process.argv.slice(2));
|
|
128
|
+
const cwd = process.cwd();
|
|
129
|
+
const userHome = os.homedir();
|
|
130
|
+
const interactive = !args.yes && !args.json && process.stdin.isTTY && process.stdout.isTTY;
|
|
131
|
+
const log = (m) => (args.json ? undefined : process.stderr.write(m + "\n"));
|
|
132
|
+
const setup = new Report();
|
|
133
|
+
// 1. the library: find it, or get one
|
|
134
|
+
let root = args.repo ? real(path.resolve(args.repo)) : findLibrary(cwd);
|
|
135
|
+
if (!root && !args.plan) {
|
|
136
|
+
let source = args.library ?? DEFAULT_LIBRARY;
|
|
137
|
+
if (interactive) {
|
|
138
|
+
p.intro("skills-sync");
|
|
139
|
+
const v = await p.text({ message: "No skills library on this machine. Clone which one into ~/.skills-sync?", initialValue: source, placeholder: "owner/repo, a git URL, or a local path" });
|
|
140
|
+
if (p.isCancel(v))
|
|
141
|
+
return p.cancel("nothing changed");
|
|
142
|
+
source = String(v);
|
|
143
|
+
}
|
|
144
|
+
if (looksLikeLibrary(source))
|
|
145
|
+
root = real(path.resolve(source));
|
|
146
|
+
else {
|
|
147
|
+
const c = cloneLibrary(source, userHome, log);
|
|
148
|
+
if (!c.ok)
|
|
149
|
+
bail(`could not clone ${source}: ${c.error}`);
|
|
150
|
+
root = c.root;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!root || !looksLikeLibrary(root))
|
|
154
|
+
bail("no skills library found: run this inside one, or pass --repo <path> or --library owner/repo");
|
|
155
|
+
const lib = new Library(root);
|
|
156
|
+
// ~/.skills-sync points at the library from now on, so every later run finds it from anywhere
|
|
157
|
+
const hl = homeLibrary(userHome);
|
|
158
|
+
if (!samePath(real(hl), lib.root)) {
|
|
159
|
+
if (!lexists(hl))
|
|
160
|
+
setup.add({ kind: "link", path: hl, target: lib.root, note: "remembers where the library is" });
|
|
161
|
+
else if (isLink(hl))
|
|
162
|
+
setup.add({ kind: "relink", path: hl, target: lib.root, note: `was ${linkTarget(hl)}` });
|
|
163
|
+
else
|
|
164
|
+
setup.add({ kind: "conflict", path: hl, note: "a folder is in the way; ~/.skills-sync is not a link to the library" });
|
|
165
|
+
}
|
|
166
|
+
apply(setup, args.plan);
|
|
167
|
+
// 2. keep the library current
|
|
168
|
+
if (args.pull && args.command !== "status" && !args.plan) {
|
|
169
|
+
if (args.pull === "force") {
|
|
170
|
+
try {
|
|
171
|
+
fs.unlinkSync(path.join(lib.root, ".git", "skills-sync-pulled"));
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
/* nothing to reset */
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const r = pullLibrary(lib.root, 30, log);
|
|
178
|
+
if (r === "pulled" && !args.quiet)
|
|
179
|
+
log("library pulled");
|
|
180
|
+
if (r === "dirty" && !args.quiet)
|
|
181
|
+
log("library has local changes; pull skipped");
|
|
182
|
+
}
|
|
183
|
+
const cfg = readConfig(lib.root);
|
|
184
|
+
const table = harnessTable();
|
|
185
|
+
if (args.command === "status") {
|
|
186
|
+
const ids = args.agents ?? cfg.agents;
|
|
187
|
+
return printStatus(lib, ids ? table.filter((h) => ids.includes(h.id)) : detected(table), args.json);
|
|
188
|
+
}
|
|
189
|
+
if (args.command === "unlink") {
|
|
190
|
+
const r = new Report();
|
|
191
|
+
unlink(lib, table, r);
|
|
192
|
+
apply(r, args.plan);
|
|
193
|
+
return printReport(r, args);
|
|
194
|
+
}
|
|
195
|
+
// 3. the choices: flags, then remembered answers, then defaults; prompts fill the gaps when interactive
|
|
196
|
+
const choices = await decide(args, cfg, lib, table, cwd, interactive);
|
|
197
|
+
if (!choices)
|
|
198
|
+
return p.cancel("nothing changed");
|
|
199
|
+
if (!args.plan)
|
|
200
|
+
saveConfig(lib.root, choices);
|
|
201
|
+
// 4. run
|
|
202
|
+
const run = () => runOnce(lib, choices, args, cwd, setup);
|
|
203
|
+
await run();
|
|
204
|
+
if (args.watch) {
|
|
205
|
+
log(`watching ${lib.own} and ${path.basename(lib.lockFile)}; ctrl-c to stop`);
|
|
206
|
+
let timer = null;
|
|
207
|
+
const trigger = (why) => {
|
|
208
|
+
if (timer)
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
timer = setTimeout(() => {
|
|
211
|
+
log(`change in ${why}`);
|
|
212
|
+
runOnce(lib, choices, { ...args, restore: false }, cwd, new Report()).catch((e) => log(String(e)));
|
|
213
|
+
}, 400);
|
|
214
|
+
};
|
|
215
|
+
fs.watch(lib.own, { recursive: true }, (_e, f) => trigger(String(f ?? "skills/")));
|
|
216
|
+
if (fs.existsSync(lib.lockFile))
|
|
217
|
+
fs.watch(lib.lockFile, () => trigger("skills-lock.json"));
|
|
218
|
+
await new Promise(() => undefined);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function saveConfig(root, c) {
|
|
222
|
+
const saved = { agents: c.agents.map((h) => h.id), global: c.global, dev: c.dev, projects: c.projects, mode: c.mode, wsl: c.wsl };
|
|
223
|
+
if (c.unavailable.length)
|
|
224
|
+
saved.unavailable = c.unavailable;
|
|
225
|
+
writeConfig(root, saved);
|
|
226
|
+
}
|
|
227
|
+
async function decide(args, cfg, lib, table, cwd, interactive) {
|
|
228
|
+
const found = detected(table);
|
|
229
|
+
const byId = new Map(table.map((h) => [h.id, h]));
|
|
230
|
+
// first run with nothing remembered: only the two harnesses this tool is built around, unless asked
|
|
231
|
+
let agentIds = args.agents ?? cfg.agents ?? found.filter((h) => h.id === "claude-code" || h.id === "codex").map((h) => h.id);
|
|
232
|
+
let global = args.global ?? cfg.global ?? true;
|
|
233
|
+
const here = path.resolve(cwd);
|
|
234
|
+
let dev = args.dev ? path.resolve(args.dev) : cfg.dev ?? (isRepo(here) && !looksLikeLibrary(here) ? path.dirname(here) : here);
|
|
235
|
+
const offered = findProjects(dev, lib).map((pp) => path.basename(pp));
|
|
236
|
+
let projectNames = args.projects === false ? [] : args.projects === "*" ? offered : args.projects ?? cfg.projects ?? [];
|
|
237
|
+
let mode = args.copy ? "copy" : cfg.mode ?? "link";
|
|
238
|
+
const distros = args.quiet ? [] : wslDistros();
|
|
239
|
+
let wsl = args.wsl === false ? [] : args.wsl === "*" ? distros : args.wsl ?? cfg.wsl ?? [];
|
|
240
|
+
const remembered = Object.keys(cfg).length > 0;
|
|
241
|
+
if (interactive && (args.ask || !remembered || args.command === "projects")) {
|
|
242
|
+
if (!remembered)
|
|
243
|
+
p.intro("skills-sync: first run on this machine");
|
|
244
|
+
const agentsPick = await p.multiselect({
|
|
245
|
+
message: "Harnesses to sync (checked = detected on this machine)",
|
|
246
|
+
options: table.map((h) => ({ value: h.id, label: h.name, hint: found.includes(h) ? "detected" : "not found" })),
|
|
247
|
+
initialValues: agentIds,
|
|
248
|
+
required: true,
|
|
249
|
+
});
|
|
250
|
+
if (p.isCancel(agentsPick))
|
|
251
|
+
return null;
|
|
252
|
+
agentIds = agentsPick;
|
|
253
|
+
const whereOptions = [{ value: "global", label: "User folders", hint: "every project on this machine; edits are live" }];
|
|
254
|
+
if (offered.length)
|
|
255
|
+
whereOptions.push({ value: "projects", label: `Projects in ${dev}`, hint: `${offered.length} git repos; only for repos that must carry copies` });
|
|
256
|
+
const where = await p.multiselect({ message: "Where", options: whereOptions, initialValues: [global ? "global" : "", projectNames.length ? "projects" : ""].filter(Boolean), required: false });
|
|
257
|
+
if (p.isCancel(where))
|
|
258
|
+
return null;
|
|
259
|
+
global = where.includes("global");
|
|
260
|
+
if (where.includes("projects")) {
|
|
261
|
+
const pick = await p.multiselect({ message: "Which projects (space to check)", options: offered.map((n) => ({ value: n, label: n })), initialValues: projectNames.length ? projectNames : offered, required: false });
|
|
262
|
+
if (p.isCancel(pick))
|
|
263
|
+
return null;
|
|
264
|
+
projectNames = pick;
|
|
265
|
+
const m = await p.select({
|
|
266
|
+
message: "Project mode",
|
|
267
|
+
options: [
|
|
268
|
+
{ value: "link", label: "link", hint: "machine-local, hidden from git through .git/info/exclude" },
|
|
269
|
+
{ value: "copy", label: "copy", hint: "committed, travels with the repo" },
|
|
270
|
+
],
|
|
271
|
+
initialValue: mode,
|
|
272
|
+
});
|
|
273
|
+
if (p.isCancel(m))
|
|
274
|
+
return null;
|
|
275
|
+
mode = m;
|
|
276
|
+
}
|
|
277
|
+
else
|
|
278
|
+
projectNames = [];
|
|
279
|
+
if (distros.length) {
|
|
280
|
+
const m = await p.multiselect({ message: "Machines", options: [{ value: "__win__", label: "Windows", hint: "this one" }, ...distros.map((d) => ({ value: d, label: `WSL: ${d}` }))], initialValues: ["__win__", ...wsl], required: true });
|
|
281
|
+
if (p.isCancel(m))
|
|
282
|
+
return null;
|
|
283
|
+
wsl = m.filter((x) => x !== "__win__");
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const agents = agentIds.map((id) => byId.get(id)).filter((h) => !!h);
|
|
287
|
+
if (!agents.length)
|
|
288
|
+
bail("no harness selected; pass --agents claude-code,codex");
|
|
289
|
+
return { agents, global, dev, projects: projectNames, mode, wsl, unavailable: args.retry ? [] : cfg.unavailable ?? [] };
|
|
290
|
+
}
|
|
291
|
+
async function runOnce(lib, c, args, cwd, report) {
|
|
292
|
+
const log = (m) => (args.json ? undefined : process.stderr.write(m + "\n"));
|
|
293
|
+
const userHome = os.homedir();
|
|
294
|
+
const missing = lib.missingFromLock().filter((n) => !c.unavailable.includes(n));
|
|
295
|
+
if (args.restore && args.command !== "projects" && missing.length && !args.plan) {
|
|
296
|
+
const r = lib.restore(log, missing);
|
|
297
|
+
log(`restored ${r.restored.length} skill(s) from skills-lock.json`);
|
|
298
|
+
for (const m of r.moved)
|
|
299
|
+
log(` moved upstream: ${m}`);
|
|
300
|
+
for (const m of r.missing)
|
|
301
|
+
log(` gone upstream: ${m}`);
|
|
302
|
+
if (r.missing.length)
|
|
303
|
+
log(` (npx skills remove <name> drops it from the lock; a copy in skills/ keeps it as your own; --retry checks again)`);
|
|
304
|
+
for (const m of r.failed)
|
|
305
|
+
log(` failed: ${m}`);
|
|
306
|
+
c.unavailable = [...new Set([...c.unavailable, ...r.missing.map((m) => m.split(":")[0])])];
|
|
307
|
+
saveConfig(lib.root, c);
|
|
308
|
+
}
|
|
309
|
+
else if (missing.length)
|
|
310
|
+
log(`${missing.length} lock entries are not installed yet (run without --plan or --no-restore to restore them)`);
|
|
311
|
+
if (c.unavailable.length && !args.quiet)
|
|
312
|
+
log(`${c.unavailable.length} lock entr${c.unavailable.length === 1 ? "y is" : "ies are"} gone upstream (${c.unavailable.join(", ")}); --retry to check again`);
|
|
313
|
+
if (args.command !== "projects") {
|
|
314
|
+
if (args.layers) {
|
|
315
|
+
const s = new Report();
|
|
316
|
+
layers(lib, c.agents, s, args.sidecars);
|
|
317
|
+
apply(s, args.plan);
|
|
318
|
+
report.merge(s);
|
|
319
|
+
}
|
|
320
|
+
if (c.global) {
|
|
321
|
+
const u = new Report();
|
|
322
|
+
home(lib, c.agents, u);
|
|
323
|
+
apply(u, args.plan);
|
|
324
|
+
report.merge(u);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (c.projects.length) {
|
|
328
|
+
const targets = c.projects.map((n) => path.join(c.dev, n)).filter((pp) => isDir(pp));
|
|
329
|
+
const pr = new Report();
|
|
330
|
+
projects(lib, c.agents, targets, null, c.mode, pr);
|
|
331
|
+
apply(pr, args.plan, gitExclude);
|
|
332
|
+
report.merge(pr);
|
|
333
|
+
}
|
|
334
|
+
printReport(report, args);
|
|
335
|
+
if (c.wsl.length && args.command !== "projects" && !args.quiet) {
|
|
336
|
+
for (const d of c.wsl) {
|
|
337
|
+
const r = runInWsl(d, lib.root, [], args.plan);
|
|
338
|
+
log(`WSL ${d}: ${r.ok ? "ok" : "failed"}${r.output ? "\n " + r.output.split("\n").slice(-3).join("\n ") : ""}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function printReport(r, args) {
|
|
343
|
+
if (args.json) {
|
|
344
|
+
process.stdout.write(JSON.stringify({ plan: args.plan, actions: r.actions }, null, 2) + "\n");
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const changes = r.changes();
|
|
348
|
+
const conflicts = r.conflicts();
|
|
349
|
+
if (args.quiet && !changes.length && !conflicts.length)
|
|
350
|
+
return;
|
|
351
|
+
for (const a of r.actions)
|
|
352
|
+
if (a.kind !== "skip")
|
|
353
|
+
process.stdout.write(line(a) + "\n");
|
|
354
|
+
const verb = args.plan ? "would change" : "changed";
|
|
355
|
+
process.stdout.write(`${args.plan ? "plan: " : ""}${changes.length} ${verb}, ${r.skips()} already right, ${conflicts.length} left alone\n`);
|
|
356
|
+
}
|
|
357
|
+
function printStatus(lib, table, json) {
|
|
358
|
+
const s = status(lib, table);
|
|
359
|
+
if (json) {
|
|
360
|
+
process.stdout.write(JSON.stringify(s, null, 2) + "\n");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
process.stdout.write(`library ${s.library}: ${s.own.length} own, ${s.thirdParty.length} third-party, ${s.missingFromLock.length} in the lock but not installed\n`);
|
|
364
|
+
for (const [layer, v] of Object.entries(s.layers))
|
|
365
|
+
process.stdout.write(`${layer}: ${v.linked} linked, ${v.missing.length} missing\n`);
|
|
366
|
+
for (const [dir, v] of Object.entries(s.user))
|
|
367
|
+
process.stdout.write(`${dir}: ${v.linked} linked, ${v.missing.length} missing\n`);
|
|
368
|
+
}
|
|
369
|
+
main().catch((e) => bail(String(e?.stack ?? e)));
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Remembered answers, kept beside the lock file so the second run needs no prompts.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export const CONFIG_NAME = "skills-sync.json";
|
|
5
|
+
export function readConfig(libraryRoot) {
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(fs.readFileSync(path.join(libraryRoot, CONFIG_NAME), "utf8"));
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function writeConfig(libraryRoot, cfg) {
|
|
14
|
+
fs.writeFileSync(path.join(libraryRoot, CONFIG_NAME), JSON.stringify(cfg, null, 2) + "\n");
|
|
15
|
+
}
|
package/dist/src/fs.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Link primitives that behave the same on Windows (junctions, no admin) and POSIX (symlinks).
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export function lexists(p) {
|
|
6
|
+
try {
|
|
7
|
+
fs.lstatSync(p);
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** A directory symlink, or a junction on Windows. */
|
|
15
|
+
export function isLink(p) {
|
|
16
|
+
try {
|
|
17
|
+
const st = fs.lstatSync(p);
|
|
18
|
+
if (st.isSymbolicLink())
|
|
19
|
+
return true;
|
|
20
|
+
// junctions report as directories with a reparse point; readlink resolves them
|
|
21
|
+
if (process.platform === "win32" && st.isDirectory()) {
|
|
22
|
+
try {
|
|
23
|
+
fs.readlinkSync(p);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function linkTarget(p) {
|
|
37
|
+
try {
|
|
38
|
+
let raw = fs.readlinkSync(p);
|
|
39
|
+
if (raw.startsWith("\\\\?\\"))
|
|
40
|
+
raw = raw.slice(4);
|
|
41
|
+
return path.isAbsolute(raw) ? raw : path.resolve(path.dirname(p), raw);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function norm(p) {
|
|
48
|
+
const n = path.normalize(p).replace(/[\\/]+$/, "");
|
|
49
|
+
return process.platform === "win32" ? n.toLowerCase() : n;
|
|
50
|
+
}
|
|
51
|
+
export function samePath(a, b) {
|
|
52
|
+
return norm(a) === norm(b);
|
|
53
|
+
}
|
|
54
|
+
export function under(p, root) {
|
|
55
|
+
const a = norm(p);
|
|
56
|
+
const r = norm(root);
|
|
57
|
+
return a === r || a.startsWith(r + path.sep);
|
|
58
|
+
}
|
|
59
|
+
export function real(p) {
|
|
60
|
+
try {
|
|
61
|
+
return fs.realpathSync.native(p);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return path.resolve(p);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function makeLink(target, link) {
|
|
68
|
+
fs.mkdirSync(path.dirname(link), { recursive: true });
|
|
69
|
+
fs.symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir");
|
|
70
|
+
}
|
|
71
|
+
export function removeLink(link) {
|
|
72
|
+
try {
|
|
73
|
+
fs.rmdirSync(link); // junctions and directory symlinks on Windows
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
fs.unlinkSync(link);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function isSkillDir(p) {
|
|
80
|
+
try {
|
|
81
|
+
return fs.statSync(path.join(p, "SKILL.md")).isFile();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function isDir(p) {
|
|
88
|
+
try {
|
|
89
|
+
return fs.statSync(p).isDirectory();
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Content hash of a folder: relative paths plus bytes, so two copies compare equal. */
|
|
96
|
+
export function folderHash(folder) {
|
|
97
|
+
const h = createHash("sha256");
|
|
98
|
+
const walk = (dir) => {
|
|
99
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
100
|
+
const full = path.join(dir, e.name);
|
|
101
|
+
if (e.isDirectory())
|
|
102
|
+
walk(full);
|
|
103
|
+
else if (e.isFile()) {
|
|
104
|
+
h.update(path.relative(folder, full).replace(/\\/g, "/"));
|
|
105
|
+
h.update(fs.readFileSync(full));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
walk(folder);
|
|
110
|
+
return h.digest("hex");
|
|
111
|
+
}
|
|
112
|
+
export function copyDir(src, dst) {
|
|
113
|
+
if (fs.existsSync(dst))
|
|
114
|
+
fs.rmSync(dst, { recursive: true, force: true });
|
|
115
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
116
|
+
fs.cpSync(src, dst, { recursive: true, dereference: true });
|
|
117
|
+
}
|
|
118
|
+
/** Add lines to a repo's .git/info/exclude (honored by git, never committed). */
|
|
119
|
+
export function gitExclude(repo, entries) {
|
|
120
|
+
const gitDir = path.join(repo, ".git");
|
|
121
|
+
let infoDir;
|
|
122
|
+
try {
|
|
123
|
+
const st = fs.statSync(gitDir);
|
|
124
|
+
if (st.isDirectory())
|
|
125
|
+
infoDir = path.join(gitDir, "info");
|
|
126
|
+
else {
|
|
127
|
+
// worktree: ".git" is a file pointing at the real git dir
|
|
128
|
+
const m = /gitdir:\s*(.+)/.exec(fs.readFileSync(gitDir, "utf8"));
|
|
129
|
+
if (!m)
|
|
130
|
+
return false;
|
|
131
|
+
infoDir = path.join(path.resolve(repo, m[1].trim()), "info");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
fs.mkdirSync(infoDir, { recursive: true });
|
|
138
|
+
const file = path.join(infoDir, "exclude");
|
|
139
|
+
const existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
|
140
|
+
const lines = new Set(existing.split(/\r?\n/));
|
|
141
|
+
const missing = entries.filter((e) => !lines.has(e));
|
|
142
|
+
if (!missing.length)
|
|
143
|
+
return false;
|
|
144
|
+
fs.writeFileSync(file, existing.replace(/\n?$/, "\n") + "# skills-sync links\n" + missing.join("\n") + "\n");
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The table of harnesses: where each one keeps user-level and project-level skills.
|
|
2
|
+
// Adding a harness is one row. Paths follow each harness's own docs, not another tool's guess.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
export function harnessTable(home = os.homedir(), env = process.env) {
|
|
7
|
+
const codexHome = env.CODEX_HOME?.trim() || path.join(home, ".codex");
|
|
8
|
+
const claudeHome = env.CLAUDE_CONFIG_DIR?.trim() || path.join(home, ".claude");
|
|
9
|
+
const configHome = env.XDG_CONFIG_HOME?.trim() || path.join(home, ".config");
|
|
10
|
+
const hermesHome = env.HERMES_HOME?.trim() || path.join(home, ".hermes");
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
id: "claude-code",
|
|
14
|
+
name: "Claude Code",
|
|
15
|
+
configDir: claudeHome,
|
|
16
|
+
userSkills: path.join(claudeHome, "skills"),
|
|
17
|
+
projectSkills: path.join(".claude", "skills"),
|
|
18
|
+
universal: false,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: "codex",
|
|
22
|
+
name: "Codex",
|
|
23
|
+
configDir: codexHome,
|
|
24
|
+
// Codex reads ~/.agents/skills; ~/.codex/skills is its deprecated location
|
|
25
|
+
userSkills: path.join(home, ".agents", "skills"),
|
|
26
|
+
projectSkills: path.join(".agents", "skills"),
|
|
27
|
+
universal: true,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "goose",
|
|
31
|
+
name: "Goose",
|
|
32
|
+
configDir: path.join(configHome, "goose"),
|
|
33
|
+
userSkills: path.join(configHome, "goose", "skills"),
|
|
34
|
+
projectSkills: path.join(".goose", "skills"),
|
|
35
|
+
universal: false,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "hermes",
|
|
39
|
+
name: "Hermes",
|
|
40
|
+
configDir: hermesHome,
|
|
41
|
+
userSkills: path.join(hermesHome, "skills"),
|
|
42
|
+
projectSkills: path.join(".hermes", "skills"),
|
|
43
|
+
universal: false,
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
export function detected(table) {
|
|
48
|
+
return table.filter((h) => {
|
|
49
|
+
try {
|
|
50
|
+
return fs.statSync(h.configDir).isDirectory();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Names a harness must never see as a skill folder. */
|
|
58
|
+
export const RESERVED = new Set(["synced", ".system"]);
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// The skills library: a folder with skills/<name>/ (own skills) and skills-lock.json (third-party pins).
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import YAML from "yaml";
|
|
7
|
+
import { isDir, isSkillDir, real } from "./fs.js";
|
|
8
|
+
import { RESERVED } from "./harnesses.js";
|
|
9
|
+
export class Library {
|
|
10
|
+
root;
|
|
11
|
+
constructor(root) {
|
|
12
|
+
this.root = root;
|
|
13
|
+
}
|
|
14
|
+
get own() {
|
|
15
|
+
return path.join(this.root, "skills");
|
|
16
|
+
}
|
|
17
|
+
get agents() {
|
|
18
|
+
return path.join(this.root, ".agents", "skills");
|
|
19
|
+
}
|
|
20
|
+
get lockFile() {
|
|
21
|
+
return path.join(this.root, "skills-lock.json");
|
|
22
|
+
}
|
|
23
|
+
ownSkills() {
|
|
24
|
+
if (!isDir(this.own))
|
|
25
|
+
return [];
|
|
26
|
+
return fs
|
|
27
|
+
.readdirSync(this.own)
|
|
28
|
+
.filter((n) => !n.startsWith(".") && isSkillDir(path.join(this.own, n)))
|
|
29
|
+
.sort();
|
|
30
|
+
}
|
|
31
|
+
/** name -> real folder, for every entry of .agents/skills that holds a SKILL.md */
|
|
32
|
+
workingSet() {
|
|
33
|
+
const out = new Map();
|
|
34
|
+
if (!isDir(this.agents))
|
|
35
|
+
return out;
|
|
36
|
+
for (const n of fs.readdirSync(this.agents).sort()) {
|
|
37
|
+
if (n.startsWith(".") || RESERVED.has(n))
|
|
38
|
+
continue;
|
|
39
|
+
const p = path.join(this.agents, n);
|
|
40
|
+
if (isSkillDir(p))
|
|
41
|
+
out.set(n, real(p));
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
lockedSkills() {
|
|
46
|
+
try {
|
|
47
|
+
const lock = JSON.parse(fs.readFileSync(this.lockFile, "utf8"));
|
|
48
|
+
return Object.keys(lock.skills ?? {}).sort();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Lock entries with no folder in .agents/skills yet. */
|
|
55
|
+
missingFromLock() {
|
|
56
|
+
return this.lockedSkills().filter((n) => !isSkillDir(path.join(this.agents, n)));
|
|
57
|
+
}
|
|
58
|
+
lockEntries() {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(fs.readFileSync(this.lockFile, "utf8")).skills ?? {};
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Restore missing lock entries into .agents/skills: one shallow clone per source, then each skill
|
|
68
|
+
* copied from its recorded path, or found by folder name when upstream moved it. Entries whose
|
|
69
|
+
* source no longer has the skill are reported, never invented. (`npx skills experimental_install`
|
|
70
|
+
* clones once per skill and stops at the first stale path, which is why this exists.)
|
|
71
|
+
*/
|
|
72
|
+
restore(log, only) {
|
|
73
|
+
const result = { restored: [], moved: [], missing: [], failed: [] };
|
|
74
|
+
const wanted = new Set(only ?? this.missingFromLock());
|
|
75
|
+
const bySource = new Map();
|
|
76
|
+
for (const [name, e] of Object.entries(this.lockEntries())) {
|
|
77
|
+
if (!wanted.has(name))
|
|
78
|
+
continue;
|
|
79
|
+
const url = cloneUrl(e);
|
|
80
|
+
if (!url) {
|
|
81
|
+
result.failed.push(`${name}: source type ${e.sourceType} is not restorable`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const key = `${url}#${e.ref ?? ""}`;
|
|
85
|
+
bySource.set(key, [...(bySource.get(key) ?? []), [name, e]]);
|
|
86
|
+
}
|
|
87
|
+
for (const [key, entries] of bySource) {
|
|
88
|
+
const [url, ref] = key.split("#");
|
|
89
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "skills-sync-src-"));
|
|
90
|
+
log(`cloning ${url}${ref ? "@" + ref : ""} for ${entries.length} skill(s)`);
|
|
91
|
+
const args = ["clone", "--quiet", "--depth", "1", ...(ref ? ["--branch", ref] : []), url, tmp];
|
|
92
|
+
const r = spawnSync("git", args, { encoding: "utf8" });
|
|
93
|
+
if (r.status !== 0) {
|
|
94
|
+
result.failed.push(`${url}: ${(r.stderr ?? "").trim().split("\n").pop()}`);
|
|
95
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
for (const [name, e] of entries) {
|
|
99
|
+
const recorded = e.skillPath ? path.join(tmp, path.dirname(e.skillPath)) : null;
|
|
100
|
+
let src = recorded && isSkillDir(recorded) ? recorded : null;
|
|
101
|
+
if (!src) {
|
|
102
|
+
src = findSkillByName(tmp, name);
|
|
103
|
+
if (src)
|
|
104
|
+
result.moved.push(`${name}: now at ${path.relative(tmp, src).replace(/\\/g, "/")}`);
|
|
105
|
+
}
|
|
106
|
+
if (!src) {
|
|
107
|
+
result.missing.push(`${name}: not in ${url} any more (was ${e.skillPath ?? "?"})`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
fs.mkdirSync(this.agents, { recursive: true });
|
|
111
|
+
fs.cpSync(src, path.join(this.agents, name), { recursive: true });
|
|
112
|
+
result.restored.push(name);
|
|
113
|
+
}
|
|
114
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function cloneUrl(e) {
|
|
120
|
+
if (e.sourceType === "github")
|
|
121
|
+
return `https://github.com/${e.source.replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, "")}.git`;
|
|
122
|
+
if (e.sourceType === "git" || e.sourceType === "gitlab")
|
|
123
|
+
return e.sourceUrl ?? null;
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
function findSkillByName(root, name) {
|
|
127
|
+
const stack = [root];
|
|
128
|
+
while (stack.length) {
|
|
129
|
+
const dir = stack.pop();
|
|
130
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
131
|
+
if (!ent.isDirectory() || ent.name === ".git" || ent.name === "node_modules")
|
|
132
|
+
continue;
|
|
133
|
+
const full = path.join(dir, ent.name);
|
|
134
|
+
if (ent.name === name && isSkillDir(full))
|
|
135
|
+
return full;
|
|
136
|
+
stack.push(full);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
export const DEFAULT_LIBRARY = "oneezy/skills";
|
|
142
|
+
/** ~/.skills-sync: the library itself, or a link to wherever the library really lives. */
|
|
143
|
+
export function homeLibrary(home = os.homedir()) {
|
|
144
|
+
return path.join(home, ".skills-sync");
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Where the library is, in this order: --repo, $SKILLS_REPO, ~/.skills-sync (a clone or a link),
|
|
148
|
+
* a library folder above the current directory. null when none exists yet.
|
|
149
|
+
*/
|
|
150
|
+
export function findLibrary(from, env = process.env, home = os.homedir()) {
|
|
151
|
+
if (env.SKILLS_REPO && looksLikeLibrary(env.SKILLS_REPO))
|
|
152
|
+
return path.resolve(env.SKILLS_REPO);
|
|
153
|
+
const hl = homeLibrary(home);
|
|
154
|
+
if (looksLikeLibrary(hl))
|
|
155
|
+
return real(hl);
|
|
156
|
+
let dir = path.resolve(from);
|
|
157
|
+
for (;;) {
|
|
158
|
+
// the walk-up never accepts a dot-folder: ~/.claude has a skills/ subfolder too
|
|
159
|
+
if (!path.basename(dir).startsWith(".") && looksLikeLibrary(dir))
|
|
160
|
+
return dir;
|
|
161
|
+
const parent = path.dirname(dir);
|
|
162
|
+
if (parent === dir)
|
|
163
|
+
break;
|
|
164
|
+
dir = parent;
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
/** Clone `owner/repo` (or a URL) into ~/.skills-sync. */
|
|
169
|
+
export function cloneLibrary(source, home = os.homedir(), log = () => undefined) {
|
|
170
|
+
const root = homeLibrary(home);
|
|
171
|
+
const url = /^(https?:|git@|ssh:)/.test(source) ? source : `https://github.com/${source.replace(/\.git$/, "")}.git`;
|
|
172
|
+
log(`no skills library here yet; cloning ${url} into ${root}`);
|
|
173
|
+
const r = spawnSync("git", ["clone", "--quiet", url, root], { encoding: "utf8" });
|
|
174
|
+
if (r.status !== 0)
|
|
175
|
+
return { ok: false, root, error: (r.stderr ?? "").trim().split("\n").pop() };
|
|
176
|
+
return { ok: true, root };
|
|
177
|
+
}
|
|
178
|
+
/** Fast-forward the library from its remote, at most once per `minutes`, only when the tree is clean. */
|
|
179
|
+
export function pullLibrary(root, minutes, log) {
|
|
180
|
+
const stamp = path.join(root, ".git", "skills-sync-pulled");
|
|
181
|
+
try {
|
|
182
|
+
const last = fs.statSync(stamp).mtimeMs;
|
|
183
|
+
if (Date.now() - last < minutes * 60_000)
|
|
184
|
+
return "throttled";
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
/* never pulled */
|
|
188
|
+
}
|
|
189
|
+
if (!fs.existsSync(path.join(root, ".git")))
|
|
190
|
+
return "skipped";
|
|
191
|
+
const status = spawnSync("git", ["-C", root, "status", "--porcelain", "--untracked-files=no"], { encoding: "utf8" });
|
|
192
|
+
if (status.status !== 0)
|
|
193
|
+
return "skipped";
|
|
194
|
+
if (status.stdout.trim())
|
|
195
|
+
return "dirty";
|
|
196
|
+
const r = spawnSync("git", ["-C", root, "pull", "--ff-only", "--quiet"], { encoding: "utf8", timeout: 20_000 });
|
|
197
|
+
try {
|
|
198
|
+
fs.writeFileSync(stamp, new Date().toISOString());
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
/* stamp is best-effort */
|
|
202
|
+
}
|
|
203
|
+
if (r.status !== 0) {
|
|
204
|
+
log(`library pull skipped: ${(r.stderr ?? "").trim().split("\n").pop()}`);
|
|
205
|
+
return "failed";
|
|
206
|
+
}
|
|
207
|
+
return "pulled";
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* A library has both skills/ and skills-lock.json, and is never a dot-folder: a harness config dir
|
|
211
|
+
* such as ~/.claude also has a skills/ subfolder, and must never be mistaken for one.
|
|
212
|
+
*/
|
|
213
|
+
export function looksLikeLibrary(dir) {
|
|
214
|
+
const abs = path.resolve(dir);
|
|
215
|
+
return isDir(path.join(abs, "skills")) && fs.existsSync(path.join(abs, "skills-lock.json"));
|
|
216
|
+
}
|
|
217
|
+
/** An empty lock, for a library that has no third-party skills yet. */
|
|
218
|
+
export const EMPTY_LOCK = JSON.stringify({ version: 1, skills: {} }, null, 2) + "\n";
|
|
219
|
+
/**
|
|
220
|
+
* Codex reads policy from agents/openai.yaml next to SKILL.md. Derive it from the Claude frontmatter
|
|
221
|
+
* of an own skill so one file is the source of truth. Returns the yaml text, or null when nothing to say.
|
|
222
|
+
*/
|
|
223
|
+
export function sidecarFor(skillDir) {
|
|
224
|
+
const md = fs.readFileSync(path.join(skillDir, "SKILL.md"), "utf8");
|
|
225
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md);
|
|
226
|
+
if (!m)
|
|
227
|
+
return null;
|
|
228
|
+
let fm;
|
|
229
|
+
try {
|
|
230
|
+
fm = YAML.parse(m[1]) ?? {};
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
const manual = fm["disable-model-invocation"] === true;
|
|
236
|
+
const desc = typeof fm.description === "string" ? fm.description : "";
|
|
237
|
+
const short = desc.length > 80 ? desc.slice(0, 77).trimEnd() + "..." : desc;
|
|
238
|
+
const doc = {
|
|
239
|
+
interface: { display_name: String(fm.name ?? path.basename(skillDir)), short_description: short },
|
|
240
|
+
};
|
|
241
|
+
if (manual)
|
|
242
|
+
doc.policy = { allow_implicit_invocation: false };
|
|
243
|
+
return "# generated by skills-sync from SKILL.md frontmatter; edit SKILL.md, not this file\n" + YAML.stringify(doc);
|
|
244
|
+
}
|
package/dist/src/plan.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Every step builds a list of actions first; apply() executes them unless planning.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { copyDir, isLink, lexists, linkTarget, makeLink, removeLink, samePath, under } from "./fs.js";
|
|
5
|
+
export class Report {
|
|
6
|
+
actions = [];
|
|
7
|
+
add(a) {
|
|
8
|
+
this.actions.push(a);
|
|
9
|
+
return a;
|
|
10
|
+
}
|
|
11
|
+
changes() {
|
|
12
|
+
return this.actions.filter((a) => a.kind !== "skip" && a.kind !== "conflict");
|
|
13
|
+
}
|
|
14
|
+
conflicts() {
|
|
15
|
+
return this.actions.filter((a) => a.kind === "conflict");
|
|
16
|
+
}
|
|
17
|
+
skips() {
|
|
18
|
+
return this.actions.filter((a) => a.kind === "skip").length;
|
|
19
|
+
}
|
|
20
|
+
merge(other) {
|
|
21
|
+
this.actions.push(...other.actions);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const MARK = { link: "+", relink: "~", remove: "-", "replace-copy": "~", copy: "+", write: "+", exclude: "+", skip: "=", conflict: "!" };
|
|
25
|
+
export function line(a) {
|
|
26
|
+
const tail = a.target ? ` -> ${a.target}` : "";
|
|
27
|
+
const note = a.note ? ` (${a.note})` : "";
|
|
28
|
+
return `${MARK[a.kind]} ${a.kind.padEnd(12)} ${a.path}${tail}${note}`;
|
|
29
|
+
}
|
|
30
|
+
export function apply(report, plan, exclude) {
|
|
31
|
+
if (plan)
|
|
32
|
+
return;
|
|
33
|
+
for (const a of report.actions) {
|
|
34
|
+
switch (a.kind) {
|
|
35
|
+
case "link":
|
|
36
|
+
makeLink(a.target, a.path);
|
|
37
|
+
break;
|
|
38
|
+
case "relink":
|
|
39
|
+
removeLink(a.path);
|
|
40
|
+
makeLink(a.target, a.path);
|
|
41
|
+
break;
|
|
42
|
+
case "remove":
|
|
43
|
+
removeLink(a.path);
|
|
44
|
+
break;
|
|
45
|
+
case "replace-copy":
|
|
46
|
+
fs.rmSync(a.path, { recursive: true, force: true });
|
|
47
|
+
makeLink(a.target, a.path);
|
|
48
|
+
break;
|
|
49
|
+
case "copy":
|
|
50
|
+
copyDir(a.target, a.path);
|
|
51
|
+
break;
|
|
52
|
+
case "write":
|
|
53
|
+
fs.mkdirSync(path.dirname(a.path), { recursive: true });
|
|
54
|
+
fs.writeFileSync(a.path, a.payload);
|
|
55
|
+
break;
|
|
56
|
+
case "exclude":
|
|
57
|
+
exclude?.(a.path, a.payload);
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Make `link` point at `target`. Never destroys a real folder; only manages links into `managedRoot`. */
|
|
63
|
+
export function ensureLink(report, link, target, managedRoot, what) {
|
|
64
|
+
if (!lexists(link)) {
|
|
65
|
+
report.add({ kind: "link", path: link, target });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (isLink(link)) {
|
|
69
|
+
const current = linkTarget(link);
|
|
70
|
+
if (current && samePath(current, target))
|
|
71
|
+
report.add({ kind: "skip", path: link, note: "ok" });
|
|
72
|
+
else if (current && under(current, managedRoot))
|
|
73
|
+
report.add({ kind: "relink", path: link, target, note: `was ${current}` });
|
|
74
|
+
else
|
|
75
|
+
report.add({ kind: "conflict", path: link, note: `${what}: link points outside the skills library (${current}); left alone` });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
report.add({ kind: "conflict", path: link, note: `${what}: a real folder is in the way; left alone` });
|
|
79
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// The steps: layers (inside the library), home (user folders), projects (other repos), unlink, status.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { folderHash, isDir, isLink, isSkillDir, lexists, linkTarget, real, samePath, under } from "./fs.js";
|
|
5
|
+
import { RESERVED } from "./harnesses.js";
|
|
6
|
+
import { sidecarFor } from "./library.js";
|
|
7
|
+
import { ensureLink } from "./plan.js";
|
|
8
|
+
const GITIGNORE_HEADER = "# Generated by skills-sync: links to this library's own skills in skills/<name>. Do not edit.\n";
|
|
9
|
+
/** Inside the library: .agents/skills links for own skills, one project layer per non-universal harness, sidecars. */
|
|
10
|
+
export function layers(lib, harnesses, report, sidecars = true) {
|
|
11
|
+
const own = lib.ownSkills();
|
|
12
|
+
for (const n of own)
|
|
13
|
+
ensureLink(report, path.join(lib.agents, n), path.join(lib.own, n), lib.root, "own skill");
|
|
14
|
+
if (isDir(lib.agents)) {
|
|
15
|
+
for (const n of fs.readdirSync(lib.agents)) {
|
|
16
|
+
const p = path.join(lib.agents, n);
|
|
17
|
+
if (!isLink(p))
|
|
18
|
+
continue;
|
|
19
|
+
const t = linkTarget(p);
|
|
20
|
+
if (t && under(t, lib.own) && (!own.includes(n) || !isDir(t)))
|
|
21
|
+
report.add({ kind: "remove", path: p, note: "stale link to a removed own skill" });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const gi = path.join(lib.agents, ".gitignore");
|
|
25
|
+
const body = GITIGNORE_HEADER + own.map((n) => `/${n}/\n`).join("");
|
|
26
|
+
if (!fs.existsSync(gi) || fs.readFileSync(gi, "utf8") !== body)
|
|
27
|
+
report.add({ kind: "write", path: gi, payload: body, note: `${own.length} entries` });
|
|
28
|
+
else
|
|
29
|
+
report.add({ kind: "skip", path: gi, note: "ok" });
|
|
30
|
+
if (sidecars) {
|
|
31
|
+
for (const n of own) {
|
|
32
|
+
const dir = path.join(lib.own, n);
|
|
33
|
+
const file = path.join(dir, "agents", "openai.yaml");
|
|
34
|
+
if (fs.existsSync(file)) {
|
|
35
|
+
report.add({ kind: "skip", path: file, note: "exists" });
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const yaml = sidecarFor(dir);
|
|
39
|
+
if (yaml)
|
|
40
|
+
report.add({ kind: "write", path: file, payload: yaml, note: "Codex sidecar from SKILL.md frontmatter" });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const working = lib.workingSet();
|
|
44
|
+
for (const n of own)
|
|
45
|
+
if (!working.has(n))
|
|
46
|
+
working.set(n, path.join(lib.own, n)); // links not applied yet
|
|
47
|
+
for (const h of harnesses) {
|
|
48
|
+
if (h.universal)
|
|
49
|
+
continue;
|
|
50
|
+
const layer = path.join(lib.root, h.projectSkills);
|
|
51
|
+
for (const [n, target] of [...working].sort()) {
|
|
52
|
+
const link = path.join(layer, n);
|
|
53
|
+
if (lexists(link) && !isLink(link) && isSkillDir(link))
|
|
54
|
+
report.add({ kind: "replace-copy", path: link, target, note: "stale copy in a generated layer" });
|
|
55
|
+
else
|
|
56
|
+
ensureLink(report, link, target, lib.root, `${h.name} layer`);
|
|
57
|
+
}
|
|
58
|
+
if (isDir(layer)) {
|
|
59
|
+
for (const n of fs.readdirSync(layer)) {
|
|
60
|
+
const p = path.join(layer, n);
|
|
61
|
+
if (!working.has(n) && isLink(p)) {
|
|
62
|
+
const t = linkTarget(p);
|
|
63
|
+
if (t && under(t, lib.root))
|
|
64
|
+
report.add({ kind: "remove", path: p, note: "no longer in .agents/skills" });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** One link per skill in each harness's user skills folder. */
|
|
71
|
+
export function home(lib, harnesses, report) {
|
|
72
|
+
const working = lib.workingSet();
|
|
73
|
+
for (const n of lib.ownSkills())
|
|
74
|
+
if (!working.has(n))
|
|
75
|
+
working.set(n, path.join(lib.own, n));
|
|
76
|
+
for (const h of harnesses) {
|
|
77
|
+
for (const [n, target] of [...working].sort()) {
|
|
78
|
+
if (RESERVED.has(n))
|
|
79
|
+
continue;
|
|
80
|
+
ensureLink(report, path.join(h.userSkills, n), target, lib.root, `${h.name} user folder`);
|
|
81
|
+
}
|
|
82
|
+
if (isDir(h.userSkills)) {
|
|
83
|
+
for (const n of fs.readdirSync(h.userSkills)) {
|
|
84
|
+
const p = path.join(h.userSkills, n);
|
|
85
|
+
if (working.has(n) || !isLink(p))
|
|
86
|
+
continue;
|
|
87
|
+
const t = linkTarget(p);
|
|
88
|
+
if (t && under(t, lib.root))
|
|
89
|
+
report.add({ kind: "remove", path: p, note: "skill no longer in the library" });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function unlink(lib, harnesses, report) {
|
|
95
|
+
for (const h of harnesses) {
|
|
96
|
+
if (!isDir(h.userSkills))
|
|
97
|
+
continue;
|
|
98
|
+
for (const n of fs.readdirSync(h.userSkills)) {
|
|
99
|
+
const p = path.join(h.userSkills, n);
|
|
100
|
+
if (!isLink(p))
|
|
101
|
+
continue;
|
|
102
|
+
const t = linkTarget(p);
|
|
103
|
+
if (t && under(t, lib.root))
|
|
104
|
+
report.add({ kind: "remove", path: p, note: "made by skills-sync" });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Git repos directly under `dir`, the library excluded. */
|
|
109
|
+
export function findProjects(dir, lib) {
|
|
110
|
+
if (!isDir(dir))
|
|
111
|
+
return [];
|
|
112
|
+
return fs
|
|
113
|
+
.readdirSync(dir)
|
|
114
|
+
.map((n) => path.join(dir, n))
|
|
115
|
+
.filter((p) => isDir(p) && fs.existsSync(path.join(p, ".git")) && !samePath(real(p), real(lib.root)))
|
|
116
|
+
.sort();
|
|
117
|
+
}
|
|
118
|
+
export function isRepo(dir) {
|
|
119
|
+
return fs.existsSync(path.join(dir, ".git"));
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Give a project its skills. Link mode: one link per skill per harness layer, hidden from git through
|
|
123
|
+
* .git/info/exclude. Copy mode: real folders, meant to be committed.
|
|
124
|
+
*/
|
|
125
|
+
export function projects(lib, harnesses, targets, names, mode, report) {
|
|
126
|
+
const working = lib.workingSet();
|
|
127
|
+
for (const n of lib.ownSkills())
|
|
128
|
+
if (!working.has(n))
|
|
129
|
+
working.set(n, path.join(lib.own, n));
|
|
130
|
+
const chosen = [...working].filter(([n]) => !names || names.includes(n)).sort();
|
|
131
|
+
const layersFor = (h) => h.projectSkills;
|
|
132
|
+
const seenLayers = new Set();
|
|
133
|
+
const layerDirs = harnesses.map(layersFor).filter((l) => (seenLayers.has(l) ? false : (seenLayers.add(l), true)));
|
|
134
|
+
for (const proj of targets) {
|
|
135
|
+
const excludes = [];
|
|
136
|
+
for (const layer of layerDirs) {
|
|
137
|
+
for (const [n, src] of chosen) {
|
|
138
|
+
const dst = path.join(proj, layer, n);
|
|
139
|
+
if (mode === "link") {
|
|
140
|
+
if (lexists(dst) && !isLink(dst))
|
|
141
|
+
report.add({ kind: "conflict", path: dst, note: "a real folder is in the way (a committed copy?); left alone" });
|
|
142
|
+
else
|
|
143
|
+
ensureLink(report, dst, src, lib.root, "project");
|
|
144
|
+
excludes.push("/" + path.join(layer, n).replace(/\\/g, "/") + "/");
|
|
145
|
+
}
|
|
146
|
+
else if (isLink(dst))
|
|
147
|
+
report.add({ kind: "conflict", path: dst, note: "is a link; a committed copy must be a real folder" });
|
|
148
|
+
else if (isDir(dst) && folderHash(dst) === folderHash(src))
|
|
149
|
+
report.add({ kind: "skip", path: dst, note: "ok" });
|
|
150
|
+
else
|
|
151
|
+
report.add({ kind: "copy", path: dst, target: src });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (mode === "link" && excludes.length)
|
|
155
|
+
report.add({ kind: "exclude", path: proj, payload: excludes, note: `${excludes.length} entries in .git/info/exclude` });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export function status(lib, harnesses) {
|
|
159
|
+
const own = lib.ownSkills();
|
|
160
|
+
const working = lib.workingSet();
|
|
161
|
+
const names = [...new Set([...own, ...working.keys()])].sort();
|
|
162
|
+
const linkedInto = (dir) => {
|
|
163
|
+
const linked = names.filter((n) => {
|
|
164
|
+
const p = path.join(dir, n);
|
|
165
|
+
const t = isLink(p) ? linkTarget(p) : null;
|
|
166
|
+
return !!t && under(t, lib.root);
|
|
167
|
+
});
|
|
168
|
+
return { linked: linked.length, missing: names.filter((n) => !linked.includes(n)) };
|
|
169
|
+
};
|
|
170
|
+
const out = {
|
|
171
|
+
library: lib.root,
|
|
172
|
+
own,
|
|
173
|
+
thirdParty: [...working.keys()].filter((n) => !own.includes(n)),
|
|
174
|
+
missingFromLock: lib.missingFromLock(),
|
|
175
|
+
layers: {},
|
|
176
|
+
user: {},
|
|
177
|
+
};
|
|
178
|
+
for (const h of harnesses) {
|
|
179
|
+
if (!h.universal)
|
|
180
|
+
out.layers[h.projectSkills] = linkedInto(path.join(lib.root, h.projectSkills));
|
|
181
|
+
out.user[h.userSkills] = linkedInto(h.userSkills);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
package/dist/src/wsl.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Windows only: list WSL distros and run the same sync inside each one.
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
export function wslDistros() {
|
|
4
|
+
if (process.platform !== "win32")
|
|
5
|
+
return [];
|
|
6
|
+
const r = spawnSync("wsl.exe", ["-l", "-q"], { encoding: "utf16le" });
|
|
7
|
+
if (r.status !== 0 || !r.stdout)
|
|
8
|
+
return [];
|
|
9
|
+
return r.stdout
|
|
10
|
+
.split(/\r?\n/)
|
|
11
|
+
.map((s) => s.replace(/\0/g, "").trim())
|
|
12
|
+
.filter((s) => s && !s.startsWith("docker-desktop"));
|
|
13
|
+
}
|
|
14
|
+
/** V:\dev\skills -> /mnt/v/dev/skills */
|
|
15
|
+
export function toWslPath(win) {
|
|
16
|
+
const m = /^([A-Za-z]):[\\/](.*)$/.exec(win);
|
|
17
|
+
if (!m)
|
|
18
|
+
return win.replace(/\\/g, "/");
|
|
19
|
+
return `/mnt/${m[1].toLowerCase()}/${m[2].replace(/\\/g, "/")}`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Run skills-sync inside a distro against the same library through /mnt. Only the user folders
|
|
23
|
+
* are synced there (projects live on the Windows side). Needs Node in the distro.
|
|
24
|
+
*/
|
|
25
|
+
export function runInWsl(distro, libraryRoot, extraArgs, plan) {
|
|
26
|
+
const lib = toWslPath(libraryRoot);
|
|
27
|
+
const bin = process.env.SKILLS_SYNC_WSL_BIN?.trim() || "npx --yes @oneezy/skills-sync";
|
|
28
|
+
const args = [bin, "--repo", `'${lib}'`, "--global", "--no-projects", "--no-wsl", "--no-layers", "--no-restore", "-y", ...(plan ? ["--plan"] : []), ...extraArgs].join(" ");
|
|
29
|
+
// -i: node is often only on PATH through the interactive profile (nvm, vite-plus); -l for login vars
|
|
30
|
+
const r = spawnSync("wsl.exe", ["-d", distro, "--", "bash", "-lic", args], { encoding: "utf8" });
|
|
31
|
+
return { distro, ok: r.status === 0, output: ((r.stdout ?? "") + (r.stderr ?? "")).trim() };
|
|
32
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oneezy/skills-sync",
|
|
3
|
+
"publishConfig": { "access": "public" },
|
|
4
|
+
"version": "0.2.0",
|
|
5
|
+
"description": "One skills library, every agent harness, every project on the machine. Restores third-party skills from skills-lock.json, links every skill into Claude Code, Codex, Goose and Hermes, and into the projects you pick.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": { "skills-sync": "dist/src/cli.js" },
|
|
9
|
+
"files": ["dist/src", "README.md"],
|
|
10
|
+
"repository": { "type": "git", "url": "https://github.com/oneezy/tools.git", "directory": "clis/skills-sync" },
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -p tsconfig.json",
|
|
13
|
+
"test": "tsc -p tsconfig.json && node --test \"dist/test/*.test.js\"",
|
|
14
|
+
"start": "node dist/src/cli.js",
|
|
15
|
+
"prepublishOnly": "npm run build && node --test \"dist/test/*.test.js\""
|
|
16
|
+
},
|
|
17
|
+
"engines": { "node": ">=20" },
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@clack/prompts": "^1.8.1",
|
|
20
|
+
"yaml": "^2.8.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^24.0.0",
|
|
24
|
+
"typescript": "^5.9.0"
|
|
25
|
+
}
|
|
26
|
+
}
|