skillrepo 2.0.0 → 3.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/README.md +215 -150
- package/bin/skillrepo.mjs +210 -36
- package/package.json +6 -3
- package/src/commands/add.mjs +176 -0
- package/src/commands/get.mjs +116 -0
- package/src/commands/init.mjs +471 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +167 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/update.mjs +67 -0
- package/src/lib/cli-config.mjs +230 -0
- package/src/lib/config.mjs +238 -0
- package/src/lib/detect-ides.mjs +0 -19
- package/src/lib/errors.mjs +264 -0
- package/src/lib/file-write.mjs +705 -0
- package/src/lib/http.mjs +817 -37
- package/src/lib/identifier.mjs +153 -0
- package/src/lib/mcp-merge.mjs +275 -0
- package/src/lib/mergers/gitignore.mjs +73 -18
- package/src/lib/paths.mjs +46 -17
- package/src/lib/prompt.mjs +11 -44
- package/src/lib/sync.mjs +305 -0
- package/src/test/commands/add.test.mjs +285 -0
- package/src/test/commands/get.test.mjs +176 -0
- package/src/test/commands/init.test.mjs +486 -0
- package/src/test/commands/list.test.mjs +172 -0
- package/src/test/commands/remove.test.mjs +234 -0
- package/src/test/commands/search.test.mjs +204 -0
- package/src/test/commands/update.test.mjs +164 -0
- package/src/test/detect-ides.test.mjs +9 -14
- package/src/test/dispatcher.test.mjs +224 -0
- package/src/test/e2e/cli-commands.test.mjs +576 -0
- package/src/test/e2e/mock-server.mjs +364 -22
- package/src/test/helpers/capture-stream.mjs +48 -0
- package/src/test/integration/file-write.integration.test.mjs +279 -0
- package/src/test/lib/cli-config.test.mjs +407 -0
- package/src/test/lib/config.test.mjs +257 -0
- package/src/test/lib/errors.test.mjs +359 -0
- package/src/test/lib/file-write.test.mjs +784 -0
- package/src/test/lib/http.test.mjs +1198 -0
- package/src/test/lib/identifier.test.mjs +157 -0
- package/src/test/lib/mcp-merge.test.mjs +345 -0
- package/src/test/lib/paths.test.mjs +83 -0
- package/src/test/lib/sync.test.mjs +514 -0
- package/src/test/mergers/gitignore.test.mjs +145 -20
- package/src/lib/write-configs.mjs +0 -202
- package/src/test/e2e/HANDOFF.md +0 -223
- package/src/test/e2e/cli-init.test.mjs +0 -213
- package/src/test/e2e/payload-factory.mjs +0 -22
package/bin/skillrepo.mjs
CHANGED
|
@@ -1,46 +1,220 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* SkillRepo CLI —
|
|
4
|
+
* SkillRepo CLI — pull-based command dispatcher (#674, part of #646).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Routes seven commands: init, update, get, add, remove, list, search.
|
|
7
|
+
*
|
|
8
|
+
* PR1 ships only the dispatcher; command modules other than init are
|
|
9
|
+
* stubbed and exit with a "not yet implemented" message. The existing
|
|
10
|
+
* v2.0.0 init flow continues to work via the runInit import below until
|
|
11
|
+
* PR3b rewrites it. This keeps the existing cli-init E2E test green
|
|
12
|
+
* throughout the rewrite.
|
|
13
|
+
*
|
|
14
|
+
* Global flags (parsed once, passed to every command):
|
|
15
|
+
* --help, -h Print help (top-level or per-command)
|
|
16
|
+
* --version, -v Print package version
|
|
17
|
+
* --verbose Print stack traces on error
|
|
18
|
+
*
|
|
19
|
+
* Per-command flags are parsed inside each command module.
|
|
20
|
+
*
|
|
21
|
+
* Exit codes are defined in src/lib/errors.mjs.
|
|
8
22
|
*/
|
|
9
23
|
|
|
10
24
|
import { runInit } from "../src/commands/init.mjs";
|
|
25
|
+
import { runUpdate } from "../src/commands/update.mjs";
|
|
26
|
+
import { runGet } from "../src/commands/get.mjs";
|
|
27
|
+
import { runAdd } from "../src/commands/add.mjs";
|
|
28
|
+
import { runRemove } from "../src/commands/remove.mjs";
|
|
29
|
+
import { runList } from "../src/commands/list.mjs";
|
|
30
|
+
import { runSearch } from "../src/commands/search.mjs";
|
|
31
|
+
import { CliError, EXIT_OK, EXIT_VALIDATION } from "../src/lib/errors.mjs";
|
|
32
|
+
|
|
33
|
+
// ── Command registry ────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Each command has a `description` (one-liner shown in top-level --help),
|
|
37
|
+
* an optional `usage` example, and a `run(argv)` async function.
|
|
38
|
+
*
|
|
39
|
+
* Stub commands print a "not yet implemented" message and exit
|
|
40
|
+
* EXIT_VALIDATION. They will be filled in by sibling PRs (PR2 + PR3a + PR3b).
|
|
41
|
+
*/
|
|
42
|
+
const COMMANDS = {
|
|
43
|
+
init: {
|
|
44
|
+
description: "Validate access key, configure detected IDEs, and run first sync",
|
|
45
|
+
usage: "skillrepo init [--key <key>] [--url <url>] [--yes]",
|
|
46
|
+
run: async (argv) => runInit(argv),
|
|
47
|
+
},
|
|
48
|
+
update: {
|
|
49
|
+
description: "Sync your library against the registry (delta + tombstones)",
|
|
50
|
+
usage: "skillrepo update [--global] [--ide <list>] [--json]",
|
|
51
|
+
run: async (argv) => runUpdate(argv),
|
|
52
|
+
},
|
|
53
|
+
get: {
|
|
54
|
+
description: "Fetch a single skill and write it to disk (no library mutation)",
|
|
55
|
+
usage: "skillrepo get <@owner/name> [--global] [--ide <list>] [--json]",
|
|
56
|
+
run: async (argv) => runGet(argv),
|
|
57
|
+
},
|
|
58
|
+
add: {
|
|
59
|
+
description: "Add a skill to your library and pull it locally",
|
|
60
|
+
usage: "skillrepo add <@owner/name> [--global] [--ide <list>] [--json]",
|
|
61
|
+
run: async (argv) => runAdd(argv),
|
|
62
|
+
},
|
|
63
|
+
remove: {
|
|
64
|
+
description: "Remove a skill from your library and delete it locally",
|
|
65
|
+
usage: "skillrepo remove <@owner/name> [--global] [--ide <list>] [--json]",
|
|
66
|
+
run: async (argv) => runRemove(argv),
|
|
67
|
+
},
|
|
68
|
+
list: {
|
|
69
|
+
description: "List skills in your library as a table",
|
|
70
|
+
usage: "skillrepo list [--json]",
|
|
71
|
+
run: async (argv) => runList(argv),
|
|
72
|
+
},
|
|
73
|
+
search: {
|
|
74
|
+
description: "Search the public registry by keyword",
|
|
75
|
+
usage: "skillrepo search <query> [--limit <n>] [--json] [--semantic]",
|
|
76
|
+
run: async (argv) => runSearch(argv),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const COMMAND_NAMES = Object.keys(COMMANDS);
|
|
81
|
+
|
|
82
|
+
// ── Top-level dispatch ──────────────────────────────────────────────────
|
|
83
|
+
// (moved to the BOTTOM of the file so the color helpers + COMMANDS map
|
|
84
|
+
// are fully initialized before main() runs — otherwise the temporal
|
|
85
|
+
// dead zone fires when main() reaches printTopLevelHelp())
|
|
86
|
+
|
|
87
|
+
async function main(command, fullArgv) {
|
|
88
|
+
// No command + no args → print top-level help
|
|
89
|
+
if (!command) {
|
|
90
|
+
printTopLevelHelp();
|
|
91
|
+
process.exit(EXIT_OK);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Top-level flags before any command
|
|
95
|
+
if (command === "--help" || command === "-h") {
|
|
96
|
+
printTopLevelHelp();
|
|
97
|
+
process.exit(EXIT_OK);
|
|
98
|
+
}
|
|
99
|
+
if (command === "--version" || command === "-v") {
|
|
100
|
+
await printVersion();
|
|
101
|
+
process.exit(EXIT_OK);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Unknown command (anything that starts with - and isn't a known flag)
|
|
105
|
+
if (!COMMANDS[command]) {
|
|
106
|
+
process.stderr.write(`\n ${red("Error:")} Unknown command "${command}"\n`);
|
|
107
|
+
process.stderr.write(` Run ${bold("skillrepo --help")} to see available commands.\n\n`);
|
|
108
|
+
process.exit(EXIT_VALIDATION);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Per-command --help short-circuit
|
|
112
|
+
const rest = fullArgv.slice(1);
|
|
113
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
114
|
+
printCommandHelp(command);
|
|
115
|
+
process.exit(EXIT_OK);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await COMMANDS[command].run(rest);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Help printing ───────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function printTopLevelHelp() {
|
|
124
|
+
process.stdout.write(`
|
|
125
|
+
${bold("SkillRepo CLI")} ${dim("— pull-based agent skills")}
|
|
126
|
+
|
|
127
|
+
${bold("Usage:")}
|
|
128
|
+
skillrepo <command> [options]
|
|
11
129
|
|
|
12
|
-
|
|
13
|
-
const command = args[0];
|
|
14
|
-
|
|
15
|
-
if (!command || command === "init") {
|
|
16
|
-
runInit(args.slice(command === "init" ? 1 : 0)).catch((err) => {
|
|
17
|
-
console.error(`\n Error: ${err.message}\n`);
|
|
18
|
-
process.exit(1);
|
|
19
|
-
});
|
|
20
|
-
} else if (command === "--help" || command === "-h") {
|
|
21
|
-
console.log(`
|
|
22
|
-
SkillRepo CLI
|
|
23
|
-
|
|
24
|
-
Usage:
|
|
25
|
-
npx skillrepo init [options]
|
|
26
|
-
|
|
27
|
-
Options:
|
|
28
|
-
--key, -k <key> Access key (or set SKILLREPO_ACCESS_KEY env var)
|
|
29
|
-
--url, -u <url> SkillRepo URL (default: https://skillrepo.dev)
|
|
30
|
-
--yes, -y Non-interactive mode
|
|
31
|
-
|
|
32
|
-
Examples:
|
|
33
|
-
npx skillrepo init
|
|
34
|
-
npx skillrepo init --key sk_live_abc123
|
|
35
|
-
npx skillrepo init --url https://my-skillrepo.com --yes
|
|
130
|
+
${bold("Commands:")}
|
|
36
131
|
`);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
132
|
+
const longest = Math.max(...COMMAND_NAMES.map((n) => n.length));
|
|
133
|
+
for (const name of COMMAND_NAMES) {
|
|
134
|
+
const padding = " ".repeat(longest - name.length + 4);
|
|
135
|
+
process.stdout.write(` ${bold(name)}${padding}${COMMANDS[name].description}\n`);
|
|
136
|
+
}
|
|
137
|
+
process.stdout.write(`
|
|
138
|
+
${bold("Global options:")}
|
|
139
|
+
--help, -h Print help
|
|
140
|
+
--version, -v Print package version
|
|
141
|
+
--verbose Print stack traces on error
|
|
142
|
+
|
|
143
|
+
Run ${bold("skillrepo <command> --help")} for command-specific options.
|
|
144
|
+
|
|
145
|
+
`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function printCommandHelp(name) {
|
|
149
|
+
const cmd = COMMANDS[name];
|
|
150
|
+
process.stdout.write(`
|
|
151
|
+
${bold(`skillrepo ${name}`)} ${dim("— ")}${cmd.description}
|
|
152
|
+
|
|
153
|
+
${bold("Usage:")}
|
|
154
|
+
${cmd.usage}
|
|
155
|
+
|
|
156
|
+
`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function printVersion() {
|
|
160
|
+
// ESM JSON imports require the import attribute; behind a try/catch
|
|
161
|
+
// so a missing package.json doesn't blow up the binary.
|
|
162
|
+
try {
|
|
163
|
+
const pkg = await import("../package.json", { with: { type: "json" } });
|
|
164
|
+
process.stdout.write(`${pkg.default.version}\n`);
|
|
165
|
+
} catch {
|
|
166
|
+
process.stdout.write("unknown\n");
|
|
167
|
+
}
|
|
46
168
|
}
|
|
169
|
+
|
|
170
|
+
// The `stub()` factory that returned a "not yet implemented" function
|
|
171
|
+
// for unwired commands is gone — all 7 commands have real
|
|
172
|
+
// implementations as of PR3a. If a future command needs to ship as
|
|
173
|
+
// a stub, reintroduce the factory from git history (PR1 + PR2 era).
|
|
174
|
+
|
|
175
|
+
// ── Color helpers (duplicate of prompt.mjs to avoid an import for the dispatcher) ──
|
|
176
|
+
|
|
177
|
+
const isTTY = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
178
|
+
function red(s) { return isTTY ? `\x1b[31m${s}\x1b[0m` : s; }
|
|
179
|
+
function bold(s) { return isTTY ? `\x1b[1m${s}\x1b[0m` : s; }
|
|
180
|
+
function dim(s) { return isTTY ? `\x1b[2m${s}\x1b[0m` : s; }
|
|
181
|
+
|
|
182
|
+
// ── Entry point ─────────────────────────────────────────────────────────
|
|
183
|
+
// Must be at the very bottom: main() calls printTopLevelHelp() which
|
|
184
|
+
// references red/bold/dim above. Putting this at the top would hit the
|
|
185
|
+
// const temporal dead zone before those declarations execute.
|
|
186
|
+
|
|
187
|
+
const argv = process.argv.slice(2);
|
|
188
|
+
const first = argv[0];
|
|
189
|
+
|
|
190
|
+
// Thread --verbose into an env var so http.mjs can surface retry
|
|
191
|
+
// attempts without every command needing to pass the flag through
|
|
192
|
+
// to safeFetch. This env var is read by `resolveRetryOptions` in
|
|
193
|
+
// http.mjs and defaults to silent (pre-existing behavior) when
|
|
194
|
+
// unset. The dispatcher is the only writer, so there's no conflict
|
|
195
|
+
// with users who set SKILLREPO_VERBOSE in their own shell.
|
|
196
|
+
const verbose = argv.includes("--verbose");
|
|
197
|
+
if (verbose) {
|
|
198
|
+
process.env.SKILLREPO_VERBOSE = "1";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
main(first, argv).catch((err) => {
|
|
202
|
+
// CliError carries an exitCode and an optional hint. Plain errors
|
|
203
|
+
// exit 1 with their message — unknown errors are treated as
|
|
204
|
+
// transient/network per the documented exit-code matrix.
|
|
205
|
+
const isCliError = err instanceof CliError;
|
|
206
|
+
const exitCode = isCliError ? err.exitCode : 1;
|
|
207
|
+
|
|
208
|
+
process.stderr.write(`\n ${red("Error:")} ${err.message}\n`);
|
|
209
|
+
if (isCliError && err.hint) {
|
|
210
|
+
process.stderr.write(` ${dim(err.hint)}\n`);
|
|
211
|
+
}
|
|
212
|
+
if (verbose && err.stack) {
|
|
213
|
+
process.stderr.write(`\n${err.stack}\n`);
|
|
214
|
+
if (err.cause && err.cause.stack) {
|
|
215
|
+
process.stderr.write(`\nCaused by:\n${err.cause.stack}\n`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
process.stderr.write("\n");
|
|
219
|
+
process.exit(exitCode);
|
|
220
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skillrepo",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.0.0",
|
|
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": {
|
|
7
7
|
"skillrepo": "./bin/skillrepo.mjs"
|
|
@@ -16,5 +16,8 @@
|
|
|
16
16
|
"directory": "packages/cli"
|
|
17
17
|
},
|
|
18
18
|
"keywords": ["skillrepo", "cli", "mcp", "ai-skills"],
|
|
19
|
-
"license": "MIT"
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"cli-table3": "^0.6.5"
|
|
22
|
+
}
|
|
20
23
|
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `skillrepo add <@owner/name>` (#676).
|
|
3
|
+
*
|
|
4
|
+
* Adds a skill to the authenticated account's library AND pulls it
|
|
5
|
+
* locally in one command. Two-step flow:
|
|
6
|
+
*
|
|
7
|
+
* 1. POST /api/v1/library { owner, name }
|
|
8
|
+
* 2. GET /api/v1/skills/{owner}/{name} → writeSkillDir
|
|
9
|
+
*
|
|
10
|
+
* Why a direct GET instead of calling sync.mjs with `since`:
|
|
11
|
+
*
|
|
12
|
+
* - The architect's review on PR2 explicitly recommended this. A
|
|
13
|
+
* sync with `since=lastSync.syncedAt` can miss the newly-added
|
|
14
|
+
* skill under clock skew (server and client clocks disagreeing,
|
|
15
|
+
* or both calls landing in the same second).
|
|
16
|
+
* - The direct GET is also simpler and has no dependency on the
|
|
17
|
+
* persistent last-sync state file.
|
|
18
|
+
*
|
|
19
|
+
* Idempotent semantics:
|
|
20
|
+
*
|
|
21
|
+
* - 201 added → POST succeeded, now write the files locally
|
|
22
|
+
* - 409 already-in-library → NOT an error; the server didn't
|
|
23
|
+
* insert, but we still want the local files. Re-fetch + write
|
|
24
|
+
* anyway so a user running `add` twice (e.g., after a local
|
|
25
|
+
* cleanup that dropped the skill from disk) gets the skill
|
|
26
|
+
* back on disk.
|
|
27
|
+
* - 404 not-found → clean error, exit 5
|
|
28
|
+
* - 409 self-ownership → clean error, exit 5 ("you can't add your
|
|
29
|
+
* own skill to your own library")
|
|
30
|
+
* - 403 plan-limit → validationError (via http.mjs) with billing hint
|
|
31
|
+
* - 403 scope → scopeError (via http.mjs) with write-key hint
|
|
32
|
+
*
|
|
33
|
+
* Flags: --global / --ide / --json / --key / --url
|
|
34
|
+
* Positional: <@owner/name>
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { addSkillToLibrary, getSkill } from "../lib/http.mjs";
|
|
38
|
+
import { writeSkillDir, cleanupOrphans } from "../lib/file-write.mjs";
|
|
39
|
+
import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
|
|
40
|
+
import { parseIdentifier, formatIdentifier } from "../lib/identifier.mjs";
|
|
41
|
+
import { validationError } from "../lib/errors.mjs";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Run `add`. Throws CliError on failure.
|
|
45
|
+
*
|
|
46
|
+
* @param {string[]} argv
|
|
47
|
+
* @param {object} [io]
|
|
48
|
+
* @param {NodeJS.WritableStream} [io.stdout=process.stdout]
|
|
49
|
+
* @param {NodeJS.WritableStream} [io.stderr=process.stderr]
|
|
50
|
+
*/
|
|
51
|
+
export async function runAdd(argv, io = {}) {
|
|
52
|
+
const stdout = io.stdout ?? process.stdout;
|
|
53
|
+
let identifier = null;
|
|
54
|
+
|
|
55
|
+
const flags = resolveFlags(argv, {
|
|
56
|
+
acceptPositional(arg) {
|
|
57
|
+
if (identifier !== null) {
|
|
58
|
+
throw validationError(
|
|
59
|
+
`Unexpected extra argument: ${arg}`,
|
|
60
|
+
{ hint: "Pass exactly one @owner/name." },
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
identifier = arg;
|
|
64
|
+
return 1;
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!identifier) {
|
|
69
|
+
throw validationError("Missing skill identifier.", {
|
|
70
|
+
hint: "Usage: skillrepo add <@owner/name>",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { owner, name } = parseIdentifier(identifier);
|
|
75
|
+
const vendors = effectiveVendors(flags);
|
|
76
|
+
|
|
77
|
+
// Pre-flight: clean orphans from prior crashes (same pattern as get.mjs)
|
|
78
|
+
cleanupOrphans({ vendors, global: flags.global });
|
|
79
|
+
|
|
80
|
+
// Step 1: POST /api/v1/library
|
|
81
|
+
const addResult = await addSkillToLibrary(flags.serverUrl, flags.apiKey, owner, name);
|
|
82
|
+
|
|
83
|
+
// Map documented outcomes to user-facing behavior
|
|
84
|
+
if (addResult.status === "not-found") {
|
|
85
|
+
throw validationError(`Skill ${formatIdentifier({ owner, name })} not found.`, {
|
|
86
|
+
hint: "Browse the catalog at /skills or run `skillrepo search <query>`.",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (addResult.status === "self-ownership") {
|
|
90
|
+
throw validationError(
|
|
91
|
+
`Cannot add your own skill ${formatIdentifier({ owner, name })} to your library.`,
|
|
92
|
+
{ hint: "Your own skills are already accessible — no need to add them." },
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// "added" and "already-in-library" both proceed to the write step.
|
|
97
|
+
// The idempotency model is: the user asked for the skill on disk,
|
|
98
|
+
// we put it there, regardless of whether the library row was
|
|
99
|
+
// newly inserted or already existed.
|
|
100
|
+
const wasNewlyAdded = addResult.status === "added";
|
|
101
|
+
|
|
102
|
+
// Step 2: GET /api/v1/skills/{owner}/{name} + writeSkillDir
|
|
103
|
+
const skill = await getSkill(flags.serverUrl, flags.apiKey, owner, name);
|
|
104
|
+
|
|
105
|
+
if (!skill) {
|
|
106
|
+
// This is a server-side inconsistency: the POST succeeded (or
|
|
107
|
+
// reported "already in library") but the single-skill GET
|
|
108
|
+
// returned 404. Most likely causes: the skill was deleted or
|
|
109
|
+
// moderated between the POST and the GET, or there's a visibility
|
|
110
|
+
// race. Surface as a typed error rather than silently writing
|
|
111
|
+
// nothing.
|
|
112
|
+
throw validationError(
|
|
113
|
+
`Skill ${formatIdentifier({ owner, name })} was added to your library but the fetch returned 404. ` +
|
|
114
|
+
`Try running \`skillrepo update\` to re-sync.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Defense in depth: same check as get.mjs — reject a server-side
|
|
119
|
+
// mismatch where the returned skill doesn't match the requested
|
|
120
|
+
// identifier.
|
|
121
|
+
if (skill.name !== name || skill.owner !== owner) {
|
|
122
|
+
throw validationError(
|
|
123
|
+
`Server returned wrong skill: requested ${formatIdentifier({ owner, name })}, got @${skill.owner}/${skill.name}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (skill.filesIncomplete) {
|
|
128
|
+
// The POST already succeeded — the skill is in the user's
|
|
129
|
+
// library on the server side. We refuse to write partial files
|
|
130
|
+
// locally because the SKILL.md body likely references files
|
|
131
|
+
// that weren't inlined, so any agent reading it would hit
|
|
132
|
+
// broken resource references. The recovery path is to wait
|
|
133
|
+
// for the server to resolve the inline issue (transient blob
|
|
134
|
+
// storage failure) and retry via `skillrepo update` OR
|
|
135
|
+
// `skillrepo add` again.
|
|
136
|
+
throw validationError(
|
|
137
|
+
`Skill ${formatIdentifier({ owner, name })} was added to your library but the server returned an incomplete payload — some files failed to inline.`,
|
|
138
|
+
{
|
|
139
|
+
hint:
|
|
140
|
+
"This is usually transient. Run `skillrepo update` to retry the fetch once the issue is resolved.",
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
writeSkillDir(skill, { vendors, global: flags.global });
|
|
146
|
+
|
|
147
|
+
if (flags.json) {
|
|
148
|
+
stdout.write(
|
|
149
|
+
JSON.stringify(
|
|
150
|
+
{
|
|
151
|
+
action: wasNewlyAdded ? "added" : "already-in-library",
|
|
152
|
+
owner,
|
|
153
|
+
name,
|
|
154
|
+
version: skill.version,
|
|
155
|
+
filesWritten: skill.files.length,
|
|
156
|
+
},
|
|
157
|
+
null,
|
|
158
|
+
2,
|
|
159
|
+
) + "\n",
|
|
160
|
+
);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const where = flags.global
|
|
165
|
+
? "personal (~/.claude/skills/)"
|
|
166
|
+
: "project (.claude/skills/)";
|
|
167
|
+
if (wasNewlyAdded) {
|
|
168
|
+
stdout.write(
|
|
169
|
+
`\n ✓ Added ${formatIdentifier({ owner, name })} to your library (${skill.files.length} files) → ${where}\n\n`,
|
|
170
|
+
);
|
|
171
|
+
} else {
|
|
172
|
+
stdout.write(
|
|
173
|
+
`\n ✓ ${formatIdentifier({ owner, name })} was already in your library — refreshed (${skill.files.length} files) → ${where}\n\n`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `skillrepo get <@owner/name>` (#672).
|
|
3
|
+
*
|
|
4
|
+
* One-shot single-skill fetch. Hits GET /api/v1/skills/{owner}/{name}
|
|
5
|
+
* and writes the result via `writeSkillDir`. Does NOT mutate the
|
|
6
|
+
* library — the user is installing for a specific project or ad-hoc
|
|
7
|
+
* test. Collisions with library skills are documented as a no-op
|
|
8
|
+
* (the next `update` will overwrite from the library version).
|
|
9
|
+
*
|
|
10
|
+
* Flags (via resolveFlags):
|
|
11
|
+
* --global Write to ~/.claude/skills/ instead of project-local
|
|
12
|
+
* --ide <list> Comma-separated vendor list
|
|
13
|
+
* --key/--url Override credentials
|
|
14
|
+
*
|
|
15
|
+
* Positional:
|
|
16
|
+
* <@owner/name> The skill identifier (with or without the @)
|
|
17
|
+
*
|
|
18
|
+
* Exit codes:
|
|
19
|
+
* 0 success
|
|
20
|
+
* 1 network failure
|
|
21
|
+
* 2 auth failure
|
|
22
|
+
* 3 disk failure
|
|
23
|
+
* 4 scope failure (read-only key — should not happen, but mapped if it does)
|
|
24
|
+
* 5 validation failure (bad identifier, malformed flags)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { getSkill } from "../lib/http.mjs";
|
|
28
|
+
import { writeSkillDir, cleanupOrphans } from "../lib/file-write.mjs";
|
|
29
|
+
import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
|
|
30
|
+
import { parseIdentifier, formatIdentifier } from "../lib/identifier.mjs";
|
|
31
|
+
import { validationError } from "../lib/errors.mjs";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Run `get`. Throws CliError on any failure.
|
|
35
|
+
*
|
|
36
|
+
* @param {string[]} argv
|
|
37
|
+
* @param {object} [io] - Optional injected streams for testability.
|
|
38
|
+
* Defaults to process.stdout/stderr.
|
|
39
|
+
* @param {NodeJS.WritableStream} [io.stdout=process.stdout]
|
|
40
|
+
* @param {NodeJS.WritableStream} [io.stderr=process.stderr]
|
|
41
|
+
*/
|
|
42
|
+
export async function runGet(argv, io = {}) {
|
|
43
|
+
const stdout = io.stdout ?? process.stdout;
|
|
44
|
+
let identifier = null;
|
|
45
|
+
|
|
46
|
+
const flags = resolveFlags(argv, {
|
|
47
|
+
acceptPositional(arg) {
|
|
48
|
+
if (identifier !== null) {
|
|
49
|
+
throw validationError(
|
|
50
|
+
`Unexpected extra argument: ${arg}`,
|
|
51
|
+
{ hint: "Pass exactly one @owner/name." },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
identifier = arg;
|
|
55
|
+
return 1; // consume one arg
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!identifier) {
|
|
60
|
+
throw validationError("Missing skill identifier.", {
|
|
61
|
+
hint: "Usage: skillrepo get <@owner/name>",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const { owner, name } = parseIdentifier(identifier);
|
|
66
|
+
const vendors = effectiveVendors(flags);
|
|
67
|
+
|
|
68
|
+
// Pre-flight: clean orphans from prior crashes before any new write
|
|
69
|
+
cleanupOrphans({ vendors, global: flags.global });
|
|
70
|
+
|
|
71
|
+
const skill = await getSkill(flags.serverUrl, flags.apiKey, owner, name);
|
|
72
|
+
|
|
73
|
+
if (!skill) {
|
|
74
|
+
throw validationError(`Skill ${formatIdentifier({ owner, name })} not found.`, {
|
|
75
|
+
hint: "Browse the catalog at /skills or run `skillrepo search <query>`.",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Defense in depth: server-returned `name` should already match
|
|
80
|
+
// the requested name, but a malicious or buggy server could send
|
|
81
|
+
// a different one. Our writeSkillDir validates frontmatter `name`
|
|
82
|
+
// matches the directory name; this catches the case where the
|
|
83
|
+
// SERVER's `name` field disagrees with the requested name.
|
|
84
|
+
if (skill.name !== name || skill.owner !== owner) {
|
|
85
|
+
throw validationError(
|
|
86
|
+
`Server returned wrong skill: requested ${formatIdentifier({ owner, name })}, got @${skill.owner}/${skill.name}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (skill.filesIncomplete) {
|
|
91
|
+
throw validationError(
|
|
92
|
+
`Skill ${formatIdentifier({ owner, name })} returned an incomplete payload from the server.`,
|
|
93
|
+
{ hint: "One or more files failed to inline. Try again later or contact support." },
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
writeSkillDir(skill, { vendors, global: flags.global });
|
|
98
|
+
|
|
99
|
+
if (flags.json) {
|
|
100
|
+
stdout.write(
|
|
101
|
+
JSON.stringify({
|
|
102
|
+
action: "fetched",
|
|
103
|
+
owner,
|
|
104
|
+
name,
|
|
105
|
+
version: skill.version,
|
|
106
|
+
filesWritten: skill.files.length,
|
|
107
|
+
}, null, 2) + "\n",
|
|
108
|
+
);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const where = flags.global ? "personal (~/.claude/skills/)" : "project (.claude/skills/)";
|
|
113
|
+
stdout.write(
|
|
114
|
+
`\n ✓ Fetched ${formatIdentifier({ owner, name })} (${skill.files.length} files) → ${where}\n\n`,
|
|
115
|
+
);
|
|
116
|
+
}
|