vskill 1.0.13 → 1.0.15
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 +63 -2
- package/agents.json +1 -1
- package/dist/bin.js +0 -0
- package/dist/clone/github-scaffold.d.ts +38 -0
- package/dist/clone/github-scaffold.js +108 -0
- package/dist/clone/github-scaffold.js.map +1 -0
- package/dist/clone/provenance-fork.d.ts +34 -0
- package/dist/clone/provenance-fork.js +97 -0
- package/dist/clone/provenance-fork.js.map +1 -0
- package/dist/clone/reference-scanner.d.ts +19 -0
- package/dist/clone/reference-scanner.js +144 -0
- package/dist/clone/reference-scanner.js.map +1 -0
- package/dist/clone/skill-locator.d.ts +26 -0
- package/dist/clone/skill-locator.js +248 -0
- package/dist/clone/skill-locator.js.map +1 -0
- package/dist/clone/target-router.d.ts +73 -0
- package/dist/clone/target-router.js +200 -0
- package/dist/clone/target-router.js.map +1 -0
- package/dist/clone/types.d.ts +82 -0
- package/dist/clone/types.js +11 -0
- package/dist/clone/types.js.map +1 -0
- package/dist/commands/add.js +96 -32
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/auth.d.ts +23 -0
- package/dist/commands/auth.js +273 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/check.d.ts +55 -0
- package/dist/commands/check.js +279 -0
- package/dist/commands/check.js.map +1 -0
- package/dist/commands/clone-prompts.d.ts +13 -0
- package/dist/commands/clone-prompts.js +67 -0
- package/dist/commands/clone-prompts.js.map +1 -0
- package/dist/commands/clone.d.ts +70 -0
- package/dist/commands/clone.js +649 -0
- package/dist/commands/clone.js.map +1 -0
- package/dist/commands/eval/serve.js +8 -1
- package/dist/commands/eval/serve.js.map +1 -1
- package/dist/commands/keys.js +54 -2
- package/dist/commands/keys.js.map +1 -1
- package/dist/core/agent-prompts.d.ts +35 -0
- package/dist/core/agent-prompts.js +201 -0
- package/dist/core/agent-prompts.js.map +1 -0
- package/dist/core/skill-generator.d.ts +25 -3
- package/dist/core/skill-generator.js +131 -0
- package/dist/core/skill-generator.js.map +1 -1
- package/dist/eval/skill-scanner.d.ts +2 -12
- package/dist/eval/skill-scanner.js +27 -5
- package/dist/eval/skill-scanner.js.map +1 -1
- package/dist/eval-server/api-routes.d.ts +14 -0
- package/dist/eval-server/api-routes.js +376 -31
- package/dist/eval-server/api-routes.js.map +1 -1
- package/dist/eval-server/data-events.d.ts +1 -1
- package/dist/eval-server/data-events.js.map +1 -1
- package/dist/eval-server/install-engine-routes-helpers.d.ts +1 -3
- package/dist/eval-server/install-engine-routes-helpers.js +6 -14
- package/dist/eval-server/install-engine-routes-helpers.js.map +1 -1
- package/dist/eval-server/origin-resolver.d.ts +42 -0
- package/dist/eval-server/origin-resolver.js +168 -0
- package/dist/eval-server/origin-resolver.js.map +1 -0
- package/dist/eval-server/platform-proxy.d.ts +10 -0
- package/dist/eval-server/platform-proxy.js +58 -2
- package/dist/eval-server/platform-proxy.js.map +1 -1
- package/dist/eval-server/skill-create-routes.d.ts +8 -0
- package/dist/eval-server/skill-create-routes.js +96 -0
- package/dist/eval-server/skill-create-routes.js.map +1 -1
- package/dist/eval-server/skill-resolver.js +40 -0
- package/dist/eval-server/skill-resolver.js.map +1 -1
- package/dist/eval-server/utils/resolve-editor.d.ts +18 -0
- package/dist/eval-server/utils/resolve-editor.js +77 -0
- package/dist/eval-server/utils/resolve-editor.js.map +1 -0
- package/dist/eval-server/utils/scan-install-locations.d.ts +7 -0
- package/dist/eval-server/utils/scan-install-locations.js +20 -0
- package/dist/eval-server/utils/scan-install-locations.js.map +1 -1
- package/dist/eval-server/utils/which.d.ts +15 -0
- package/dist/eval-server/utils/which.js +76 -0
- package/dist/eval-server/utils/which.js.map +1 -0
- package/dist/eval-ui/assets/{CreateSkillPage-T0YWZWw-.js → CreateSkillPage-BmbvQEzE.js} +1 -1
- package/dist/eval-ui/assets/{FindSkillsPalette-KcFM32hZ.js → FindSkillsPalette-D0Zjhm31.js} +2 -2
- package/dist/eval-ui/assets/{SearchPaletteCore-EhBtr4Xx.js → SearchPaletteCore-EhcN1xEa.js} +1 -1
- package/dist/eval-ui/assets/SkillDetailPanel-B5J60ffv.js +1 -0
- package/dist/eval-ui/assets/{UpdateDropdown-pjFhHTi6.js → UpdateDropdown-Celf0_Cr.js} +1 -1
- package/dist/eval-ui/assets/index-BV7k6fdk.js +124 -0
- package/dist/eval-ui/assets/{index-BKAvJDDF.css → index-CKLqBL52.css} +1 -1
- package/dist/eval-ui/index.html +2 -2
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -1
- package/dist/installer/frontmatter.d.ts +26 -0
- package/dist/installer/frontmatter.js +90 -0
- package/dist/installer/frontmatter.js.map +1 -1
- package/dist/lib/github-fetch.d.ts +22 -0
- package/dist/lib/github-fetch.js +152 -0
- package/dist/lib/github-fetch.js.map +1 -0
- package/dist/lib/keychain.d.ts +41 -0
- package/dist/lib/keychain.js +232 -0
- package/dist/lib/keychain.js.map +1 -0
- package/dist/studio/types.d.ts +13 -0
- package/dist/utils/claude-plugin.d.ts +26 -0
- package/dist/utils/claude-plugin.js +60 -0
- package/dist/utils/claude-plugin.js.map +1 -1
- package/package.json +2 -1
- package/dist/eval-ui/assets/SkillDetailPanel-cyzLsLcK.js +0 -1
- package/dist/eval-ui/assets/index-C3S9iHnq.js +0 -122
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// vskill clone — orchestrator
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Drives the 9-step atomic pipeline described in plan.md §5:
|
|
5
|
+
//
|
|
6
|
+
// 1. Validate source (exists, SKILL.md parseable)
|
|
7
|
+
// 2. Resolve target (collision check; honor --force)
|
|
8
|
+
// 3. Copy via target-router into <target>.tmp
|
|
9
|
+
// 4. applyForkMetadata to <target>.tmp/SKILL.md
|
|
10
|
+
// 5. writeForkProvenance to <target>.tmp/.vskill-meta.json
|
|
11
|
+
// 6. Validate cloned (frontmatter parses; agents/* references resolve)
|
|
12
|
+
// 7. (plugin target only) stage updated plugin.json at <plugin.json>.tmp
|
|
13
|
+
// 8. Atomic rename(s) — and only at this point delete the live target if --force
|
|
14
|
+
// 9. Optional `runGh repo create + push` — never on partial state
|
|
15
|
+
//
|
|
16
|
+
// Failure path: any throw before step 8 triggers `bestEffortRm` on every
|
|
17
|
+
// staged `.tmp` path. The shape is deliberately the same as
|
|
18
|
+
// src/commands/add.ts:rollbackInstall (best-effort rm, swallow per-call errors)
|
|
19
|
+
// so a partial filesystem state never suppresses the underlying diagnostic.
|
|
20
|
+
//
|
|
21
|
+
// See spec AC-US6-01..03, AC-US1-01..04, AC-US2-01..03, AC-US3-01..03.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
import { existsSync, promises as fs } from "node:fs";
|
|
24
|
+
import { join, resolve } from "node:path";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { applyForkMetadata } from "../installer/frontmatter.js";
|
|
27
|
+
import { locateSkill, enumeratePluginSkills, } from "../clone/skill-locator.js";
|
|
28
|
+
import { scanReferences, scanSelfNameOccurrences } from "../clone/reference-scanner.js";
|
|
29
|
+
import { writeForkProvenance } from "../clone/provenance-fork.js";
|
|
30
|
+
import { bestEffortRm, bareSkillName, tmpSibling, writeNewPlugin, writeStandalone, writeToPlugin, } from "../clone/target-router.js";
|
|
31
|
+
import { scaffoldGitHub } from "../clone/github-scaffold.js";
|
|
32
|
+
import { bold, cyan, dim, green, red, yellow } from "../utils/output.js";
|
|
33
|
+
import { confirmPrompt, promptInput, promptChoice } from "./clone-prompts.js";
|
|
34
|
+
const TARGET_KINDS = ["standalone", "plugin", "new-plugin"];
|
|
35
|
+
const SOURCE_LOCATIONS = ["project", "personal", "cache"];
|
|
36
|
+
function slugify(name) {
|
|
37
|
+
return name
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
40
|
+
.replace(/^-+|-+$/g, "")
|
|
41
|
+
.slice(0, 64);
|
|
42
|
+
}
|
|
43
|
+
async function detectGitUserName(cwd) {
|
|
44
|
+
try {
|
|
45
|
+
const { spawn } = await import("node:child_process");
|
|
46
|
+
return await new Promise((resolveName) => {
|
|
47
|
+
const child = spawn("git", ["config", "user.name"], { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
48
|
+
let stdout = "";
|
|
49
|
+
child.stdout?.on("data", (c) => {
|
|
50
|
+
stdout += c.toString();
|
|
51
|
+
});
|
|
52
|
+
child.on("error", () => resolveName(undefined));
|
|
53
|
+
child.on("close", () => {
|
|
54
|
+
const trimmed = stdout.trim();
|
|
55
|
+
resolveName(trimmed || undefined);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Disambiguate when multiple sources match. When --source is provided, filter
|
|
65
|
+
* to that location; otherwise prompt the user to pick (or take the first match
|
|
66
|
+
* in non-TTY environments).
|
|
67
|
+
*/
|
|
68
|
+
async function disambiguateSource(matches, preferred) {
|
|
69
|
+
if (matches.length === 0) {
|
|
70
|
+
throw new Error("no source matches");
|
|
71
|
+
}
|
|
72
|
+
if (preferred) {
|
|
73
|
+
const filtered = matches.filter((m) => m.location === preferred);
|
|
74
|
+
if (filtered.length === 0) {
|
|
75
|
+
throw new Error(`--source ${preferred} did not match any installed skill (found locations: ${matches.map((m) => m.location).join(", ")})`);
|
|
76
|
+
}
|
|
77
|
+
return filtered[0];
|
|
78
|
+
}
|
|
79
|
+
if (matches.length === 1)
|
|
80
|
+
return matches[0];
|
|
81
|
+
if (!process.stdin.isTTY) {
|
|
82
|
+
// Non-interactive: take the first match in deterministic search order.
|
|
83
|
+
return matches[0];
|
|
84
|
+
}
|
|
85
|
+
const labels = matches.map((m) => `${m.location}: ${m.skillDir}`);
|
|
86
|
+
const choice = await promptChoice("Multiple matches found — choose source:", labels);
|
|
87
|
+
return matches[choice];
|
|
88
|
+
}
|
|
89
|
+
function describeCloneSummary(result) {
|
|
90
|
+
const lines = [];
|
|
91
|
+
lines.push(green(`✔ Cloned ${cyan(result.source.skillName)} → ${cyan(result.finalSkillName)}`));
|
|
92
|
+
lines.push(dim(` source: ${result.source.skillDir} (${result.source.location})`));
|
|
93
|
+
lines.push(dim(` target: ${result.target.targetSkillDir}`));
|
|
94
|
+
lines.push(dim(` files: ${result.filesCopied}`));
|
|
95
|
+
if (result.provenance.forkChain && result.provenance.forkChain.length > 0) {
|
|
96
|
+
lines.push(dim(` fork chain: ${result.provenance.forkChain.join(" → ")}`));
|
|
97
|
+
}
|
|
98
|
+
if (result.referenceReport.length > 0) {
|
|
99
|
+
lines.push("");
|
|
100
|
+
lines.push(yellow(`Cross-skill references found (review manually — NOT auto-rewritten):`));
|
|
101
|
+
for (const m of result.referenceReport) {
|
|
102
|
+
lines.push(dim(` ${m.file}:${m.line} [${m.kind}] ${m.match}`));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (result.selfNameMatches.length > 0) {
|
|
106
|
+
lines.push("");
|
|
107
|
+
lines.push(yellow(`Old skill name occurrences in prose (review manually):`));
|
|
108
|
+
for (const m of result.selfNameMatches) {
|
|
109
|
+
lines.push(dim(` ${m.file}:${m.line} ${m.match}`));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (result.githubRepoUrl) {
|
|
113
|
+
lines.push("");
|
|
114
|
+
lines.push(green(`✔ GitHub repo created: ${result.githubRepoUrl}`));
|
|
115
|
+
}
|
|
116
|
+
return lines.join("\n");
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Recursively scan a skill directory for cross-skill references and
|
|
120
|
+
* self-name occurrences across SKILL.md and any .md files in subdirectories
|
|
121
|
+
* (e.g., agents/*.md). Operates on the staged `.tmp` content so the report
|
|
122
|
+
* reflects what actually got copied.
|
|
123
|
+
*/
|
|
124
|
+
async function scanCopiedSkill(stagingDir, oldSkillName) {
|
|
125
|
+
const refs = [];
|
|
126
|
+
const selfNames = [];
|
|
127
|
+
async function walk(dir, relBase) {
|
|
128
|
+
let entries;
|
|
129
|
+
try {
|
|
130
|
+
entries = await fs.readdir(dir);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
const full = join(dir, entry);
|
|
137
|
+
const rel = relBase ? `${relBase}/${entry}` : entry;
|
|
138
|
+
const st = await fs.stat(full);
|
|
139
|
+
if (st.isDirectory()) {
|
|
140
|
+
await walk(full, rel);
|
|
141
|
+
}
|
|
142
|
+
else if (st.isFile() && entry.endsWith(".md")) {
|
|
143
|
+
const content = await fs.readFile(full, "utf-8");
|
|
144
|
+
refs.push(...scanReferences(content, { oldSkillName, file: rel }));
|
|
145
|
+
selfNames.push(...scanSelfNameOccurrences(content, { oldSkillName, file: rel }));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
await walk(stagingDir, "");
|
|
150
|
+
return { refs, selfNames };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Validate the staged clone: SKILL.md must parse and any `agents/*.md`
|
|
154
|
+
* referenced by frontmatter must exist. Throws on any inconsistency.
|
|
155
|
+
*/
|
|
156
|
+
async function validateStagedClone(stagingDir) {
|
|
157
|
+
const skillMd = join(stagingDir, "SKILL.md");
|
|
158
|
+
if (!existsSync(skillMd)) {
|
|
159
|
+
throw new Error(`staged clone missing SKILL.md at ${skillMd}`);
|
|
160
|
+
}
|
|
161
|
+
const raw = await fs.readFile(skillMd, "utf-8");
|
|
162
|
+
if (!/^---\n[\s\S]*?\n---/.test(raw.replace(/\r\n/g, "\n"))) {
|
|
163
|
+
throw new Error(`staged clone SKILL.md is missing a YAML frontmatter block`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function resolveArgs(positionalSource, opts, homePath, cwdPath) {
|
|
167
|
+
// 1. locate source
|
|
168
|
+
const matches = await locateSkill(positionalSource, { home: homePath, cwd: cwdPath });
|
|
169
|
+
if (matches.length === 0) {
|
|
170
|
+
throw new Error(`no installed skill named "${positionalSource}" found in project (.claude/skills), personal (~/.claude/skills), or plugin cache`);
|
|
171
|
+
}
|
|
172
|
+
const source = await disambiguateSource(matches, opts.source);
|
|
173
|
+
// 2. target kind
|
|
174
|
+
let targetKind = opts.target;
|
|
175
|
+
if (!targetKind) {
|
|
176
|
+
if (!process.stdin.isTTY) {
|
|
177
|
+
throw new Error(`--target is required (one of ${TARGET_KINDS.join(", ")})`);
|
|
178
|
+
}
|
|
179
|
+
const idx = await promptChoice("Target shape?", TARGET_KINDS);
|
|
180
|
+
targetKind = TARGET_KINDS[idx];
|
|
181
|
+
}
|
|
182
|
+
// 3. author / namespace
|
|
183
|
+
let author = opts.author;
|
|
184
|
+
if (!author) {
|
|
185
|
+
author = await detectGitUserName(cwdPath);
|
|
186
|
+
}
|
|
187
|
+
if (!author) {
|
|
188
|
+
if (!process.stdin.isTTY) {
|
|
189
|
+
throw new Error("--author is required (could not detect from `git config user.name`)");
|
|
190
|
+
}
|
|
191
|
+
author = await promptInput("Author name?");
|
|
192
|
+
}
|
|
193
|
+
let namespace = opts.namespace || slugify(author);
|
|
194
|
+
if (!namespace) {
|
|
195
|
+
throw new Error("namespace is empty — pass --namespace explicitly");
|
|
196
|
+
}
|
|
197
|
+
const bareSrc = bareSkillName(source.skillName);
|
|
198
|
+
const newSkillBase = bareSrc;
|
|
199
|
+
const newSkillFullName = `${namespace}/${newSkillBase}`;
|
|
200
|
+
// 4. resolve final destination by target kind
|
|
201
|
+
let finalDir;
|
|
202
|
+
let pluginRoot;
|
|
203
|
+
let pluginName;
|
|
204
|
+
switch (targetKind) {
|
|
205
|
+
case "standalone": {
|
|
206
|
+
if (!opts.path) {
|
|
207
|
+
if (!process.stdin.isTTY)
|
|
208
|
+
throw new Error("--path is required for --target standalone");
|
|
209
|
+
opts.path = await promptInput("Standalone path?");
|
|
210
|
+
}
|
|
211
|
+
finalDir = resolve(opts.path);
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
case "plugin": {
|
|
215
|
+
if (!opts.plugin) {
|
|
216
|
+
if (!process.stdin.isTTY)
|
|
217
|
+
throw new Error("--plugin is required for --target plugin");
|
|
218
|
+
opts.plugin = await promptInput("Existing plugin path?");
|
|
219
|
+
}
|
|
220
|
+
pluginRoot = resolve(opts.plugin);
|
|
221
|
+
finalDir = join(pluginRoot, "skills", newSkillBase);
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case "new-plugin": {
|
|
225
|
+
if (!opts.path) {
|
|
226
|
+
if (!process.stdin.isTTY)
|
|
227
|
+
throw new Error("--path is required for --target new-plugin");
|
|
228
|
+
opts.path = await promptInput("New plugin path?");
|
|
229
|
+
}
|
|
230
|
+
if (!opts.pluginName) {
|
|
231
|
+
if (!process.stdin.isTTY)
|
|
232
|
+
throw new Error("--plugin-name is required for --target new-plugin");
|
|
233
|
+
opts.pluginName = await promptInput("New plugin name?");
|
|
234
|
+
}
|
|
235
|
+
pluginRoot = resolve(opts.path);
|
|
236
|
+
pluginName = opts.pluginName;
|
|
237
|
+
finalDir = join(pluginRoot, "skills", newSkillBase);
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
source,
|
|
243
|
+
targetKind,
|
|
244
|
+
newSkillBase,
|
|
245
|
+
newSkillFullName,
|
|
246
|
+
author,
|
|
247
|
+
namespace,
|
|
248
|
+
finalDir,
|
|
249
|
+
pluginRoot,
|
|
250
|
+
pluginName,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Run the clone pipeline. Returns the CloneResult on success. On failure,
|
|
255
|
+
* cleans up every staged `.tmp` path before propagating the error.
|
|
256
|
+
*
|
|
257
|
+
* The function is exported for use by the whole-plugin path (T-010) and unit
|
|
258
|
+
* tests; the CLI entry point is `cloneCommand` below.
|
|
259
|
+
*/
|
|
260
|
+
export async function runCloneOnce(resolved, opts) {
|
|
261
|
+
const stagingPaths = [];
|
|
262
|
+
let copy = null;
|
|
263
|
+
let pluginRootStaging;
|
|
264
|
+
try {
|
|
265
|
+
// STEP 2 — collision check (real disk). Final rename is the last write step.
|
|
266
|
+
if (existsSync(resolved.finalDir) && !opts.force) {
|
|
267
|
+
throw new Error(`target already exists: ${resolved.finalDir} (pass --force to overwrite)`);
|
|
268
|
+
}
|
|
269
|
+
// For new-plugin, the *plugin root* must also be checked for collision
|
|
270
|
+
// since writeNewPlugin stages the entire pluginRoot at <pluginRoot>.tmp.
|
|
271
|
+
if (resolved.targetKind === "new-plugin" && resolved.pluginRoot) {
|
|
272
|
+
if (existsSync(resolved.pluginRoot) && !opts.force) {
|
|
273
|
+
throw new Error(`target plugin root already exists: ${resolved.pluginRoot} (pass --force to overwrite)`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (opts.dryRun) {
|
|
277
|
+
console.log(dim(`[dry-run] would clone ${resolved.source.skillName} → ${resolved.newSkillFullName}`));
|
|
278
|
+
console.log(dim(`[dry-run] target: ${resolved.targetKind} ${resolved.finalDir}`));
|
|
279
|
+
// Emit a synthetic CloneResult so callers (whole-plugin path) can iterate without writing.
|
|
280
|
+
return {
|
|
281
|
+
source: resolved.source,
|
|
282
|
+
target: {
|
|
283
|
+
kind: resolved.targetKind,
|
|
284
|
+
targetSkillDir: resolved.finalDir,
|
|
285
|
+
},
|
|
286
|
+
finalSkillName: resolved.newSkillFullName,
|
|
287
|
+
filesCopied: 0,
|
|
288
|
+
referenceReport: [],
|
|
289
|
+
selfNameMatches: [],
|
|
290
|
+
githubRepoUrl: null,
|
|
291
|
+
provenance: {
|
|
292
|
+
promotedFrom: "global",
|
|
293
|
+
sourcePath: resolved.source.skillDir,
|
|
294
|
+
promotedAt: Date.now(),
|
|
295
|
+
forkedFrom: {
|
|
296
|
+
source: resolved.source.skillName,
|
|
297
|
+
version: resolved.source.version,
|
|
298
|
+
clonedAt: new Date().toISOString(),
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
// STEP 3 — copy to .tmp
|
|
304
|
+
if (resolved.targetKind === "standalone") {
|
|
305
|
+
copy = await writeStandalone({
|
|
306
|
+
sourceSkillDir: resolved.source.skillDir,
|
|
307
|
+
finalDir: resolved.finalDir,
|
|
308
|
+
});
|
|
309
|
+
stagingPaths.push(copy.stagingDir);
|
|
310
|
+
}
|
|
311
|
+
else if (resolved.targetKind === "plugin") {
|
|
312
|
+
if (!resolved.pluginRoot)
|
|
313
|
+
throw new Error("internal: pluginRoot missing for --target plugin");
|
|
314
|
+
copy = await writeToPlugin({
|
|
315
|
+
sourceSkillDir: resolved.source.skillDir,
|
|
316
|
+
pluginRoot: resolved.pluginRoot,
|
|
317
|
+
newSkillName: resolved.newSkillBase,
|
|
318
|
+
});
|
|
319
|
+
stagingPaths.push(copy.stagingDir);
|
|
320
|
+
stagingPaths.push(copy.manifestTmpPath);
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
// new-plugin
|
|
324
|
+
if (!resolved.pluginRoot || !resolved.pluginName) {
|
|
325
|
+
throw new Error("internal: pluginRoot or pluginName missing for --target new-plugin");
|
|
326
|
+
}
|
|
327
|
+
copy = await writeNewPlugin({
|
|
328
|
+
sourceSkillDir: resolved.source.skillDir,
|
|
329
|
+
pluginRoot: resolved.pluginRoot,
|
|
330
|
+
pluginName: resolved.pluginName,
|
|
331
|
+
newSkillName: resolved.newSkillBase,
|
|
332
|
+
author: resolved.author,
|
|
333
|
+
});
|
|
334
|
+
pluginRootStaging = copy.stagingDir;
|
|
335
|
+
stagingPaths.push(copy.stagingDir);
|
|
336
|
+
}
|
|
337
|
+
// STEP 4 — applyForkMetadata to the staged SKILL.md
|
|
338
|
+
const stagedSkillMd = resolved.targetKind === "new-plugin"
|
|
339
|
+
? join(pluginRootStaging, "skills", resolved.newSkillBase, "SKILL.md")
|
|
340
|
+
: join(copy.stagingDir, "SKILL.md");
|
|
341
|
+
const skillMdContent = await fs.readFile(stagedSkillMd, "utf-8");
|
|
342
|
+
const rewritten = applyForkMetadata(skillMdContent, {
|
|
343
|
+
name: resolved.newSkillFullName,
|
|
344
|
+
author: resolved.author,
|
|
345
|
+
version: "1.0.0",
|
|
346
|
+
forkedFrom: resolved.source.skillName,
|
|
347
|
+
});
|
|
348
|
+
await fs.writeFile(stagedSkillMd, rewritten, "utf-8");
|
|
349
|
+
// STEP 5 — write fork provenance to staged sidecar
|
|
350
|
+
const provenanceTargetDir = resolved.targetKind === "new-plugin"
|
|
351
|
+
? join(pluginRootStaging, "skills", resolved.newSkillBase)
|
|
352
|
+
: copy.stagingDir;
|
|
353
|
+
const provenance = await writeForkProvenance({
|
|
354
|
+
targetSkillDir: provenanceTargetDir,
|
|
355
|
+
forkedFrom: {
|
|
356
|
+
source: resolved.source.skillName,
|
|
357
|
+
version: resolved.source.version,
|
|
358
|
+
clonedAt: new Date().toISOString(),
|
|
359
|
+
},
|
|
360
|
+
sourceProvenance: resolved.source.existingProvenance,
|
|
361
|
+
sourcePath: resolved.source.skillDir,
|
|
362
|
+
sourceVersion: resolved.source.version,
|
|
363
|
+
});
|
|
364
|
+
// STEP 6 — validate staged clone
|
|
365
|
+
await validateStagedClone(provenanceTargetDir);
|
|
366
|
+
// STEP 6b — reference scan on the staged content
|
|
367
|
+
const { refs, selfNames } = await scanCopiedSkill(provenanceTargetDir, resolved.source.skillName);
|
|
368
|
+
// STEP 8 — atomic rename(s). If --force, remove the live target only NOW,
|
|
369
|
+
// immediately before the rename, to minimize the window where the user
|
|
370
|
+
// has neither the old nor new copy on disk.
|
|
371
|
+
if (resolved.targetKind === "new-plugin") {
|
|
372
|
+
// Single-rename: <pluginRoot>.tmp → <pluginRoot>
|
|
373
|
+
if (opts.force && existsSync(resolved.pluginRoot)) {
|
|
374
|
+
await fs.rm(resolved.pluginRoot, { recursive: true, force: true });
|
|
375
|
+
}
|
|
376
|
+
await fs.rename(pluginRootStaging, resolved.pluginRoot);
|
|
377
|
+
}
|
|
378
|
+
else if (resolved.targetKind === "plugin") {
|
|
379
|
+
// Two-phase commit: rename skill dir, then manifest. If the manifest
|
|
380
|
+
// rename fails, roll back the skill rename.
|
|
381
|
+
const pluginCopy = copy;
|
|
382
|
+
// .bak staging for --force + plugin (AC-US2-01): rename the live target
|
|
383
|
+
// to <finalDir>.bak so a manifest-rename failure can restore the
|
|
384
|
+
// original. Removed via bestEffortRm only after both renames succeed.
|
|
385
|
+
let bakDir;
|
|
386
|
+
if (opts.force && existsSync(resolved.finalDir)) {
|
|
387
|
+
bakDir = `${resolved.finalDir}.bak`;
|
|
388
|
+
bestEffortRm(bakDir);
|
|
389
|
+
await fs.rename(resolved.finalDir, bakDir);
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
await fs.rename(pluginCopy.stagingDir, resolved.finalDir);
|
|
393
|
+
await fs.rename(pluginCopy.manifestTmpPath, pluginCopy.manifestFinalPath);
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
// Roll back the skill-dir rename on manifest failure.
|
|
397
|
+
try {
|
|
398
|
+
await fs.rm(resolved.finalDir, { recursive: true, force: true });
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
// best-effort
|
|
402
|
+
}
|
|
403
|
+
// Restore original from .bak if we staged it. AC-US2-02: if the
|
|
404
|
+
// restore-rename itself fails, keep .bak on disk and warn loudly so
|
|
405
|
+
// the user can recover manually.
|
|
406
|
+
if (bakDir) {
|
|
407
|
+
try {
|
|
408
|
+
await fs.rename(bakDir, resolved.finalDir);
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
process.stderr.write(`WARNING: failed to restore from .bak at ${bakDir} — manual recovery needed\n`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
throw new Error(`manifest update failed; rolled back skill copy: ${err.message}`);
|
|
415
|
+
}
|
|
416
|
+
// Both renames succeeded — drop the .bak.
|
|
417
|
+
if (bakDir)
|
|
418
|
+
bestEffortRm(bakDir);
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
// standalone
|
|
422
|
+
if (opts.force && existsSync(resolved.finalDir)) {
|
|
423
|
+
await fs.rm(resolved.finalDir, { recursive: true, force: true });
|
|
424
|
+
}
|
|
425
|
+
await fs.rename(copy.stagingDir, resolved.finalDir);
|
|
426
|
+
}
|
|
427
|
+
// STEP 9 — optional gh
|
|
428
|
+
let githubRepoUrl = null;
|
|
429
|
+
if (opts.github && resolved.targetKind === "new-plugin") {
|
|
430
|
+
const ghRes = await scaffoldGitHub({
|
|
431
|
+
pluginDir: resolved.pluginRoot,
|
|
432
|
+
repoName: resolved.pluginName,
|
|
433
|
+
runGh: opts.runGh,
|
|
434
|
+
});
|
|
435
|
+
if (ghRes.skipped) {
|
|
436
|
+
console.warn(yellow(`[gh] skipped: ${ghRes.reason}`));
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
githubRepoUrl = ghRes.repoUrl ?? null;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
else if (opts.github && resolved.targetKind !== "new-plugin") {
|
|
443
|
+
console.warn(yellow("[gh] --github is only supported with --target new-plugin; skipping repo creation."));
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
source: resolved.source,
|
|
447
|
+
target: {
|
|
448
|
+
kind: resolved.targetKind,
|
|
449
|
+
targetSkillDir: resolved.finalDir,
|
|
450
|
+
existingPluginManifestPath: resolved.targetKind === "plugin"
|
|
451
|
+
? join(resolved.pluginRoot, ".claude-plugin", "plugin.json")
|
|
452
|
+
: undefined,
|
|
453
|
+
newPluginRoot: resolved.targetKind === "new-plugin" ? resolved.pluginRoot : undefined,
|
|
454
|
+
newPluginName: resolved.pluginName,
|
|
455
|
+
},
|
|
456
|
+
finalSkillName: resolved.newSkillFullName,
|
|
457
|
+
filesCopied: copy.filesCopied,
|
|
458
|
+
referenceReport: refs,
|
|
459
|
+
selfNameMatches: selfNames,
|
|
460
|
+
githubRepoUrl,
|
|
461
|
+
provenance,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
catch (err) {
|
|
465
|
+
// Best-effort cleanup of every staged path.
|
|
466
|
+
for (const p of stagingPaths)
|
|
467
|
+
bestEffortRm(p);
|
|
468
|
+
throw err;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/** CLI entry point (Commander action). */
|
|
472
|
+
export async function cloneCommand(source, opts) {
|
|
473
|
+
const homePath = opts.home ?? homedir();
|
|
474
|
+
const cwdPath = opts.cwd ?? process.cwd();
|
|
475
|
+
// Validate flag combinations early.
|
|
476
|
+
if (opts.target && !TARGET_KINDS.includes(opts.target)) {
|
|
477
|
+
console.error(red(`--target must be one of: ${TARGET_KINDS.join(", ")}`));
|
|
478
|
+
process.exit(1);
|
|
479
|
+
}
|
|
480
|
+
if (opts.source && !SOURCE_LOCATIONS.includes(opts.source)) {
|
|
481
|
+
console.error(red(`--source must be one of: ${SOURCE_LOCATIONS.join(", ")}`));
|
|
482
|
+
process.exit(1);
|
|
483
|
+
}
|
|
484
|
+
// Whole-plugin path (T-010): --plugin <name> with no positional source.
|
|
485
|
+
if (!source && opts.plugin) {
|
|
486
|
+
await runWholePluginClone(opts.plugin, opts, homePath, cwdPath);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (!source) {
|
|
490
|
+
console.error(red(`source skill name is required (or pass --plugin <name> for whole-plugin clone)`));
|
|
491
|
+
process.exit(1);
|
|
492
|
+
}
|
|
493
|
+
let resolved;
|
|
494
|
+
try {
|
|
495
|
+
resolved = await resolveArgs(source, opts, homePath, cwdPath);
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
console.error(red(err.message));
|
|
499
|
+
process.exit(1);
|
|
500
|
+
}
|
|
501
|
+
let result;
|
|
502
|
+
try {
|
|
503
|
+
result = await runCloneOnce(resolved, opts);
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
console.error(red(`clone failed: ${err.message}`));
|
|
507
|
+
process.exit(1);
|
|
508
|
+
}
|
|
509
|
+
if (opts.dryRun) {
|
|
510
|
+
console.log(dim(`[dry-run] no files written.`));
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
console.log(describeCloneSummary(result));
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Whole-plugin clone path (T-010). Delegated to its own module is overkill —
|
|
517
|
+
* the orchestration is small and shares all the same primitives.
|
|
518
|
+
*/
|
|
519
|
+
async function runWholePluginClone(pluginName, opts, homePath, cwdPath) {
|
|
520
|
+
const skills = await enumeratePluginSkills(pluginName, { home: homePath, cwd: cwdPath });
|
|
521
|
+
if (skills.length === 0) {
|
|
522
|
+
console.error(red(`no skills found in plugin "${pluginName}" under ~/.claude/plugins/cache`));
|
|
523
|
+
process.exit(1);
|
|
524
|
+
}
|
|
525
|
+
// Author / namespace defaulting (same logic as resolveArgs).
|
|
526
|
+
let author = opts.author ?? (await detectGitUserName(cwdPath));
|
|
527
|
+
if (!author) {
|
|
528
|
+
if (!process.stdin.isTTY) {
|
|
529
|
+
console.error(red(`--author is required (could not detect from \`git config user.name\`)`));
|
|
530
|
+
process.exit(1);
|
|
531
|
+
}
|
|
532
|
+
author = await promptInput("Author name?");
|
|
533
|
+
}
|
|
534
|
+
const namespace = opts.namespace || slugify(author);
|
|
535
|
+
if (!namespace) {
|
|
536
|
+
console.error(red(`namespace resolved to empty — pass --namespace explicitly`));
|
|
537
|
+
process.exit(1);
|
|
538
|
+
}
|
|
539
|
+
const targetKind = opts.target ?? "standalone";
|
|
540
|
+
if (targetKind === "plugin" && !opts.plugin) {
|
|
541
|
+
console.error(red(`--plugin <name> conflicts with whole-plugin clone target=plugin without an explicit destination plugin`));
|
|
542
|
+
process.exit(1);
|
|
543
|
+
}
|
|
544
|
+
if ((targetKind === "standalone" || targetKind === "new-plugin") && !opts.path) {
|
|
545
|
+
console.error(red(`--path is required for whole-plugin clone (it is the parent directory under which each cloned skill lands)`));
|
|
546
|
+
process.exit(1);
|
|
547
|
+
}
|
|
548
|
+
// Confirmation listing (AC-US4-02).
|
|
549
|
+
console.log(bold(`Whole-plugin clone — ${cyan(pluginName)}:`));
|
|
550
|
+
for (const s of skills) {
|
|
551
|
+
console.log(` ${dim("→")} ${s.skillName} ${dim(`(${s.location}, ${s.version})`)}`);
|
|
552
|
+
}
|
|
553
|
+
console.log(dim(`Each will be cloned under namespace ${cyan(namespace)} as a ${targetKind} target.`));
|
|
554
|
+
if (!opts.dryRun) {
|
|
555
|
+
const ok = await confirmPrompt(`Proceed with ${skills.length} clones?`, { yes: opts.yes });
|
|
556
|
+
if (!ok) {
|
|
557
|
+
console.log(yellow(`Aborted.`));
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// Per-skill resolution; rolls back already-cloned siblings on failure.
|
|
562
|
+
const completed = [];
|
|
563
|
+
// Tracks the live plugin root scaffolded by iter 1 of a `new-plugin` bulk clone.
|
|
564
|
+
// When `targetKind === "new-plugin"` and a later iteration fails, this entire
|
|
565
|
+
// root is removed in the rollback (AC-US1-01). For `targetKind === "plugin"`,
|
|
566
|
+
// the user's existing root is preserved (AC-US1-03).
|
|
567
|
+
let scaffoldedPluginRoot;
|
|
568
|
+
for (const s of skills) {
|
|
569
|
+
const newSkillBase = bareSkillName(s.skillName);
|
|
570
|
+
const newSkillFullName = `${namespace}/${newSkillBase}`;
|
|
571
|
+
let finalDir;
|
|
572
|
+
let pluginRoot;
|
|
573
|
+
let resolvedPluginName;
|
|
574
|
+
switch (targetKind) {
|
|
575
|
+
case "standalone": {
|
|
576
|
+
finalDir = resolve(opts.path, newSkillBase);
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
case "plugin": {
|
|
580
|
+
pluginRoot = resolve(opts.plugin);
|
|
581
|
+
finalDir = join(pluginRoot, "skills", newSkillBase);
|
|
582
|
+
break;
|
|
583
|
+
}
|
|
584
|
+
case "new-plugin": {
|
|
585
|
+
// For whole-plugin → new-plugin, scaffold the same plugin once and add subsequent skills via writeToPlugin.
|
|
586
|
+
// To keep this implementation simple and consistent with the spec, treat each skill as standalone-inside-the-plugin-root:
|
|
587
|
+
// use writeToPlugin for skills 2..N after the first writeNewPlugin.
|
|
588
|
+
pluginRoot = resolve(opts.path);
|
|
589
|
+
resolvedPluginName = opts.pluginName ?? pluginName;
|
|
590
|
+
finalDir = join(pluginRoot, "skills", newSkillBase);
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const resolved = {
|
|
595
|
+
source: s,
|
|
596
|
+
targetKind: targetKind === "new-plugin" && completed.length > 0 ? "plugin" : targetKind,
|
|
597
|
+
newSkillBase,
|
|
598
|
+
newSkillFullName,
|
|
599
|
+
author,
|
|
600
|
+
namespace,
|
|
601
|
+
finalDir,
|
|
602
|
+
pluginRoot,
|
|
603
|
+
pluginName: resolvedPluginName,
|
|
604
|
+
};
|
|
605
|
+
try {
|
|
606
|
+
const result = await runCloneOnce(resolved, opts);
|
|
607
|
+
completed.push({ finalDir: result.target.targetSkillDir });
|
|
608
|
+
// After iter 1 of a new-plugin bulk clone, the plugin root has been
|
|
609
|
+
// committed to disk. Capture it so the rollback path can also remove it
|
|
610
|
+
// (AC-US1-01). For target=plugin, leave the user's plugin root alone.
|
|
611
|
+
if (targetKind === "new-plugin" &&
|
|
612
|
+
scaffoldedPluginRoot === undefined &&
|
|
613
|
+
pluginRoot) {
|
|
614
|
+
scaffoldedPluginRoot = pluginRoot;
|
|
615
|
+
}
|
|
616
|
+
console.log(green(`✔ ${s.skillName} → ${newSkillFullName}`));
|
|
617
|
+
}
|
|
618
|
+
catch (err) {
|
|
619
|
+
console.error(red(`✗ ${s.skillName}: ${err.message}`));
|
|
620
|
+
// Roll back every previously-completed clone.
|
|
621
|
+
for (const c of completed) {
|
|
622
|
+
try {
|
|
623
|
+
if (existsSync(c.finalDir)) {
|
|
624
|
+
await fs.rm(c.finalDir, { recursive: true, force: true });
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
// best-effort
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// Also clean any stray .tmp from this iteration.
|
|
632
|
+
bestEffortRm(tmpSibling(finalDir));
|
|
633
|
+
// For new-plugin bulk clone, also remove the parent root scaffolded by
|
|
634
|
+
// iter 1 — otherwise re-running the clone fails on a half-built plugin
|
|
635
|
+
// (AC-US1-01). Skipped when no iteration succeeded (AC-US1-02) or when
|
|
636
|
+
// target=plugin (AC-US1-03).
|
|
637
|
+
if (scaffoldedPluginRoot && completed.length > 0) {
|
|
638
|
+
bestEffortRm(scaffoldedPluginRoot);
|
|
639
|
+
}
|
|
640
|
+
console.error(red(`Rolled back ${completed.length} prior clone(s) — target left clean.`));
|
|
641
|
+
process.exit(1);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
console.log(green(`✔ Cloned ${completed.length} skill(s) under namespace ${namespace}.`));
|
|
646
|
+
}
|
|
647
|
+
/** Re-exports for tests. */
|
|
648
|
+
export { resolveArgs, validateStagedClone, scanCopiedSkill, slugify, runWholePluginClone };
|
|
649
|
+
//# sourceMappingURL=clone.js.map
|