@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,83 @@
|
|
|
1
|
+
import { toArray } from "../args.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Split `owner/repo@skill` into source and skill names.
|
|
5
|
+
*
|
|
6
|
+
* The `@` is only a separator on the *right*, and only when what follows looks
|
|
7
|
+
* like skill names. That guard matters for SSH remotes: in
|
|
8
|
+
* `git@github.com:owner/repo` the `@` sits in the middle and the remainder is a
|
|
9
|
+
* path, not a name. Requiring `name[,name…]` shaped like identifiers keeps that
|
|
10
|
+
* URL intact.
|
|
11
|
+
*
|
|
12
|
+
* `explicit` distinguishes "the user wrote `source@names`" from "no `@` was
|
|
13
|
+
* present" — without it a bare skill name would be misread as a source.
|
|
14
|
+
*/
|
|
15
|
+
const SKILL_NAME_LIST = /^[A-Za-z0-9_.-]+(,[A-Za-z0-9_.-]+)*$/;
|
|
16
|
+
|
|
17
|
+
export function parseTarget(spec) {
|
|
18
|
+
const at = spec.lastIndexOf("@");
|
|
19
|
+
if (at > 0) {
|
|
20
|
+
const source = spec.slice(0, at);
|
|
21
|
+
const raw = spec.slice(at + 1).trim();
|
|
22
|
+
if (source && raw && SKILL_NAME_LIST.test(raw)) {
|
|
23
|
+
return {
|
|
24
|
+
source,
|
|
25
|
+
names: raw.split(",").map((name) => name.trim()),
|
|
26
|
+
explicit: true,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return { source: spec, names: [], explicit: false };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function splitNames(value) {
|
|
34
|
+
return value.includes(",") ? value.split(",").map((name) => name.trim()) : [value];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Work out what to install from positional arguments plus the repeatable
|
|
39
|
+
* `--skill` flag.
|
|
40
|
+
*
|
|
41
|
+
* A bare name (`ds-cs add brand-config`) means "from the DS-CS registry" — the
|
|
42
|
+
* common case should not require the user to know where a skill lives. A bare
|
|
43
|
+
* `owner/repo` is GitHub shorthand and is treated as a source instead.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveRequest(positional, flags) {
|
|
46
|
+
const skillFlags = toArray(flags.skill);
|
|
47
|
+
|
|
48
|
+
if (positional.length === 0) {
|
|
49
|
+
return { source: "", names: skillFlags };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const parsed = parseTarget(positional[0]);
|
|
53
|
+
|
|
54
|
+
if (parsed.explicit) {
|
|
55
|
+
// `owner/repo@skill` names both; any further positionals are more skill names.
|
|
56
|
+
return {
|
|
57
|
+
source: parsed.source,
|
|
58
|
+
names: [...parsed.names, ...positional.slice(1).flatMap(splitNames), ...skillFlags],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Nothing carried an `@`, so every positional is a skill name. Resolve them
|
|
63
|
+
// against the DS-CS registry — unless the first one is clearly a source.
|
|
64
|
+
const first = positional[0];
|
|
65
|
+
if (looksLikeSource(first)) {
|
|
66
|
+
return { source: first, names: [...positional.slice(1).flatMap(splitNames), ...skillFlags] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { source: "", names: [...positional.flatMap(splitNames), ...skillFlags] };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function looksLikeSource(spec) {
|
|
73
|
+
return (
|
|
74
|
+
spec.startsWith(".") ||
|
|
75
|
+
spec.startsWith("/") ||
|
|
76
|
+
spec.startsWith("~") ||
|
|
77
|
+
spec.startsWith("http") ||
|
|
78
|
+
spec.startsWith("git@") ||
|
|
79
|
+
spec.startsWith("git://") ||
|
|
80
|
+
spec.startsWith("ssh://") ||
|
|
81
|
+
/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(spec)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { CANONICAL_AGENT, skillsDirFor } from "../agents.mjs";
|
|
2
|
+
import { readLockfile } from "../lockfile.mjs";
|
|
3
|
+
import { refreshSkill } from "../install.mjs";
|
|
4
|
+
import { toArray } from "../args.mjs";
|
|
5
|
+
import { hashDirectory, exists } from "../utils/fs.mjs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { log } from "../utils/log.mjs";
|
|
8
|
+
import { select } from "../utils/prompt.mjs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Re-fetch locked skills and report what actually changed.
|
|
12
|
+
*
|
|
13
|
+
* The comparison is against the recorded `computedHash`, so a skill whose files
|
|
14
|
+
* were edited locally shows up as "modified" rather than being silently
|
|
15
|
+
* overwritten. The default is to leave those alone — an agent's local tweak is
|
|
16
|
+
* usually deliberate, and clobbering it on a routine `update` is the kind of
|
|
17
|
+
* surprise that makes people stop running the tool.
|
|
18
|
+
*/
|
|
19
|
+
export async function updateCommand(positional, flags, { projectRoot }) {
|
|
20
|
+
const lockfile = await readLockfile(projectRoot);
|
|
21
|
+
const all = Object.keys(lockfile.skills).sort();
|
|
22
|
+
|
|
23
|
+
if (all.length === 0) {
|
|
24
|
+
log.info("Nothing to update — no skills in skills-lock.json.");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let names = [...positional, ...toArray(flags.skill)];
|
|
29
|
+
|
|
30
|
+
if (names.length === 0 || names.includes("*")) {
|
|
31
|
+
if (flags.yes) {
|
|
32
|
+
names = all;
|
|
33
|
+
} else {
|
|
34
|
+
const picked = await select("Which skills should be updated?", all.map((name) => ({
|
|
35
|
+
label: name,
|
|
36
|
+
value: name,
|
|
37
|
+
})));
|
|
38
|
+
names = picked.map((choice) => choice.value);
|
|
39
|
+
if (names.length === 0) return;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let changed = 0;
|
|
44
|
+
let unchanged = 0;
|
|
45
|
+
let skipped = 0;
|
|
46
|
+
const problems = [];
|
|
47
|
+
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
const entry = lockfile.skills[name];
|
|
50
|
+
if (!entry) {
|
|
51
|
+
log.warn(`"${name}" is not in skills-lock.json`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const localDir = path.join(
|
|
56
|
+
skillsDirFor(CANONICAL_AGENT, { projectRoot, global: Boolean(flags.global) }),
|
|
57
|
+
name
|
|
58
|
+
);
|
|
59
|
+
const locallyModified = (await exists(localDir)) && (await hashDirectory(localDir)) !== entry.computedHash;
|
|
60
|
+
|
|
61
|
+
let result;
|
|
62
|
+
try {
|
|
63
|
+
result = await refreshSkill(projectRoot, entry, { global: Boolean(flags.global) });
|
|
64
|
+
} catch (error) {
|
|
65
|
+
problems.push(`${name}: ${error.message}`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (result.status === "missing") {
|
|
70
|
+
problems.push(`${name}: no longer present in ${result.source.describe()}`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (locallyModified && !flags.force) {
|
|
75
|
+
skipped += 1;
|
|
76
|
+
log.warn(`${name} has local changes — skipped (use --force to overwrite)`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (result.hash === entry.computedHash) {
|
|
81
|
+
unchanged += 1;
|
|
82
|
+
} else {
|
|
83
|
+
changed += 1;
|
|
84
|
+
log.success(`${name} updated`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
log.plain("");
|
|
89
|
+
log.info(`${changed} updated, ${unchanged} already current, ${skipped} skipped`);
|
|
90
|
+
|
|
91
|
+
for (const problem of problems) {
|
|
92
|
+
log.warn(problem);
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { parseArgs } from "./args.mjs";
|
|
5
|
+
import { addCommand } from "./commands/add.mjs";
|
|
6
|
+
import { loginCommand, logoutCommand, whoamiCommand } from "./commands/auth.mjs";
|
|
7
|
+
import { initCommand } from "./commands/init.mjs";
|
|
8
|
+
import { listCommand } from "./commands/list.mjs";
|
|
9
|
+
import { mcpCommand } from "./commands/mcp.mjs";
|
|
10
|
+
import { removeCommand } from "./commands/remove.mjs";
|
|
11
|
+
import { searchCommand } from "./commands/search.mjs";
|
|
12
|
+
import { updateCommand } from "./commands/update.mjs";
|
|
13
|
+
import { CliError } from "./utils/errors.mjs";
|
|
14
|
+
import { bold, log } from "./utils/log.mjs";
|
|
15
|
+
|
|
16
|
+
const COMMANDS = [
|
|
17
|
+
"init",
|
|
18
|
+
"add",
|
|
19
|
+
"list",
|
|
20
|
+
"update",
|
|
21
|
+
"remove",
|
|
22
|
+
"search",
|
|
23
|
+
"mcp",
|
|
24
|
+
"login",
|
|
25
|
+
"whoami",
|
|
26
|
+
"logout",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const VERSION = await readVersion();
|
|
30
|
+
|
|
31
|
+
const USAGE = `${bold("ds-cs")} — skills and tooling for Dastyare Social — CS
|
|
32
|
+
|
|
33
|
+
${bold("Usage")}
|
|
34
|
+
ds-cs <command> [options]
|
|
35
|
+
|
|
36
|
+
${bold("Commands")}
|
|
37
|
+
init [dir] Prepare a project for DS-CS skills
|
|
38
|
+
add <skill|source> Install skills from DS-CS or any skills repo
|
|
39
|
+
list Show installed skills and their sources
|
|
40
|
+
update [skill…] Re-fetch installed skills that changed
|
|
41
|
+
remove <skill…> Uninstall skills
|
|
42
|
+
search [query] Search installed skills
|
|
43
|
+
mcp [agent] Print (or write) MCP server config for a channel
|
|
44
|
+
login Store a channel URL + API key
|
|
45
|
+
whoami Show the stored channel
|
|
46
|
+
logout Remove stored credentials
|
|
47
|
+
|
|
48
|
+
${bold("Sources")}
|
|
49
|
+
ds-cs add brand-config from the DS-CS registry (default)
|
|
50
|
+
ds-cs add owner/repo from a GitHub repo
|
|
51
|
+
ds-cs add owner/repo@skill pick one skill from that repo
|
|
52
|
+
ds-cs add ./my-skills from a local directory
|
|
53
|
+
ds-cs add https://…/SKILL.md from a direct URL
|
|
54
|
+
|
|
55
|
+
${bold("Options")}
|
|
56
|
+
-s, --skill <name> Install/remove this skill (repeatable, '*' for all)
|
|
57
|
+
-a, --agent <name> Target agent, or a directory path starting with . or /
|
|
58
|
+
-g, --global Install into the user directory instead of the project
|
|
59
|
+
--copy Copy files instead of symlinking between agents
|
|
60
|
+
-y, --yes Non-interactive; accept defaults
|
|
61
|
+
--list List what a source offers instead of installing
|
|
62
|
+
--url <url> Channel URL (login, mcp, init)
|
|
63
|
+
--api-key <key> Channel API key (login, mcp, init)
|
|
64
|
+
-h, --help Show this help
|
|
65
|
+
-v, --version Print the CLI version
|
|
66
|
+
|
|
67
|
+
${bold("Examples")}
|
|
68
|
+
ds-cs init --mcp
|
|
69
|
+
ds-cs add post-with-media
|
|
70
|
+
ds-cs add emilkowalski/skill@animate -a opencode
|
|
71
|
+
ds-cs login --url https://cs.example.com --api-key $KEY
|
|
72
|
+
ds-cs mcp claude-code > claude-mcp.json
|
|
73
|
+
|
|
74
|
+
${bold("Environment")}
|
|
75
|
+
DS_CS_URL, DS_CS_API_KEY used instead of a stored login
|
|
76
|
+
DS_CS_CONFIG_DIR where credentials are stored
|
|
77
|
+
GITHUB_TOKEN / GH_TOKEN for private skill repos
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
async function readVersion() {
|
|
81
|
+
try {
|
|
82
|
+
const file = new URL("../package.json", import.meta.url);
|
|
83
|
+
return JSON.parse(await fs.readFile(file, "utf8")).version;
|
|
84
|
+
} catch {
|
|
85
|
+
return "0.0.0";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Find the project root by walking up for a VCS or package marker.
|
|
91
|
+
*
|
|
92
|
+
* Skills and the lockfile are project-scoped, so a `ds-cs add` run from a nested
|
|
93
|
+
* directory must land in the same place as one run from the root — otherwise the
|
|
94
|
+
* lockfile and the installed skills drift apart.
|
|
95
|
+
*/
|
|
96
|
+
async function findProjectRoot(startDir) {
|
|
97
|
+
let directory = path.resolve(startDir);
|
|
98
|
+
for (let depth = 0; depth < 20; depth += 1) {
|
|
99
|
+
const markers = [".git", "skills-lock.json", "package.json"];
|
|
100
|
+
for (const marker of markers) {
|
|
101
|
+
try {
|
|
102
|
+
await fs.stat(path.join(directory, marker));
|
|
103
|
+
return directory;
|
|
104
|
+
} catch {
|
|
105
|
+
// keep walking
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const parent = path.dirname(directory);
|
|
109
|
+
if (parent === directory) break;
|
|
110
|
+
directory = parent;
|
|
111
|
+
}
|
|
112
|
+
return path.resolve(startDir);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function main(argv) {
|
|
116
|
+
let parsed;
|
|
117
|
+
try {
|
|
118
|
+
parsed = parseArgs(argv);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
throw new CliError(error.message, { hint: "Run `ds-cs --help`." });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const { flags, positional } = parsed;
|
|
124
|
+
|
|
125
|
+
if (flags.version) {
|
|
126
|
+
log.plain(VERSION);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const command = positional.shift();
|
|
131
|
+
|
|
132
|
+
if (!command || flags.help) {
|
|
133
|
+
log.plain(USAGE);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (command === "help") {
|
|
138
|
+
log.plain(USAGE);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const projectRoot = await findProjectRoot(flags.cwd ?? process.cwd());
|
|
143
|
+
const context = { projectRoot, version: VERSION };
|
|
144
|
+
|
|
145
|
+
switch (command) {
|
|
146
|
+
case "init":
|
|
147
|
+
return initCommand(positional, flags, context);
|
|
148
|
+
case "add":
|
|
149
|
+
return addCommand(positional, flags, context);
|
|
150
|
+
case "list":
|
|
151
|
+
case "ls":
|
|
152
|
+
return listCommand(positional, flags, context);
|
|
153
|
+
case "update":
|
|
154
|
+
case "upgrade":
|
|
155
|
+
return updateCommand(positional, flags, context);
|
|
156
|
+
case "remove":
|
|
157
|
+
case "rm":
|
|
158
|
+
case "uninstall":
|
|
159
|
+
return removeCommand(positional, flags, context);
|
|
160
|
+
case "search":
|
|
161
|
+
case "find":
|
|
162
|
+
return searchCommand(positional, flags, context);
|
|
163
|
+
case "mcp":
|
|
164
|
+
return mcpCommand(positional, flags, context);
|
|
165
|
+
case "login":
|
|
166
|
+
return loginCommand(positional, flags, context);
|
|
167
|
+
case "whoami":
|
|
168
|
+
return whoamiCommand(positional, flags, context);
|
|
169
|
+
case "logout":
|
|
170
|
+
return logoutCommand(positional, flags, context);
|
|
171
|
+
default:
|
|
172
|
+
throw new CliError(`Unknown command "${command}"`, {
|
|
173
|
+
hint: `Did you mean one of: ${COMMANDS.join(", ")}?`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
package/src/install.mjs
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { CANONICAL_AGENT, dedupeDirectories, linkSkill, skillsDirFor } from "./agents.mjs";
|
|
5
|
+
import { recordSkill } from "./lockfile.mjs";
|
|
6
|
+
import { resolveSource } from "./registry/sources.mjs";
|
|
7
|
+
import { assertSkillExists, discoverSkills } from "./skills/discover.mjs";
|
|
8
|
+
import { CliError } from "./utils/errors.mjs";
|
|
9
|
+
import {
|
|
10
|
+
copyDirectory,
|
|
11
|
+
ensureDir,
|
|
12
|
+
hashDirectory,
|
|
13
|
+
removeDirectory,
|
|
14
|
+
} from "./utils/fs.mjs";
|
|
15
|
+
import { select } from "./utils/prompt.mjs";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Install skills from a source into one or more agents.
|
|
19
|
+
*
|
|
20
|
+
* The canonical copy always lands in `.agents/skills/<name>`, and every other
|
|
21
|
+
* target is linked to it. That ordering matters: it keeps one authoritative
|
|
22
|
+
* directory per project, so `ds-cs update` replaces content once instead of
|
|
23
|
+
* drifting per-agent copies.
|
|
24
|
+
*/
|
|
25
|
+
export async function installSkills({
|
|
26
|
+
source: sourceSpec,
|
|
27
|
+
names = [],
|
|
28
|
+
projectRoot,
|
|
29
|
+
targets,
|
|
30
|
+
global = false,
|
|
31
|
+
copy = false,
|
|
32
|
+
yes = false,
|
|
33
|
+
preferLocal = true,
|
|
34
|
+
}) {
|
|
35
|
+
const resolved = await resolveSource(sourceSpec, { cwd: projectRoot, preferLocal });
|
|
36
|
+
|
|
37
|
+
const available = (await discoverSkills(resolved.root)).filter(
|
|
38
|
+
(skill) => process.env.INSTALL_INTERNAL_SKILLS === "1" || !skill.internal
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
if (available.length === 0) {
|
|
42
|
+
throw new CliError(`No skills found in ${resolved.describe()}`, {
|
|
43
|
+
hint: "A skill is a directory containing a SKILL.md with name and description frontmatter.",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const chosen = await chooseSkills(available, names, resolved, { yes });
|
|
48
|
+
if (chosen.length === 0) return { installed: [], resolved };
|
|
49
|
+
|
|
50
|
+
const canonicalDir = skillsDirFor(CANONICAL_AGENT, { projectRoot, global });
|
|
51
|
+
await ensureDir(canonicalDir);
|
|
52
|
+
|
|
53
|
+
const destinations = dedupeDirectories(
|
|
54
|
+
(targets?.length ? targets : [canonicalDir]).map((directory) =>
|
|
55
|
+
global && !path.isAbsolute(directory)
|
|
56
|
+
? skillsDirFor(directory, { projectRoot, global })
|
|
57
|
+
: directory
|
|
58
|
+
)
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const installed = [];
|
|
62
|
+
for (const skill of chosen) {
|
|
63
|
+
const destination = path.join(canonicalDir, skill.name);
|
|
64
|
+
|
|
65
|
+
// Replace rather than merge: a reference file removed upstream must not
|
|
66
|
+
// survive in the installed copy. Skipped when the source *is* the target,
|
|
67
|
+
// which happens if someone points `--source` at an installed skills dir —
|
|
68
|
+
// the delete below would otherwise destroy the source mid-copy.
|
|
69
|
+
if (!isSameDirectory(skill.dir, destination)) {
|
|
70
|
+
await removeDirectory(destination);
|
|
71
|
+
await copyDirectory(skill.dir, destination);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const hash = await hashDirectory(destination);
|
|
75
|
+
const links = [];
|
|
76
|
+
for (const target of destinations) {
|
|
77
|
+
if (path.resolve(target) === path.resolve(canonicalDir)) continue;
|
|
78
|
+
links.push(await linkSkill(destination, target, { copy }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await recordSkill(projectRoot, { ...skill, hash }, resolved);
|
|
82
|
+
installed.push({ skill, destination, links, hash });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { installed, resolved };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** True when a copy from `source` to `destination` would clobber its own input. */
|
|
89
|
+
function isSameDirectory(source, destination) {
|
|
90
|
+
return path.resolve(source) === path.resolve(destination);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function chooseSkills(available, names, resolved, { yes }) {
|
|
94
|
+
if (names.includes("*")) return available;
|
|
95
|
+
|
|
96
|
+
if (names.length > 0) {
|
|
97
|
+
return names.map((name) => assertSkillExists(available, name));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (yes) return [available[0]];
|
|
101
|
+
|
|
102
|
+
const picked = await select(`Which skills from ${resolved.describe()}?`, available.map((skill) => ({
|
|
103
|
+
label: skill.name,
|
|
104
|
+
value: skill,
|
|
105
|
+
description: skill.description,
|
|
106
|
+
})));
|
|
107
|
+
|
|
108
|
+
return picked.map((choice) => choice.value);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Re-install a locked skill from its recorded source.
|
|
113
|
+
*
|
|
114
|
+
* The lockfile stores the skill's path *within* the source (`skills/foo/SKILL.md`)
|
|
115
|
+
* so the same source can be re-fetched even if the skill moved within its own
|
|
116
|
+
* repository layout.
|
|
117
|
+
*/
|
|
118
|
+
export async function refreshSkill(projectRoot, entry, options = {}) {
|
|
119
|
+
const resolved = await resolveSource(entry.source, { cwd: projectRoot });
|
|
120
|
+
const available = await discoverSkills(resolved.root);
|
|
121
|
+
const match =
|
|
122
|
+
available.find((skill) => skill.name === entry.name) ??
|
|
123
|
+
available.find((skill) => skill.skillPath === entry.skillPath);
|
|
124
|
+
|
|
125
|
+
if (!match) {
|
|
126
|
+
return { name: entry.name, status: "missing", source: resolved };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const canonicalDir = skillsDirFor(CANONICAL_AGENT, {
|
|
130
|
+
projectRoot,
|
|
131
|
+
global: options.global ?? false,
|
|
132
|
+
});
|
|
133
|
+
const destination = path.join(canonicalDir, match.name);
|
|
134
|
+
|
|
135
|
+
if (!isSameDirectory(match.dir, destination)) {
|
|
136
|
+
await removeDirectory(destination);
|
|
137
|
+
await copyDirectory(match.dir, destination);
|
|
138
|
+
}
|
|
139
|
+
const hash = await hashDirectory(destination);
|
|
140
|
+
|
|
141
|
+
await recordSkill(projectRoot, { name: match.name, skillPath: match.skillPath, hash }, resolved);
|
|
142
|
+
|
|
143
|
+
return { name: match.name, status: "changed", source: resolved, hash, skill: match };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Uninstall from every target directory.
|
|
148
|
+
*
|
|
149
|
+
* Non-canonical directories are swept **first**. They hold symlinks pointing at
|
|
150
|
+
* the canonical copy, so deleting the target before the links turns them into
|
|
151
|
+
* dangling symlinks that `stat` then reports as absent — leaving the links
|
|
152
|
+
* themselves on disk.
|
|
153
|
+
*/
|
|
154
|
+
export async function removeSkill(name, targets) {
|
|
155
|
+
const removed = [];
|
|
156
|
+
for (const directory of [...targets].reverse()) {
|
|
157
|
+
const target = path.join(directory, name);
|
|
158
|
+
if (await removePath(target)) {
|
|
159
|
+
removed.push(target);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return removed;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** `lstat`, not `stat`: a dangling symlink still occupies its path. */
|
|
166
|
+
async function removePath(target) {
|
|
167
|
+
try {
|
|
168
|
+
await lstat(target);
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
await removeDirectory(target);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
package/src/lockfile.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { CliError } from "./utils/errors.mjs";
|
|
4
|
+
import { exists, readJson, writeJson } from "./utils/fs.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `skills-lock.json` is the same lockfile `npx skills` (vercel-labs) reads and
|
|
8
|
+
* writes. Matching its v1 shape exactly is deliberate: a project can install
|
|
9
|
+
* DS-CS skills with `ds-cs add` and third-party skills with `npx skills add`
|
|
10
|
+
* without the two clobbering each other's state, and `ds-cs list` can report on
|
|
11
|
+
* skills installed by either tool.
|
|
12
|
+
*
|
|
13
|
+
* Shape:
|
|
14
|
+
* { "version": 1,
|
|
15
|
+
* "skills": { "<skill name>": {
|
|
16
|
+
* source, sourceType, skillPath, computedHash } } }
|
|
17
|
+
*/
|
|
18
|
+
export const LOCKFILE_NAME = "skills-lock.json";
|
|
19
|
+
export const LOCKFILE_VERSION = 1;
|
|
20
|
+
|
|
21
|
+
export function lockfilePath(projectRoot) {
|
|
22
|
+
return path.join(projectRoot, LOCKFILE_NAME);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function readLockfile(projectRoot) {
|
|
26
|
+
const file = lockfilePath(projectRoot);
|
|
27
|
+
if (!(await exists(file))) return { version: LOCKFILE_VERSION, skills: {} };
|
|
28
|
+
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = await readJson(file);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new CliError(`${LOCKFILE_NAME} is not valid JSON`, {
|
|
34
|
+
hint: "Fix or delete the file, then re-run.",
|
|
35
|
+
cause: error,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (parsed.version !== LOCKFILE_VERSION) {
|
|
40
|
+
throw new CliError(
|
|
41
|
+
`${LOCKFILE_NAME} has unsupported version ${JSON.stringify(parsed.version)} (expected ${LOCKFILE_VERSION})`,
|
|
42
|
+
{ hint: "Update the ds-cs CLI, or regenerate the lockfile." }
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { version: LOCKFILE_VERSION, skills: parsed.skills ?? {} };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function writeLockfile(projectRoot, lockfile) {
|
|
50
|
+
const sorted = {};
|
|
51
|
+
for (const name of Object.keys(lockfile.skills).sort()) {
|
|
52
|
+
sorted[name] = lockfile.skills[name];
|
|
53
|
+
}
|
|
54
|
+
await writeJson(lockfilePath(projectRoot), { version: LOCKFILE_VERSION, skills: sorted });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function recordSkill(projectRoot, skill, source) {
|
|
58
|
+
const lockfile = await readLockfile(projectRoot);
|
|
59
|
+
lockfile.skills[skill.name] = {
|
|
60
|
+
source: source.id,
|
|
61
|
+
sourceType: source.type,
|
|
62
|
+
skillPath: skill.skillPath,
|
|
63
|
+
computedHash: skill.hash,
|
|
64
|
+
};
|
|
65
|
+
await writeLockfile(projectRoot, lockfile);
|
|
66
|
+
return lockfile;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function forgetSkill(projectRoot, name) {
|
|
70
|
+
const lockfile = await readLockfile(projectRoot);
|
|
71
|
+
delete lockfile.skills[name];
|
|
72
|
+
await writeLockfile(projectRoot, lockfile);
|
|
73
|
+
return lockfile;
|
|
74
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server config, in the shape each client actually reads.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode uses `mcp` rather than `mcpServers` and requires an explicit
|
|
5
|
+
* `type`, while Claude Code, Cursor, Codex and Windsurf use `mcpServers`. The
|
|
6
|
+
* difference is not cosmetic — a config with the wrong root key is silently
|
|
7
|
+
* ignored by the client, which looks exactly like a server that never connects.
|
|
8
|
+
*/
|
|
9
|
+
export const MCP_TARGETS = [
|
|
10
|
+
{ agent: "opencode", file: "opencode.json" },
|
|
11
|
+
{ agent: "claude-code", file: ".mcp.json" },
|
|
12
|
+
{ agent: "cursor", file: ".cursor/mcp.json" },
|
|
13
|
+
{ agent: "codex", file: ".codex/config.json" },
|
|
14
|
+
{ agent: "windsurf", file: ".windsurf/mcp.json" },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function mcpEndpoint(baseUrl) {
|
|
18
|
+
return `${baseUrl.replace(/\/+$/, "")}/api/mcp`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildMcpConfig(agent, url, apiKey) {
|
|
22
|
+
const endpoint = mcpEndpoint(url);
|
|
23
|
+
const headers = apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined;
|
|
24
|
+
|
|
25
|
+
if (agent === "opencode") {
|
|
26
|
+
return {
|
|
27
|
+
$schema: "https://opencode.ai/config.json",
|
|
28
|
+
mcp: {
|
|
29
|
+
"ds-cs": { type: "remote", url: endpoint, enabled: true, ...(headers ? { headers } : {}) },
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
mcpServers: {
|
|
36
|
+
"ds-cs": { url: endpoint, ...(headers ? { headers } : {}) },
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|