@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.
Files changed (39) hide show
  1. package/README.md +134 -2
  2. package/bin/ds-cs.mjs +14 -0
  3. package/package.json +33 -4
  4. package/skills/README.md +71 -0
  5. package/skills/dev/add-component/SKILL.md +166 -0
  6. package/skills/dev/add-orpc-endpoint/SKILL.md +143 -0
  7. package/skills/dev/db-migration/SKILL.md +104 -0
  8. package/skills/dev/release/SKILL.md +106 -0
  9. package/skills/product/brand-config/SKILL.md +98 -0
  10. package/skills/product/manage-stories/SKILL.md +119 -0
  11. package/skills/product/mcp-connect/SKILL.md +116 -0
  12. package/skills/product/post-with-media/SKILL.md +197 -0
  13. package/skills/product/self-host-deploy/SKILL.md +112 -0
  14. package/skills/product/webhooks/SKILL.md +160 -0
  15. package/src/agents.mjs +130 -0
  16. package/src/args.mjs +111 -0
  17. package/src/auth.mjs +82 -0
  18. package/src/commands/add.mjs +118 -0
  19. package/src/commands/auth.mjs +87 -0
  20. package/src/commands/init.mjs +106 -0
  21. package/src/commands/list.mjs +89 -0
  22. package/src/commands/mcp.mjs +101 -0
  23. package/src/commands/remove.mjs +80 -0
  24. package/src/commands/search.mjs +51 -0
  25. package/src/commands/target.mjs +83 -0
  26. package/src/commands/update.mjs +94 -0
  27. package/src/index.mjs +176 -0
  28. package/src/install.mjs +174 -0
  29. package/src/lockfile.mjs +74 -0
  30. package/src/mcp-config.mjs +39 -0
  31. package/src/registry/archive.mjs +209 -0
  32. package/src/registry/fetch.mjs +114 -0
  33. package/src/registry/sources.mjs +395 -0
  34. package/src/skills/discover.mjs +170 -0
  35. package/src/skills/frontmatter.mjs +85 -0
  36. package/src/utils/errors.mjs +13 -0
  37. package/src/utils/fs.mjs +106 -0
  38. package/src/utils/log.mjs +85 -0
  39. package/src/utils/prompt.mjs +69 -0
@@ -0,0 +1,118 @@
1
+ import path from "node:path";
2
+
3
+ import {
4
+ CANONICAL_AGENT,
5
+ dedupeDirectories,
6
+ detectAgents,
7
+ getAgent,
8
+ skillsDirFor,
9
+ } from "../agents.mjs";
10
+ import { installSkills } from "../install.mjs";
11
+ import { lockfilePath } from "../lockfile.mjs";
12
+ import { discoverSkills } from "../skills/discover.mjs";
13
+ import { toArray } from "../args.mjs";
14
+ import { CliError } from "../utils/errors.mjs";
15
+ import { cyan, log, table } from "../utils/log.mjs";
16
+ import { resolveRequest } from "./target.mjs";
17
+
18
+ export async function addCommand(positional, flags, { projectRoot }) {
19
+ const { source, names } = resolveRequest(positional, flags);
20
+
21
+ if (flags.list) {
22
+ await listSource(source, projectRoot);
23
+ return;
24
+ }
25
+
26
+ const global = Boolean(flags.global);
27
+ const targets = resolveTargets(flags, { projectRoot, global });
28
+
29
+ const { installed, resolved } = await installSkills({
30
+ source,
31
+ names,
32
+ projectRoot,
33
+ targets,
34
+ global,
35
+ copy: Boolean(flags.copy),
36
+ yes: Boolean(flags.yes),
37
+ });
38
+
39
+ log.success(`Installed ${installed.length} skill${installed.length === 1 ? "" : "s"} from ${resolved.describe()}`);
40
+
41
+ for (const entry of installed) {
42
+ log.plain(` ${cyan(entry.skill.name)} ${entry.skill.description}`);
43
+ log.plain(` ${display(entry.destination)}`);
44
+ for (const link of entry.links) {
45
+ log.plain(` → ${display(link.target)} (${link.linked ? "linked" : "copied — symlinks unavailable"})`);
46
+ }
47
+ }
48
+
49
+ log.plain(`\n Recorded in ${display(lockfilePath(projectRoot))}`);
50
+ }
51
+
52
+ /**
53
+ * Show paths relative to the current directory, but fall back to absolute when
54
+ * the relative form would be a chain of `../` — nobody can place that.
55
+ */
56
+ function display(target) {
57
+ const relative = path.relative(process.cwd(), target);
58
+ return relative === "" || relative.startsWith("..") ? target : relative;
59
+ }
60
+
61
+ async function listSource(sourceSpec, projectRoot) {
62
+ const { resolveSource } = await import("../registry/sources.mjs");
63
+ const resolved = await resolveSource(sourceSpec, { cwd: projectRoot });
64
+ const skills = (await discoverSkills(resolved.root)).filter(
65
+ (skill) => process.env.INSTALL_INTERNAL_SKILLS === "1" || !skill.internal
66
+ );
67
+
68
+ if (skills.length === 0) {
69
+ log.warn(`No skills found in ${resolved.describe()}`);
70
+ return;
71
+ }
72
+
73
+ log.info(`Skills in ${resolved.describe()}:\n`);
74
+ table(
75
+ skills.map((skill) => ({
76
+ name: skill.name,
77
+ path: skill.skillPath.replace(/SKILL\.md$/, ""),
78
+ description: skill.description,
79
+ })),
80
+ [
81
+ { key: "name", title: "NAME" },
82
+ { key: "path", title: "PATH" },
83
+ { key: "description", title: "DESCRIPTION", flex: true },
84
+ ]
85
+ );
86
+ }
87
+
88
+ function resolveTargets(flags, { projectRoot, global }) {
89
+ const requested = toArray(flags.agent);
90
+ if (requested.includes("*")) return [canonicalDir(projectRoot, global)];
91
+
92
+ if (requested.length === 0) {
93
+ return detectAgents({ projectRoot, global }).then((agents) =>
94
+ dedupeDirectories(
95
+ agents.map((agent) => skillsDirFor(agent, { projectRoot, global }))
96
+ )
97
+ );
98
+ }
99
+
100
+ return requested.map((name) => {
101
+ try {
102
+ return skillsDirFor(getAgent(name).name, { projectRoot, global });
103
+ } catch {
104
+ // `--agent` accepts a literal directory too, so an agent this CLI does not
105
+ // know about can still be targeted without waiting to be added here.
106
+ if (name.startsWith(".") || path.isAbsolute(name)) {
107
+ return path.isAbsolute(name) ? name : path.join(projectRoot, name);
108
+ }
109
+ throw new CliError(`Unknown agent "${name}"`, {
110
+ hint: "Run `ds-cs init --list-agents`, or pass a directory starting with \".\" or \"/\".",
111
+ });
112
+ }
113
+ });
114
+ }
115
+
116
+ function canonicalDir(projectRoot, global) {
117
+ return skillsDirFor(CANONICAL_AGENT, { projectRoot, global });
118
+ }
@@ -0,0 +1,87 @@
1
+ import { clearAuth, configPath, readAuth, redact, writeAuth } from "../auth.mjs";
2
+ import { CliError } from "../utils/errors.mjs";
3
+ import { log } from "../utils/log.mjs";
4
+ import { promptText } from "../utils/prompt.mjs";
5
+
6
+ /**
7
+ * Store channel credentials and prove they work before saving them.
8
+ *
9
+ * The verification matters: an API key typed from memory is often wrong, and
10
+ * discovering that at the first `create_post` — inside an agent, mid-task — is a
11
+ * much worse place to find out than at login.
12
+ */
13
+ export async function loginCommand(positional, flags) {
14
+ const baseUrl =
15
+ flags.url ??
16
+ positional[0] ??
17
+ process.env.DS_CS_URL ??
18
+ (await readAuth()).baseUrl ??
19
+ (await promptText("Channel URL:", "https://cs.dastyare.social"));
20
+
21
+ if (!baseUrl) {
22
+ throw new CliError("A channel URL is required", { hint: "Pass --url https://your-channel.com" });
23
+ }
24
+
25
+ const normalized = baseUrl.replace(/\/+$/, "");
26
+ const apiKey = flags.apiKey ?? (await promptText("API key (blank for read-only):", ""));
27
+
28
+ await verify(normalized, apiKey);
29
+
30
+ const file = await writeAuth({ baseUrl: normalized, apiKey: apiKey || null });
31
+
32
+ log.success(`Logged in to ${normalized}`);
33
+ if (apiKey) {
34
+ log.info(` key ${redact(apiKey)}`);
35
+ } else {
36
+ log.warn(" no API key stored — read tools only");
37
+ }
38
+ log.info(` saved ${file} (mode 0600)`);
39
+ }
40
+
41
+ async function verify(baseUrl, apiKey) {
42
+ const headers = apiKey ? { authorization: `Bearer ${apiKey}` } : {};
43
+ let response;
44
+ try {
45
+ response = await fetch(`${baseUrl}/api/posts?type=count`, { headers });
46
+ } catch (error) {
47
+ throw new CliError(`Could not reach ${baseUrl}`, {
48
+ hint: "Check the URL and that the channel is running.",
49
+ cause: error,
50
+ });
51
+ }
52
+
53
+ if (response.status === 401 || response.status === 403) {
54
+ throw new CliError(`${baseUrl} rejected the API key`, {
55
+ hint: "The key is the API_KEY from your channel's .env. Read access needs no key.",
56
+ });
57
+ }
58
+ if (!response.ok) {
59
+ throw new CliError(`${baseUrl} returned ${response.status} ${response.statusText}`);
60
+ }
61
+
62
+ const body = await response.json().catch(() => null);
63
+ log.info(` connected — ${body?.total ?? "?"} posts visible`);
64
+ }
65
+
66
+ export async function whoamiCommand() {
67
+ const stored = await readAuth();
68
+
69
+ if (!stored.baseUrl) {
70
+ log.info("Not logged in. Run `ds-cs login --url https://your-channel.com`.");
71
+ log.info(` (credentials would live in ${configPath()})`);
72
+ return;
73
+ }
74
+
75
+ log.info(`Channel ${stored.baseUrl}`);
76
+ log.info(`API key ${stored.apiKey ? redact(stored.apiKey) : "none (read-only)"}`);
77
+ if (stored.loggedInAt) log.info(`Since ${stored.loggedInAt}`);
78
+ }
79
+
80
+ export async function logoutCommand() {
81
+ const removed = await clearAuth();
82
+ if (removed) {
83
+ log.success(`Removed ${configPath()}`);
84
+ } else {
85
+ log.info("No stored credentials to remove.");
86
+ }
87
+ }
@@ -0,0 +1,106 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { CANONICAL_AGENT, detectAgents, skillsDirFor } from "../agents.mjs";
5
+ import { toArray } from "../args.mjs";
6
+ import { MCP_TARGETS, buildMcpConfig } from "../mcp-config.mjs";
7
+ import { exists, ensureDir } from "../utils/fs.mjs";
8
+ import { cyan, log } from "../utils/log.mjs";
9
+ import { printAgents } from "./list.mjs";
10
+
11
+ const GITIGNORE_BLOCK = `# ds-cs
12
+ # Skills installed by \`ds-cs add\` are meant to be committed: they are project
13
+ # context, shared with the team, the same way source is. Add .agents/ here only
14
+ # if you want every agent's skills to stay local to one machine.
15
+ `;
16
+
17
+ /**
18
+ * Prepare a project for DS-CS skills.
19
+ *
20
+ * `init` deliberately installs nothing by itself. It creates the directories and
21
+ * optionally the MCP wiring, then prints what to run next — a new user who has
22
+ * not asked for a specific skill should not silently end up with forty of them
23
+ * in their repository.
24
+ */
25
+ export async function initCommand(positional, flags, { projectRoot }) {
26
+ if (flags.listAgents) {
27
+ printAgents();
28
+ return;
29
+ }
30
+
31
+ const global = Boolean(flags.global);
32
+ const detected = await detectAgents({ projectRoot, global });
33
+ const requested = toArray(flags.agent);
34
+ const agents = requested.length > 0 ? requested : detected;
35
+
36
+ for (const agent of agents) {
37
+ const directory = skillsDirFor(agent, { projectRoot, global });
38
+ await ensureDir(directory);
39
+ }
40
+
41
+ // Many agents share one directory (OpenCode, Cursor and Codex all read
42
+ // `.agents/skills`), so report each real directory once.
43
+ for (const directory of new Set(agents.map((agent) => skillsDirFor(agent, { projectRoot, global })))) {
44
+ log.success(`Skills directory ready: ${display(directory)}`);
45
+ }
46
+
47
+ if (!global) {
48
+ await explainGitignore(projectRoot);
49
+ }
50
+
51
+ if (flags.mcp) {
52
+ await writeMcpConfigs(projectRoot, flags);
53
+ }
54
+
55
+ log.plain("");
56
+ log.plain(`Next: ${cyan("ds-cs add")} to install skills.`);
57
+ log.plain(` ${cyan("ds-cs add")} browse the DS-CS registry`);
58
+ log.plain(` ${cyan("ds-cs add brand-config")} install one skill by name`);
59
+ log.plain(` ${cyan("ds-cs add owner/repo@skill")} install from any GitHub skills repo`);
60
+ }
61
+
62
+ function display(target) {
63
+ return path.relative(process.cwd(), target) || ".";
64
+ }
65
+
66
+ /**
67
+ * ds-cs skills are meant to be committed, so this writes no ignore rule by
68
+ * default — it only leaves a comment explaining the choice, and warns if the
69
+ * project already excludes them.
70
+ */
71
+ async function explainGitignore(projectRoot) {
72
+ const file = path.join(projectRoot, ".gitignore");
73
+
74
+ if (!(await exists(file))) {
75
+ await fs.writeFile(file, GITIGNORE_BLOCK, "utf8");
76
+ log.success("Created .gitignore explaining that ds-cs skills are meant to be committed");
77
+ return;
78
+ }
79
+
80
+ const existing = await fs.readFile(file, "utf8");
81
+ if (/^\/?\.agents\/?\s*$/m.test(existing)) {
82
+ log.warn(".gitignore excludes .agents — skills will not be shared with your team");
83
+ }
84
+ }
85
+
86
+ async function writeMcpConfigs(projectRoot, flags) {
87
+ const url = flags.url ?? "https://cs.dastyare.social";
88
+
89
+ for (const target of MCP_TARGETS) {
90
+ const file = path.join(projectRoot, target.file);
91
+ if (await exists(file)) {
92
+ log.warn(`Skipped ${display(file)} — it already exists`);
93
+ continue;
94
+ }
95
+
96
+ await fs.mkdir(path.dirname(file), { recursive: true });
97
+ await fs.writeFile(
98
+ file,
99
+ `${JSON.stringify(buildMcpConfig(target.agent, url, flags.apiKey), null, 2)}\n`,
100
+ "utf8"
101
+ );
102
+ log.success(`MCP server "ds-cs" written to ${display(file)}`);
103
+ }
104
+ }
105
+
106
+ export { CANONICAL_AGENT };
@@ -0,0 +1,89 @@
1
+ import path from "node:path";
2
+
3
+ import { AGENTS, CANONICAL_AGENT, skillsDirFor } from "../agents.mjs";
4
+ import { readLockfile } from "../lockfile.mjs";
5
+ import { readSkillManifest } from "../skills/frontmatter.mjs";
6
+ import { isFile } from "../utils/fs.mjs";
7
+ import { dim, log, table } from "../utils/log.mjs";
8
+
9
+ /**
10
+ * Report what is installed.
11
+ *
12
+ * The lockfile is the source of truth for *what was asked for*; the filesystem is
13
+ * checked to show what actually survived. Those two can disagree — someone can
14
+ * delete a skill directory by hand — and an inventory that hides that is worse
15
+ * than useless.
16
+ */
17
+ export async function listCommand(positional, flags, { projectRoot }) {
18
+ const global = Boolean(flags.global);
19
+ const lockfile = await readLockfile(projectRoot);
20
+ const names = Object.keys(lockfile.skills).sort();
21
+
22
+ const filter = positional[0];
23
+
24
+ if (names.length === 0) {
25
+ log.info(
26
+ global
27
+ ? "No skills installed globally."
28
+ : `No skills installed in this project. Try \`ds-cs add\` or run \`ds-cs init\`.`
29
+ );
30
+ return;
31
+ }
32
+
33
+ const rows = [];
34
+ for (const name of names) {
35
+ if (filter && !name.includes(filter)) continue;
36
+ const entry = lockfile.skills[name];
37
+ const installed = await readInstalledSkill(projectRoot, name, global);
38
+
39
+ rows.push({
40
+ name,
41
+ source: entry.source,
42
+ path: entry.skillPath,
43
+ status: installed ? "installed" : "MISSING",
44
+ description: installed?.description ?? "",
45
+ });
46
+ }
47
+
48
+ if (rows.length === 0) {
49
+ log.info(`No installed skill matches "${filter}".`);
50
+ return;
51
+ }
52
+
53
+ table(rows, [
54
+ { key: "name", title: "NAME" },
55
+ { key: "status", title: "STATUS" },
56
+ { key: "source", title: "SOURCE" },
57
+ { key: "description", title: "DESCRIPTION", flex: true },
58
+ ]);
59
+
60
+ const missing = rows.filter((row) => row.status === "MISSING");
61
+ if (missing.length > 0) {
62
+ log.plain("");
63
+ log.warn(
64
+ `${missing.length} skill${missing.length === 1 ? "" : "s"} recorded in ${path.basename("skills-lock.json")} but not on disk: ${missing.map((row) => row.name).join(", ")}`
65
+ );
66
+ log.plain(dim("Run `ds-cs add <name>` to reinstall."));
67
+ }
68
+ }
69
+
70
+ async function readInstalledSkill(projectRoot, name, global) {
71
+ const skillFile = path.join(
72
+ skillsDirFor(CANONICAL_AGENT, { projectRoot, global }),
73
+ name,
74
+ "SKILL.md"
75
+ );
76
+ if (!(await isFile(skillFile))) return null;
77
+ return readSkillManifest(skillFile);
78
+ }
79
+
80
+ export function printAgents() {
81
+ table(
82
+ AGENTS.map((agent) => ({ name: agent.name, project: agent.project, global: agent.global })),
83
+ [
84
+ { key: "name", title: "AGENT" },
85
+ { key: "project", title: "PROJECT PATH" },
86
+ { key: "global", title: "GLOBAL PATH" },
87
+ ]
88
+ );
89
+ }
@@ -0,0 +1,101 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ import { readAuth, redact } from "../auth.mjs";
5
+ import { MCP_TARGETS, buildMcpConfig, mcpEndpoint } from "../mcp-config.mjs";
6
+ import { exists } from "../utils/fs.mjs";
7
+ import { cyan, log } from "../utils/log.mjs";
8
+
9
+ /**
10
+ * Print — and optionally write — the MCP server config for a channel.
11
+ *
12
+ * Every format is printed by default and only the named agent's file is
13
+ * written. An existing config is never overwritten: the snippet is shown so the
14
+ * user can merge it, because clobbering a working MCP config to add one server
15
+ * is a bad trade.
16
+ */
17
+ export async function mcpCommand(positional, flags, { projectRoot }) {
18
+ const stored = await readAuth();
19
+ const url = (flags.url ?? stored.baseUrl ?? "https://cs.dastyare.social").replace(/\/+$/, "");
20
+ const apiKey = flags.apiKey ?? stored.apiKey ?? null;
21
+ const endpoint = mcpEndpoint(url);
22
+
23
+ const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined;
24
+
25
+ const snippets = [
26
+ {
27
+ name: "Remote (recommended)",
28
+ note: "Works for every MCP client. No local process.",
29
+ configs: {
30
+ "Claude Desktop / Cursor / generic": buildMcpConfig("claude-code", url, apiKey),
31
+ "Claude Code": {
32
+ mcpServers: {
33
+ "ds-cs": { type: "url", url: endpoint, ...(headers ? { headers } : {}) },
34
+ },
35
+ },
36
+ OpenCode: buildMcpConfig("opencode", url, apiKey),
37
+ },
38
+ },
39
+ {
40
+ name: "Local (stdio, needs a clone)",
41
+ note: "Faster and works offline. Requires DATABASE_URL and the repo.",
42
+ configs: {
43
+ "Claude Code / Cursor": {
44
+ mcpServers: {
45
+ "ds-cs": { command: "bun", args: ["run", "mcp"], cwd: "<path-to-ds-cs-repo>" },
46
+ },
47
+ },
48
+ },
49
+ },
50
+ ];
51
+
52
+ for (const section of snippets) {
53
+ log.plain(`\n${section.name} — ${section.note}`);
54
+ for (const [label, config] of Object.entries(section.configs)) {
55
+ log.plain(`\n ${label}:`);
56
+ log.plain(indent(JSON.stringify(config, null, 2)));
57
+ }
58
+ }
59
+
60
+ if (apiKey) {
61
+ log.plain(`\n Using API key ${cyan(redact(apiKey))} (write access enabled)`);
62
+ } else {
63
+ log.plain("\n No API key configured — read tools only. Run `ds-cs login` for write access.");
64
+ }
65
+
66
+ const writeTarget = positional[0];
67
+ if (writeTarget) {
68
+ await writeConfig(projectRoot, writeTarget, url, apiKey);
69
+ }
70
+ }
71
+
72
+ function indent(text) {
73
+ return text
74
+ .split("\n")
75
+ .map((line) => ` ${line}`)
76
+ .join("\n");
77
+ }
78
+
79
+ async function writeConfig(projectRoot, agent, url, apiKey) {
80
+ const target = MCP_TARGETS.find((candidate) => candidate.agent === agent);
81
+ if (!target) {
82
+ log.warn(
83
+ `Unknown agent "${agent}" — nothing written. Valid: ${MCP_TARGETS.map((c) => c.agent).join(", ")}`
84
+ );
85
+ return;
86
+ }
87
+
88
+ const file = path.join(projectRoot, target.file);
89
+ if (await exists(file)) {
90
+ log.warn(`${target.file} already exists — not overwritten. Merge the snippet manually.`);
91
+ return;
92
+ }
93
+
94
+ await fs.mkdir(path.dirname(file), { recursive: true });
95
+ await fs.writeFile(
96
+ file,
97
+ `${JSON.stringify(buildMcpConfig(agent, url, apiKey), null, 2)}\n`,
98
+ "utf8"
99
+ );
100
+ log.success(`\nWrote ${target.file}`);
101
+ }
@@ -0,0 +1,80 @@
1
+ import path from "node:path";
2
+
3
+ import { CANONICAL_AGENT, skillsDirFor } from "../agents.mjs";
4
+ import { forgetSkill, readLockfile } from "../lockfile.mjs";
5
+ import { removeSkill } from "../install.mjs";
6
+ import { toArray } from "../args.mjs";
7
+ import { ensureDir } from "../utils/fs.mjs";
8
+ import { log } from "../utils/log.mjs";
9
+ import { select } from "../utils/prompt.mjs";
10
+
11
+ /**
12
+ * Uninstall skills.
13
+ *
14
+ * Removing from the canonical directory alone would leave the symlinks in other
15
+ * agents' directories dangling, so every configured target is swept.
16
+ */
17
+ export async function removeCommand(positional, flags, { projectRoot }) {
18
+ const global = Boolean(flags.global);
19
+ const lockfile = await readLockfile(projectRoot);
20
+ const installed = Object.keys(lockfile.skills).sort();
21
+
22
+ let names = [...positional.flatMap(splitCommas), ...toArray(flags.skill)];
23
+
24
+ if (names.includes("*") || names.length === 0) {
25
+ if (installed.length === 0) {
26
+ log.info("Nothing to remove.");
27
+ return;
28
+ }
29
+ if (flags.all) {
30
+ names = installed;
31
+ } else {
32
+ const picked = await select("Which skills should be removed?", installed.map((name) => ({
33
+ label: name,
34
+ value: name,
35
+ })));
36
+ names = picked.map((choice) => choice.value);
37
+ if (names.length === 0) return;
38
+ }
39
+ }
40
+
41
+ const unknown = names.filter((name) => !lockfile.skills[name]);
42
+ const targets = await agentDirectories(projectRoot, flags, global);
43
+
44
+ for (const directory of targets) {
45
+ await ensureDir(directory);
46
+ }
47
+
48
+ for (const name of names) {
49
+ const removed = await removeSkill(name, targets);
50
+ await forgetSkill(projectRoot, name);
51
+
52
+ if (unknown.includes(name)) {
53
+ log.warn(`"${name}" was not in the lockfile; removed ${removed.length} file(s) from disk`);
54
+ continue;
55
+ }
56
+ log.success(`Removed ${name} from ${removed.length} location(s)`);
57
+ }
58
+
59
+ log.plain(`\n ${path.relative(process.cwd(), path.join(projectRoot, "skills-lock.json"))} updated`);
60
+ }
61
+
62
+ function splitCommas(value) {
63
+ return value.includes(",") ? value.split(",").map((name) => name.trim()) : [value];
64
+ }
65
+
66
+ async function agentDirectories(projectRoot, flags, global) {
67
+ const canonical = skillsDirFor(CANONICAL_AGENT, { projectRoot, global });
68
+ const requested = toArray(flags.agent);
69
+ if (requested.length === 0 || requested.includes("*")) return [canonical];
70
+
71
+ const directories = [canonical];
72
+ for (const agent of requested) {
73
+ if (agent.startsWith(".") || path.isAbsolute(agent)) {
74
+ directories.push(path.isAbsolute(agent) ? agent : path.join(projectRoot, agent));
75
+ } else {
76
+ directories.push(skillsDirFor(agent, { projectRoot, global }));
77
+ }
78
+ }
79
+ return [...new Set(directories)];
80
+ }
@@ -0,0 +1,51 @@
1
+ import { CANONICAL_AGENT, skillsDirFor } from "../agents.mjs";
2
+ import { readLockfile } from "../lockfile.mjs";
3
+ import { listInstalledSkills } from "../skills/discover.mjs";
4
+ import { log, table } from "../utils/log.mjs";
5
+
6
+ /**
7
+ * Search installed skills by name and description.
8
+ *
9
+ * Offline by design: it walks the skills that are actually installed rather than
10
+ * reaching for a registry. `ds-cs add <source> --list` is the command for
11
+ * browsing a source you have not installed from yet.
12
+ */
13
+ export async function searchCommand(positional, flags, { projectRoot }) {
14
+ const query = (positional.join(" ") || "").toLowerCase();
15
+ const lockfile = await readLockfile(projectRoot);
16
+
17
+ const canonical = skillsDirFor(CANONICAL_AGENT, {
18
+ projectRoot,
19
+ global: Boolean(flags.global),
20
+ });
21
+
22
+ const available = await listInstalledSkills(canonical);
23
+ if (available.length === 0) {
24
+ log.info("No skills installed in this project.");
25
+ return;
26
+ }
27
+ const matches = available
28
+ .filter((skill) => {
29
+ if (query === "") return true;
30
+ return (
31
+ skill.name.toLowerCase().includes(query) ||
32
+ skill.description.toLowerCase().includes(query)
33
+ );
34
+ })
35
+ .map((skill) => ({
36
+ name: skill.name,
37
+ source: lockfile.skills[skill.name]?.source ?? "(local)",
38
+ description: skill.description,
39
+ }));
40
+
41
+ if (matches.length === 0) {
42
+ log.info(query === "" ? "No skills installed." : `No installed skill matches "${query}".`);
43
+ return;
44
+ }
45
+
46
+ table(matches, [
47
+ { key: "name", title: "NAME" },
48
+ { key: "source", title: "SOURCE" },
49
+ { key: "description", title: "DESCRIPTION", flex: true },
50
+ ]);
51
+ }