@khorsheed/dsh-capability-catalog 0.1.95

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 (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +286 -0
  4. package/README.zh.md +138 -0
  5. package/cordis.patch.yml +8 -0
  6. package/lib/client.js +10100 -0
  7. package/lib/index.js +4022 -0
  8. package/lib/invariant.js +25 -0
  9. package/lib/mcpConnector-C2TbkPBY.js +69 -0
  10. package/lib/typert.host.d.ts +3 -0
  11. package/lib/typert.host.js +1417 -0
  12. package/lib/typert.remote-client.d.ts +68 -0
  13. package/lib/typert.remote-client.js +1136 -0
  14. package/lib/types/capabilities.d.ts +103 -0
  15. package/lib/types/capabilities.js +116 -0
  16. package/lib/types/channels.d.ts +41 -0
  17. package/lib/types/channels.js +75 -0
  18. package/lib/types/client/AddMcpDialog.d.ts +13 -0
  19. package/lib/types/client/AddMcpDialog.js +79 -0
  20. package/lib/types/client/AddSkillModal.d.ts +17 -0
  21. package/lib/types/client/AddSkillModal.js +138 -0
  22. package/lib/types/client/BundleFileTree.d.ts +7 -0
  23. package/lib/types/client/BundleFileTree.js +74 -0
  24. package/lib/types/client/CapabilityCatalogCard.d.ts +57 -0
  25. package/lib/types/client/CapabilityCatalogCard.js +479 -0
  26. package/lib/types/client/CredentialField.d.ts +14 -0
  27. package/lib/types/client/CredentialField.js +9 -0
  28. package/lib/types/client/McpCards.d.ts +13 -0
  29. package/lib/types/client/McpCards.js +12 -0
  30. package/lib/types/client/McpServerManageModal.d.ts +18 -0
  31. package/lib/types/client/McpServerManageModal.js +63 -0
  32. package/lib/types/client/MetadataRow.d.ts +10 -0
  33. package/lib/types/client/MetadataRow.js +6 -0
  34. package/lib/types/client/ModalShell.d.ts +16 -0
  35. package/lib/types/client/ModalShell.js +27 -0
  36. package/lib/types/client/ModeChips.d.ts +38 -0
  37. package/lib/types/client/ModeChips.js +42 -0
  38. package/lib/types/client/SchemaView.d.ts +11 -0
  39. package/lib/types/client/SchemaView.js +143 -0
  40. package/lib/types/client/SkillCards.d.ts +28 -0
  41. package/lib/types/client/SkillCards.js +43 -0
  42. package/lib/types/client/SkillDetailModal.d.ts +69 -0
  43. package/lib/types/client/SkillDetailModal.js +167 -0
  44. package/lib/types/client/ToolCards.d.ts +28 -0
  45. package/lib/types/client/ToolCards.js +52 -0
  46. package/lib/types/client/ToolDetailModal.d.ts +12 -0
  47. package/lib/types/client/ToolDetailModal.js +29 -0
  48. package/lib/types/client/icons.d.ts +26 -0
  49. package/lib/types/client/icons.js +10 -0
  50. package/lib/types/client/index.d.ts +21 -0
  51. package/lib/types/client/index.js +196 -0
  52. package/lib/types/client/locales.d.ts +392 -0
  53. package/lib/types/client/locales.js +395 -0
  54. package/lib/types/client/mcp-model.d.ts +24 -0
  55. package/lib/types/client/mcp-model.js +87 -0
  56. package/lib/types/client/mode-model.d.ts +108 -0
  57. package/lib/types/client/mode-model.js +140 -0
  58. package/lib/types/client/preset-display.d.ts +21 -0
  59. package/lib/types/client/preset-display.js +20 -0
  60. package/lib/types/client/settle.d.ts +40 -0
  61. package/lib/types/client/settle.js +57 -0
  62. package/lib/types/client/slots.d.ts +86 -0
  63. package/lib/types/client/slots.js +1 -0
  64. package/lib/types/envHint.d.ts +22 -0
  65. package/lib/types/envHint.js +76 -0
  66. package/lib/types/import.d.ts +106 -0
  67. package/lib/types/import.js +635 -0
  68. package/lib/types/index.d.ts +216 -0
  69. package/lib/types/index.js +739 -0
  70. package/lib/types/invariant.d.ts +16 -0
  71. package/lib/types/invariant.js +23 -0
  72. package/lib/types/mcpConnector.d.ts +24 -0
  73. package/lib/types/mcpConnector.js +73 -0
  74. package/lib/types/mcpStore.d.ts +68 -0
  75. package/lib/types/mcpStore.js +235 -0
  76. package/lib/types/mcpTools.d.ts +45 -0
  77. package/lib/types/mcpTools.js +125 -0
  78. package/lib/types/mcps.d.ts +64 -0
  79. package/lib/types/mcps.js +157 -0
  80. package/lib/types/modes.d.ts +65 -0
  81. package/lib/types/modes.js +97 -0
  82. package/lib/types/namespace.d.ts +9 -0
  83. package/lib/types/namespace.js +8 -0
  84. package/lib/types/official-tools.d.ts +3 -0
  85. package/lib/types/official-tools.js +44 -0
  86. package/lib/types/preset-scope.d.ts +105 -0
  87. package/lib/types/preset-scope.js +117 -0
  88. package/lib/types/remote.d.ts +48 -0
  89. package/lib/types/remote.js +120 -0
  90. package/lib/types/scoped-delivery.d.ts +243 -0
  91. package/lib/types/scoped-delivery.js +686 -0
  92. package/lib/types/scoped-edits.d.ts +90 -0
  93. package/lib/types/scoped-edits.js +234 -0
  94. package/lib/types/settings.d.ts +28 -0
  95. package/lib/types/settings.js +28 -0
  96. package/lib/types/shellEnv.d.ts +65 -0
  97. package/lib/types/shellEnv.js +232 -0
  98. package/lib/types/skills.d.ts +103 -0
  99. package/lib/types/skills.js +303 -0
  100. package/lib/types/tool-origin.d.ts +29 -0
  101. package/lib/types/tool-origin.js +30 -0
  102. package/lib/types/types.d.ts +315 -0
  103. package/lib/types/types.js +8 -0
  104. package/lib/types/zip.d.ts +24 -0
  105. package/lib/types/zip.js +80 -0
  106. package/locale/en.json +6 -0
  107. package/locale/zh.json +6 -0
  108. package/package.json +119 -0
  109. package/scripts/gen-official-tools.mts +52 -0
@@ -0,0 +1,635 @@
1
+ /**
2
+ * Add-skill import: write a skill from a pasted SKILL.md payload or an uploaded
3
+ * zip archive into the managed skills root and extract its name/frontmatter.
4
+ *
5
+ * A skill is a directory bundle (`<name>/SKILL.md` plus optional
6
+ * `references`/`scripts`/`assets`) or a single flat file, discovered by the
7
+ * skill-filesystem watcher from one of the managed roots. So "installing" a
8
+ * skill is just placing it under the root; nothing executes the payload.
9
+ *
10
+ * The zip path reads the archive with the dependency-free zip reader
11
+ * (`./zip.ts`, node:zlib only), locates the `SKILL.md`, derives the kebab-case
12
+ * `name` from frontmatter, and extracts the bundle under `<root>/<name>/`
13
+ * (strip the archive's own wrapping folder if present). The clone path resolves
14
+ * a repo spec — including a whole `npx skills add <repo> --skill <name>` command
15
+ * copied from a skill's README — clones it into a scratch directory, and lifts
16
+ * the selected skill bundle(s) into the root.
17
+ * @module @khorsheed/dsh-capability-catalog/import
18
+ */
19
+ import { execFile } from 'node:child_process';
20
+ import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
21
+ import { homedir, tmpdir } from 'node:os';
22
+ import { basename, dirname, join } from 'node:path';
23
+ import { promisify } from 'node:util';
24
+ import { extractZip } from './zip.js';
25
+ const execFileAsync = promisify(execFile);
26
+ const FRONTMATTER = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
27
+ /** First bytes of a ZIP archive (local file header / empty / spanned). */
28
+ const ZIP_MAGIC_PK = 0x50; // 'P'
29
+ const ZIP_MAGIC_K = 0x4b; // 'K'
30
+ /** Resolve the managed root directory. */
31
+ export function managedRoot(root, dshHome) {
32
+ return root === 'user'
33
+ ? join(dshHome, 'skills')
34
+ : join(dshHome, '.agents', 'skills');
35
+ }
36
+ /** Extract the kebab-case `name` and `description` from a SKILL.md body. */
37
+ export function resolveSkillNameFromContent(content) {
38
+ const m = FRONTMATTER.exec(content);
39
+ if (m === null)
40
+ return undefined;
41
+ const front = m[1] ?? '';
42
+ const name = /^name:\s*(.+)$/m.exec(front)?.[1]?.trim();
43
+ const description = /^description:\s*(.+)$/m.exec(front)?.[1]?.trim();
44
+ if (name === undefined || description === undefined || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name))
45
+ return undefined;
46
+ return { name, description };
47
+ }
48
+ /** Decode base64 bytes to a UTF-8 string. */
49
+ function decodeBase64(payload) {
50
+ return Buffer.from(payload, 'base64').toString('utf8');
51
+ }
52
+ /** Read the frontmatter `disable-model-invocation` flag (default model-invocable true). */
53
+ export function inferModelInvocable(content) {
54
+ const m = FRONTMATTER.exec(content);
55
+ if (m === null)
56
+ return true;
57
+ const front = m[1] ?? '';
58
+ const line = /^disable-model-invocation:\s*(true|false)$/m.exec(front)?.[1];
59
+ return line !== 'true';
60
+ }
61
+ /** Whether a filesystem path exists (dir or file). */
62
+ async function pathExists(p) {
63
+ try {
64
+ await stat(p);
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ /** True when a base64 payload decodes to a ZIP archive (magic bytes). */
72
+ export function isZipPayload(payload) {
73
+ let bytes;
74
+ try {
75
+ bytes = Buffer.from(payload, 'base64');
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ return bytes.length >= 4 && bytes[0] === ZIP_MAGIC_PK && bytes[1] === ZIP_MAGIC_K &&
81
+ (bytes[2] === 0x03 || bytes[2] === 0x05 || bytes[2] === 0x07);
82
+ }
83
+ /** Reject unsafe relative paths (absolute, drive-letter, or `..` traversal). */
84
+ function sanitizeRelPath(p) {
85
+ if (p === '' || p.startsWith('/') || /^[a-zA-Z]:/.test(p))
86
+ return undefined;
87
+ const safe = [];
88
+ for (const seg of p.split('/')) {
89
+ if (seg === '' || seg === '.')
90
+ continue;
91
+ if (seg === '..')
92
+ return undefined;
93
+ safe.push(seg);
94
+ }
95
+ return safe.length === 0 ? undefined : safe.join('/');
96
+ }
97
+ /** Apply the frontmatter `disable-model-invocation` line to match the request. */
98
+ function finalizeSkillText(content, modelInvocable) {
99
+ const m = FRONTMATTER.exec(content);
100
+ if (m === null)
101
+ return content;
102
+ const front = String(m[1] ?? '');
103
+ const body = String(m[2] ?? '');
104
+ const effective = inferModelInvocable(content);
105
+ if (effective === modelInvocable)
106
+ return content;
107
+ const without = front
108
+ .split('\n')
109
+ .filter(line => !line.trim().startsWith('disable-model-invocation:'))
110
+ .join('\n');
111
+ const withFlag = modelInvocable
112
+ ? without
113
+ : `${without}${without.endsWith('\n') ? '' : '\n'}disable-model-invocation: true`;
114
+ return `---\n${withFlag}\n---\n${body}`;
115
+ }
116
+ /**
117
+ * Add a skill from a base64 SKILL.md text payload into the managed root.
118
+ * @param request - the add-skill request.
119
+ * @param dshHome - the dsh home root.
120
+ */
121
+ export async function addSkillFromText(request, dshHome) {
122
+ let text;
123
+ try {
124
+ text = decodeBase64(request.payload);
125
+ }
126
+ catch {
127
+ return { ok: false, error: 'invalid payload: not base64 text' };
128
+ }
129
+ const parsed = resolveSkillNameFromContent(text);
130
+ if (parsed === undefined) {
131
+ return { ok: false, error: 'SKILL.md missing or missing name/description frontmatter' };
132
+ }
133
+ const finalText = finalizeSkillText(text, request.modelInvocable);
134
+ const target = join(managedRoot(request.root, dshHome), parsed.name);
135
+ if (!request.overwrite && await pathExists(target))
136
+ return { ok: false, exists: true, name: parsed.name };
137
+ try {
138
+ await mkdir(target, { recursive: true });
139
+ await writeFile(join(target, 'SKILL.md'), finalText, 'utf8');
140
+ }
141
+ catch (error) {
142
+ return { ok: false, error: `failed to write skill: ${String(error)}` };
143
+ }
144
+ return { ok: true, name: parsed.name };
145
+ }
146
+ /** Pick the SKILL.md entry: the shallowest absolute path ending in `SKILL.md`. */
147
+ function pickSkillEntry(entries) {
148
+ let best;
149
+ let bestDepth = Number.POSITIVE_INFINITY;
150
+ for (const entry of entries) {
151
+ const parts = entry.path.split('/');
152
+ if (parts[parts.length - 1] !== 'SKILL.md')
153
+ continue;
154
+ const depth = parts.length - 1;
155
+ if (depth < bestDepth) {
156
+ best = entry;
157
+ bestDepth = depth;
158
+ }
159
+ }
160
+ return best;
161
+ }
162
+ /** The archive's wrapping folder prefix ('' when SKILL.md is at the root). */
163
+ function skillDirPrefix(entryName) {
164
+ const idx = entryName.lastIndexOf('/');
165
+ return idx >= 0 ? entryName.slice(0, idx + 1) : '';
166
+ }
167
+ /**
168
+ * Add a skill from a base64 ZIP archive payload into the managed root.
169
+ *
170
+ * The archive should contain a skill bundle. If the `SKILL.md` sits directly at
171
+ * the archive root it is extracted as-is; if it sits under a single wrapping
172
+ * folder that folder is stripped so the bundle lands at `<root>/<name>/`. Path
173
+ * traversal entries are skipped; the frontmatter name drives the target folder.
174
+ * @param request - the add-skill request.
175
+ * @param dshHome - the dsh home root.
176
+ */
177
+ export async function addSkillFromZip(request, dshHome) {
178
+ let bytes;
179
+ try {
180
+ bytes = Buffer.from(request.payload, 'base64');
181
+ }
182
+ catch {
183
+ return { ok: false, error: 'invalid payload: not base64' };
184
+ }
185
+ let entries;
186
+ try {
187
+ entries = extractZip(bytes);
188
+ }
189
+ catch (error) {
190
+ return { ok: false, error: `invalid zip archive: ${String(error)}` };
191
+ }
192
+ const skillEntry = pickSkillEntry(entries);
193
+ if (skillEntry === undefined)
194
+ return { ok: false, error: 'archive did not contain a SKILL.md' };
195
+ const original = skillEntry.data.toString('utf8');
196
+ const parsed = resolveSkillNameFromContent(original);
197
+ if (parsed === undefined)
198
+ return { ok: false, error: 'SKILL.md missing name/description frontmatter' };
199
+ const name = parsed.name;
200
+ const prefix = skillDirPrefix(skillEntry.path);
201
+ const finalContent = finalizeSkillText(original, request.modelInvocable);
202
+ const target = join(managedRoot(request.root, dshHome), name);
203
+ if (!request.overwrite && await pathExists(target))
204
+ return { ok: false, exists: true, name };
205
+ let wrote = false;
206
+ try {
207
+ if (request.overwrite === true)
208
+ await rm(target, { recursive: true, force: true });
209
+ await mkdir(target, { recursive: true });
210
+ }
211
+ catch (error) {
212
+ return { ok: false, error: `failed to create skill dir: ${String(error)}` };
213
+ }
214
+ for (const entry of entries) {
215
+ const rel = entry.path;
216
+ if (prefix !== '' && !rel.startsWith(prefix))
217
+ continue;
218
+ const relPath = prefix !== '' ? rel.slice(prefix.length) : rel;
219
+ const safe = sanitizeRelPath(relPath);
220
+ if (safe === undefined)
221
+ continue;
222
+ const outPath = join(target, safe);
223
+ if (!outPath.startsWith(target))
224
+ continue;
225
+ const isMain = safe === 'SKILL.md';
226
+ const data = isMain ? Buffer.from(finalContent, 'utf8') : entry.data;
227
+ try {
228
+ await mkdir(dirname(outPath), { recursive: true });
229
+ await writeFile(outPath, data);
230
+ wrote = true;
231
+ }
232
+ catch (error) {
233
+ return { ok: false, error: `failed to write skill file: ${String(error)}` };
234
+ }
235
+ }
236
+ return wrote ? { ok: true, name } : { ok: false, error: 'archive contained no reusable skill files' };
237
+ }
238
+ /**
239
+ * Add a skill from a base64 payload into the managed root, auto-detecting a
240
+ * ZIP archive (magic bytes) from a raw SKILL.md text payload.
241
+ * @param request - the add-skill request.
242
+ * @param dshHome - the dsh home root.
243
+ */
244
+ export async function addSkillFromPayload(request, dshHome) {
245
+ if (isZipPayload(request.payload))
246
+ return addSkillFromZip(request, dshHome);
247
+ return addSkillFromText(request, dshHome);
248
+ }
249
+ /** `skills add` flags whose following non-flag values dsh consumes and ignores. */
250
+ const DROPPED_VALUE_FLAGS = new Set(['-a', '--agent', '--subagent']);
251
+ /** `skills add` flags that take exactly one value dsh consumes and ignores. */
252
+ const DROPPED_SINGLE_VALUE_FLAGS = new Set(['--metadata']);
253
+ /** `skills add` boolean flags dsh consumes and ignores (`-g` is meaningless for a root dsh owns). */
254
+ const DROPPED_BOOL_FLAGS = new Set(['-g', '--global', '-y', '--yes', '-l', '--list', '--all', '--full-depth', '--json', '--copy']);
255
+ /** `skills add` flags that select sub-skills; their greedy values are kept. */
256
+ const SKILL_FLAGS = new Set(['-s', '--skill']);
257
+ /**
258
+ * Parse a `command`-channel spec — `owner/repo`, a git URL, or a whole
259
+ * `npx skills add <repo> [--skill <name>]… [-g]` command — into its source
260
+ * reference and `--skill` selections.
261
+ *
262
+ * Every flag token is consumed here, never left in the string: the spec used to
263
+ * be handed to `git clone` after only a prefix and a trailing `-g` were
264
+ * stripped, so the README form `npx skills add owner/repo --skill name` became
265
+ * the clone URL `https://github.com/owner/repo --skill name`, which git rejects
266
+ * as a malformed URL. The recognized set mirrors the `skills` CLI's own `add`
267
+ * parser (vercel-labs/skills) so a command copied from a skill's README installs
268
+ * what it says: a flag's value is consumed with it, never glued onto the
269
+ * reference.
270
+ * @param spec - the raw spec from the add-skill command channel.
271
+ */
272
+ export function parseRepoSpec(spec) {
273
+ const tokens = spec.trim().replace(/^npx\s+skills\s+add\s+/i, '').trim().split(/\s+/);
274
+ const skills = [];
275
+ let ref;
276
+ for (let i = 0; i < tokens.length; i++) {
277
+ const token = tokens[i] ?? '';
278
+ if (token === '')
279
+ continue;
280
+ if (SKILL_FLAGS.has(token)) {
281
+ // Greedy, like the CLI: `--skill a b` selects both.
282
+ while (i + 1 < tokens.length && !tokens[i + 1].startsWith('-'))
283
+ skills.push(tokens[++i]);
284
+ continue;
285
+ }
286
+ if (DROPPED_VALUE_FLAGS.has(token)) {
287
+ while (i + 1 < tokens.length && !tokens[i + 1].startsWith('-'))
288
+ i++;
289
+ continue;
290
+ }
291
+ if (DROPPED_SINGLE_VALUE_FLAGS.has(token)) {
292
+ i++;
293
+ continue;
294
+ }
295
+ // Known boolean flags — and anything else flag-shaped — are dropped: no flag
296
+ // token may ever reach the clone URL.
297
+ if (DROPPED_BOOL_FLAGS.has(token) || token.startsWith('-'))
298
+ continue;
299
+ // The first non-flag token is the source; the CLI accepts several, dsh one.
300
+ if (ref === undefined)
301
+ ref = token;
302
+ }
303
+ return ref === undefined ? undefined : { ref, skills };
304
+ }
305
+ /** A copy-pasted SCP-style remote (`git@host:owner/repo`): a clone URL, never `owner/repo`. */
306
+ const SCP_STYLE_RE = /^[^/@\s]+@[^/\s:]+:/;
307
+ /** A scheme-prefixed spec, which must carry a path after its host. */
308
+ const SCHEME_RE = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
309
+ /** The only host whose URL shape is known to be `owner/repo[/path…]`. */
310
+ const GITHUB_HOST = 'github.com';
311
+ /** Segments a copied browser URL puts in front of the real in-repo path. */
312
+ const BROWSER_PATH_MARKERS = new Set(['tree', 'blob']);
313
+ /** Local-path shapes: `stat` already ran, so a miss here is a missing directory, not a repo spec. */
314
+ const LOCAL_PATH_RE = /^(?:~\/|\/|\.\.?\/|\.\.?$)/;
315
+ /** Whether a spec's first segment names a host rather than an owner (`github.com`, `localhost:3000`). */
316
+ function isHostSegment(segment) {
317
+ return segment !== '.' && segment !== '..' && (segment.includes('.') || segment.includes(':'));
318
+ }
319
+ /**
320
+ * Build a target from `owner/repo` plus whatever path followed it, dropping the
321
+ * copy-paste noise a browser URL carries: a `tree/<branch>`/`blob/<branch>`
322
+ * marker, and a trailing `SKILL.md` naming the file rather than its directory.
323
+ */
324
+ function targetFromParts(host, owner, repo, rest) {
325
+ let path = rest;
326
+ if (path.length >= 2 && BROWSER_PATH_MARKERS.has(path[0] ?? ''))
327
+ path = path.slice(2);
328
+ if (path[path.length - 1] === 'SKILL.md')
329
+ path = path.slice(0, -1);
330
+ return {
331
+ ok: true,
332
+ url: `${host === undefined ? `https://${GITHUB_HOST}` : `https://${host}`}/${owner}/${repo}`,
333
+ subpath: path.join('/'),
334
+ };
335
+ }
336
+ /**
337
+ * Resolve a source reference into a clone URL plus an optional in-repo subpath.
338
+ *
339
+ * Accepted: a git URL with a transport, `owner/repo`, and either of those behind
340
+ * a host — `github.com/owner/repo`, `github.com/owner/repo/skills/name`, a copied
341
+ * browser URL's `tree/<branch>/…` or `blob/<branch>/SKILL.md`. Only `github.com`
342
+ * gets the `owner/repo` split; on any other host the whole path is the repository
343
+ * (GitLab subgroups are real paths, not repo + subpath), so it is handed to git
344
+ * untouched.
345
+ *
346
+ * Getting this wrong is what the host rule exists for: the old code matched the
347
+ * `owner/repo` shape anywhere in the string and kept only the FIRST TWO segments,
348
+ * so the everyday paste `github.com/larashero3-dotcom/lieflat-charts` cloned
349
+ * `https://github.com/github.com/larashero3-dotcom` — the host taken for the
350
+ * owner, the real repo name silently dropped — and failed with GitHub's
351
+ * "Repository not found", which reads like the skill is unsupported.
352
+ * @param ref - the source reference from `parseRepoSpec`.
353
+ */
354
+ function resolveCloneTarget(ref) {
355
+ // A copied URL's `?tab=`/`#readme` suffix is never part of a git URL.
356
+ const spec = (ref.trim().split(/[?#]/)[0] ?? '').replace(/\.git$/, '');
357
+ if (spec === '')
358
+ return { ok: false, error: 'the spec is empty — use owner/repo or a git URL' };
359
+ const urlMatch = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/]+)\/(.*)$/.exec(spec);
360
+ if (urlMatch !== null) {
361
+ const host = urlMatch[1];
362
+ const rest = urlMatch[2].split('/').filter(part => part !== '');
363
+ if (host.toLowerCase() !== GITHUB_HOST || rest.length <= 2)
364
+ return { ok: true, url: spec, subpath: '' };
365
+ return targetFromParts(host, rest[0], rest[1], rest.slice(2));
366
+ }
367
+ // `git@host:owner/repo` and a scheme with no path are not `owner/repo`: the
368
+ // former is a clone URL as it stands, the latter names no repository at all.
369
+ if (SCP_STYLE_RE.test(spec))
370
+ return { ok: true, url: spec, subpath: '' };
371
+ if (SCHEME_RE.test(spec))
372
+ return { ok: false, error: `${spec} has no repository path` };
373
+ const parts = spec.split('/').filter(part => part !== '');
374
+ const first = parts[0];
375
+ const host = first !== undefined && isHostSegment(first) ? parts.shift() : undefined;
376
+ if (parts.length < 2) {
377
+ const shown = [host, ...parts].filter(part => part !== undefined).join('/');
378
+ return {
379
+ ok: false,
380
+ error: `${shown} is not a repository — a source needs owner/repo (a host or a user page alone has nothing to clone)`,
381
+ };
382
+ }
383
+ if (host !== undefined && host.toLowerCase() !== GITHUB_HOST) {
384
+ return { ok: true, url: `https://${host}/${parts.join('/')}`, subpath: '' };
385
+ }
386
+ return targetFromParts(host, parts[0], parts[1], parts.slice(2));
387
+ }
388
+ /** An actionable line appended to a failed clone, keyed off git's own stderr. */
389
+ function cloneFailureHint(text) {
390
+ if (/Repository not found/i.test(text))
391
+ return '\ncheck the owner and repo name: GitHub answers "Repository not found" for a misspelled repo and for a private one alike.';
392
+ if (/Could not resolve host/i.test(text))
393
+ return '\ncheck the host in the clone URL.';
394
+ return '';
395
+ }
396
+ /** Every skill bundle at or under `dir` (depth ≤ 4, `.git` skipped), shallowest first. */
397
+ async function collectSkills(dir, depth = 0) {
398
+ if (depth > 4)
399
+ return [];
400
+ const out = [];
401
+ const content = await readFile(join(dir, 'SKILL.md'), 'utf8').catch(() => undefined);
402
+ const parsed = content === undefined ? undefined : resolveSkillNameFromContent(content);
403
+ if (content !== undefined && parsed !== undefined) {
404
+ out.push({ dir, dirName: basename(dir), name: parsed.name, content });
405
+ }
406
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
407
+ for (const entry of entries) {
408
+ if (!entry.isDirectory() || entry.name === '.git')
409
+ continue;
410
+ out.push(...await collectSkills(join(dir, entry.name), depth + 1));
411
+ }
412
+ return out;
413
+ }
414
+ /**
415
+ * Resolve `--skill` selections against the skills a repo carries. A selection
416
+ * matches a skill's directory name or its frontmatter `name`, case-insensitively
417
+ * — the `skills` CLI's own rule — and `*` selects every skill.
418
+ * @returns the chosen skills; `[]` when nothing was selected but the repo is
419
+ * ambiguous (more than one skill); `undefined` when a selection matched nothing.
420
+ */
421
+ function selectSkills(skills, selections) {
422
+ if (selections.length === 0)
423
+ return skills.length > 1 ? [] : [...skills];
424
+ if (selections.includes('*'))
425
+ return [...skills];
426
+ const chosen = skills.filter(s => selections.some(sel => sel.toLowerCase() === s.dirName.toLowerCase() || sel.toLowerCase() === s.name.toLowerCase()));
427
+ return chosen.length === 0 ? undefined : chosen;
428
+ }
429
+ /** Expand a leading `~` to the OS home directory. */
430
+ function expandHome(p) {
431
+ if (p === '~')
432
+ return homedir();
433
+ if (p.startsWith('~/'))
434
+ return join(homedir(), p.slice(2));
435
+ return p;
436
+ }
437
+ /** Sub-directories of `dir` that contain a SKILL.md (a skills container). */
438
+ async function listSkillDirs(dir) {
439
+ try {
440
+ const entries = await readdir(dir, { withFileTypes: true });
441
+ const out = [];
442
+ for (const entry of entries) {
443
+ if (!entry.isDirectory())
444
+ continue;
445
+ try {
446
+ await stat(join(dir, entry.name, 'SKILL.md'));
447
+ out.push(entry.name);
448
+ }
449
+ catch {
450
+ // not a skill dir
451
+ }
452
+ }
453
+ return out;
454
+ }
455
+ catch {
456
+ return [];
457
+ }
458
+ }
459
+ /**
460
+ * Install a skill by cloning a source repo (owner/repo, a git URL, a host-prefixed
461
+ * `github.com/owner/repo[/path]`, a copied browser URL, or a whole
462
+ * `npx skills add <repo> [--skill <name>]` command) into the managed root. This
463
+ * is the dsh-native "install from source" — a plain `npx skills add` writes into
464
+ * an external skills dir the dsh filesystem watcher does not scan.
465
+ *
466
+ * The clone lands in a scratch directory; the chosen skill bundle (the repo's
467
+ * only one, `request.skills`, or the command's `--skill` values) is then lifted
468
+ * to `<managed root>/<frontmatter name>/`. A repo carrying several skills and no
469
+ * selection is refused with the list rather than installing the shallowest one.
470
+ * A spec naming a path inside the repo (`…/skills/<name>`) searches only that
471
+ * path.
472
+ * @param request - the add-skill request (channel 'command', repo carries the spec).
473
+ * @param dshHome - the dsh home root.
474
+ */
475
+ export async function commandInstall(request, dshHome) {
476
+ const spec = request.repo ?? '';
477
+ const trimmed = spec.trim();
478
+ // A `npx skills add ...` form is treated as its repo (the real npx installer
479
+ // writes into an external skills dir dsh cannot scan); parseRepoSpec below
480
+ // extracts the repo and any `--skill` selection from that form. Local
481
+ // directory copy is handled next.
482
+ // Local directory: copy it (or a selected sub-skill) into the managed root.
483
+ const expanded = expandHome(trimmed);
484
+ let dirStat;
485
+ try {
486
+ dirStat = await stat(expanded);
487
+ }
488
+ catch {
489
+ dirStat = undefined;
490
+ }
491
+ if (dirStat?.isDirectory() === true) {
492
+ const copyInto = async (src, skillName) => {
493
+ const skillMd = join(src, 'SKILL.md');
494
+ let content;
495
+ try {
496
+ content = await readFile(skillMd, 'utf8');
497
+ }
498
+ catch {
499
+ return { ok: false, error: 'directory has no SKILL.md' };
500
+ }
501
+ const parsed = resolveSkillNameFromContent(content);
502
+ if (parsed === undefined)
503
+ return { ok: false, error: 'SKILL.md missing name/description frontmatter' };
504
+ const name = skillName ?? parsed.name;
505
+ const target = join(managedRoot(request.root, dshHome), name);
506
+ if (!request.overwrite && await pathExists(target))
507
+ return { ok: false, exists: true, name };
508
+ try {
509
+ await rm(target, { recursive: true, force: true });
510
+ }
511
+ catch {
512
+ // ignore
513
+ }
514
+ await cp(src, target, { recursive: true });
515
+ await writeFile(join(target, 'SKILL.md'), finalizeSkillText(content, request.modelInvocable), 'utf8');
516
+ return { ok: true, name };
517
+ };
518
+ // A skills container (multiple <name>/SKILL.md): install the selected
519
+ // sub-skills (request.skills), or error listing them for the chooser.
520
+ const children = await listSkillDirs(expanded);
521
+ if (request.skills !== undefined && request.skills.length > 0) {
522
+ const results = await Promise.all(request.skills.map(async (s) => copyInto(join(expanded, s), s)));
523
+ const existing = results.find(r => !r.ok && r.exists === true);
524
+ if (existing !== undefined)
525
+ return existing;
526
+ const ok = results.filter(r => r.ok);
527
+ if (ok.length > 0)
528
+ return { ok: true, name: ok.map(r => r.name).join(', ') };
529
+ const firstBad = results.find(r => !r.ok);
530
+ return firstBad ?? { ok: false, error: 'no skills installed' };
531
+ }
532
+ if (children.length > 1) {
533
+ return { ok: false, error: `directory has ${children.length} skills — pick one: ${children.join(', ')}` };
534
+ }
535
+ const only = children[0];
536
+ if (children.length === 1 && only !== undefined)
537
+ return copyInto(join(expanded, only), only);
538
+ return copyInto(expanded);
539
+ }
540
+ // A path-shaped spec reached this point only because `stat` missed it (the
541
+ // directory case returned above): say so instead of reading its segments as an
542
+ // owner/repo and cloning something that does not exist.
543
+ if (LOCAL_PATH_RE.test(trimmed))
544
+ return { ok: false, error: `local directory not found: ${expanded}` };
545
+ const source = parseRepoSpec(spec);
546
+ if (source === undefined)
547
+ return { ok: false, error: `unrecognized repo spec: ${spec || '(empty)'} — use owner/repo, a git URL, or an existing local directory` };
548
+ const resolved = resolveCloneTarget(source.ref);
549
+ if (!resolved.ok)
550
+ return { ok: false, error: `unrecognized repo spec: ${resolved.error}` };
551
+ const { url, subpath } = resolved;
552
+ const root = managedRoot(request.root, dshHome);
553
+ const repoName = url.replace(/\.git$/, '').split('/').pop() ?? 'skill';
554
+ // Clone into a scratch directory, never straight into `root`: a partial clone
555
+ // must not surface as a skill, and a repo named like an installed skill must
556
+ // not overwrite it on the way in. Naming the clone after the repo keeps the
557
+ // CLI's install name available to `--skill` matching.
558
+ const scratchRoot = await mkdtemp(join(tmpdir(), 'dsh-skill-clone-'));
559
+ const scratch = join(scratchRoot, repoName);
560
+ try {
561
+ try {
562
+ await execFileAsync('git', ['clone', '--depth', '1', url, scratch]);
563
+ }
564
+ catch (error) {
565
+ const text = String(error);
566
+ return { ok: false, error: `install failed: ${text}${cloneFailureHint(text)}` };
567
+ }
568
+ // A spec that named a path inside the repo searches only there: the rest of the
569
+ // repo is not a candidate, and a miss is a miss, not a silent whole-repo scan.
570
+ const searchRoot = subpath === '' ? scratch : join(scratch, subpath);
571
+ if (searchRoot !== scratch && !await pathExists(searchRoot)) {
572
+ return { ok: false, error: `no such path in the repo: ${subpath} — the clone succeeded, but it has nothing there` };
573
+ }
574
+ const skills = await collectSkills(searchRoot);
575
+ if (skills.length === 0) {
576
+ return { ok: false, error: subpath === '' ? 'cloned repo has no SKILL.md' : `cloned repo has no SKILL.md under ${subpath}` };
577
+ }
578
+ // An explicit selection (a chooser's `request.skills`) and `--skill` flags in
579
+ // a pasted command are the same intent; the caller wins when both are present.
580
+ const selections = request.skills !== undefined && request.skills.length > 0 ? request.skills : source.skills;
581
+ const chosen = selectSkills(skills, selections);
582
+ if (chosen === undefined) {
583
+ return { ok: false, error: `no matching skill for: ${selections.join(', ')} — available: ${skills.map(s => s.dirName).join(', ')}` };
584
+ }
585
+ if (chosen.length === 0) {
586
+ // Several skills and no selection: installing the shallowest one silently is
587
+ // how the wrong skill lands. Mirror the directory path and ask for a choice.
588
+ return { ok: false, error: `directory has ${skills.length} skills — pick one: ${skills.map(s => s.dirName).join(', ')}` };
589
+ }
590
+ const plans = chosen.map(skill => ({ skill, target: join(root, skill.name) }));
591
+ // Check every target before writing any, so a selection spanning a new and an
592
+ // existing skill never half-installs.
593
+ for (const { skill, target } of plans) {
594
+ if (!request.overwrite && await pathExists(target))
595
+ return { ok: false, exists: true, name: skill.name };
596
+ }
597
+ const installed = [];
598
+ for (const { skill, target } of plans) {
599
+ await rm(target, { recursive: true, force: true }).catch(() => { });
600
+ // Lift the bundle (which may be nested, e.g. <repo>/skills/<name>) to <root>/<name>/.
601
+ await cp(skill.dir, target, { recursive: true });
602
+ await writeFile(join(target, 'SKILL.md'), finalizeSkillText(skill.content, request.modelInvocable), 'utf8');
603
+ installed.push(skill.name);
604
+ }
605
+ return { ok: true, name: installed.join(', ') };
606
+ }
607
+ finally {
608
+ await rm(scratchRoot, { recursive: true, force: true }).catch(() => { });
609
+ }
610
+ }
611
+ /**
612
+ * List the skills inside a local dir for the add-skill chooser. If the dir is a
613
+ * **skill container** (a set of `<name>/SKILL.md` sub-dirs) the sub-skills are
614
+ * listed as `kind:'child'`; if the dir is **itself a single skill bundle** (a
615
+ * `SKILL.md` at its root) it is listed as one `kind:'self'` entry so the user can
616
+ * install the directory as-is. Both shapes are returned when a dir has a root
617
+ * `SKILL.md` AND child skill dirs.
618
+ * @param dirPath - the local directory path (may be `~`-prefixed).
619
+ */
620
+ export async function listDirSkills(dirPath) {
621
+ const expanded = expandHome(dirPath);
622
+ const out = [];
623
+ const selfContent = await readFile(join(expanded, 'SKILL.md'), 'utf8').catch(() => undefined);
624
+ if (selfContent !== undefined) {
625
+ const parsed = resolveSkillNameFromContent(selfContent);
626
+ if (parsed !== undefined)
627
+ out.push({ name: parsed.name, description: parsed.description, kind: 'self' });
628
+ }
629
+ for (const name of await listSkillDirs(expanded)) {
630
+ const content = await readFile(join(expanded, name, 'SKILL.md'), 'utf8').catch(() => '');
631
+ const parsed = resolveSkillNameFromContent(content);
632
+ out.push({ name, description: parsed?.description ?? '', kind: 'child' });
633
+ }
634
+ return out;
635
+ }