skillrepo 2.0.0 → 3.1.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 (72) hide show
  1. package/README.md +276 -145
  2. package/bin/skillrepo.mjs +224 -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 +589 -143
  7. package/src/commands/list.mjs +176 -0
  8. package/src/commands/remove.mjs +162 -0
  9. package/src/commands/search.mjs +188 -0
  10. package/src/commands/session-sync.mjs +152 -0
  11. package/src/commands/uninstall.mjs +484 -0
  12. package/src/commands/update.mjs +184 -0
  13. package/src/lib/artifact-registry.mjs +265 -0
  14. package/src/lib/cli-config.mjs +230 -0
  15. package/src/lib/config.mjs +238 -0
  16. package/src/lib/detect-ides.mjs +0 -19
  17. package/src/lib/errors.mjs +264 -0
  18. package/src/lib/file-write.mjs +705 -0
  19. package/src/lib/fs-utils.mjs +83 -1
  20. package/src/lib/http.mjs +817 -37
  21. package/src/lib/identifier.mjs +153 -0
  22. package/src/lib/mcp-merge.mjs +275 -0
  23. package/src/lib/mergers/gitignore.mjs +73 -18
  24. package/src/lib/mergers/session-hook.mjs +298 -0
  25. package/src/lib/paths.mjs +67 -17
  26. package/src/lib/prompt.mjs +11 -44
  27. package/src/lib/removers/claude-mcp.mjs +67 -0
  28. package/src/lib/removers/cursor-mcp.mjs +60 -0
  29. package/src/lib/removers/env-local.mjs +55 -0
  30. package/src/lib/removers/gitignore.mjs +108 -0
  31. package/src/lib/removers/settings.mjs +183 -0
  32. package/src/lib/removers/vscode-mcp.mjs +87 -0
  33. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  34. package/src/lib/sync.mjs +305 -0
  35. package/src/test/commands/add.test.mjs +285 -0
  36. package/src/test/commands/get.test.mjs +176 -0
  37. package/src/test/commands/init.test.mjs +697 -0
  38. package/src/test/commands/list.test.mjs +172 -0
  39. package/src/test/commands/remove.test.mjs +234 -0
  40. package/src/test/commands/search.test.mjs +204 -0
  41. package/src/test/commands/session-sync.test.mjs +350 -0
  42. package/src/test/commands/uninstall.test.mjs +768 -0
  43. package/src/test/commands/update.test.mjs +322 -0
  44. package/src/test/detect-ides.test.mjs +9 -14
  45. package/src/test/dispatcher.test.mjs +224 -0
  46. package/src/test/e2e/cli-commands.test.mjs +576 -0
  47. package/src/test/e2e/mock-server.mjs +364 -22
  48. package/src/test/helpers/capture-stream.mjs +48 -0
  49. package/src/test/integration/file-write.integration.test.mjs +279 -0
  50. package/src/test/lib/artifact-registry.test.mjs +268 -0
  51. package/src/test/lib/cli-config.test.mjs +407 -0
  52. package/src/test/lib/config.test.mjs +257 -0
  53. package/src/test/lib/errors.test.mjs +359 -0
  54. package/src/test/lib/file-write.test.mjs +784 -0
  55. package/src/test/lib/http.test.mjs +1198 -0
  56. package/src/test/lib/identifier.test.mjs +157 -0
  57. package/src/test/lib/mcp-merge.test.mjs +345 -0
  58. package/src/test/lib/paths.test.mjs +83 -0
  59. package/src/test/lib/sync.test.mjs +514 -0
  60. package/src/test/mergers/gitignore.test.mjs +145 -20
  61. package/src/test/mergers/session-hook.test.mjs +745 -0
  62. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  63. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  64. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  65. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  66. package/src/test/mergers/uninstall-settings.test.mjs +285 -0
  67. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  68. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
  69. package/src/lib/write-configs.mjs +0 -202
  70. package/src/test/e2e/HANDOFF.md +0 -223
  71. package/src/test/e2e/cli-init.test.mjs +0 -213
  72. 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,162 @@
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
+ * - `remove` is called with a specific (owner, name), so the CLI
15
+ * already knows exactly what to delete. Going through a full
16
+ * sync round-trip to rediscover the tombstone is wasteful —
17
+ * a direct unlink is the shortest correct path.
18
+ * - Cross-machine remove propagation is handled by the sync
19
+ * path: the ETag factors in `libraryRemovals` (fixed in #875)
20
+ * so a conditional GET from another machine correctly
21
+ * invalidates and returns the tombstone list.
22
+ *
23
+ * Idempotent semantics:
24
+ *
25
+ * - 200 removed → DELETE succeeded, now wipe local files
26
+ * - 404 not-in-library → NOT an error; the skill wasn't in the
27
+ * library anyway. Still wipe local files (the user asked for
28
+ * the skill to be gone from disk — if they have stale local
29
+ * copies from a prior manual checkout or from another machine,
30
+ * clean them up anyway). This matches the "user intent is the
31
+ * authoritative request" principle.
32
+ * - 403 scope → scopeError (via http.mjs) with write-key hint
33
+ * - 401 → authError
34
+ *
35
+ * Flags: --global / --ide / --json / --key / --url
36
+ * Positional: <@owner/name>
37
+ */
38
+
39
+ import { removeSkillFromLibrary } from "../lib/http.mjs";
40
+ import { removeSkillDir, cleanupOrphans } from "../lib/file-write.mjs";
41
+ import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
42
+ import { parseIdentifier, formatIdentifier } from "../lib/identifier.mjs";
43
+ import { validationError } from "../lib/errors.mjs";
44
+
45
+ /**
46
+ * Run `remove`. Throws CliError on failure.
47
+ *
48
+ * @param {string[]} argv
49
+ * @param {object} [io]
50
+ * @param {NodeJS.WritableStream} [io.stdout=process.stdout]
51
+ * @param {NodeJS.WritableStream} [io.stderr=process.stderr]
52
+ */
53
+ export async function runRemove(argv, io = {}) {
54
+ const stdout = io.stdout ?? process.stdout;
55
+ let identifier = null;
56
+
57
+ const flags = resolveFlags(argv, {
58
+ acceptPositional(arg) {
59
+ if (identifier !== null) {
60
+ throw validationError(
61
+ `Unexpected extra argument: ${arg}`,
62
+ { hint: "Pass exactly one @owner/name." },
63
+ );
64
+ }
65
+ identifier = arg;
66
+ return 1;
67
+ },
68
+ });
69
+
70
+ if (!identifier) {
71
+ throw validationError("Missing skill identifier.", {
72
+ hint: "Usage: skillrepo remove <@owner/name>",
73
+ });
74
+ }
75
+
76
+ const { owner, name } = parseIdentifier(identifier);
77
+ const vendors = effectiveVendors(flags);
78
+
79
+ // Pre-flight: clean any .old/ orphans from a prior crashed write.
80
+ // `remove` isn't writing new files, but it IS deleting, and the
81
+ // asymmetric behavior of "get/add/update clean orphans but remove
82
+ // doesn't" was flagged in review. Cleanup is idempotent and
83
+ // preserves any .tmp/ with a missing live target (Windows recovery
84
+ // invariant — see cleanupOrphans' comment block).
85
+ cleanupOrphans({ vendors, global: flags.global });
86
+
87
+ // Step 1: DELETE /api/v1/library/{owner}/{name}
88
+ const removeResult = await removeSkillFromLibrary(
89
+ flags.serverUrl,
90
+ flags.apiKey,
91
+ owner,
92
+ name,
93
+ );
94
+
95
+ // Both "removed" and "not-in-library" proceed to the local delete.
96
+ // The user asked for the skill to be gone; we honor that regardless
97
+ // of whether the library row was there to begin with.
98
+ const wasRemovedFromLibrary = removeResult.status === "removed";
99
+
100
+ // Step 2: delete local files via removeSkillDir. Passes `name` only
101
+ // (not owner) — the CLI places skills by name alone, matching
102
+ // sync.mjs's tombstone loop. See the comment there for the future
103
+ // owner-namespacing caveat.
104
+ const localResult = removeSkillDir(name, { vendors, global: flags.global });
105
+
106
+ if (flags.json) {
107
+ stdout.write(
108
+ JSON.stringify(
109
+ {
110
+ action: wasRemovedFromLibrary ? "removed" : "not-in-library",
111
+ owner,
112
+ name,
113
+ libraryUpdated: wasRemovedFromLibrary,
114
+ localDirsRemoved: localResult.removed.length,
115
+ localDirsNotFound: localResult.notFound.length,
116
+ },
117
+ null,
118
+ 2,
119
+ ) + "\n",
120
+ );
121
+ return;
122
+ }
123
+
124
+ // Human-readable summary. Four possible shapes:
125
+ //
126
+ // 1. Library updated + local files existed:
127
+ // "✓ Removed @a/b from library and deleted N local dirs"
128
+ // 2. Library updated + no local files:
129
+ // "✓ Removed @a/b from library (no local files found)"
130
+ // 3. Not in library + local files existed:
131
+ // "✓ @a/b wasn't in your library; deleted N orphaned local dirs"
132
+ // 4. Not in library + no local files:
133
+ // "• @a/b wasn't in your library and no local files existed — nothing to do"
134
+ const ident = formatIdentifier({ owner, name });
135
+ const localCount = localResult.removed.length;
136
+
137
+ if (wasRemovedFromLibrary && localCount > 0) {
138
+ stdout.write(
139
+ `\n ✓ Removed ${ident} from your library and deleted ${localCount} local director${localCount === 1 ? "y" : "ies"}\n\n`,
140
+ );
141
+ } else if (wasRemovedFromLibrary) {
142
+ stdout.write(
143
+ `\n ✓ Removed ${ident} from your library (no local files found)\n\n`,
144
+ );
145
+ } else if (localCount > 0) {
146
+ // The skill wasn't in the library but local files DID exist.
147
+ // These could be leftover from a prior `remove` that crashed
148
+ // after the DELETE but before the local delete, OR manually-
149
+ // placed files the user dropped into `.claude/skills/<name>/`
150
+ // outside the CLI. Either way, the user asked for this skill
151
+ // to be gone; we delete. We avoid the word "orphaned" in the
152
+ // message because it implies CLI-managed state, which may not
153
+ // be the case.
154
+ stdout.write(
155
+ `\n ✓ ${ident} wasn't in your library — deleted ${localCount} local director${localCount === 1 ? "y" : "ies"} that matched the name\n\n`,
156
+ );
157
+ } else {
158
+ stdout.write(
159
+ `\n • ${ident} wasn't in your library and no local files existed — nothing to do\n\n`,
160
+ );
161
+ }
162
+ }
@@ -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,152 @@
1
+ /**
2
+ * `skillrepo session-sync enable|disable` (#884).
3
+ *
4
+ * Thin command wrapper over the session-hook installer/remover in
5
+ * `src/lib/mergers/session-hook.mjs`. The command exists so users who
6
+ * want to toggle session-start sync WITHOUT re-running `skillrepo init`
7
+ * can do so with a single command. `init` calls the same underlying
8
+ * `mergeSessionHook` helper at its step 6.
9
+ *
10
+ * Usage:
11
+ * skillrepo session-sync enable — install the SessionStart hook
12
+ * skillrepo session-sync disable — remove it
13
+ * skillrepo session-sync status — print current state (future — not wired yet)
14
+ *
15
+ * Flags:
16
+ * --global Operate on ~/.claude/settings.local.json instead of the
17
+ * project-local file. Mirrors `init --global` semantics.
18
+ * --json Emit structured JSON instead of human output.
19
+ *
20
+ * Exit codes:
21
+ * 0 success (installed, updated, unchanged, removed, or already disabled)
22
+ * 3 disk error (cannot read/write the settings file)
23
+ * 5 validation error (bad subcommand or flag combination)
24
+ *
25
+ * This command does NOT share the `update --session-hook` exit-0-on-
26
+ * errors contract. That contract exists specifically for the hook
27
+ * runner's startup path; when a user explicitly invokes
28
+ * `skillrepo session-sync enable` at the shell, they expect a
29
+ * non-zero exit on failure so their shell script knows something
30
+ * went wrong.
31
+ */
32
+
33
+ import {
34
+ mergeSessionHook,
35
+ removeSessionHook,
36
+ } from "../lib/mergers/session-hook.mjs";
37
+ import { resolveFlags } from "../lib/cli-config.mjs";
38
+ import { validationError } from "../lib/errors.mjs";
39
+
40
+ /**
41
+ * Run `session-sync <subcommand>`. Throws CliError on failure; the
42
+ * dispatcher maps to the appropriate exit code.
43
+ *
44
+ * @param {string[]} argv
45
+ * @param {object} [io]
46
+ * @param {NodeJS.WritableStream} [io.stdout=process.stdout]
47
+ * @param {NodeJS.WritableStream} [io.stderr=process.stderr]
48
+ */
49
+ export async function runSessionSync(argv, io = {}) {
50
+ const stdout = io.stdout ?? process.stdout;
51
+
52
+ // ── Subcommand + flag parsing ───────────────────────────────────
53
+ //
54
+ // The subcommand is the first positional arg — "enable" or
55
+ // "disable". Use resolveFlags' acceptPositional callback to
56
+ // consume it (same pattern `get`, `search`, `add`, `remove` use
57
+ // for their positional args).
58
+ let subcommand = null;
59
+ const flags = resolveFlags(argv, {
60
+ requireAuth: false, // no server call — key not needed
61
+ skipConfig: true, // don't read the global config file
62
+ acceptPositional(arg) {
63
+ if (arg === "enable" || arg === "disable") {
64
+ if (subcommand !== null) {
65
+ throw validationError(
66
+ `session-sync accepts exactly one subcommand; got "${subcommand}" and "${arg}"`,
67
+ );
68
+ }
69
+ subcommand = arg;
70
+ return 1;
71
+ }
72
+ return false;
73
+ },
74
+ });
75
+
76
+ if (!subcommand) {
77
+ throw validationError(
78
+ "session-sync requires a subcommand (enable or disable).",
79
+ { hint: "Usage: skillrepo session-sync <enable|disable> [--global] [--json]" },
80
+ );
81
+ }
82
+
83
+ // ── Dispatch ────────────────────────────────────────────────────
84
+ if (subcommand === "enable") {
85
+ const result = mergeSessionHook({ global: flags.global });
86
+ if (flags.json) {
87
+ stdout.write(JSON.stringify(result, null, 2) + "\n");
88
+ return;
89
+ }
90
+ printEnableResult(result, stdout);
91
+ return;
92
+ }
93
+
94
+ if (subcommand === "disable") {
95
+ const result = removeSessionHook({ global: flags.global });
96
+ if (flags.json) {
97
+ stdout.write(JSON.stringify(result, null, 2) + "\n");
98
+ return;
99
+ }
100
+ printDisableResult(result, stdout);
101
+ return;
102
+ }
103
+
104
+ // Unreachable — the acceptPositional callback only accepts the
105
+ // two subcommands. Defensive throw so a future addition with a
106
+ // typo'd branch surfaces immediately.
107
+ throw validationError(`session-sync: unknown subcommand "${subcommand}"`);
108
+ }
109
+
110
+ function printEnableResult(result, out) {
111
+ if (result.action === "installed") {
112
+ out.write(`\n ✓ SessionStart hook installed (${result.path})\n\n`);
113
+ return;
114
+ }
115
+ if (result.action === "updated") {
116
+ out.write(`\n ✓ SessionStart hook updated (${result.path})\n\n`);
117
+ return;
118
+ }
119
+ if (result.action === "unchanged") {
120
+ out.write(`\n ✓ SessionStart hook already installed (${result.path})\n\n`);
121
+ return;
122
+ }
123
+ // "skipped" — reason is set
124
+ out.write(`\n ⚠ Could not enable session sync: ${result.reason}\n\n`);
125
+ }
126
+
127
+ function printDisableResult(result, out) {
128
+ if (result.action === "removed") {
129
+ out.write(`\n ✓ SessionStart hook removed (${result.path})\n\n`);
130
+ return;
131
+ }
132
+ if (result.action === "unchanged") {
133
+ out.write(
134
+ `\n ✓ SessionStart hook was not installed (${result.path}) — nothing to do.\n\n`,
135
+ );
136
+ return;
137
+ }
138
+ // "skipped" with an error — the settings file exists but couldn't
139
+ // be parsed. Both reviewers (round 2) flagged the observability
140
+ // gap: without this branch, a corrupt file looked identical to
141
+ // "file doesn't exist" in human output, and users couldn't tell
142
+ // the difference. The --json path already surfaces result.error
143
+ // via its full serialize, so only human mode was affected.
144
+ if (result.action === "skipped" && result.error) {
145
+ out.write(`\n ⚠ ${result.error}\n\n`);
146
+ return;
147
+ }
148
+ // "skipped" without an error — file genuinely doesn't exist.
149
+ out.write(
150
+ `\n ✓ No settings file at ${result.path} — session sync is not enabled.\n\n`,
151
+ );
152
+ }