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.
- package/README.md +276 -145
- package/bin/skillrepo.mjs +224 -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 +589 -143
- package/src/commands/list.mjs +176 -0
- package/src/commands/remove.mjs +162 -0
- package/src/commands/search.mjs +188 -0
- package/src/commands/session-sync.mjs +152 -0
- package/src/commands/uninstall.mjs +484 -0
- package/src/commands/update.mjs +184 -0
- package/src/lib/artifact-registry.mjs +265 -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/fs-utils.mjs +83 -1
- 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/mergers/session-hook.mjs +298 -0
- package/src/lib/paths.mjs +67 -17
- package/src/lib/prompt.mjs +11 -44
- package/src/lib/removers/claude-mcp.mjs +67 -0
- package/src/lib/removers/cursor-mcp.mjs +60 -0
- package/src/lib/removers/env-local.mjs +55 -0
- package/src/lib/removers/gitignore.mjs +108 -0
- package/src/lib/removers/settings.mjs +183 -0
- package/src/lib/removers/vscode-mcp.mjs +87 -0
- package/src/lib/removers/windsurf-mcp.mjs +65 -0
- 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 +697 -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/session-sync.test.mjs +350 -0
- package/src/test/commands/uninstall.test.mjs +768 -0
- package/src/test/commands/update.test.mjs +322 -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/artifact-registry.test.mjs +268 -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/test/mergers/session-hook.test.mjs +745 -0
- package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
- package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
- package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
- package/src/test/mergers/uninstall-settings.test.mjs +285 -0
- package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
- package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
- 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,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
|
+
}
|