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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `skillrepo list` (#679).
|
|
3
|
+
*
|
|
4
|
+
* Lists the authenticated account's library contents. Default output
|
|
5
|
+
* is a human-readable table via `cli-table3`. `--json` flag prints
|
|
6
|
+
* the raw metadata array for piping into `jq` or other tools.
|
|
7
|
+
*
|
|
8
|
+
* Calls GET /api/v1/library and ignores the inlined file content —
|
|
9
|
+
* we only need metadata for display. There is no metadata-only
|
|
10
|
+
* endpoint; reading the full library payload is the cost of reusing
|
|
11
|
+
* the same route the sync engine uses.
|
|
12
|
+
*
|
|
13
|
+
* Flags:
|
|
14
|
+
* --json Pipe-friendly JSON output
|
|
15
|
+
* --key/--url Override credentials
|
|
16
|
+
*
|
|
17
|
+
* No --global / --ide flags — `list` is a library-state inspector,
|
|
18
|
+
* not a writer.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import Table from "cli-table3";
|
|
22
|
+
|
|
23
|
+
import { getLibrary } from "../lib/http.mjs";
|
|
24
|
+
import { resolveFlags } from "../lib/cli-config.mjs";
|
|
25
|
+
import { formatIdentifier } from "../lib/identifier.mjs";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Run `list`. Throws CliError on any failure.
|
|
29
|
+
*
|
|
30
|
+
* @param {string[]} argv
|
|
31
|
+
* @param {object} [io]
|
|
32
|
+
* @param {NodeJS.WritableStream} [io.stdout=process.stdout]
|
|
33
|
+
* @param {NodeJS.WritableStream} [io.stderr=process.stderr]
|
|
34
|
+
*/
|
|
35
|
+
export async function runList(argv, io = {}) {
|
|
36
|
+
const stdout = io.stdout ?? process.stdout;
|
|
37
|
+
const flags = resolveFlags(argv);
|
|
38
|
+
|
|
39
|
+
const result = await getLibrary(flags.serverUrl, flags.apiKey);
|
|
40
|
+
|
|
41
|
+
// `list` does not care about removals or sync state — we just want
|
|
42
|
+
// the current set. Skills are returned by the same endpoint as the
|
|
43
|
+
// sync, but with full file content we discard.
|
|
44
|
+
//
|
|
45
|
+
// Defensive guard: `list` calls getLibrary without an If-None-Match
|
|
46
|
+
// header so it can't legitimately receive a 304 today. But getLibrary's
|
|
47
|
+
// contract documents `notModified: true` as a possible return shape,
|
|
48
|
+
// and a future refactor that adds caching at this layer could
|
|
49
|
+
// accidentally start sending the header. Treat `result.skills` as
|
|
50
|
+
// possibly empty rather than possibly undefined.
|
|
51
|
+
const skills = Array.isArray(result.skills) ? result.skills : [];
|
|
52
|
+
|
|
53
|
+
if (flags.json) {
|
|
54
|
+
stdout.write(JSON.stringify(formatJson(skills), null, 2) + "\n");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (skills.length === 0) {
|
|
59
|
+
stdout.write(
|
|
60
|
+
"\n Your library is empty.\n Use `skillrepo add <@owner/name>` to add a skill.\n\n",
|
|
61
|
+
);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
printTable(skills, stdout);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read the column width from the active output stream, falling back
|
|
70
|
+
* to process.stdout's TTY column count if the injected stream
|
|
71
|
+
* doesn't carry one (e.g., a test capture stream), then to a 100-col
|
|
72
|
+
* default for non-TTY contexts. Reading from `out.columns` first
|
|
73
|
+
* means a future test that injects a stream advertising a specific
|
|
74
|
+
* width will be honored.
|
|
75
|
+
*/
|
|
76
|
+
function streamColumns(out) {
|
|
77
|
+
if (out && typeof out.columns === "number" && out.columns > 0) {
|
|
78
|
+
return out.columns;
|
|
79
|
+
}
|
|
80
|
+
if (process.stdout.columns && process.stdout.columns > 0) {
|
|
81
|
+
return process.stdout.columns;
|
|
82
|
+
}
|
|
83
|
+
return 100;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Strip file content and reduce to the JSON shape that scripts care
|
|
88
|
+
* about. Kept stable as a public-ish contract for `--json` consumers.
|
|
89
|
+
*/
|
|
90
|
+
function formatJson(skills) {
|
|
91
|
+
return skills
|
|
92
|
+
.slice()
|
|
93
|
+
.sort(sortByOwnerAndName)
|
|
94
|
+
.map((s) => ({
|
|
95
|
+
owner: s.owner,
|
|
96
|
+
name: s.name,
|
|
97
|
+
version: s.version,
|
|
98
|
+
description: s.description,
|
|
99
|
+
updatedAt: s.updatedAt,
|
|
100
|
+
filesIncomplete: s.filesIncomplete ?? false,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function printTable(skills, out) {
|
|
105
|
+
const sorted = skills.slice().sort(sortByOwnerAndName);
|
|
106
|
+
|
|
107
|
+
// cli-table3 supports word wrapping but we manually truncate
|
|
108
|
+
// descriptions so the table stays readable on standard 80-col
|
|
109
|
+
// terminals. The full description is available via --json.
|
|
110
|
+
const table = new Table({
|
|
111
|
+
head: ["Skill", "Version", "Updated", "Description"],
|
|
112
|
+
colWidths: computeColWidths(streamColumns(out)),
|
|
113
|
+
wordWrap: true,
|
|
114
|
+
style: { head: ["bold"] },
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
for (const s of sorted) {
|
|
118
|
+
table.push([
|
|
119
|
+
formatIdentifier(s),
|
|
120
|
+
s.version || "—",
|
|
121
|
+
formatRelativeDate(s.updatedAt),
|
|
122
|
+
truncate(s.description || "", 60),
|
|
123
|
+
]);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
out.write("\n" + table.toString() + "\n\n");
|
|
127
|
+
out.write(` ${sorted.length} skill${sorted.length === 1 ? "" : "s"} in your library.\n\n`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function computeColWidths(terminalColumns) {
|
|
131
|
+
// Cap at 120 so the table doesn't get unreadably wide on
|
|
132
|
+
// ultra-wide terminals; floor at 100 so the description still has
|
|
133
|
+
// room when the terminal is narrower than typical.
|
|
134
|
+
const total = terminalColumns > 60 ? Math.min(terminalColumns, 120) : 100;
|
|
135
|
+
// Skill ~30, version ~10, updated ~14, description gets the rest
|
|
136
|
+
const skillCol = 32;
|
|
137
|
+
const versionCol = 12;
|
|
138
|
+
const updatedCol = 14;
|
|
139
|
+
const descCol = Math.max(20, total - skillCol - versionCol - updatedCol - 6); // -6 for borders
|
|
140
|
+
return [skillCol, versionCol, updatedCol, descCol];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function truncate(s, n) {
|
|
144
|
+
if (s.length <= n) return s;
|
|
145
|
+
return s.slice(0, n - 1) + "…";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Render an ISO timestamp as a relative human-friendly string.
|
|
150
|
+
* Falls back to the date if it's older than ~6 months.
|
|
151
|
+
*/
|
|
152
|
+
function formatRelativeDate(iso) {
|
|
153
|
+
if (!iso) return "—";
|
|
154
|
+
const ts = new Date(iso).getTime();
|
|
155
|
+
if (!Number.isFinite(ts)) return "—";
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
const seconds = Math.floor((now - ts) / 1000);
|
|
158
|
+
if (seconds < 60) return "just now";
|
|
159
|
+
const minutes = Math.floor(seconds / 60);
|
|
160
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
161
|
+
const hours = Math.floor(minutes / 60);
|
|
162
|
+
if (hours < 24) return `${hours}h ago`;
|
|
163
|
+
const days = Math.floor(hours / 24);
|
|
164
|
+
if (days < 7) return `${days}d ago`;
|
|
165
|
+
const weeks = Math.floor(days / 7);
|
|
166
|
+
if (weeks < 26) return `${weeks}w ago`;
|
|
167
|
+
// Older than ~6 months: fall back to a date
|
|
168
|
+
return new Date(iso).toISOString().slice(0, 10);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function sortByOwnerAndName(a, b) {
|
|
172
|
+
const ao = a.owner || "";
|
|
173
|
+
const bo = b.owner || "";
|
|
174
|
+
if (ao !== bo) return ao.localeCompare(bo);
|
|
175
|
+
return (a.name || "").localeCompare(b.name || "");
|
|
176
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `skillrepo remove <@owner/name>` (#678).
|
|
3
|
+
*
|
|
4
|
+
* Removes a skill from the authenticated account's library AND
|
|
5
|
+
* deletes the local files in one command. Two-step flow:
|
|
6
|
+
*
|
|
7
|
+
* 1. DELETE /api/v1/library/{owner}/{name}
|
|
8
|
+
* 2. removeSkillDir(name) — direct local delete across all
|
|
9
|
+
* configured placement targets
|
|
10
|
+
*
|
|
11
|
+
* Why direct local delete instead of calling sync.mjs to process
|
|
12
|
+
* the tombstone:
|
|
13
|
+
*
|
|
14
|
+
* - The server's ETag computation does NOT currently factor in
|
|
15
|
+
* `libraryRemovals`, so a conditional sync after the DELETE
|
|
16
|
+
* would return 304 and the CLI would never see the tombstone.
|
|
17
|
+
* The CLI would leave the orphaned skill files on disk until
|
|
18
|
+
* some unrelated mutation bumps the ETag. This is documented
|
|
19
|
+
* in follow-up #875.
|
|
20
|
+
* - A direct local delete avoids that problem entirely. Since
|
|
21
|
+
* `remove` is called with a specific (owner, name) the CLI
|
|
22
|
+
* already knows exactly what to delete — there's no reason to
|
|
23
|
+
* go through a tombstone round-trip.
|
|
24
|
+
* - Cross-machine remove still works via the tombstone-on-server
|
|
25
|
+
* path (once #875 lands); `remove` just short-circuits the
|
|
26
|
+
* single-machine case.
|
|
27
|
+
*
|
|
28
|
+
* Idempotent semantics:
|
|
29
|
+
*
|
|
30
|
+
* - 200 removed → DELETE succeeded, now wipe local files
|
|
31
|
+
* - 404 not-in-library → NOT an error; the skill wasn't in the
|
|
32
|
+
* library anyway. Still wipe local files (the user asked for
|
|
33
|
+
* the skill to be gone from disk — if they have stale local
|
|
34
|
+
* copies from a prior manual checkout or from another machine,
|
|
35
|
+
* clean them up anyway). This matches the "user intent is the
|
|
36
|
+
* authoritative request" principle.
|
|
37
|
+
* - 403 scope → scopeError (via http.mjs) with write-key hint
|
|
38
|
+
* - 401 → authError
|
|
39
|
+
*
|
|
40
|
+
* Flags: --global / --ide / --json / --key / --url
|
|
41
|
+
* Positional: <@owner/name>
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { removeSkillFromLibrary } from "../lib/http.mjs";
|
|
45
|
+
import { removeSkillDir, cleanupOrphans } from "../lib/file-write.mjs";
|
|
46
|
+
import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
|
|
47
|
+
import { parseIdentifier, formatIdentifier } from "../lib/identifier.mjs";
|
|
48
|
+
import { validationError } from "../lib/errors.mjs";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Run `remove`. Throws CliError on failure.
|
|
52
|
+
*
|
|
53
|
+
* @param {string[]} argv
|
|
54
|
+
* @param {object} [io]
|
|
55
|
+
* @param {NodeJS.WritableStream} [io.stdout=process.stdout]
|
|
56
|
+
* @param {NodeJS.WritableStream} [io.stderr=process.stderr]
|
|
57
|
+
*/
|
|
58
|
+
export async function runRemove(argv, io = {}) {
|
|
59
|
+
const stdout = io.stdout ?? process.stdout;
|
|
60
|
+
let identifier = null;
|
|
61
|
+
|
|
62
|
+
const flags = resolveFlags(argv, {
|
|
63
|
+
acceptPositional(arg) {
|
|
64
|
+
if (identifier !== null) {
|
|
65
|
+
throw validationError(
|
|
66
|
+
`Unexpected extra argument: ${arg}`,
|
|
67
|
+
{ hint: "Pass exactly one @owner/name." },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
identifier = arg;
|
|
71
|
+
return 1;
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (!identifier) {
|
|
76
|
+
throw validationError("Missing skill identifier.", {
|
|
77
|
+
hint: "Usage: skillrepo remove <@owner/name>",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { owner, name } = parseIdentifier(identifier);
|
|
82
|
+
const vendors = effectiveVendors(flags);
|
|
83
|
+
|
|
84
|
+
// Pre-flight: clean any .old/ orphans from a prior crashed write.
|
|
85
|
+
// `remove` isn't writing new files, but it IS deleting, and the
|
|
86
|
+
// asymmetric behavior of "get/add/update clean orphans but remove
|
|
87
|
+
// doesn't" was flagged in review. Cleanup is idempotent and
|
|
88
|
+
// preserves any .tmp/ with a missing live target (Windows recovery
|
|
89
|
+
// invariant — see cleanupOrphans' comment block).
|
|
90
|
+
cleanupOrphans({ vendors, global: flags.global });
|
|
91
|
+
|
|
92
|
+
// Step 1: DELETE /api/v1/library/{owner}/{name}
|
|
93
|
+
const removeResult = await removeSkillFromLibrary(
|
|
94
|
+
flags.serverUrl,
|
|
95
|
+
flags.apiKey,
|
|
96
|
+
owner,
|
|
97
|
+
name,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Both "removed" and "not-in-library" proceed to the local delete.
|
|
101
|
+
// The user asked for the skill to be gone; we honor that regardless
|
|
102
|
+
// of whether the library row was there to begin with.
|
|
103
|
+
const wasRemovedFromLibrary = removeResult.status === "removed";
|
|
104
|
+
|
|
105
|
+
// Step 2: delete local files via removeSkillDir. Passes `name` only
|
|
106
|
+
// (not owner) — the CLI places skills by name alone, matching
|
|
107
|
+
// sync.mjs's tombstone loop. See the comment there for the future
|
|
108
|
+
// owner-namespacing caveat.
|
|
109
|
+
const localResult = removeSkillDir(name, { vendors, global: flags.global });
|
|
110
|
+
|
|
111
|
+
if (flags.json) {
|
|
112
|
+
stdout.write(
|
|
113
|
+
JSON.stringify(
|
|
114
|
+
{
|
|
115
|
+
action: wasRemovedFromLibrary ? "removed" : "not-in-library",
|
|
116
|
+
owner,
|
|
117
|
+
name,
|
|
118
|
+
libraryUpdated: wasRemovedFromLibrary,
|
|
119
|
+
localDirsRemoved: localResult.removed.length,
|
|
120
|
+
localDirsNotFound: localResult.notFound.length,
|
|
121
|
+
},
|
|
122
|
+
null,
|
|
123
|
+
2,
|
|
124
|
+
) + "\n",
|
|
125
|
+
);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Human-readable summary. Four possible shapes:
|
|
130
|
+
//
|
|
131
|
+
// 1. Library updated + local files existed:
|
|
132
|
+
// "✓ Removed @a/b from library and deleted N local dirs"
|
|
133
|
+
// 2. Library updated + no local files:
|
|
134
|
+
// "✓ Removed @a/b from library (no local files found)"
|
|
135
|
+
// 3. Not in library + local files existed:
|
|
136
|
+
// "✓ @a/b wasn't in your library; deleted N orphaned local dirs"
|
|
137
|
+
// 4. Not in library + no local files:
|
|
138
|
+
// "• @a/b wasn't in your library and no local files existed — nothing to do"
|
|
139
|
+
const ident = formatIdentifier({ owner, name });
|
|
140
|
+
const localCount = localResult.removed.length;
|
|
141
|
+
|
|
142
|
+
if (wasRemovedFromLibrary && localCount > 0) {
|
|
143
|
+
stdout.write(
|
|
144
|
+
`\n ✓ Removed ${ident} from your library and deleted ${localCount} local director${localCount === 1 ? "y" : "ies"}\n\n`,
|
|
145
|
+
);
|
|
146
|
+
} else if (wasRemovedFromLibrary) {
|
|
147
|
+
stdout.write(
|
|
148
|
+
`\n ✓ Removed ${ident} from your library (no local files found)\n\n`,
|
|
149
|
+
);
|
|
150
|
+
} else if (localCount > 0) {
|
|
151
|
+
// The skill wasn't in the library but local files DID exist.
|
|
152
|
+
// These could be leftover from a prior `remove` that crashed
|
|
153
|
+
// after the DELETE but before the local delete, OR manually-
|
|
154
|
+
// placed files the user dropped into `.claude/skills/<name>/`
|
|
155
|
+
// outside the CLI. Either way, the user asked for this skill
|
|
156
|
+
// to be gone; we delete. We avoid the word "orphaned" in the
|
|
157
|
+
// message because it implies CLI-managed state, which may not
|
|
158
|
+
// be the case.
|
|
159
|
+
stdout.write(
|
|
160
|
+
`\n ✓ ${ident} wasn't in your library — deleted ${localCount} local director${localCount === 1 ? "y" : "ies"} that matched the name\n\n`,
|
|
161
|
+
);
|
|
162
|
+
} else {
|
|
163
|
+
stdout.write(
|
|
164
|
+
`\n • ${ident} wasn't in your library and no local files existed — nothing to do\n\n`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `skillrepo search <query>` (#677).
|
|
3
|
+
*
|
|
4
|
+
* Hits GET /api/v1/skills/search via the account-key authenticated
|
|
5
|
+
* route (NOT the catalog API). Renders results as a `cli-table3`
|
|
6
|
+
* table by default, or as JSON with `--json`.
|
|
7
|
+
*
|
|
8
|
+
* Flags:
|
|
9
|
+
* --limit <n> Max results to return (default 20, max 100)
|
|
10
|
+
* --json Pipe-friendly JSON
|
|
11
|
+
* --semantic No-op in v1; reserved for v1.1 semantic search
|
|
12
|
+
* (documented in --help; sending the flag does not
|
|
13
|
+
* error so v1.0 users can still preview the future
|
|
14
|
+
* behavior without a CLI upgrade)
|
|
15
|
+
* --key/--url Override credentials
|
|
16
|
+
*
|
|
17
|
+
* Positional:
|
|
18
|
+
* <query> Free text search query (single argument; quote
|
|
19
|
+
* multi-word queries from the shell)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import Table from "cli-table3";
|
|
23
|
+
|
|
24
|
+
import { searchSkills } from "../lib/http.mjs";
|
|
25
|
+
import { resolveFlags } from "../lib/cli-config.mjs";
|
|
26
|
+
import { formatIdentifier } from "../lib/identifier.mjs";
|
|
27
|
+
import { validationError } from "../lib/errors.mjs";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run `search`. Throws CliError on any failure.
|
|
31
|
+
*
|
|
32
|
+
* @param {string[]} argv
|
|
33
|
+
* @param {object} [io]
|
|
34
|
+
* @param {NodeJS.WritableStream} [io.stdout=process.stdout]
|
|
35
|
+
* @param {NodeJS.WritableStream} [io.stderr=process.stderr]
|
|
36
|
+
*/
|
|
37
|
+
export async function runSearch(argv, io = {}) {
|
|
38
|
+
const stdout = io.stdout ?? process.stdout;
|
|
39
|
+
const stderr = io.stderr ?? process.stderr;
|
|
40
|
+
let query = null;
|
|
41
|
+
let limit = 20;
|
|
42
|
+
let semantic = false;
|
|
43
|
+
|
|
44
|
+
const flags = resolveFlags(argv, {
|
|
45
|
+
acceptPositional(arg, i, all) {
|
|
46
|
+
// --limit <n> and --semantic are command-specific; intercept
|
|
47
|
+
// them here so they don't fall through to "Unknown argument".
|
|
48
|
+
if (arg === "--limit" && all[i + 1] !== undefined) {
|
|
49
|
+
const n = Number(all[i + 1]);
|
|
50
|
+
if (!Number.isInteger(n) || n < 1 || n > 100) {
|
|
51
|
+
throw validationError(`--limit must be an integer between 1 and 100`);
|
|
52
|
+
}
|
|
53
|
+
limit = n;
|
|
54
|
+
return 2; // consume `--limit` + value
|
|
55
|
+
}
|
|
56
|
+
if (arg === "--semantic") {
|
|
57
|
+
semantic = true;
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
// Otherwise treat as the query (only the FIRST non-flag becomes the query)
|
|
61
|
+
if (query !== null) {
|
|
62
|
+
throw validationError(
|
|
63
|
+
`Unexpected extra argument: ${arg}`,
|
|
64
|
+
{ hint: "Quote multi-word queries: `skillrepo search \"my query\"`" },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
query = arg;
|
|
68
|
+
return 1;
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!query || query.trim() === "") {
|
|
73
|
+
throw validationError("Search query is required.", {
|
|
74
|
+
hint: 'Usage: skillrepo search "<query>"',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const result = await searchSkills(flags.serverUrl, flags.apiKey, {
|
|
79
|
+
q: query,
|
|
80
|
+
limit,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (flags.json) {
|
|
84
|
+
stdout.write(
|
|
85
|
+
JSON.stringify(
|
|
86
|
+
{
|
|
87
|
+
query,
|
|
88
|
+
semantic,
|
|
89
|
+
// Surface the no-op explicitly so scripts can see it
|
|
90
|
+
// wasn't honored, even though the request succeeded.
|
|
91
|
+
semanticSupported: false,
|
|
92
|
+
results: result.skills,
|
|
93
|
+
pagination: result.pagination,
|
|
94
|
+
},
|
|
95
|
+
null,
|
|
96
|
+
2,
|
|
97
|
+
) + "\n",
|
|
98
|
+
);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Emit the --semantic note BEFORE any table or empty-results
|
|
103
|
+
// message so the user reads it as context for whatever follows.
|
|
104
|
+
// The previous ordering only emitted the warning AFTER the table
|
|
105
|
+
// rendered (and not at all on empty results), which obscured the
|
|
106
|
+
// fact that semantic search was silently downgraded to keyword.
|
|
107
|
+
if (semantic) {
|
|
108
|
+
stderr.write(
|
|
109
|
+
" note: --semantic is reserved for v1.1; using keyword search.\n",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (result.skills.length === 0) {
|
|
114
|
+
stdout.write(
|
|
115
|
+
`\n No skills found matching "${query}".\n ` +
|
|
116
|
+
`Try a broader search or browse the catalog at /skills.\n\n`,
|
|
117
|
+
);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
printTable(query, result, stdout);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function printTable(query, result, out) {
|
|
125
|
+
const table = new Table({
|
|
126
|
+
head: ["Skill", "Version", "Installs", "Description"],
|
|
127
|
+
colWidths: computeColWidths(streamColumns(out)),
|
|
128
|
+
wordWrap: true,
|
|
129
|
+
style: { head: ["bold"] },
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
for (const s of result.skills) {
|
|
133
|
+
table.push([
|
|
134
|
+
formatIdentifier(s),
|
|
135
|
+
s.version || "—",
|
|
136
|
+
formatInstallCount(s.installs),
|
|
137
|
+
truncate(s.description || "", 60),
|
|
138
|
+
]);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
out.write(`\n Results for "${query}":\n`);
|
|
142
|
+
out.write(table.toString() + "\n\n");
|
|
143
|
+
const total = result.pagination?.total ?? result.skills.length;
|
|
144
|
+
const shown = result.skills.length;
|
|
145
|
+
if (total > shown) {
|
|
146
|
+
out.write(
|
|
147
|
+
` Showing ${shown} of ${total} results. Use --limit to see more.\n\n`,
|
|
148
|
+
);
|
|
149
|
+
} else {
|
|
150
|
+
out.write(` ${total} result${total === 1 ? "" : "s"}.\n\n`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function computeColWidths(terminalColumns) {
|
|
155
|
+
const total = terminalColumns > 60 ? Math.min(terminalColumns, 120) : 100;
|
|
156
|
+
const skillCol = 32;
|
|
157
|
+
const versionCol = 10;
|
|
158
|
+
const installsCol = 10;
|
|
159
|
+
const descCol = Math.max(20, total - skillCol - versionCol - installsCol - 6);
|
|
160
|
+
return [skillCol, versionCol, installsCol, descCol];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Read the column width from the active output stream, falling back
|
|
165
|
+
* to process.stdout's TTY column count, then to a 100-col default.
|
|
166
|
+
* Same rationale as list.mjs streamColumns().
|
|
167
|
+
*/
|
|
168
|
+
function streamColumns(out) {
|
|
169
|
+
if (out && typeof out.columns === "number" && out.columns > 0) {
|
|
170
|
+
return out.columns;
|
|
171
|
+
}
|
|
172
|
+
if (process.stdout.columns && process.stdout.columns > 0) {
|
|
173
|
+
return process.stdout.columns;
|
|
174
|
+
}
|
|
175
|
+
return 100;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function formatInstallCount(n) {
|
|
179
|
+
if (typeof n !== "number" || !Number.isFinite(n)) return "—";
|
|
180
|
+
if (n < 1000) return String(n);
|
|
181
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
|
182
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function truncate(s, n) {
|
|
186
|
+
if (s.length <= n) return s;
|
|
187
|
+
return s.slice(0, n - 1) + "…";
|
|
188
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `skillrepo update` (#675).
|
|
3
|
+
*
|
|
4
|
+
* Re-syncs the user's library against the registry by calling the
|
|
5
|
+
* shared `sync.mjs` engine. This is a thin command wrapper around
|
|
6
|
+
* `runSync` plus argument parsing and a printer.
|
|
7
|
+
*
|
|
8
|
+
* Flags (parsed by the shared `resolveFlags` helper):
|
|
9
|
+
* --global Write to ~/.claude/skills/ instead of project-local
|
|
10
|
+
* --ide <list> Comma-separated vendor list
|
|
11
|
+
* --json Print summary as JSON
|
|
12
|
+
* --key <key> Override config-file access key
|
|
13
|
+
* --url <url> Override config-file server URL
|
|
14
|
+
*
|
|
15
|
+
* Exit codes are inherited from sync.mjs / http.mjs error types.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { runSync } from "../lib/sync.mjs";
|
|
19
|
+
import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Run `update`. Throws CliError on any failure; the dispatcher
|
|
23
|
+
* formats and exits.
|
|
24
|
+
*
|
|
25
|
+
* @param {string[]} argv
|
|
26
|
+
* @param {object} [io] - Optional injected streams for testability.
|
|
27
|
+
* Defaults to process.stdout/stderr. Tests pass a Writable
|
|
28
|
+
* sink so they can capture output without monkey-patching the
|
|
29
|
+
* global stdout (which collides with node:test's TAP IPC).
|
|
30
|
+
* @param {NodeJS.WritableStream} [io.stdout=process.stdout]
|
|
31
|
+
* @param {NodeJS.WritableStream} [io.stderr=process.stderr]
|
|
32
|
+
*/
|
|
33
|
+
export async function runUpdate(argv, io = {}) {
|
|
34
|
+
const stdout = io.stdout ?? process.stdout;
|
|
35
|
+
const flags = resolveFlags(argv);
|
|
36
|
+
const vendors = effectiveVendors(flags);
|
|
37
|
+
|
|
38
|
+
// Forward `io` to runSync so the non-fatal "failed to persist
|
|
39
|
+
// last-sync state" warning lands on the injected stderr stream
|
|
40
|
+
// when tests inject one.
|
|
41
|
+
const summary = await runSync({
|
|
42
|
+
serverUrl: flags.serverUrl,
|
|
43
|
+
apiKey: flags.apiKey,
|
|
44
|
+
vendors,
|
|
45
|
+
global: flags.global,
|
|
46
|
+
io,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (flags.json) {
|
|
50
|
+
stdout.write(JSON.stringify(summary, null, 2) + "\n");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
printSummary(summary, stdout);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function printSummary(s, out) {
|
|
57
|
+
const total = s.added + s.updated + s.removed;
|
|
58
|
+
if (s.notModified || total === 0) {
|
|
59
|
+
out.write(" ✓ Library is up to date.\n");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
out.write("\n Library sync complete:\n");
|
|
63
|
+
if (s.added > 0) out.write(` + ${s.added} added\n`);
|
|
64
|
+
if (s.updated > 0) out.write(` ↻ ${s.updated} updated\n`);
|
|
65
|
+
if (s.removed > 0) out.write(` − ${s.removed} removed\n`);
|
|
66
|
+
out.write("\n");
|
|
67
|
+
}
|