@dastyaresocial/ds-cs 0.0.0-stage → 0.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 +134 -2
- package/bin/ds-cs.mjs +14 -0
- package/package.json +33 -4
- package/skills/README.md +71 -0
- package/skills/dev/add-component/SKILL.md +166 -0
- package/skills/dev/add-orpc-endpoint/SKILL.md +143 -0
- package/skills/dev/db-migration/SKILL.md +104 -0
- package/skills/dev/release/SKILL.md +106 -0
- package/skills/product/brand-config/SKILL.md +98 -0
- package/skills/product/manage-stories/SKILL.md +119 -0
- package/skills/product/mcp-connect/SKILL.md +116 -0
- package/skills/product/post-with-media/SKILL.md +197 -0
- package/skills/product/self-host-deploy/SKILL.md +112 -0
- package/skills/product/webhooks/SKILL.md +160 -0
- package/src/agents.mjs +130 -0
- package/src/args.mjs +111 -0
- package/src/auth.mjs +82 -0
- package/src/commands/add.mjs +118 -0
- package/src/commands/auth.mjs +87 -0
- package/src/commands/init.mjs +106 -0
- package/src/commands/list.mjs +89 -0
- package/src/commands/mcp.mjs +101 -0
- package/src/commands/remove.mjs +80 -0
- package/src/commands/search.mjs +51 -0
- package/src/commands/target.mjs +83 -0
- package/src/commands/update.mjs +94 -0
- package/src/index.mjs +176 -0
- package/src/install.mjs +174 -0
- package/src/lockfile.mjs +74 -0
- package/src/mcp-config.mjs +39 -0
- package/src/registry/archive.mjs +209 -0
- package/src/registry/fetch.mjs +114 -0
- package/src/registry/sources.mjs +395 -0
- package/src/skills/discover.mjs +170 -0
- package/src/skills/frontmatter.mjs +85 -0
- package/src/utils/errors.mjs +13 -0
- package/src/utils/fs.mjs +106 -0
- package/src/utils/log.mjs +85 -0
- package/src/utils/prompt.mjs +69 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
|
|
9
|
+
import { CliError } from "../utils/errors.mjs";
|
|
10
|
+
import { isDirectory } from "../utils/fs.mjs";
|
|
11
|
+
import { extractBuffer, extractTarGz } from "./archive.mjs";
|
|
12
|
+
import { cachedBuffer, fetchBuffer, fetchJson, fetchText } from "./fetch.mjs";
|
|
13
|
+
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
|
|
16
|
+
/** The first-party registry. Skills ship from the DS-CS repo's `skills/` dir. */
|
|
17
|
+
export const DS_CS_REPO = "dastyare-social/DS-CS";
|
|
18
|
+
export const DS_CS_REF = "main";
|
|
19
|
+
export const DS_CS_SKILLS_DIR = "skills";
|
|
20
|
+
|
|
21
|
+
export const SOURCE_TYPES = {
|
|
22
|
+
DS_CS: "ds-cs",
|
|
23
|
+
GITHUB: "github",
|
|
24
|
+
GIT: "git",
|
|
25
|
+
URL: "url",
|
|
26
|
+
LOCAL: "local",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function hashKey(value) {
|
|
30
|
+
return crypto.createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Relative when that is readable, absolute when it would be a chain of `../`. */
|
|
34
|
+
function readable(target) {
|
|
35
|
+
const relative = path.relative(process.cwd(), target);
|
|
36
|
+
return relative === "" || relative.startsWith("..") ? target : relative;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function makeTempDir() {
|
|
40
|
+
return fs.mkdtemp(path.join(os.tmpdir(), "ds-cs-src-"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* -------------------------------------------------------------------------- */
|
|
44
|
+
/* First-party registry */
|
|
45
|
+
/* -------------------------------------------------------------------------- */
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where the registry can be found inside this package.
|
|
49
|
+
*
|
|
50
|
+
* Two layouts have to work. In a DS-CS checkout the CLI lives at
|
|
51
|
+
* `scripts/cli/src/registry/`, so the registry is the repo root's `skills/`.
|
|
52
|
+
* Published to npm, `sync-skills` copies the registry to `scripts/cli/skills/`
|
|
53
|
+
* so it travels inside the package. Both are checked against the package root,
|
|
54
|
+
* cheapest first.
|
|
55
|
+
*/
|
|
56
|
+
function bundledRegistryRoots() {
|
|
57
|
+
// `../..` from src/registry/ is the package root in both layouts.
|
|
58
|
+
const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
|
|
59
|
+
return [
|
|
60
|
+
path.join(packageRoot, "skills"), // published layout: <pkg>/skills
|
|
61
|
+
path.resolve(packageRoot, "..", "..", "skills"), // checkout: <repo>/skills
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Walk up from `cwd` looking for a DS-CS checkout that ships a `skills/` dir. */
|
|
66
|
+
async function findLocalCheckout(cwd) {
|
|
67
|
+
let directory = path.resolve(cwd);
|
|
68
|
+
for (let depth = 0; depth < 6; depth += 1) {
|
|
69
|
+
const skills = path.join(directory, DS_CS_SKILLS_DIR);
|
|
70
|
+
if (await isDirectory(skills)) return directory;
|
|
71
|
+
const parent = path.dirname(directory);
|
|
72
|
+
if (parent === directory) break;
|
|
73
|
+
directory = parent;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Fetch only the `skills/` subtree of the DS-CS repository.
|
|
80
|
+
*
|
|
81
|
+
* The full repo tarball is ~28MB — almost all of it `public/` and generated
|
|
82
|
+
* assets — to read a handful of markdown files. Listing the tree once and
|
|
83
|
+
* pulling just the registry's files keeps the fallback under a megabyte, and
|
|
84
|
+
* keeps it working under the archive size limit.
|
|
85
|
+
*/
|
|
86
|
+
async function fetchRegistrySubtree(ref) {
|
|
87
|
+
const listing = await fetchJson(
|
|
88
|
+
`https://api.github.com/repos/${DS_CS_REPO}/git/trees/${ref}?recursive=1`
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (!Array.isArray(listing.tree)) {
|
|
92
|
+
throw new CliError(`GitHub returned no tree for ${DS_CS_REPO}@${ref}`, {
|
|
93
|
+
hint: "The branch may not exist, or the API rate limit may be reached.",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const wanted = listing.tree.filter(
|
|
98
|
+
(node) =>
|
|
99
|
+
node.type === "blob" &&
|
|
100
|
+
node.path.startsWith(`${DS_CS_SKILLS_DIR}/`) &&
|
|
101
|
+
// Skip build artefacts that would otherwise ride along in the registry.
|
|
102
|
+
!node.path.endsWith("/.DS_Store") &&
|
|
103
|
+
!path.posix.basename(node.path).startsWith("._")
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
if (wanted.length === 0) {
|
|
107
|
+
throw new CliError(`No ${DS_CS_SKILLS_DIR}/ directory at ${DS_CS_REPO}@${ref}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const extraction = await makeTempDir();
|
|
111
|
+
for (const node of wanted) {
|
|
112
|
+
const target = path.join(extraction, node.path);
|
|
113
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
114
|
+
const body = await fetchText(
|
|
115
|
+
`https://raw.githubusercontent.com/${DS_CS_REPO}/${ref}/${node.path}`
|
|
116
|
+
);
|
|
117
|
+
await fs.writeFile(target, body);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { root: extraction, fileCount: wanted.length };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolve the DS-CS registry to a directory of skills.
|
|
125
|
+
*
|
|
126
|
+
* Order matters, and each step exists for a reason:
|
|
127
|
+
*
|
|
128
|
+
* 1. A local checkout — so editing a skill in the repo and reinstalling it
|
|
129
|
+
* works, instead of resolving against whatever `main` says.
|
|
130
|
+
* 2. The copy bundled in this package — works offline, matches the CLI
|
|
131
|
+
* version the user actually has, and needs no network at all.
|
|
132
|
+
* 3. GitHub — a last resort for a CLI older than the skills it wants.
|
|
133
|
+
*/
|
|
134
|
+
export async function resolveDsCs({ cwd, preferLocal = true }) {
|
|
135
|
+
if (preferLocal) {
|
|
136
|
+
const localRoot = await findLocalCheckout(cwd);
|
|
137
|
+
if (localRoot) {
|
|
138
|
+
return {
|
|
139
|
+
id: DS_CS_REPO,
|
|
140
|
+
type: SOURCE_TYPES.DS_CS,
|
|
141
|
+
root: localRoot,
|
|
142
|
+
origin: "local",
|
|
143
|
+
describe: () => `${DS_CS_REPO} (local checkout at ${readable(localRoot)})`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const bundled of bundledRegistryRoots()) {
|
|
149
|
+
if (await isDirectory(bundled)) {
|
|
150
|
+
return {
|
|
151
|
+
id: DS_CS_REPO,
|
|
152
|
+
type: SOURCE_TYPES.DS_CS,
|
|
153
|
+
root: path.resolve(bundled, ".."),
|
|
154
|
+
origin: "bundled",
|
|
155
|
+
describe: () => `${DS_CS_REPO} (bundled with this CLI)`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const { root, fileCount } = await fetchRegistrySubtree(DS_CS_REF);
|
|
161
|
+
return {
|
|
162
|
+
id: DS_CS_REPO,
|
|
163
|
+
type: SOURCE_TYPES.DS_CS,
|
|
164
|
+
root,
|
|
165
|
+
origin: "remote",
|
|
166
|
+
describe: () => `${DS_CS_REPO}@${DS_CS_REF} (${fileCount} files)`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* -------------------------------------------------------------------------- */
|
|
171
|
+
/* GitHub */
|
|
172
|
+
/* -------------------------------------------------------------------------- */
|
|
173
|
+
|
|
174
|
+
export function parseGithubShorthand(spec) {
|
|
175
|
+
const match = /^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/.exec(spec);
|
|
176
|
+
return match ? { owner: match[1], repo: match[2], subpath: null, ref: null } : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function parseGithubUrl(spec) {
|
|
180
|
+
let url;
|
|
181
|
+
try {
|
|
182
|
+
url = new URL(spec);
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
if (url.hostname !== "github.com" && url.hostname !== "www.github.com") return null;
|
|
187
|
+
|
|
188
|
+
const segments = url.pathname.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
|
|
189
|
+
if (segments.length < 2) return null;
|
|
190
|
+
|
|
191
|
+
const [owner, repo] = segments;
|
|
192
|
+
let ref = null;
|
|
193
|
+
let subpath = null;
|
|
194
|
+
|
|
195
|
+
if (segments[2] === "tree" && segments[3]) {
|
|
196
|
+
ref = segments[3];
|
|
197
|
+
subpath = segments.slice(4).join("/") || null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { owner, repo, subpath, ref };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Fetch a GitHub repository as a tarball.
|
|
205
|
+
*
|
|
206
|
+
* Deliberately not `git clone`: a tarball is one HTTP request, needs no git
|
|
207
|
+
* binary, and cannot run a repository's hooks. Branches and tags both resolve
|
|
208
|
+
* through `refs/heads` / `refs/tags`.
|
|
209
|
+
*/
|
|
210
|
+
export async function resolveGithub({ owner, repo, ref, subpath }, spec) {
|
|
211
|
+
const resolvedRef = ref ?? "main";
|
|
212
|
+
const headRef = resolvedRef.includes("/") ? `heads/${resolvedRef}` : `heads/${resolvedRef}`;
|
|
213
|
+
const url = `https://codeload.github.com/${owner}/${repo}/tar.gz/refs/${headRef}`;
|
|
214
|
+
|
|
215
|
+
let buffer;
|
|
216
|
+
try {
|
|
217
|
+
buffer = await cachedBuffer(url, hashKey(url));
|
|
218
|
+
} catch (error) {
|
|
219
|
+
throw new CliError(`Could not fetch ${owner}/${repo}@${resolvedRef} from GitHub`, {
|
|
220
|
+
hint: "Check the branch exists, or set GITHUB_TOKEN for a private repository.",
|
|
221
|
+
cause: error,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const extraction = await makeTempDir();
|
|
226
|
+
await extractTarGz(buffer, extraction, { strip: 1 });
|
|
227
|
+
|
|
228
|
+
const root = subpath ? path.join(extraction, subpath) : extraction;
|
|
229
|
+
if (!(await isDirectory(root))) {
|
|
230
|
+
throw new CliError(`${owner}/${repo} has no "${subpath}" directory`, {
|
|
231
|
+
hint: "Check the path in the /tree/<ref>/<path> URL.",
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
id: spec ?? `${owner}/${repo}`,
|
|
237
|
+
type: SOURCE_TYPES.GITHUB,
|
|
238
|
+
root,
|
|
239
|
+
origin: "remote",
|
|
240
|
+
describe: () => `${owner}/${repo}@${resolvedRef}${subpath ? `/${subpath}` : ""}`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* -------------------------------------------------------------------------- */
|
|
245
|
+
/* Git */
|
|
246
|
+
/* -------------------------------------------------------------------------- */
|
|
247
|
+
|
|
248
|
+
export function isGitUrl(spec) {
|
|
249
|
+
return /^(git@|git:\/\/|ssh:\/\/|git\+)/.test(spec) || /^https?:\/\//.test(spec);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Clone is only reached for non-GitHub git hosts, where no tarball API exists. */
|
|
253
|
+
export async function resolveGit(spec) {
|
|
254
|
+
const staging = await makeTempDir();
|
|
255
|
+
try {
|
|
256
|
+
await execFileAsync("git", [
|
|
257
|
+
"clone",
|
|
258
|
+
"--depth",
|
|
259
|
+
"1",
|
|
260
|
+
"--quiet",
|
|
261
|
+
spec,
|
|
262
|
+
path.join(staging, "repo"),
|
|
263
|
+
]);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
throw new CliError(`git clone failed for ${spec}`, {
|
|
266
|
+
hint: "Git must be installed, and credentials configured for private remotes.",
|
|
267
|
+
cause: error,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
id: spec,
|
|
272
|
+
type: SOURCE_TYPES.GIT,
|
|
273
|
+
root: path.join(staging, "repo"),
|
|
274
|
+
origin: "remote",
|
|
275
|
+
describe: () => spec,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/* -------------------------------------------------------------------------- */
|
|
280
|
+
/* Direct URLs */
|
|
281
|
+
/* -------------------------------------------------------------------------- */
|
|
282
|
+
|
|
283
|
+
const ARCHIVE_EXTENSIONS = [".zip", ".tar", ".tar.gz", ".tgz"];
|
|
284
|
+
|
|
285
|
+
function looksLikeArchive(url) {
|
|
286
|
+
const pathName = new URL(url).pathname.toLowerCase();
|
|
287
|
+
return ARCHIVE_EXTENSIONS.some((extension) => pathName.endsWith(extension));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A URL is either a single SKILL.md or an archive of a skill tree. The body is
|
|
292
|
+
* sniffed rather than trusted: a `.md` URL that returns HTML is an error page,
|
|
293
|
+
* and treating it as a skill would install a file with no frontmatter.
|
|
294
|
+
*/
|
|
295
|
+
export async function resolveUrl(spec) {
|
|
296
|
+
const buffer = await fetchBuffer(spec);
|
|
297
|
+
const isArchive = looksLikeArchive(spec);
|
|
298
|
+
const isBinary =
|
|
299
|
+
buffer.includes(0x50) && buffer.includes(0x4b) && buffer[0] === 0x50 && buffer[1] === 0x4b;
|
|
300
|
+
|
|
301
|
+
if (isArchive || isBinary) {
|
|
302
|
+
const staging = await makeTempDir();
|
|
303
|
+
const filename = new URL(spec).pathname.split("/").pop() || "archive.zip";
|
|
304
|
+
await extractBuffer(buffer, filename, staging, { strip: 1 });
|
|
305
|
+
return {
|
|
306
|
+
id: spec,
|
|
307
|
+
type: SOURCE_TYPES.URL,
|
|
308
|
+
root: staging,
|
|
309
|
+
origin: "remote",
|
|
310
|
+
describe: () => spec,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const body = buffer.toString("utf8");
|
|
315
|
+
if (!/^---\r?\n/.test(body)) {
|
|
316
|
+
throw new CliError(`${spec} does not look like a SKILL.md or a skills archive`, {
|
|
317
|
+
hint: "The URL must serve a SKILL.md, or a .zip / .tar.gz archive of one.",
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const skillDir = await makeTempDir();
|
|
322
|
+
await fs.mkdir(skillDir, { recursive: true });
|
|
323
|
+
await fs.writeFile(path.join(skillDir, "SKILL.md"), body);
|
|
324
|
+
return {
|
|
325
|
+
id: spec,
|
|
326
|
+
type: SOURCE_TYPES.URL,
|
|
327
|
+
root: skillDir,
|
|
328
|
+
origin: "remote",
|
|
329
|
+
describe: () => spec,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/* -------------------------------------------------------------------------- */
|
|
334
|
+
/* Local */
|
|
335
|
+
/* -------------------------------------------------------------------------- */
|
|
336
|
+
|
|
337
|
+
export async function resolveLocal(spec) {
|
|
338
|
+
const expanded = spec.startsWith("~") ? path.join(os.homedir(), spec.slice(1)) : spec;
|
|
339
|
+
const root = path.resolve(process.cwd(), expanded);
|
|
340
|
+
if (!(await isDirectory(root))) {
|
|
341
|
+
throw new CliError(`Local skill path does not exist: ${root}`);
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
id: spec,
|
|
345
|
+
type: SOURCE_TYPES.LOCAL,
|
|
346
|
+
root,
|
|
347
|
+
origin: "local",
|
|
348
|
+
describe: () => readable(root),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/* -------------------------------------------------------------------------- */
|
|
353
|
+
/* Dispatch */
|
|
354
|
+
/* -------------------------------------------------------------------------- */
|
|
355
|
+
|
|
356
|
+
export function isLocalPath(spec) {
|
|
357
|
+
return (
|
|
358
|
+
spec.startsWith(".") ||
|
|
359
|
+
spec.startsWith("/") ||
|
|
360
|
+
spec.startsWith("~") ||
|
|
361
|
+
spec.startsWith("file://")
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Turn a source spec into a materialised directory of skills.
|
|
367
|
+
*
|
|
368
|
+
* An empty spec means the DS-CS registry, which is what makes the common case
|
|
369
|
+
* (`ds-cs add brand-config`) work with no ceremony.
|
|
370
|
+
*/
|
|
371
|
+
export async function resolveSource(spec, { cwd = process.cwd(), preferLocal = true } = {}) {
|
|
372
|
+
const trimmed = (spec ?? "").trim();
|
|
373
|
+
|
|
374
|
+
if (trimmed === "" || trimmed === "ds-cs" || trimmed === "@ds-cs") {
|
|
375
|
+
return resolveDsCs({ cwd, preferLocal });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (isLocalPath(trimmed)) {
|
|
379
|
+
return resolveLocal(trimmed.replace(/^file:\/\//, ""));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const github = trimmed.startsWith("http")
|
|
383
|
+
? parseGithubUrl(trimmed)
|
|
384
|
+
: parseGithubShorthand(trimmed);
|
|
385
|
+
|
|
386
|
+
if (github) {
|
|
387
|
+
return resolveGithub(github, trimmed.startsWith("http") ? trimmed : trimmed);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (isGitUrl(trimmed)) {
|
|
391
|
+
return resolveGit(trimmed);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return resolveUrl(trimmed);
|
|
395
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { CliError } from "../utils/errors.mjs";
|
|
5
|
+
import { hashDirectory, isDirectory, isFile } from "../utils/fs.mjs";
|
|
6
|
+
import { readSkillManifest } from "./frontmatter.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Directories that hold skills in a source tree. A `skills/` at the repo root is
|
|
10
|
+
* the canonical first-party layout for DS-CS; the rest are recognised so a
|
|
11
|
+
* third-party repo can be consumed by the same command.
|
|
12
|
+
*/
|
|
13
|
+
export const CONTAINER_DIRECTORIES = [
|
|
14
|
+
"skills",
|
|
15
|
+
"skills/.curated",
|
|
16
|
+
"skills/.experimental",
|
|
17
|
+
"skills/.system",
|
|
18
|
+
".agents/skills",
|
|
19
|
+
".claude/skills",
|
|
20
|
+
"agent/skills",
|
|
21
|
+
"data/skills",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/** How deep to walk inside a container. 3 covers skills/<name>/SKILL.md plus two category levels. */
|
|
25
|
+
const MAX_DEPTH = 3;
|
|
26
|
+
|
|
27
|
+
async function collectSkillFiles(dir, depth, out) {
|
|
28
|
+
if (depth > MAX_DEPTH) return out;
|
|
29
|
+
|
|
30
|
+
let entries;
|
|
31
|
+
try {
|
|
32
|
+
entries = await import("node:fs/promises").then((fs) =>
|
|
33
|
+
fs.readdir(dir, { withFileTypes: true })
|
|
34
|
+
);
|
|
35
|
+
} catch {
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const hasSkillFile = entries.some(
|
|
40
|
+
(entry) => entry.isFile() && entry.name === "SKILL.md"
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (hasSkillFile) {
|
|
44
|
+
out.push(path.join(dir, "SKILL.md"));
|
|
45
|
+
// A skill directory is a unit; never descend into it looking for nested
|
|
46
|
+
// skills (reference docs and scripts live there).
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
51
|
+
if (!entry.isDirectory()) continue;
|
|
52
|
+
if (entry.name.startsWith(".") && entry.name !== ".curated" && entry.name !== ".system") {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
await collectSkillFiles(path.join(dir, entry.name), depth + 1, out);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Find every skill in a directory tree.
|
|
63
|
+
*
|
|
64
|
+
* Returns descriptors carrying the skill's directory *and* the container-relative
|
|
65
|
+
* path (`skillPath` in the lockfile), because `ds-cs update` needs the latter to
|
|
66
|
+
* re-fetch a single skill from a source it has never fully cloned.
|
|
67
|
+
*/
|
|
68
|
+
export async function discoverSkills(root) {
|
|
69
|
+
const skills = [];
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
|
|
72
|
+
// A repository whose root *is* the skill is valid — register it before the
|
|
73
|
+
// containers so a root skill is never missed.
|
|
74
|
+
const rootSkillFile = path.join(root, "SKILL.md");
|
|
75
|
+
if (await isFile(rootSkillFile)) {
|
|
76
|
+
seen.add(rootSkillFile);
|
|
77
|
+
const manifest = await readSkillManifest(rootSkillFile);
|
|
78
|
+
skills.push({
|
|
79
|
+
name: manifest.name,
|
|
80
|
+
description: manifest.description,
|
|
81
|
+
internal: manifest.internal,
|
|
82
|
+
dir: root,
|
|
83
|
+
skillFile: rootSkillFile,
|
|
84
|
+
skillPath: "SKILL.md",
|
|
85
|
+
hash: await hashDirectory(root),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const container of CONTAINER_DIRECTORIES) {
|
|
90
|
+
const containerDir = path.join(root, container);
|
|
91
|
+
if (!(await isDirectory(containerDir))) continue;
|
|
92
|
+
|
|
93
|
+
const files = await collectSkillFiles(containerDir, 1, []);
|
|
94
|
+
for (const file of files) {
|
|
95
|
+
if (seen.has(file)) continue;
|
|
96
|
+
seen.add(file);
|
|
97
|
+
|
|
98
|
+
const dir = path.dirname(file);
|
|
99
|
+
const manifest = await readSkillManifest(file);
|
|
100
|
+
skills.push({
|
|
101
|
+
name: manifest.name,
|
|
102
|
+
description: manifest.description,
|
|
103
|
+
internal: manifest.internal,
|
|
104
|
+
dir,
|
|
105
|
+
skillFile: file,
|
|
106
|
+
skillPath: path.relative(root, file).split(path.sep).join("/"),
|
|
107
|
+
hash: await hashDirectory(dir),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function assertSkillExists(skills, name) {
|
|
116
|
+
const found = skills.find((skill) => skill.name === name);
|
|
117
|
+
if (found) return found;
|
|
118
|
+
|
|
119
|
+
const available = skills.filter((skill) => !skill.internal).map((skill) => skill.name);
|
|
120
|
+
throw new CliError(`No skill named "${name}" in that source`, {
|
|
121
|
+
hint:
|
|
122
|
+
available.length > 0
|
|
123
|
+
? `Available: ${available.join(", ")}`
|
|
124
|
+
: "That source contains no skills.",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* List skills already installed into an agent directory.
|
|
130
|
+
*
|
|
131
|
+
* `discoverSkills` is for *source* trees, where skills sit inside a `skills/`
|
|
132
|
+
* container. An installed directory is already that container, so its skills are
|
|
133
|
+
* one level down — running discovery against it would search for containers
|
|
134
|
+
* inside itself and find nothing.
|
|
135
|
+
*/
|
|
136
|
+
export async function listInstalledSkills(skillsDirectory) {
|
|
137
|
+
if (!(await isDirectory(skillsDirectory))) return [];
|
|
138
|
+
|
|
139
|
+
const entries = await fs.readdir(skillsDirectory, { withFileTypes: true });
|
|
140
|
+
const skills = [];
|
|
141
|
+
|
|
142
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
143
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
144
|
+
|
|
145
|
+
const dir = path.join(skillsDirectory, entry.name);
|
|
146
|
+
const skillFile = path.join(dir, "SKILL.md");
|
|
147
|
+
if (!(await isFile(skillFile))) continue;
|
|
148
|
+
|
|
149
|
+
let manifest;
|
|
150
|
+
try {
|
|
151
|
+
manifest = await readSkillManifest(skillFile);
|
|
152
|
+
} catch {
|
|
153
|
+
// A directory that is not a skill (a stray file, an unrelated folder) is
|
|
154
|
+
// not an error — it just is not a skill.
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
skills.push({
|
|
159
|
+
name: manifest.name,
|
|
160
|
+
description: manifest.description,
|
|
161
|
+
internal: manifest.internal,
|
|
162
|
+
dir,
|
|
163
|
+
skillFile,
|
|
164
|
+
skillPath: `${entry.name}/SKILL.md`,
|
|
165
|
+
hash: await hashDirectory(dir),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return skills;
|
|
170
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the YAML frontmatter of a SKILL.md.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not a general YAML parser: the Agent Skills spec only needs
|
|
5
|
+
* `name`, `description` and `metadata.internal` out of the header, and pulling a
|
|
6
|
+
* YAML dependency into a zero-dependency CLI for three scalar keys is not a
|
|
7
|
+
* trade worth making. Anything more complex is ignored rather than misread.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function parseScalar(raw) {
|
|
11
|
+
const value = raw.trim();
|
|
12
|
+
if (
|
|
13
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
14
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
15
|
+
) {
|
|
16
|
+
return value.slice(1, -1);
|
|
17
|
+
}
|
|
18
|
+
if (value === "true") return true;
|
|
19
|
+
if (value === "false") return false;
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseFrontmatter(source) {
|
|
24
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(source);
|
|
25
|
+
if (!match) return {};
|
|
26
|
+
|
|
27
|
+
const result = {};
|
|
28
|
+
let currentKey = null;
|
|
29
|
+
|
|
30
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
31
|
+
if (line.trim() === "" || line.trim().startsWith("#")) continue;
|
|
32
|
+
|
|
33
|
+
// A nested key (`metadata:` block) only records the key; its indented
|
|
34
|
+
// children are read on the following iterations.
|
|
35
|
+
const indented = /^\s+/.test(line);
|
|
36
|
+
const separator = line.indexOf(":");
|
|
37
|
+
|
|
38
|
+
if (separator === -1) continue;
|
|
39
|
+
const key = line.slice(0, separator).trim();
|
|
40
|
+
const rest = line.slice(separator + 1);
|
|
41
|
+
|
|
42
|
+
if (indented && currentKey === "metadata") {
|
|
43
|
+
if (key === "internal") result.internal = parseScalar(rest);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (rest.trim() === "") {
|
|
48
|
+
currentKey = key;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
currentKey = null;
|
|
53
|
+
result[key] = parseScalar(rest);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function hasFrontmatter(source) {
|
|
60
|
+
return /^---\r?\n/.test(source);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read a skill's identity. Throws with an actionable message when the header is
|
|
65
|
+
* missing, because a nameless skill is unaddressable — `ds-cs remove <name>`
|
|
66
|
+
* could never find it again.
|
|
67
|
+
*/
|
|
68
|
+
export async function readSkillManifest(skillFile) {
|
|
69
|
+
const fs = await import("node:fs/promises");
|
|
70
|
+
const source = await fs.readFile(skillFile, "utf8");
|
|
71
|
+
const frontmatter = parseFrontmatter(source);
|
|
72
|
+
|
|
73
|
+
if (!frontmatter.name || !frontmatter.description) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`${skillFile} is missing required frontmatter ("name" and "description" are both required by the Agent Skills spec)`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
name: String(frontmatter.name).trim(),
|
|
81
|
+
description: String(frontmatter.description).trim(),
|
|
82
|
+
internal: frontmatter.internal === true || frontmatter.internal === "true",
|
|
83
|
+
body: source,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An error the CLI knows how to explain to a human. `hint` is printed under
|
|
3
|
+
* the message; anything without a hint is probably a bug.
|
|
4
|
+
*/
|
|
5
|
+
export class CliError extends Error {
|
|
6
|
+
constructor(message, options = {}) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "CliError";
|
|
9
|
+
this.hint = options.hint;
|
|
10
|
+
this.exitCode = options.exitCode ?? 1;
|
|
11
|
+
if (options.cause) this.cause = options.cause;
|
|
12
|
+
}
|
|
13
|
+
}
|