skillrepo 4.1.0 → 4.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/bin/skillrepo.mjs +8 -0
- package/package.json +10 -4
- package/src/commands/push.mjs +187 -0
- package/src/lib/http.mjs +169 -11
- package/src/lib/skill-walk.mjs +97 -0
- package/src/test/commands/push.test.mjs +289 -0
- package/src/test/dispatcher.test.mjs +10 -2
- package/src/test/e2e/mock-server.mjs +92 -10
- package/src/test/lib/http.test.mjs +242 -1
- package/src/test/lib/skill-walk.test.mjs +127 -0
package/bin/skillrepo.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { runInit } from "../src/commands/init.mjs";
|
|
|
25
25
|
import { runUpdate } from "../src/commands/update.mjs";
|
|
26
26
|
import { runGet } from "../src/commands/get.mjs";
|
|
27
27
|
import { runAdd } from "../src/commands/add.mjs";
|
|
28
|
+
import { runPush } from "../src/commands/push.mjs";
|
|
28
29
|
import { runRemove } from "../src/commands/remove.mjs";
|
|
29
30
|
import { runList } from "../src/commands/list.mjs";
|
|
30
31
|
import { runSearch } from "../src/commands/search.mjs";
|
|
@@ -62,6 +63,13 @@ const COMMANDS = {
|
|
|
62
63
|
usage: "skillrepo add <@owner/name> [--global] [--agent <list>] [--json]",
|
|
63
64
|
run: async (argv) => runAdd(argv),
|
|
64
65
|
},
|
|
66
|
+
push: {
|
|
67
|
+
description: "Push a local skill directory to your library (create or release new version)",
|
|
68
|
+
usage:
|
|
69
|
+
"skillrepo push <path> [--version <label>] [--changelog <text>] " +
|
|
70
|
+
"[--idempotency-key <key>] [--json]",
|
|
71
|
+
run: async (argv) => runPush(argv),
|
|
72
|
+
},
|
|
65
73
|
remove: {
|
|
66
74
|
description: "Remove a skill from your library and delete it locally",
|
|
67
75
|
usage: "skillrepo remove <@owner/name> [--global] [--agent <list>] [--json]",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skillrepo",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,13 +13,19 @@
|
|
|
13
13
|
],
|
|
14
14
|
"repository": {
|
|
15
15
|
"type": "git",
|
|
16
|
-
"url": "https://github.com/
|
|
16
|
+
"url": "https://github.com/skill-repo/skill-repo.git",
|
|
17
17
|
"directory": "packages/cli"
|
|
18
18
|
},
|
|
19
|
-
"keywords": [
|
|
19
|
+
"keywords": [
|
|
20
|
+
"skillrepo",
|
|
21
|
+
"cli",
|
|
22
|
+
"mcp",
|
|
23
|
+
"ai-skills"
|
|
24
|
+
],
|
|
20
25
|
"author": "SkillRepo LLC",
|
|
21
26
|
"license": "SEE LICENSE IN LICENSE",
|
|
22
27
|
"dependencies": {
|
|
23
|
-
"cli-table3": "^0.6.5"
|
|
28
|
+
"cli-table3": "^0.6.5",
|
|
29
|
+
"gray-matter": "^4.0.3"
|
|
24
30
|
}
|
|
25
31
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `skillrepo push <path>` (#1455).
|
|
3
|
+
*
|
|
4
|
+
* Smart upsert: walks a local skill directory and uploads it via
|
|
5
|
+
* `POST /api/v1/library` (#1452 multipart endpoint). The server detects
|
|
6
|
+
* existence by SKILL.md frontmatter name and either creates a new
|
|
7
|
+
* private skill (first push) or releases a new version (subsequent push
|
|
8
|
+
* with changed content). Identical content is a server-side no-op.
|
|
9
|
+
*
|
|
10
|
+
* **No write-back.** The files already live at `<path>` on the user's
|
|
11
|
+
* disk. The CLI uploads them and prints success — it does not write
|
|
12
|
+
* anything back. `skillrepo update` remains the canonical command for
|
|
13
|
+
* disk sync from server → local.
|
|
14
|
+
*
|
|
15
|
+
* Flags: --idempotency-key / --json / --key / --url
|
|
16
|
+
* Positional: <path>
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { promises as fs } from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import matter from "gray-matter";
|
|
22
|
+
|
|
23
|
+
import { pushSkill } from "../lib/http.mjs";
|
|
24
|
+
import { walkSkillFiles } from "../lib/skill-walk.mjs";
|
|
25
|
+
import { resolveFlags } from "../lib/cli-config.mjs";
|
|
26
|
+
import { validationError } from "../lib/errors.mjs";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run `push`. Throws CliError on failure.
|
|
30
|
+
*
|
|
31
|
+
* @param {string[]} argv
|
|
32
|
+
* @param {object} [io]
|
|
33
|
+
* @param {NodeJS.WritableStream} [io.stdout=process.stdout]
|
|
34
|
+
* @param {NodeJS.WritableStream} [io.stderr=process.stderr]
|
|
35
|
+
*/
|
|
36
|
+
export async function runPush(argv, io = {}) {
|
|
37
|
+
const stdout = io.stdout ?? process.stdout;
|
|
38
|
+
let skillPath = null;
|
|
39
|
+
|
|
40
|
+
// `resolveFlags` only knows the shared flags (`--global`, `--agent`,
|
|
41
|
+
// `--json`, `--key`, `--url`, `--verbose`). The per-command value flag
|
|
42
|
+
// (`--idempotency-key <val>`) is consumed in `acceptPositional` by
|
|
43
|
+
// returning `2` to claim both the flag name and its value.
|
|
44
|
+
let idempotencyKey = null;
|
|
45
|
+
const flags = resolveFlags(argv, {
|
|
46
|
+
acceptPositional(arg, i, allArgv) {
|
|
47
|
+
// Value flags: claim flag + value (2 args).
|
|
48
|
+
if (arg === "--idempotency-key") {
|
|
49
|
+
if (allArgv[i + 1] === undefined) {
|
|
50
|
+
throw validationError("Missing value for --idempotency-key.", {
|
|
51
|
+
hint: "Pass a key, e.g., --idempotency-key my-uuid-here.",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
idempotencyKey = allArgv[i + 1];
|
|
55
|
+
return 2;
|
|
56
|
+
}
|
|
57
|
+
// Otherwise it must be the (sole) positional skill-path argument.
|
|
58
|
+
if (skillPath !== null) {
|
|
59
|
+
throw validationError(`Unexpected extra argument: ${arg}`, {
|
|
60
|
+
hint: "Pass exactly one local directory path.",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
skillPath = arg;
|
|
64
|
+
return 1;
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!skillPath) {
|
|
69
|
+
throw validationError("Missing skill directory path.", {
|
|
70
|
+
hint: "Usage: skillrepo push <path-to-skill>",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Resolve the skill directory ────────────────────────────────────
|
|
75
|
+
const absDir = path.resolve(process.cwd(), skillPath);
|
|
76
|
+
let stat;
|
|
77
|
+
try {
|
|
78
|
+
stat = await fs.stat(absDir);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
throw validationError(
|
|
81
|
+
`Path not found: ${skillPath}`,
|
|
82
|
+
{ hint: `Resolved to ${absDir}. Pass a directory containing a SKILL.md.`, cause: err },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (!stat.isDirectory()) {
|
|
86
|
+
throw validationError(`Not a directory: ${skillPath}`, {
|
|
87
|
+
hint: "Pass a directory containing a SKILL.md, not a single file.",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Read + parse SKILL.md (local validation only) ──────────────────
|
|
92
|
+
// The walker below picks SKILL.md up as a regular file and sends it
|
|
93
|
+
// through the multipart `files[]` array. We still read it here for an
|
|
94
|
+
// early failure check: without this the user would upload every
|
|
95
|
+
// supporting file before the server's frontmatter-parser rejected the
|
|
96
|
+
// push.
|
|
97
|
+
const skillMdPath = path.join(absDir, "SKILL.md");
|
|
98
|
+
let skillMdLocal;
|
|
99
|
+
try {
|
|
100
|
+
skillMdLocal = await fs.readFile(skillMdPath, "utf-8");
|
|
101
|
+
} catch (err) {
|
|
102
|
+
throw validationError(`No SKILL.md at ${skillPath}/SKILL.md.`, {
|
|
103
|
+
hint:
|
|
104
|
+
"Every skill must have a SKILL.md at its root with YAML " +
|
|
105
|
+
"frontmatter including `name` and `description` fields.",
|
|
106
|
+
cause: err,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let frontmatter;
|
|
111
|
+
try {
|
|
112
|
+
const parsed = matter(skillMdLocal);
|
|
113
|
+
frontmatter = parsed.data;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
throw validationError(
|
|
116
|
+
`SKILL.md frontmatter could not be parsed.`,
|
|
117
|
+
{
|
|
118
|
+
hint:
|
|
119
|
+
"Ensure the file starts with `---`, contains valid YAML, and " +
|
|
120
|
+
"ends the frontmatter block with `---` on its own line.",
|
|
121
|
+
cause: err,
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!frontmatter?.name || typeof frontmatter.name !== "string") {
|
|
127
|
+
throw validationError("SKILL.md is missing the required `name` field.", {
|
|
128
|
+
hint: "Add `name: my-skill-name` to the YAML frontmatter.",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Walk the skill folder ──────────────────────────────────────────
|
|
133
|
+
// The walker returns every file (including the root `SKILL.md`) per
|
|
134
|
+
// the agentskills.io spec. They all go up as `files[]` parts.
|
|
135
|
+
const walked = await walkSkillFiles(absDir);
|
|
136
|
+
|
|
137
|
+
const files = await Promise.all(
|
|
138
|
+
walked.map(async (f) => ({
|
|
139
|
+
relativePath: f.relativePath,
|
|
140
|
+
content: await fs.readFile(f.absolutePath),
|
|
141
|
+
})),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// ── POST to /api/v1/library ────────────────────────────────────────
|
|
145
|
+
const result = await pushSkill(flags.serverUrl, flags.apiKey, {
|
|
146
|
+
files,
|
|
147
|
+
idempotencyKey: idempotencyKey ?? undefined,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── Report ─────────────────────────────────────────────────────────
|
|
151
|
+
const totalUploaded = files.length;
|
|
152
|
+
if (flags.json) {
|
|
153
|
+
stdout.write(
|
|
154
|
+
JSON.stringify(
|
|
155
|
+
{
|
|
156
|
+
action: result.action,
|
|
157
|
+
bump: result.bump,
|
|
158
|
+
owner: result.skill?.owner ?? null,
|
|
159
|
+
name: result.skill?.name ?? frontmatter.name,
|
|
160
|
+
version: result.skill?.version ?? null,
|
|
161
|
+
filesUploaded: totalUploaded,
|
|
162
|
+
},
|
|
163
|
+
null,
|
|
164
|
+
2,
|
|
165
|
+
) + "\n",
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const ident = `@${result.skill?.owner ?? "?"}/${result.skill?.name ?? frontmatter.name}`;
|
|
171
|
+
const fileCount = `${totalUploaded} file${totalUploaded === 1 ? "" : "s"}`;
|
|
172
|
+
|
|
173
|
+
if (result.action === "created") {
|
|
174
|
+
stdout.write(
|
|
175
|
+
`\n ✓ Created ${ident} v${result.skill?.version ?? "1.0"} (${fileCount})\n\n`,
|
|
176
|
+
);
|
|
177
|
+
} else if (result.action === "updated") {
|
|
178
|
+
stdout.write(
|
|
179
|
+
`\n ✓ Released ${ident} v${result.skill?.version} (${result.bump} bump, ${fileCount})\n\n`,
|
|
180
|
+
);
|
|
181
|
+
} else {
|
|
182
|
+
// unchanged
|
|
183
|
+
stdout.write(
|
|
184
|
+
`\n ✓ No changes — ${ident} is already at v${result.skill?.version}\n\n`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
package/src/lib/http.mjs
CHANGED
|
@@ -368,9 +368,9 @@ async function mapErrorResponse(res, url) {
|
|
|
368
368
|
return networkError("Rate limit exceeded — server returned 429.", {
|
|
369
369
|
hint:
|
|
370
370
|
"Wait a minute and retry. Read-only commands (update, get, " +
|
|
371
|
-
"list, search)
|
|
372
|
-
"
|
|
373
|
-
"
|
|
371
|
+
"list, search) and idempotent writes (push) retry " +
|
|
372
|
+
"automatically; non-idempotent writes (add, remove) are " +
|
|
373
|
+
"single-shot. Pass --verbose to see retry attempts.",
|
|
374
374
|
});
|
|
375
375
|
}
|
|
376
376
|
if (res.status >= 500) {
|
|
@@ -621,7 +621,13 @@ export async function getSkill(serverUrl, apiKey, owner, name) {
|
|
|
621
621
|
return body.skill ?? null;
|
|
622
622
|
}
|
|
623
623
|
|
|
624
|
-
// ── /api/v1/library (POST — add to library)
|
|
624
|
+
// ── /api/v1/library/refs (POST — add catalog skill to library) ─────────
|
|
625
|
+
//
|
|
626
|
+
// Previously POSTed to `/api/v1/library` directly. After epic #1444
|
|
627
|
+
// Release 1 (#1452), `POST /api/v1/library` is the multipart file-push
|
|
628
|
+
// endpoint and the JSON `{owner, name}` catalog-add operation lives at
|
|
629
|
+
// `POST /api/v1/library/refs` (#1451). The handler logic and response
|
|
630
|
+
// shape are unchanged — only the URL moved.
|
|
625
631
|
|
|
626
632
|
/**
|
|
627
633
|
* @typedef {Object} LibraryAddSuccess
|
|
@@ -650,19 +656,21 @@ export async function getSkill(serverUrl, apiKey, owner, name) {
|
|
|
650
656
|
*/
|
|
651
657
|
|
|
652
658
|
/**
|
|
653
|
-
* POST /api/v1/library — add
|
|
654
|
-
* library.
|
|
659
|
+
* POST /api/v1/library/refs — add an existing catalog skill to the
|
|
660
|
+
* authenticated account's library by `{owner, name}` reference.
|
|
655
661
|
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
662
|
+
* Distinct from `pushSkill` above, which uploads a SKILL.md + files
|
|
663
|
+
* folder via multipart on `POST /api/v1/library`.
|
|
664
|
+
*
|
|
665
|
+
* The server distinguishes several outcomes via HTTP status + `code`
|
|
666
|
+
* field (see `src/app/api/v1/library/refs/route.ts`):
|
|
659
667
|
*
|
|
660
668
|
* 201 → { added: {...} } → "added"
|
|
661
669
|
* 404 { code: "not_found" } → "not-found"
|
|
662
670
|
* 409 { code: "already_in_library" } → "already-in-library"
|
|
663
671
|
* 409 { code: "self_ownership" } → "self-ownership"
|
|
664
672
|
* 403 { code: "plan_limit" } → mapErrorResponse → validationError
|
|
665
|
-
* 403 { code: "
|
|
673
|
+
* 403 { code: "insufficient_scope" } → mapErrorResponse → scopeError
|
|
666
674
|
* 401 → mapErrorResponse → authError
|
|
667
675
|
*
|
|
668
676
|
* The 404 / 409 cases are documented idempotent-friendly outcomes that
|
|
@@ -682,7 +690,7 @@ export async function getSkill(serverUrl, apiKey, owner, name) {
|
|
|
682
690
|
* @returns {Promise<LibraryAddResult>}
|
|
683
691
|
*/
|
|
684
692
|
export async function addSkillToLibrary(serverUrl, apiKey, owner, name) {
|
|
685
|
-
const url = `${normalizeUrl(serverUrl)}/api/v1/library`;
|
|
693
|
+
const url = `${normalizeUrl(serverUrl)}/api/v1/library/refs`;
|
|
686
694
|
const res = await safeFetch(url, {
|
|
687
695
|
method: "POST",
|
|
688
696
|
headers: {
|
|
@@ -756,6 +764,156 @@ export async function addSkillToLibrary(serverUrl, apiKey, owner, name) {
|
|
|
756
764
|
throw err;
|
|
757
765
|
}
|
|
758
766
|
|
|
767
|
+
// ── /api/v1/library (POST — multipart file-push, #1452) ─────────────────
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* @typedef {Object} PushSkillFile
|
|
771
|
+
* @property {string} relativePath - POSIX-style skill-relative path (e.g. "references/intro.md").
|
|
772
|
+
* @property {Uint8Array | Buffer} content - Raw file bytes.
|
|
773
|
+
*/
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* @typedef {Object} PushSkillResultCreated
|
|
777
|
+
* @property {"created"} action
|
|
778
|
+
* @property {null} bump
|
|
779
|
+
* @property {any} skill
|
|
780
|
+
*
|
|
781
|
+
* @typedef {Object} PushSkillResultUpdated
|
|
782
|
+
* @property {"updated"} action
|
|
783
|
+
* @property {"major" | "minor"} bump
|
|
784
|
+
* @property {any} skill
|
|
785
|
+
*
|
|
786
|
+
* @typedef {Object} PushSkillResultUnchanged
|
|
787
|
+
* @property {"unchanged"} action
|
|
788
|
+
* @property {null} bump
|
|
789
|
+
* @property {any} skill
|
|
790
|
+
*
|
|
791
|
+
* @typedef {PushSkillResultCreated | PushSkillResultUpdated | PushSkillResultUnchanged} PushSkillResult
|
|
792
|
+
*/
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* POST /api/v1/library — multipart file-push / upsert (#1452).
|
|
796
|
+
*
|
|
797
|
+
* Uploads the whole skill folder per the agentskills.io spec. Every file
|
|
798
|
+
* (including the root `SKILL.md`) goes up as a `files[]` part with its
|
|
799
|
+
* in-skill relative path carried via the `Content-Disposition: filename`
|
|
800
|
+
* parameter (RFC 7578). The server identifies SKILL.md by exact-match
|
|
801
|
+
* `filename === "SKILL.md"` (case-sensitive, no path prefix).
|
|
802
|
+
*
|
|
803
|
+
* The `Idempotency-Key` header is set on every call (auto-generated if
|
|
804
|
+
* not supplied) so transient network failures can be retried safely. The
|
|
805
|
+
* server replays the cached response on a same-key + same-body retry
|
|
806
|
+
* within 24h.
|
|
807
|
+
*
|
|
808
|
+
* Server outcomes (see `src/app/api/v1/library/route.ts`):
|
|
809
|
+
* 201 { action: "created", bump: null, skill: SyncSkill }
|
|
810
|
+
* 200 { action: "updated", bump: "major"|"minor", skill: SyncSkill }
|
|
811
|
+
* 200 { action: "unchanged", bump: null, skill: SyncSkill }
|
|
812
|
+
* 400 { code: "missing_skill_md" | "invalid_skill_md" | "invalid_path" | "invalid_multipart" }
|
|
813
|
+
* 403 { code: "plan_limit" | "scope_required" }
|
|
814
|
+
* 409 { code: "source_conflict" | "idempotency_key_in_progress" }
|
|
815
|
+
* 413 { code: "payload_too_large" }
|
|
816
|
+
* 422 { code: "idempotency_key_reused" }
|
|
817
|
+
* 429 — rate-limited
|
|
818
|
+
*
|
|
819
|
+
* Documented 4xx/2xx outcomes return a discriminated union. Anything
|
|
820
|
+
* else is routed through mapErrorResponse for typed-error throwing.
|
|
821
|
+
*
|
|
822
|
+
* @param {string} serverUrl
|
|
823
|
+
* @param {string} apiKey
|
|
824
|
+
* @param {Object} input
|
|
825
|
+
* @param {PushSkillFile[]} input.files - The skill folder (SKILL.md plus supporting files).
|
|
826
|
+
* @param {string} [input.idempotencyKey] - Override the auto-generated key. Pass to retry idempotently.
|
|
827
|
+
* @returns {Promise<PushSkillResult>}
|
|
828
|
+
*/
|
|
829
|
+
export async function pushSkill(serverUrl, apiKey, input) {
|
|
830
|
+
const url = `${normalizeUrl(serverUrl)}/api/v1/library`;
|
|
831
|
+
const idempotencyKey = input.idempotencyKey ?? generateIdempotencyKey();
|
|
832
|
+
|
|
833
|
+
const fd = new FormData();
|
|
834
|
+
for (const file of input.files) {
|
|
835
|
+
// Per RFC 7578 §4.2, `filename` carries the per-part relative path.
|
|
836
|
+
// The `File` constructor's second arg sets the `Content-Disposition:
|
|
837
|
+
// filename` parameter via undici's native FormData encoding. `File`
|
|
838
|
+
// accepts a `BlobPart[]` directly (Uint8Array / Buffer / Blob /
|
|
839
|
+
// string), so the previous `new Blob([content])` round-trip was
|
|
840
|
+
// unnecessary allocation.
|
|
841
|
+
fd.append("files", new File([file.content], file.relativePath));
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// `retry: true` is safe here despite this being a write endpoint —
|
|
845
|
+
// every request carries an `Idempotency-Key` header (auto-generated
|
|
846
|
+
// above if not provided), and the server (#1465 middleware) replays
|
|
847
|
+
// the cached response on same-key + same-body retries within 24h.
|
|
848
|
+
// Without retry, a transient 503 from a cold-start Vercel function
|
|
849
|
+
// surfaces as an immediate `networkError` to the user even though
|
|
850
|
+
// the operation could safely re-attempt.
|
|
851
|
+
const res = await safeFetch(url, {
|
|
852
|
+
method: "POST",
|
|
853
|
+
headers: {
|
|
854
|
+
...(await authHeaders(apiKey)),
|
|
855
|
+
"Idempotency-Key": idempotencyKey,
|
|
856
|
+
// Note: do NOT set Content-Type for FormData — undici will set
|
|
857
|
+
// `multipart/form-data; boundary=...` automatically. Setting it
|
|
858
|
+
// here would omit the boundary parameter and the server's parse
|
|
859
|
+
// would fail.
|
|
860
|
+
},
|
|
861
|
+
body: fd,
|
|
862
|
+
retry: true,
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
if (res.status === 200 || res.status === 201) {
|
|
866
|
+
const body = await parseJsonOrThrow(res, url);
|
|
867
|
+
if (
|
|
868
|
+
body?.action === "created" ||
|
|
869
|
+
body?.action === "updated" ||
|
|
870
|
+
body?.action === "unchanged"
|
|
871
|
+
) {
|
|
872
|
+
return {
|
|
873
|
+
action: body.action,
|
|
874
|
+
bump: body.bump ?? null,
|
|
875
|
+
skill: body.skill,
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
throw networkError(
|
|
879
|
+
`Library push returned ${res.status} with unexpected action "${body?.action}" from ${url}`,
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Documented 4xx outcomes flow through mapErrorResponse so the CLI
|
|
884
|
+
// surfaces them with the same `code`-discriminated handling pattern
|
|
885
|
+
// used for catalog-add. The `*_too_large`, `*_in_progress`, and
|
|
886
|
+
// `*_reused` codes share a 4xx envelope; mapErrorResponse maps them
|
|
887
|
+
// to validationError/scopeError/authError consistently.
|
|
888
|
+
const err = await mapErrorResponse(res, url);
|
|
889
|
+
if (err === null) {
|
|
890
|
+
throw validationError(
|
|
891
|
+
`Library push returned ${res.status} with no documented meaning.`,
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
throw err;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Generate a random Idempotency-Key. UUID v4 if `crypto.randomUUID` is
|
|
899
|
+
* available; falls back to a hex random for older Node runtimes (Node
|
|
900
|
+
* 19+ ships randomUUID at the top level, so this is defensive).
|
|
901
|
+
*/
|
|
902
|
+
function generateIdempotencyKey() {
|
|
903
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
904
|
+
return crypto.randomUUID();
|
|
905
|
+
}
|
|
906
|
+
const bytes = new Uint8Array(16);
|
|
907
|
+
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
|
|
908
|
+
crypto.getRandomValues(bytes);
|
|
909
|
+
} else {
|
|
910
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
911
|
+
}
|
|
912
|
+
return Array.from(bytes)
|
|
913
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
914
|
+
.join("");
|
|
915
|
+
}
|
|
916
|
+
|
|
759
917
|
// ── /api/v1/library/[owner]/[name] (DELETE — remove from library) ──────
|
|
760
918
|
|
|
761
919
|
/**
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directory walker for `skillrepo push` (#1455).
|
|
3
|
+
*
|
|
4
|
+
* Walks a local skill directory and collects files for upload — the
|
|
5
|
+
* whole skill folder uniformly, including the root `SKILL.md`, per the
|
|
6
|
+
* agentskills.io specification (a skill is a directory containing
|
|
7
|
+
* `SKILL.md` at the root plus optional supporting files).
|
|
8
|
+
*
|
|
9
|
+
* Excludes:
|
|
10
|
+
* - Any path component starting with `.` (e.g. `.git`, `.DS_Store`)
|
|
11
|
+
* so `skillrepo push .` from a repo root doesn't accidentally
|
|
12
|
+
* upload git internals.
|
|
13
|
+
* - `node_modules` directories anywhere in the tree.
|
|
14
|
+
*
|
|
15
|
+
* Paths returned are relative to the skill directory and use forward
|
|
16
|
+
* slashes regardless of platform — that's what the server's
|
|
17
|
+
* `validateFilePath` expects, and what RFC 7578 `Content-Disposition:
|
|
18
|
+
* filename` carries on the wire.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { promises as fs } from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
|
|
24
|
+
const EXCLUDED_DIRS = new Set(["node_modules"]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object} WalkedFile
|
|
28
|
+
* @property {string} relativePath - POSIX-style path within the skill directory.
|
|
29
|
+
* @property {string} absolutePath - Absolute filesystem path on local disk.
|
|
30
|
+
* @property {number} size - File size in bytes (from stat).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Walk a skill directory and yield every file that should be sent as
|
|
35
|
+
* a multipart `files` part.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} skillDir - Absolute path to the skill directory.
|
|
38
|
+
* @returns {Promise<WalkedFile[]>}
|
|
39
|
+
*/
|
|
40
|
+
export async function walkSkillFiles(skillDir) {
|
|
41
|
+
const absRoot = path.resolve(skillDir);
|
|
42
|
+
const out = [];
|
|
43
|
+
await walkRecursive(absRoot, absRoot, out);
|
|
44
|
+
// Deterministic order so SHA fingerprints (and tests that assert on
|
|
45
|
+
// ordered output) are reproducible.
|
|
46
|
+
out.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} absRoot
|
|
52
|
+
* @param {string} currentDir
|
|
53
|
+
* @param {WalkedFile[]} out
|
|
54
|
+
*/
|
|
55
|
+
async function walkRecursive(absRoot, currentDir, out) {
|
|
56
|
+
let entries;
|
|
57
|
+
try {
|
|
58
|
+
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (err && /** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT") {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const name = entry.name;
|
|
68
|
+
|
|
69
|
+
// Hidden files / directories (anything starting with `.`).
|
|
70
|
+
if (name.startsWith(".")) continue;
|
|
71
|
+
|
|
72
|
+
// Excluded directory names.
|
|
73
|
+
if (entry.isDirectory() && EXCLUDED_DIRS.has(name)) continue;
|
|
74
|
+
|
|
75
|
+
const absChild = path.join(currentDir, name);
|
|
76
|
+
|
|
77
|
+
if (entry.isDirectory()) {
|
|
78
|
+
await walkRecursive(absRoot, absChild, out);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!entry.isFile()) continue; // skip symlinks / sockets / etc.
|
|
83
|
+
|
|
84
|
+
// POSIX-style relative path from the skill root.
|
|
85
|
+
const relative = path
|
|
86
|
+
.relative(absRoot, absChild)
|
|
87
|
+
.split(path.sep)
|
|
88
|
+
.join("/");
|
|
89
|
+
|
|
90
|
+
const stat = await fs.stat(absChild);
|
|
91
|
+
out.push({
|
|
92
|
+
relativePath: relative,
|
|
93
|
+
absolutePath: absChild,
|
|
94
|
+
size: stat.size,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|