skillship 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 skillship contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # skillship
2
+
3
+ Make any [Agent Skill](https://agentskills.io/specification) (a `SKILL.md`
4
+ directory) portable across **Cursor**, **Claude Code**, **Claude Web**, and
5
+ **Claude Cowork**.
6
+
7
+ `skillship` is a thin orchestration layer. It does **not** reimplement the
8
+ multi-agent install matrix (that's [`npx skills`](https://skills.sh)) or host a
9
+ registry. It adds the three things the ecosystem is missing:
10
+
11
+ 1. Strict, per-surface **validation profiles** (notably Claude's 200-char upload
12
+ cap, which the official validator doesn't enforce).
13
+ 2. **`.skill` packaging** for Claude Web / Cowork uploads.
14
+ 3. **`init` scaffolding** with reusable release-please CI and commit
15
+ conventions.
16
+
17
+ ## Install / usage
18
+
19
+ ```bash
20
+ npx skillship <command>
21
+ ```
22
+
23
+ Requires Node.js >= 18.
24
+
25
+ ## Commands
26
+
27
+ ```
28
+ skillship validate <dir> [--profile <p>] [--json]
29
+ skillship package <dir> [--out <dir>]
30
+ skillship install <dir> [--agent <a,b>] [--global] [--copy]
31
+ skillship init [name] [--ci] [--snippets]
32
+ skillship doctor
33
+ ```
34
+
35
+ `<dir>` defaults to `.` and must contain a `SKILL.md`. Validation exits non-zero
36
+ on failure.
37
+
38
+ ### validate
39
+
40
+ Parses the `SKILL.md` YAML frontmatter (`name`, `description`, optional
41
+ `license`, `metadata`, `allowed-tools`) and body, then applies checks per
42
+ profile:
43
+
44
+ | Check | spec | cursor | claude-web | claude-cowork |
45
+ | --- | --- | --- | --- | --- |
46
+ | `name` present, lowercase/numbers/hyphens | yes | yes | yes | yes |
47
+ | `name` matches parent folder | yes | yes | yes | yes |
48
+ | `description` non-empty, no `<`/`>` | yes | yes | yes | yes |
49
+ | `description` length | <= 1024 | <= 1024 | **<= 200** | **<= 200** |
50
+ | Body recommended <= 500 lines | warn | warn | warn | warn |
51
+
52
+ `--profile` is one of `spec | cursor | claude-web | claude-cowork | all`
53
+ (default `all`, the strictest combination — description must be <= 200 chars).
54
+ `--json` emits machine-readable output for CI.
55
+
56
+ The frontmatter parser handles YAML block scalars (`>`, `>-`, `>+`, `|`, `|-`,
57
+ `|+`) and nested maps (e.g. `metadata:` with indented children) without
58
+ mis-joining keys. If `agentskills` (the Python spec validator) is on `PATH`, its
59
+ findings are merged in; it is never a hard dependency.
60
+
61
+ ### package
62
+
63
+ ```bash
64
+ skillship package ./my-skill # -> dist/my-skill.skill
65
+ skillship package ./my-skill --out out # -> out/my-skill.skill
66
+ ```
67
+
68
+ Runs `validate --profile all` first (aborts on failure), then produces a
69
+ `<name>.skill` zip whose **archive root is the skill folder** (entries are
70
+ `<name>/SKILL.md`, ...) — Claude rejects archives with files at the zip root.
71
+ Excludes `__pycache__/`, `.DS_Store`, `node_modules/`, `dist/`, `.git/`.
72
+
73
+ ### install
74
+
75
+ ```bash
76
+ skillship install ./my-skill -a cursor,claude-code
77
+ ```
78
+
79
+ For filesystem agents, shells out to `npx skills add <dir> [--global] [--copy]
80
+ -a <agents>`. Default agents are `cursor,claude-code`. For upload-only surfaces
81
+ (`claude-web`, `claude-cowork`) it prints upload instructions instead.
82
+
83
+ ### init
84
+
85
+ ```bash
86
+ skillship init demo --ci --snippets
87
+ ```
88
+
89
+ Scaffolds a skill repo (see layout below) that auto-releases via
90
+ [release-please](https://github.com/googleapis/release-please-action) +
91
+ [Conventional Commits](https://www.conventionalcommits.org/). `--ci` adds the
92
+ GitHub Actions workflows; `--snippets` adds `cursor-rule.mdc` and
93
+ `claude-md.md`.
94
+
95
+ Scaffolded layout:
96
+
97
+ ```
98
+ my-skill/
99
+ my-skill/SKILL.md
100
+ snippets/ # if --snippets
101
+ cursor-rule.mdc
102
+ claude-md.md
103
+ release-please-config.json
104
+ .release-please-manifest.json
105
+ version.txt
106
+ .github/workflows/{validate,release}.yml
107
+ AGENTS.md
108
+ README.md
109
+ ```
110
+
111
+ > After pushing the scaffolded repo, enable **Settings -> Actions -> Workflow
112
+ > permissions**: "Read and write" and "Allow GitHub Actions to create and
113
+ > approve pull requests" so release-please can open release PRs and upload the
114
+ > `.skill` asset.
115
+
116
+ The `SKILL.md` version line uses an inline marker so release-please updates it
117
+ in
118
+ place and the validator ignores it:
119
+
120
+ ```yaml
121
+ metadata:
122
+ version: "1.0.0" # x-release-please-version
123
+ ```
124
+
125
+ ### doctor
126
+
127
+ Checks the local environment: Node >= 18 and `npx` (required), plus `gh` and
128
+ `agentskills` (optional).
129
+
130
+ ## Development
131
+
132
+ ```bash
133
+ npm install
134
+ npm run build # tsup -> dist/cli.js
135
+ npm run lint # tsc --noEmit
136
+ npm test # vitest
137
+ ```
138
+
139
+ Project layout:
140
+
141
+ ```
142
+ src/
143
+ cli.ts # arg parsing, command dispatch (commander)
144
+ commands/{validate,package,install,init,doctor}.ts
145
+ lib/frontmatter.ts # YAML frontmatter parser (block scalars + maps)
146
+ lib/profiles.ts # profile definitions and checks
147
+ lib/zip.ts # .skill packaging (archiver)
148
+ lib/exec.ts # spawn wrappers for npx skills / gh / agentskills
149
+ lib/load.ts # SKILL.md loader
150
+ templates/ # CI + snippet + AGENTS/README/SKILL templates for init
151
+ test/ # vitest specs + fixtures
152
+ ```
153
+
154
+ ## License
155
+
156
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,722 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/lib/load.ts
7
+ import { readFile } from "fs/promises";
8
+ import { existsSync } from "fs";
9
+ import { join, resolve } from "path";
10
+
11
+ // src/lib/frontmatter.ts
12
+ var FENCE = /^---\s*$/;
13
+ function splitFrontmatter(content) {
14
+ const normalized = content.replace(/\r\n/g, "\n");
15
+ const lines = normalized.split("\n");
16
+ if (lines.length === 0 || !FENCE.test(lines[0])) {
17
+ return { raw: "", body: normalized };
18
+ }
19
+ let end = -1;
20
+ for (let i = 1; i < lines.length; i++) {
21
+ if (FENCE.test(lines[i])) {
22
+ end = i;
23
+ break;
24
+ }
25
+ }
26
+ if (end === -1) {
27
+ return { raw: "", body: normalized };
28
+ }
29
+ const raw = lines.slice(1, end).join("\n");
30
+ const body = lines.slice(end + 1).join("\n");
31
+ return { raw, body };
32
+ }
33
+ function tokenize(raw) {
34
+ return raw.split("\n").map((raw2) => {
35
+ const match = raw2.match(/^(\s*)(.*)$/);
36
+ const indent = match ? match[1].length : 0;
37
+ const text = match ? match[2] : raw2;
38
+ return { indent, text, raw: raw2 };
39
+ });
40
+ }
41
+ function stripComment(value) {
42
+ let inSingle = false;
43
+ let inDouble = false;
44
+ for (let i = 0; i < value.length; i++) {
45
+ const ch = value[i];
46
+ if (ch === "'" && !inDouble) inSingle = !inSingle;
47
+ else if (ch === '"' && !inSingle) inDouble = !inDouble;
48
+ else if (ch === "#" && !inSingle && !inDouble) {
49
+ if (i === 0 || /\s/.test(value[i - 1])) {
50
+ return value.slice(0, i).trimEnd();
51
+ }
52
+ }
53
+ }
54
+ return value;
55
+ }
56
+ function unquote(value) {
57
+ const v = value.trim();
58
+ if (v.length >= 2) {
59
+ if (v.startsWith('"') && v.endsWith('"')) {
60
+ return v.slice(1, -1).replace(/\\"/g, '"').replace(/\\n/g, "\n");
61
+ }
62
+ if (v.startsWith("'") && v.endsWith("'")) {
63
+ return v.slice(1, -1).replace(/''/g, "'");
64
+ }
65
+ }
66
+ return v;
67
+ }
68
+ var BLOCK_SCALAR = /^([|>])([+-]?)\s*$/;
69
+ function collectBlockScalar(lines, start, parentIndent, style, chomp) {
70
+ const collected = [];
71
+ let i = start;
72
+ let blockIndent = -1;
73
+ for (; i < lines.length; i++) {
74
+ const line = lines[i];
75
+ const isBlank = line.text === "";
76
+ if (isBlank) {
77
+ collected.push("");
78
+ continue;
79
+ }
80
+ if (line.indent <= parentIndent) break;
81
+ if (blockIndent === -1) blockIndent = line.indent;
82
+ collected.push(line.raw.slice(blockIndent));
83
+ }
84
+ let value;
85
+ if (style === ">") {
86
+ const parts = [];
87
+ let buffer = [];
88
+ const flush = () => {
89
+ if (buffer.length) {
90
+ parts.push(buffer.join(" "));
91
+ buffer = [];
92
+ }
93
+ };
94
+ for (const l of collected) {
95
+ if (l === "") {
96
+ flush();
97
+ parts.push("");
98
+ } else {
99
+ buffer.push(l);
100
+ }
101
+ }
102
+ flush();
103
+ value = parts.join("\n").replace(/\n{2,}/g, "\n");
104
+ } else {
105
+ value = collected.join("\n");
106
+ }
107
+ if (chomp === "-") {
108
+ value = value.replace(/\n+$/, "");
109
+ } else if (chomp === "+") {
110
+ } else {
111
+ value = value.replace(/\n+$/, "\n");
112
+ value = value.replace(/\n$/, "");
113
+ }
114
+ value = value.trim();
115
+ return { value, next: i };
116
+ }
117
+ function parseFrontmatter(raw) {
118
+ const fm = {};
119
+ if (!raw.trim()) return fm;
120
+ const lines = tokenize(raw);
121
+ let i = 0;
122
+ while (i < lines.length) {
123
+ const line = lines[i];
124
+ if (line.text === "" || line.text.startsWith("#")) {
125
+ i++;
126
+ continue;
127
+ }
128
+ if (line.indent !== 0) {
129
+ i++;
130
+ continue;
131
+ }
132
+ const colon = line.text.indexOf(":");
133
+ if (colon === -1) {
134
+ i++;
135
+ continue;
136
+ }
137
+ const key = line.text.slice(0, colon).trim();
138
+ let rest = line.text.slice(colon + 1).trim();
139
+ const blockMatch = rest.match(BLOCK_SCALAR);
140
+ if (blockMatch) {
141
+ const style = blockMatch[1];
142
+ const chomp = blockMatch[2] || "";
143
+ const { value, next } = collectBlockScalar(
144
+ lines,
145
+ i + 1,
146
+ line.indent,
147
+ style,
148
+ chomp
149
+ );
150
+ fm[key] = value;
151
+ i = next;
152
+ continue;
153
+ }
154
+ if (rest === "") {
155
+ const childIndent = line.indent;
156
+ const children = {};
157
+ let j = i + 1;
158
+ let sawChild = false;
159
+ for (; j < lines.length; j++) {
160
+ const child = lines[j];
161
+ if (child.text === "" || child.text.startsWith("#")) continue;
162
+ if (child.indent <= childIndent) break;
163
+ const cColon = child.text.indexOf(":");
164
+ if (cColon === -1) continue;
165
+ const cKey = child.text.slice(0, cColon).trim();
166
+ const cVal = unquote(stripComment(child.text.slice(cColon + 1).trim()));
167
+ children[cKey] = cVal;
168
+ sawChild = true;
169
+ }
170
+ if (sawChild) {
171
+ fm[key] = children;
172
+ } else {
173
+ fm[key] = "";
174
+ }
175
+ i = j;
176
+ continue;
177
+ }
178
+ if (rest.startsWith("[") && rest.endsWith("]")) {
179
+ const inner = rest.slice(1, -1).trim();
180
+ fm[key] = inner ? inner.split(",").map((s) => unquote(s.trim())) : [];
181
+ i++;
182
+ continue;
183
+ }
184
+ rest = unquote(stripComment(rest));
185
+ fm[key] = rest;
186
+ i++;
187
+ }
188
+ return fm;
189
+ }
190
+ function parseSkill(content) {
191
+ const { raw, body } = splitFrontmatter(content);
192
+ return {
193
+ frontmatter: parseFrontmatter(raw),
194
+ body
195
+ };
196
+ }
197
+
198
+ // src/lib/load.ts
199
+ async function loadSkill(dir) {
200
+ const abs = resolve(dir);
201
+ const skillMdPath = join(abs, "SKILL.md");
202
+ if (!existsSync(skillMdPath)) {
203
+ throw new Error(`No SKILL.md found in ${abs}`);
204
+ }
205
+ const content = await readFile(skillMdPath, "utf8");
206
+ return { dir: abs, skillMdPath, parsed: parseSkill(content) };
207
+ }
208
+
209
+ // src/lib/profiles.ts
210
+ import { basename } from "path";
211
+ var PROFILE_NAMES = [
212
+ "spec",
213
+ "cursor",
214
+ "claude-web",
215
+ "claude-cowork",
216
+ "all"
217
+ ];
218
+ var DESCRIPTION_MAX = {
219
+ spec: 1024,
220
+ cursor: 1024,
221
+ "claude-web": 200,
222
+ "claude-cowork": 200
223
+ };
224
+ var BODY_RECOMMENDED_MAX_LINES = 500;
225
+ var NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
226
+ function descriptionMax(profile) {
227
+ if (profile === "all") {
228
+ return Math.min(...Object.values(DESCRIPTION_MAX));
229
+ }
230
+ return DESCRIPTION_MAX[profile];
231
+ }
232
+ function checkName(fm, folderName, findings) {
233
+ const name = typeof fm.name === "string" ? fm.name : void 0;
234
+ if (!name) {
235
+ findings.push({
236
+ severity: "error",
237
+ check: "name-present",
238
+ message: "`name` is missing from frontmatter."
239
+ });
240
+ return;
241
+ }
242
+ if (!NAME_RE.test(name)) {
243
+ findings.push({
244
+ severity: "error",
245
+ check: "name-format",
246
+ message: `\`name\` "${name}" must be lowercase letters, numbers, and single hyphens (no leading/trailing/double hyphens).`
247
+ });
248
+ }
249
+ if (name !== folderName) {
250
+ findings.push({
251
+ severity: "error",
252
+ check: "name-matches-folder",
253
+ message: `\`name\` "${name}" must match the parent folder "${folderName}".`
254
+ });
255
+ }
256
+ }
257
+ function checkDescription(fm, max, findings) {
258
+ const desc = typeof fm.description === "string" ? fm.description : void 0;
259
+ if (!desc || desc.trim() === "") {
260
+ findings.push({
261
+ severity: "error",
262
+ check: "description-present",
263
+ message: "`description` is missing or empty."
264
+ });
265
+ return;
266
+ }
267
+ if (/[<>]/.test(desc)) {
268
+ findings.push({
269
+ severity: "error",
270
+ check: "description-xml",
271
+ message: "`description` must not contain `<` or `>` characters."
272
+ });
273
+ }
274
+ if (desc.length > max) {
275
+ findings.push({
276
+ severity: "error",
277
+ check: "description-length",
278
+ message: `\`description\` is ${desc.length} chars; limit is ${max}.`
279
+ });
280
+ }
281
+ }
282
+ function checkBody(body, findings) {
283
+ const lineCount = body.split("\n").length;
284
+ if (lineCount > BODY_RECOMMENDED_MAX_LINES) {
285
+ findings.push({
286
+ severity: "warning",
287
+ check: "body-length",
288
+ message: `Body is ${lineCount} lines; recommended <= ${BODY_RECOMMENDED_MAX_LINES}.`
289
+ });
290
+ }
291
+ }
292
+ function validateProfile(skill, skillDir, profile) {
293
+ const findings = [];
294
+ const folderName = basename(skillDir);
295
+ checkName(skill.frontmatter, folderName, findings);
296
+ checkDescription(skill.frontmatter, descriptionMax(profile), findings);
297
+ checkBody(skill.body, findings);
298
+ const ok = !findings.some((f) => f.severity === "error");
299
+ return { profile, ok, findings };
300
+ }
301
+
302
+ // src/lib/exec.ts
303
+ import { spawn, spawnSync } from "child_process";
304
+ function buildSkillsAddArgv(opts) {
305
+ const argv = ["skills", "add", opts.dir];
306
+ if (opts.global) argv.push("--global");
307
+ if (opts.copy) argv.push("--copy");
308
+ if (opts.agents.length > 0) argv.push("-a", opts.agents.join(","));
309
+ return argv;
310
+ }
311
+ function run(command, args, opts = {}) {
312
+ return new Promise((resolve3, reject) => {
313
+ const child = spawn(command, args, {
314
+ cwd: opts.cwd,
315
+ stdio: "inherit",
316
+ shell: false
317
+ });
318
+ child.on("error", reject);
319
+ child.on("close", (code) => resolve3(code ?? 1));
320
+ });
321
+ }
322
+ function isAvailable(command) {
323
+ const probe = process.platform === "win32" ? "where" : "which";
324
+ const res = spawnSync(probe, [command], { stdio: "ignore" });
325
+ return res.status === 0;
326
+ }
327
+ function runCapture(command, args, opts = {}) {
328
+ const res = spawnSync(command, args, {
329
+ cwd: opts.cwd,
330
+ encoding: "utf8"
331
+ });
332
+ return {
333
+ code: res.status ?? 1,
334
+ stdout: res.stdout ?? "",
335
+ stderr: res.stderr ?? ""
336
+ };
337
+ }
338
+
339
+ // src/commands/validate.ts
340
+ function profilesToRun(profile) {
341
+ if (profile === "all") return ["all"];
342
+ return [profile];
343
+ }
344
+ function mergeAgentskills(dir, results) {
345
+ if (!isAvailable("agentskills")) return;
346
+ const res = runCapture("agentskills", ["validate", dir]);
347
+ if (res.code !== 0) {
348
+ const message = (res.stderr || res.stdout || "agentskills reported errors").trim();
349
+ for (const r of results) {
350
+ r.findings.push({
351
+ severity: "error",
352
+ check: "agentskills",
353
+ message
354
+ });
355
+ r.ok = false;
356
+ }
357
+ }
358
+ }
359
+ async function validateCommand(dir, options) {
360
+ const profileArg = options.profile ?? "all";
361
+ if (!PROFILE_NAMES.includes(profileArg)) {
362
+ process.stderr.write(
363
+ `Unknown profile "${profileArg}". Valid: ${PROFILE_NAMES.join(", ")}
364
+ `
365
+ );
366
+ return 2;
367
+ }
368
+ let loaded;
369
+ try {
370
+ loaded = await loadSkill(dir);
371
+ } catch (err) {
372
+ const msg = err instanceof Error ? err.message : String(err);
373
+ if (options.json) {
374
+ process.stdout.write(
375
+ JSON.stringify({ ok: false, error: msg }, null, 2) + "\n"
376
+ );
377
+ } else {
378
+ process.stderr.write(`Error: ${msg}
379
+ `);
380
+ }
381
+ return 1;
382
+ }
383
+ const results = profilesToRun(profileArg).map(
384
+ (p) => validateProfile(loaded.parsed, loaded.dir, p)
385
+ );
386
+ mergeAgentskills(loaded.dir, results);
387
+ const ok = results.every((r) => r.ok);
388
+ if (options.json) {
389
+ process.stdout.write(JSON.stringify({ ok, results }, null, 2) + "\n");
390
+ } else {
391
+ printHuman(results, ok);
392
+ }
393
+ return ok ? 0 : 1;
394
+ }
395
+ function printHuman(results, ok) {
396
+ for (const result of results) {
397
+ const errors = result.findings.filter((f) => f.severity === "error");
398
+ const warnings = result.findings.filter((f) => f.severity === "warning");
399
+ const status = result.ok ? "PASS" : "FAIL";
400
+ process.stdout.write(`[${status}] profile: ${result.profile}
401
+ `);
402
+ for (const f of [...errors, ...warnings]) printFinding(f);
403
+ }
404
+ process.stdout.write(ok ? "\nAll checks passed.\n" : "\nValidation failed.\n");
405
+ }
406
+ function printFinding(f) {
407
+ const tag = f.severity === "error" ? " \u2717" : " \u26A0";
408
+ process.stdout.write(`${tag} ${f.check}: ${f.message}
409
+ `);
410
+ }
411
+
412
+ // src/commands/package.ts
413
+ import { join as join3 } from "path";
414
+
415
+ // src/lib/zip.ts
416
+ import { createWriteStream } from "fs";
417
+ import { mkdir, readdir } from "fs/promises";
418
+ import { join as join2, relative } from "path";
419
+ import archiver from "archiver";
420
+ var EXCLUDED_DIRS = /* @__PURE__ */ new Set([
421
+ "__pycache__",
422
+ "node_modules",
423
+ "dist",
424
+ ".git"
425
+ ]);
426
+ var EXCLUDED_FILES = /* @__PURE__ */ new Set([".DS_Store"]);
427
+ async function collectFiles(root) {
428
+ const out = [];
429
+ async function walk(dir) {
430
+ const entries = await readdir(dir, { withFileTypes: true });
431
+ for (const entry of entries) {
432
+ const full = join2(dir, entry.name);
433
+ if (entry.isDirectory()) {
434
+ if (EXCLUDED_DIRS.has(entry.name)) continue;
435
+ await walk(full);
436
+ } else if (entry.isFile()) {
437
+ if (EXCLUDED_FILES.has(entry.name)) continue;
438
+ out.push(full);
439
+ }
440
+ }
441
+ }
442
+ await walk(root);
443
+ return out.sort();
444
+ }
445
+ async function packSkill(opts) {
446
+ const { skillDir, name, outDir } = opts;
447
+ await mkdir(outDir, { recursive: true });
448
+ const outPath = join2(outDir, `${name}.skill`);
449
+ const files = await collectFiles(skillDir);
450
+ await new Promise((resolve3, reject) => {
451
+ const output = createWriteStream(outPath);
452
+ const archive = archiver("zip", { zlib: { level: 9 } });
453
+ output.on("close", () => resolve3());
454
+ output.on("error", reject);
455
+ archive.on("error", reject);
456
+ archive.pipe(output);
457
+ for (const file of files) {
458
+ const rel = relative(skillDir, file);
459
+ archive.file(file, { name: `${name}/${rel}` });
460
+ }
461
+ void archive.finalize();
462
+ });
463
+ return outPath;
464
+ }
465
+
466
+ // src/commands/package.ts
467
+ async function packageCommand(dir, options) {
468
+ let loaded;
469
+ try {
470
+ loaded = await loadSkill(dir);
471
+ } catch (err) {
472
+ process.stderr.write(
473
+ `Error: ${err instanceof Error ? err.message : String(err)}
474
+ `
475
+ );
476
+ return 1;
477
+ }
478
+ const result = validateProfile(loaded.parsed, loaded.dir, "all");
479
+ if (!result.ok) {
480
+ process.stderr.write("Cannot package: validation failed (--profile all).\n");
481
+ for (const f of result.findings.filter((x) => x.severity === "error")) {
482
+ process.stderr.write(` \u2717 ${f.check}: ${f.message}
483
+ `);
484
+ }
485
+ process.stderr.write("\nRun `skillship validate` for details.\n");
486
+ return 1;
487
+ }
488
+ const name = String(loaded.parsed.frontmatter.name);
489
+ const outDir = options.out ?? join3(process.cwd(), "dist");
490
+ const outPath = await packSkill({ skillDir: loaded.dir, name, outDir });
491
+ process.stdout.write(`Packaged ${name} -> ${outPath}
492
+ `);
493
+ return 0;
494
+ }
495
+
496
+ // src/commands/install.ts
497
+ var DEFAULT_AGENTS = ["cursor", "claude-code"];
498
+ var UPLOAD_ONLY = /* @__PURE__ */ new Set(["claude-web", "claude-cowork"]);
499
+ async function installCommand(dir, options) {
500
+ let loaded;
501
+ try {
502
+ loaded = await loadSkill(dir);
503
+ } catch (err) {
504
+ process.stderr.write(
505
+ `Error: ${err instanceof Error ? err.message : String(err)}
506
+ `
507
+ );
508
+ return 1;
509
+ }
510
+ const requested = options.agent ? options.agent.split(",").map((a) => a.trim()).filter(Boolean) : DEFAULT_AGENTS;
511
+ const uploadOnly = requested.filter((a) => UPLOAD_ONLY.has(a));
512
+ const filesystem = requested.filter((a) => !UPLOAD_ONLY.has(a));
513
+ if (uploadOnly.length > 0) {
514
+ printUploadInstructions(uploadOnly, String(loaded.parsed.frontmatter.name));
515
+ }
516
+ if (filesystem.length === 0) return 0;
517
+ if (!isAvailable("npx")) {
518
+ process.stderr.write(
519
+ "Error: `npx` not found. Install Node.js (>=18). Run `skillship doctor`.\n"
520
+ );
521
+ return 1;
522
+ }
523
+ const argv = buildSkillsAddArgv({
524
+ dir: loaded.dir,
525
+ agents: filesystem,
526
+ global: options.global,
527
+ copy: options.copy
528
+ });
529
+ process.stdout.write(`Running: npx ${argv.join(" ")}
530
+ `);
531
+ const code = await run("npx", argv);
532
+ return code;
533
+ }
534
+ function printUploadInstructions(agents, name) {
535
+ process.stdout.write(
536
+ `
537
+ The following surfaces are upload-only (no filesystem install): ${agents.join(", ")}
538
+ `
539
+ );
540
+ process.stdout.write(
541
+ `Run \`skillship package .\` then upload \`dist/${name}.skill\`.
542
+ `
543
+ );
544
+ if (agents.includes("claude-web")) {
545
+ process.stdout.write(
546
+ " Claude Web: Settings -> Capabilities -> Upload skill -> enable toggle.\n"
547
+ );
548
+ }
549
+ if (agents.includes("claude-cowork")) {
550
+ process.stdout.write(
551
+ " Claude Cowork: Customize -> Skills -> Upload (desktop app only).\n"
552
+ );
553
+ }
554
+ process.stdout.write("\n");
555
+ }
556
+
557
+ // src/commands/init.ts
558
+ import { fileURLToPath } from "url";
559
+ import { dirname, join as join4, resolve as resolve2 } from "path";
560
+ import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
561
+ import { existsSync as existsSync2 } from "fs";
562
+ var __dirname2 = dirname(fileURLToPath(import.meta.url));
563
+ function templatesDir() {
564
+ const candidates = [
565
+ join4(__dirname2, "..", "templates"),
566
+ // dist/cli.js -> repo/templates
567
+ join4(__dirname2, "..", "..", "templates"),
568
+ // src/commands -> repo/templates
569
+ join4(__dirname2, "templates")
570
+ ];
571
+ for (const c of candidates) {
572
+ if (existsSync2(c)) return c;
573
+ }
574
+ return candidates[0];
575
+ }
576
+ async function renderTemplate(file, name) {
577
+ const raw = await readFile2(join4(templatesDir(), file), "utf8");
578
+ return raw.replaceAll("{{name}}", name);
579
+ }
580
+ async function emit(target, content) {
581
+ await mkdir2(dirname(target), { recursive: true });
582
+ await writeFile(target, content);
583
+ }
584
+ async function initCommand(name, options) {
585
+ const skillName = name ?? "my-skill";
586
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(skillName)) {
587
+ process.stderr.write(
588
+ `Invalid skill name "${skillName}". Use lowercase letters, numbers, and single hyphens.
589
+ `
590
+ );
591
+ return 1;
592
+ }
593
+ const root = resolve2(process.cwd(), skillName);
594
+ if (existsSync2(root)) {
595
+ process.stderr.write(`Error: directory "${skillName}" already exists.
596
+ `);
597
+ return 1;
598
+ }
599
+ const writes = [];
600
+ writes.push([
601
+ join4(root, skillName, "SKILL.md"),
602
+ await renderTemplate("SKILL.md", skillName)
603
+ ]);
604
+ writes.push([join4(root, "README.md"), await renderTemplate("README.md", skillName)]);
605
+ writes.push([join4(root, "AGENTS.md"), await renderTemplate("AGENTS.md", skillName)]);
606
+ writes.push([
607
+ join4(root, "release-please-config.json"),
608
+ await renderTemplate("release-please-config.json", skillName)
609
+ ]);
610
+ writes.push([
611
+ join4(root, ".release-please-manifest.json"),
612
+ JSON.stringify({ ".": "1.0.0" }, null, 2) + "\n"
613
+ ]);
614
+ writes.push([join4(root, "version.txt"), "1.0.0\n"]);
615
+ if (options.ci) {
616
+ writes.push([
617
+ join4(root, ".github", "workflows", "validate.yml"),
618
+ await renderTemplate("validate.yml", skillName)
619
+ ]);
620
+ writes.push([
621
+ join4(root, ".github", "workflows", "release.yml"),
622
+ await renderTemplate("release.yml", skillName)
623
+ ]);
624
+ }
625
+ if (options.snippets) {
626
+ writes.push([
627
+ join4(root, "snippets", "cursor-rule.mdc"),
628
+ await renderTemplate("cursor-rule.mdc", skillName)
629
+ ]);
630
+ writes.push([
631
+ join4(root, "snippets", "claude-md.md"),
632
+ await renderTemplate("claude-md.md", skillName)
633
+ ]);
634
+ }
635
+ for (const [target, content] of writes) {
636
+ await emit(target, content);
637
+ }
638
+ process.stdout.write(`Scaffolded ${skillName}/ (${writes.length} files)
639
+ `);
640
+ process.stdout.write(` cd ${skillName}
641
+ `);
642
+ process.stdout.write(` npx skillship validate ${skillName} --profile all
643
+ `);
644
+ return 0;
645
+ }
646
+
647
+ // src/commands/doctor.ts
648
+ async function doctorCommand() {
649
+ const checks = [];
650
+ const nodeOk = nodeVersionOk();
651
+ checks.push({
652
+ name: "node >= 18",
653
+ required: true,
654
+ ok: nodeOk,
655
+ detail: process.version
656
+ });
657
+ const npx = isAvailable("npx");
658
+ checks.push({
659
+ name: "npx (for `skills add`)",
660
+ required: true,
661
+ ok: npx,
662
+ detail: npx ? "found" : "missing \u2014 install Node.js"
663
+ });
664
+ const gh = isAvailable("gh");
665
+ checks.push({
666
+ name: "gh (GitHub CLI, for releases)",
667
+ required: false,
668
+ ok: gh,
669
+ detail: gh ? "found" : "optional \u2014 needed for release uploads"
670
+ });
671
+ const agentskills = isAvailable("agentskills");
672
+ checks.push({
673
+ name: "agentskills (optional spec validator)",
674
+ required: false,
675
+ ok: agentskills,
676
+ detail: agentskills ? versionOf("agentskills") : "optional"
677
+ });
678
+ for (const c of checks) {
679
+ const mark = c.ok ? "\u2713" : c.required ? "\u2717" : "\u2013";
680
+ process.stdout.write(` ${mark} ${c.name}: ${c.detail}
681
+ `);
682
+ }
683
+ const failedRequired = checks.some((c) => c.required && !c.ok);
684
+ process.stdout.write(
685
+ failedRequired ? "\nMissing required dependencies.\n" : "\nEnvironment looks good.\n"
686
+ );
687
+ return failedRequired ? 1 : 0;
688
+ }
689
+ function nodeVersionOk() {
690
+ const major = Number(process.versions.node.split(".")[0]);
691
+ return major >= 18;
692
+ }
693
+ function versionOf(cmd) {
694
+ const res = runCapture(cmd, ["--version"]);
695
+ return (res.stdout || res.stderr).trim() || "found";
696
+ }
697
+
698
+ // src/cli.ts
699
+ var program = new Command();
700
+ program.name("skillship").description(
701
+ "Make any Agent Skill (SKILL.md) portable across Cursor, Claude Code, Claude Web, and Claude Cowork."
702
+ ).version("1.0.0");
703
+ program.command("validate").description("Validate a SKILL.md against per-surface profiles").argument("[dir]", "skill directory", ".").option("--profile <p>", "spec | cursor | claude-web | claude-cowork | all", "all").option("--json", "machine-readable output").action(async (dir, opts) => {
704
+ process.exit(await validateCommand(dir, opts));
705
+ });
706
+ program.command("package").description("Validate then build a .skill zip for Claude upload").argument("[dir]", "skill directory", ".").option("--out <dir>", "output directory", "dist").action(async (dir, opts) => {
707
+ process.exit(await packageCommand(dir, opts));
708
+ });
709
+ program.command("install").description("Install a skill via `npx skills`, or print upload instructions").argument("[dir]", "skill directory", ".").option("--agent <a,b>", "comma-separated agents").option("--global", "install globally").option("--copy", "copy instead of symlink").action(async (dir, opts) => {
710
+ process.exit(await installCommand(dir, opts));
711
+ });
712
+ program.command("init").description("Scaffold a new skill repo with release-please CI").argument("[name]", "skill name").option("--ci", "include GitHub Actions workflows").option("--snippets", "include cursor-rule.mdc and claude-md.md snippets").action(async (name, opts) => {
713
+ process.exit(await initCommand(name, opts));
714
+ });
715
+ program.command("doctor").description("Check the local environment for required/optional tools").action(async () => {
716
+ process.exit(await doctorCommand());
717
+ });
718
+ program.parseAsync(process.argv).catch((err) => {
719
+ process.stderr.write(`${err instanceof Error ? err.stack : String(err)}
720
+ `);
721
+ process.exit(1);
722
+ });
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "skillship",
3
+ "version": "1.0.0",
4
+ "description": "Make any Agent Skill (SKILL.md) portable across Cursor, Claude Code, Claude Web, and Claude Cowork.",
5
+ "type": "module",
6
+ "bin": {
7
+ "skillship": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "templates",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "scripts": {
18
+ "build": "tsup",
19
+ "dev": "tsup --watch",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
22
+ "lint": "tsc --noEmit",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "keywords": [
26
+ "agent-skills",
27
+ "skill",
28
+ "cursor",
29
+ "claude",
30
+ "cli"
31
+ ],
32
+ "license": "MIT",
33
+ "dependencies": {
34
+ "archiver": "^7.0.1",
35
+ "commander": "^12.1.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/archiver": "^6.0.3",
39
+ "@types/node": "^22.10.0",
40
+ "tsup": "^8.3.5",
41
+ "typescript": "^5.7.2",
42
+ "vitest": "^2.1.8",
43
+ "yauzl": "^3.2.0",
44
+ "@types/yauzl": "^2.10.3"
45
+ }
46
+ }
@@ -0,0 +1,29 @@
1
+ # Repository guide for agents
2
+
3
+ This repo packages the `{{name}}` Agent Skill for distribution across Cursor,
4
+ Claude Code, Claude Web, and Claude Cowork.
5
+
6
+ ## Layout
7
+
8
+ - `{{name}}/SKILL.md` — the skill itself (source of truth).
9
+ - `release-please-config.json`, `.release-please-manifest.json`, `version.txt` —
10
+ release automation via release-please + Conventional Commits.
11
+ - `.github/workflows/validate.yml` — validates the skill on PRs/pushes.
12
+ - `.github/workflows/release.yml` — cuts releases and uploads `{{name}}.skill`.
13
+
14
+ ## Conventions
15
+
16
+ - Use Conventional Commits (`feat:`, `fix:`, `docs:`, ...). `feat`/`fix` bump
17
+ the
18
+ version; merging the release PR publishes `{{name}}.skill` to a GitHub
19
+ Release.
20
+ - Keep the `description` in `{{name}}/SKILL.md` <= 200 chars so it uploads to
21
+ Claude Web/Cowork.
22
+ - The version line in `SKILL.md` carries `# x-release-please-version` so
23
+ release-please updates it in place.
24
+
25
+ ## Commands
26
+
27
+ - `npx skillship validate {{name}} --profile all`
28
+ - `npx skillship package {{name}}`
29
+ - `npx skillship install {{name}} -a cursor,claude-code`
@@ -0,0 +1,27 @@
1
+ # {{name}}
2
+
3
+ An [Agent Skill](https://agentskills.io/specification) packaged for Cursor,
4
+ Claude Code, Claude Web, and Claude Cowork.
5
+
6
+ ## Develop
7
+
8
+ - Validate: `npx skillship validate {{name}} --profile all`
9
+ - Package: `npx skillship package {{name}}` (produces `dist/{{name}}.skill`)
10
+ - Install locally: `npx skillship install {{name}} -a cursor,claude-code`
11
+
12
+ ## Upload to Claude
13
+
14
+ 1. `npx skillship package {{name}}`
15
+ 2. Upload `dist/{{name}}.skill`:
16
+ - Claude Web: Settings -> Capabilities -> Upload skill -> enable toggle.
17
+ - Claude Cowork: Customize -> Skills -> Upload (desktop app only).
18
+
19
+ ## Releasing
20
+
21
+ This repo auto-releases with
22
+ [release-please](https://github.com/googleapis/release-please-action) using
23
+ [Conventional Commits](https://www.conventionalcommits.org/). Merging the
24
+ generated release PR publishes `{{name}}.skill` to a GitHub Release.
25
+
26
+ > Enable **Settings -> Actions -> Workflow permissions**: "Read and write" and
27
+ > "Allow GitHub Actions to create and approve pull requests".
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: {{name}}
3
+ description: Describe when an agent should use {{name}} and what it does. Keep this under 200 characters so it uploads cleanly to Claude Web and Cowork.
4
+ license: MIT
5
+ metadata:
6
+ version: "1.0.0" # x-release-please-version
7
+ ---
8
+
9
+ # {{name}}
10
+
11
+ Explain what this skill does and when an agent should use it.
12
+
13
+ ## Instructions
14
+
15
+ 1. Step one.
16
+ 2. Step two.
@@ -0,0 +1,5 @@
1
+ # {{name}}
2
+
3
+ This project ships the `{{name}}` Agent Skill.
4
+
5
+ When a task matches the skill's purpose, read `{{name}}/SKILL.md` and follow it.
@@ -0,0 +1,11 @@
1
+ ---
2
+ description: {{name}} skill guidance
3
+ alwaysApply: false
4
+ ---
5
+
6
+ # {{name}}
7
+
8
+ This rule points the agent at the `{{name}}` Agent Skill.
9
+
10
+ Read `{{name}}/SKILL.md` and follow its instructions when the task matches the
11
+ skill's description.
@@ -0,0 +1,23 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
3
+ "packages": {
4
+ ".": {
5
+ "release-type": "simple",
6
+ "changelog-sections": [
7
+ { "type": "feat", "section": "Features" },
8
+ { "type": "fix", "section": "Bug Fixes" },
9
+ { "type": "perf", "section": "Performance" },
10
+ { "type": "docs", "section": "Documentation" },
11
+ { "type": "refactor", "section": "Refactors" },
12
+ { "type": "ci", "section": "CI", "hidden": true },
13
+ { "type": "chore", "section": "Chores", "hidden": true }
14
+ ],
15
+ "extra-files": [
16
+ {
17
+ "type": "generic",
18
+ "path": "{{name}}/SKILL.md"
19
+ }
20
+ ]
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,30 @@
1
+ name: release
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ permissions:
6
+ contents: write
7
+ pull-requests: write
8
+ jobs:
9
+ release:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: googleapis/release-please-action@v4
13
+ id: release
14
+ with:
15
+ config-file: release-please-config.json
16
+ manifest-file: .release-please-manifest.json
17
+ - uses: actions/checkout@v4
18
+ if: ${{ steps.release.outputs.release_created }}
19
+ - uses: actions/setup-node@v4
20
+ if: ${{ steps.release.outputs.release_created }}
21
+ with:
22
+ node-version: "20"
23
+ - name: Package skill
24
+ if: ${{ steps.release.outputs.release_created }}
25
+ run: npx skillship package {{name}}
26
+ - name: Upload to release
27
+ if: ${{ steps.release.outputs.release_created }}
28
+ env:
29
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30
+ run: gh release upload "${{ steps.release.outputs.tag_name }}" dist/{{name}}.skill --clobber
@@ -0,0 +1,14 @@
1
+ name: validate
2
+ on:
3
+ pull_request:
4
+ push:
5
+ branches: [main]
6
+ jobs:
7
+ validate:
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: actions/setup-node@v4
12
+ with:
13
+ node-version: "20"
14
+ - run: npx skillship validate {{name}} --profile all