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.
Files changed (49) hide show
  1. package/README.md +215 -150
  2. package/bin/skillrepo.mjs +210 -36
  3. package/package.json +6 -3
  4. package/src/commands/add.mjs +176 -0
  5. package/src/commands/get.mjs +116 -0
  6. package/src/commands/init.mjs +471 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +167 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/update.mjs +67 -0
  11. package/src/lib/cli-config.mjs +230 -0
  12. package/src/lib/config.mjs +238 -0
  13. package/src/lib/detect-ides.mjs +0 -19
  14. package/src/lib/errors.mjs +264 -0
  15. package/src/lib/file-write.mjs +705 -0
  16. package/src/lib/http.mjs +817 -37
  17. package/src/lib/identifier.mjs +153 -0
  18. package/src/lib/mcp-merge.mjs +275 -0
  19. package/src/lib/mergers/gitignore.mjs +73 -18
  20. package/src/lib/paths.mjs +46 -17
  21. package/src/lib/prompt.mjs +11 -44
  22. package/src/lib/sync.mjs +305 -0
  23. package/src/test/commands/add.test.mjs +285 -0
  24. package/src/test/commands/get.test.mjs +176 -0
  25. package/src/test/commands/init.test.mjs +486 -0
  26. package/src/test/commands/list.test.mjs +172 -0
  27. package/src/test/commands/remove.test.mjs +234 -0
  28. package/src/test/commands/search.test.mjs +204 -0
  29. package/src/test/commands/update.test.mjs +164 -0
  30. package/src/test/detect-ides.test.mjs +9 -14
  31. package/src/test/dispatcher.test.mjs +224 -0
  32. package/src/test/e2e/cli-commands.test.mjs +576 -0
  33. package/src/test/e2e/mock-server.mjs +364 -22
  34. package/src/test/helpers/capture-stream.mjs +48 -0
  35. package/src/test/integration/file-write.integration.test.mjs +279 -0
  36. package/src/test/lib/cli-config.test.mjs +407 -0
  37. package/src/test/lib/config.test.mjs +257 -0
  38. package/src/test/lib/errors.test.mjs +359 -0
  39. package/src/test/lib/file-write.test.mjs +784 -0
  40. package/src/test/lib/http.test.mjs +1198 -0
  41. package/src/test/lib/identifier.test.mjs +157 -0
  42. package/src/test/lib/mcp-merge.test.mjs +345 -0
  43. package/src/test/lib/paths.test.mjs +83 -0
  44. package/src/test/lib/sync.test.mjs +514 -0
  45. package/src/test/mergers/gitignore.test.mjs +145 -20
  46. package/src/lib/write-configs.mjs +0 -202
  47. package/src/test/e2e/HANDOFF.md +0 -223
  48. package/src/test/e2e/cli-init.test.mjs +0 -213
  49. 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
+ }