vipcode 0.0.0-stage → 0.2.1

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.
@@ -0,0 +1,38 @@
1
+ // Lo que llega del modelo, de las herramientas o de los proveedores no puede mover el cursor, borrar la pantalla,
2
+ // cambiar el portapapeles (OSC 52) ni disfrazar un comando en el diálogo de permiso
3
+ // Secuencias completas (CSI, OSC, DCS…) y escapes sueltos
4
+ const SEQUENCES = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[PX^_][^\x1b]*(?:\x1b\\)?|\x1b[@-Z\\-_]?/g;
5
+ // Caracteres de control salvo el tabulador y el salto de línea (incluye \r y los C1, como el CSI de un byte)
6
+ const CONTROLS = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
7
+ // Colores y estilos (SGR): son inofensivos salvo «oculto» (8), que escondería texto
8
+ const SGR = /\x1b\[([0-9;]*)m/g;
9
+ // Para enseñar texto que no es nuestro
10
+ export function stripControl(text) {
11
+ return String(text).replace(SEQUENCES, "").replace(CONTROLS, "");
12
+ }
13
+ // Para nuestros propios bloques (tablas con colores) que llevan dentro datos de fuera: deja solo los colores
14
+ export function stripUnsafe(text) {
15
+ const input = String(text);
16
+ let result = "";
17
+ let last = 0;
18
+ for (const match of input.matchAll(SGR)) {
19
+ result += stripControl(input.slice(last, match.index));
20
+ if (!match[1].split(";").includes("8")) {
21
+ result += match[0];
22
+ }
23
+ last = (match.index || 0) + match[0].length;
24
+ }
25
+ return result + stripControl(input.slice(last));
26
+ }
27
+ // Para lo que vas a aprobar (comandos, rutas, diffs): los caracteres de control se ven en vez de actuar
28
+ export function showControl(text) {
29
+ return String(text).replace(CONTROLS, (char) => {
30
+ if (char === "\x1b") {
31
+ return "␛";
32
+ }
33
+ if (char === "\r") {
34
+ return "␍";
35
+ }
36
+ return `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`;
37
+ });
38
+ }
@@ -0,0 +1,219 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { EXPORTS_DIR, HISTORY_PATH, SESSIONS_DIR, ensureHomeDir } from "./paths.js";
4
+ export function newSession(cwd) {
5
+ const now = new Date();
6
+ // id legible y ordenable: 20261029-174512-k3x
7
+ const stamp = now.toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/(\d{8})(\d{6})/, "$1-$2");
8
+ const suffix = Math.random().toString(36).slice(2, 5);
9
+ return {
10
+ id: `${stamp}-${suffix}`,
11
+ title: "(sin título)",
12
+ cwd,
13
+ created: Date.now(),
14
+ updated: Date.now(),
15
+ messages: [],
16
+ todos: [],
17
+ turns: [],
18
+ redo: [],
19
+ epoch: 0,
20
+ usage: { prompt: 0, completion: 0 },
21
+ };
22
+ }
23
+ // Las sesiones de la versión anterior no tenían todos los campos
24
+ function normalize(data) {
25
+ return {
26
+ id: String(data.id),
27
+ title: data.title || "(sin título)",
28
+ cwd: data.cwd || "",
29
+ created: data.created || data.updated || Date.now(),
30
+ updated: data.updated || Date.now(),
31
+ messages: Array.isArray(data.messages) ? data.messages : [],
32
+ todos: Array.isArray(data.todos) ? data.todos : [],
33
+ turns: Array.isArray(data.turns) ? data.turns : [],
34
+ redo: Array.isArray(data.redo) ? data.redo : [],
35
+ epoch: data.epoch || 0,
36
+ usage: data.usage || { prompt: 0, completion: 0 },
37
+ agent: data.agent,
38
+ };
39
+ }
40
+ function firstUserText(session) {
41
+ const first = session.messages.find((m) => m.role === "user");
42
+ if (!first) {
43
+ return "";
44
+ }
45
+ if (typeof first.content === "string") {
46
+ return first.content;
47
+ }
48
+ if (Array.isArray(first.content)) {
49
+ const part = first.content.find((p) => p.type === "text");
50
+ return part?.text || "";
51
+ }
52
+ return "";
53
+ }
54
+ export function saveSession(session) {
55
+ if (session.messages.length === 0) {
56
+ return;
57
+ }
58
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true });
59
+ session.updated = Date.now();
60
+ if (session.title === "(sin título)") {
61
+ // Sin los ficheros adjuntos con @
62
+ const text = firstUserText(session).split("\n\nFicheros adjuntos con @")[0];
63
+ if (text) {
64
+ session.title = text.replace(/\s+/g, " ").trim().slice(0, 60);
65
+ }
66
+ }
67
+ fs.writeFileSync(path.join(SESSIONS_DIR, `${session.id}.json`), JSON.stringify(session));
68
+ }
69
+ export function listSessions(cwd) {
70
+ if (!fs.existsSync(SESSIONS_DIR)) {
71
+ return [];
72
+ }
73
+ const sessions = [];
74
+ for (const file of fs.readdirSync(SESSIONS_DIR)) {
75
+ if (!file.endsWith(".json")) {
76
+ continue;
77
+ }
78
+ try {
79
+ const data = normalize(JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, file), "utf8")));
80
+ if (!cwd || data.cwd === cwd) {
81
+ sessions.push(data);
82
+ }
83
+ }
84
+ catch {
85
+ // sesión corrupta: la ignoramos
86
+ }
87
+ }
88
+ sessions.sort((a, b) => b.updated - a.updated);
89
+ return sessions;
90
+ }
91
+ export function loadSession(id) {
92
+ try {
93
+ return normalize(JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, `${id}.json`), "utf8")));
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ export function deleteSession(id) {
100
+ const file = path.join(SESSIONS_DIR, `${id}.json`);
101
+ if (!fs.existsSync(file)) {
102
+ return false;
103
+ }
104
+ fs.rmSync(file);
105
+ return true;
106
+ }
107
+ function contentText(content) {
108
+ if (typeof content === "string") {
109
+ return content;
110
+ }
111
+ if (Array.isArray(content)) {
112
+ return content
113
+ .map((part) => {
114
+ if (part.type === "text") {
115
+ return part.text;
116
+ }
117
+ if (part.type === "image_url") {
118
+ return "[imagen]";
119
+ }
120
+ return "";
121
+ })
122
+ .join("\n");
123
+ }
124
+ return "";
125
+ }
126
+ // La conversación en Markdown, para guardarla o compartirla
127
+ export function exportMarkdown(session) {
128
+ const lines = [
129
+ `# ${session.title}`,
130
+ "",
131
+ `- Carpeta: \`${session.cwd}\``,
132
+ `- Empezada: ${new Date(session.created).toLocaleString()}`,
133
+ `- Última actividad: ${new Date(session.updated).toLocaleString()}`,
134
+ `- Tokens: ${session.usage.prompt} de entrada, ${session.usage.completion} de salida`,
135
+ "",
136
+ ];
137
+ for (const message of session.messages) {
138
+ if (message.role === "system") {
139
+ continue;
140
+ }
141
+ if (message.role === "user") {
142
+ let text = contentText(message.content);
143
+ const attached = text.indexOf("\n\nFicheros adjuntos con @");
144
+ if (attached >= 0) {
145
+ const names = [...text.slice(attached).matchAll(/<(?:fichero|carpeta) ruta="([^"]+)">/g)].map((match) => match[1]);
146
+ text = `${text.slice(0, attached)}\n\n_Adjuntos: ${names.join(", ")}_`;
147
+ }
148
+ lines.push("## Tú", "", text, "");
149
+ }
150
+ else if (message.role === "assistant") {
151
+ const text = contentText(message.content);
152
+ lines.push("## vipcode", "");
153
+ if (text.trim() !== "") {
154
+ lines.push(text, "");
155
+ }
156
+ const calls = message.tool_calls || [];
157
+ for (const call of calls) {
158
+ let args = call.function?.arguments || "";
159
+ if (args.length > 600) {
160
+ args = `${args.slice(0, 600)}…`;
161
+ }
162
+ lines.push(`> 🔧 **${call.function?.name}** \`${args.replace(/`/g, "'")}\``, "");
163
+ }
164
+ }
165
+ else if (message.role === "tool") {
166
+ let text = contentText(message.content);
167
+ if (text.length > 2000) {
168
+ text = `${text.slice(0, 2000)}\n[…]`;
169
+ }
170
+ lines.push("<details><summary>Resultado</summary>", "", "```", text.replace(/```/g, "'''"), "```", "", "</details>", "");
171
+ }
172
+ }
173
+ return lines.join("\n");
174
+ }
175
+ export function saveExport(session) {
176
+ fs.mkdirSync(EXPORTS_DIR, { recursive: true });
177
+ const file = path.join(EXPORTS_DIR, `${session.id}.md`);
178
+ fs.writeFileSync(file, exportMarkdown(session));
179
+ return file;
180
+ }
181
+ // Historial de lo que escribes (flechas arriba/abajo), compartido entre sesiones
182
+ export function loadHistory() {
183
+ try {
184
+ return fs
185
+ .readFileSync(HISTORY_PATH, "utf8")
186
+ .split("\n")
187
+ .filter((line) => line.trim() !== "")
188
+ .map((line) => {
189
+ try {
190
+ return String(JSON.parse(line));
191
+ }
192
+ catch {
193
+ return line;
194
+ }
195
+ })
196
+ .slice(-500);
197
+ }
198
+ catch {
199
+ return [];
200
+ }
201
+ }
202
+ export function appendHistory(text) {
203
+ if (text.trim() === "") {
204
+ return;
205
+ }
206
+ try {
207
+ ensureHomeDir();
208
+ fs.appendFileSync(HISTORY_PATH, `${JSON.stringify(text)}\n`);
209
+ // De vez en cuando lo recortamos para que no crezca sin fin
210
+ const stat = fs.statSync(HISTORY_PATH);
211
+ if (stat.size > 512 * 1024) {
212
+ const lines = fs.readFileSync(HISTORY_PATH, "utf8").split("\n").filter(Boolean).slice(-1000);
213
+ fs.writeFileSync(HISTORY_PATH, `${lines.join("\n")}\n`);
214
+ }
215
+ }
216
+ catch {
217
+ // no es grave
218
+ }
219
+ }
package/dist/skills.js ADDED
@@ -0,0 +1,374 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { parseFrontmatter } from "./frontmatter.js";
6
+ import { GLOBAL_SKILLS_DIR } from "./paths.js";
7
+ import { truncate } from "./tools.js";
8
+ const MAX_BODY = 40000;
9
+ const SKILL_FILES = ["SKILL.md", "skill.md", "Skill.md"];
10
+ // Nombre según el estándar: minúsculas, números y guiones, hasta 64 caracteres
11
+ export function normalizeSkillName(name) {
12
+ return name
13
+ .toLowerCase()
14
+ .normalize("NFD")
15
+ .replace(/[̀-ͯ]/g, "")
16
+ .replace(/[^a-z0-9]+/g, "-")
17
+ .replace(/^-+|-+$/g, "")
18
+ .slice(0, 64)
19
+ .replace(/-+$/, "");
20
+ }
21
+ function tildify(file) {
22
+ const home = os.homedir();
23
+ if (file === home || file.startsWith(home + path.sep)) {
24
+ return `~${file.slice(home.length)}`;
25
+ }
26
+ return file;
27
+ }
28
+ function safeReaddir(dir) {
29
+ try {
30
+ return fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
31
+ }
32
+ catch {
33
+ return [];
34
+ }
35
+ }
36
+ function skillFile(dir) {
37
+ for (const name of SKILL_FILES) {
38
+ const file = path.join(dir, name);
39
+ if (fs.existsSync(file) && fs.statSync(file).isFile()) {
40
+ return file;
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+ function readSkill(dir, where) {
46
+ const file = skillFile(dir);
47
+ if (!file) {
48
+ return null;
49
+ }
50
+ let text = "";
51
+ try {
52
+ text = fs.readFileSync(file, "utf8");
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ const { data, body } = parseFrontmatter(text);
58
+ const name = normalizeSkillName(String(data.name || path.basename(dir)));
59
+ if (!name) {
60
+ return null;
61
+ }
62
+ let description = typeof data.description === "string" ? data.description.trim() : "";
63
+ if (!description) {
64
+ // Sin descripción, la primera línea con texto: el agente la necesita para saber cuándo usarla
65
+ description =
66
+ body
67
+ .split("\n")
68
+ .map((line) => line.replace(/^#+\s*/, "").trim())
69
+ .find((line) => line !== "") || `Skill ${name}`;
70
+ }
71
+ return {
72
+ name,
73
+ description: description.replace(/\s+/g, " ").slice(0, 1024),
74
+ dir,
75
+ body: body.trim(),
76
+ where,
77
+ userInvocable: data["user-invocable"] !== false,
78
+ modelInvocable: data["disable-model-invocation"] !== true,
79
+ };
80
+ }
81
+ // La carpeta puede ser una skill (en "paths" del config) o contener una skill por subcarpeta
82
+ function scanDir(dir, where) {
83
+ const self = readSkill(dir, where);
84
+ if (self) {
85
+ return [self];
86
+ }
87
+ const result = [];
88
+ for (const entry of safeReaddir(dir)) {
89
+ if (entry.name.startsWith(".") || (!entry.isDirectory() && !entry.isSymbolicLink())) {
90
+ continue;
91
+ }
92
+ const skill = readSkill(path.join(dir, entry.name), where);
93
+ if (skill) {
94
+ result.push(skill);
95
+ }
96
+ }
97
+ return result;
98
+ }
99
+ // Dónde se buscan, de menos a más prioridad (con el mismo nombre gana la última: las del proyecto)
100
+ export function skillDirs(root, config) {
101
+ const home = os.homedir();
102
+ const external = config.skills?.external !== false;
103
+ const dirs = [];
104
+ if (external) {
105
+ for (const parts of [[".claude", "skills"], [".config", "opencode", "skill"], [".config", "opencode", "skills"], [".agents", "skills"]]) {
106
+ dirs.push({ dir: path.join(home, ...parts), where: `~/${parts.join("/")}` });
107
+ }
108
+ }
109
+ dirs.push({ dir: GLOBAL_SKILLS_DIR, where: tildify(GLOBAL_SKILLS_DIR) });
110
+ if (external) {
111
+ for (const parts of [[".claude", "skills"], [".opencode", "skill"], [".opencode", "skills"], [".agents", "skills"]]) {
112
+ dirs.push({ dir: path.join(root, ...parts), where: parts.join("/") });
113
+ }
114
+ }
115
+ dirs.push({ dir: path.join(root, ".vipcode", "skills"), where: ".vipcode/skills" });
116
+ for (const extra of config.skills?.paths || []) {
117
+ dirs.push({ dir: path.resolve(root, String(extra).replace(/^~(?=$|[\\/])/, home)), where: String(extra) });
118
+ }
119
+ return dirs;
120
+ }
121
+ export function loadSkills(root, config) {
122
+ if (config.skills?.enabled === false) {
123
+ return [];
124
+ }
125
+ const disabled = new Set((config.skills?.disabled || []).map((name) => normalizeSkillName(String(name))));
126
+ const found = new Map();
127
+ for (const { dir, where } of skillDirs(root, config)) {
128
+ for (const skill of scanDir(dir, where)) {
129
+ if (!disabled.has(skill.name)) {
130
+ found.set(skill.name, skill);
131
+ }
132
+ }
133
+ }
134
+ return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
135
+ }
136
+ // Ficheros que trae la skill además de SKILL.md, para que el agente sepa qué puede leer
137
+ function skillFiles(dir, max = 60) {
138
+ const result = [];
139
+ const walk = (current, prefix, depth) => {
140
+ for (const entry of safeReaddir(current)) {
141
+ if (result.length >= max || depth > 3) {
142
+ return;
143
+ }
144
+ if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "__pycache__") {
145
+ continue;
146
+ }
147
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
148
+ if (entry.isDirectory()) {
149
+ walk(path.join(current, entry.name), relative, depth + 1);
150
+ }
151
+ else if (!SKILL_FILES.includes(relative)) {
152
+ result.push(relative);
153
+ }
154
+ }
155
+ };
156
+ walk(dir, "", 0);
157
+ return result;
158
+ }
159
+ // Lo que recibe el modelo al cargar una skill (con la herramienta o con /nombre)
160
+ export function skillContent(skill) {
161
+ let extra = "";
162
+ const files = skillFiles(skill.dir);
163
+ if (files.length > 0) {
164
+ extra = `\n\nFicheros de la skill (en ${skill.dir}):\n${files.map((file) => `- ${file}`).join("\n")}\nLéelos con la herramienta skill (name: "${skill.name}", file: "ruta") y ejecuta sus scripts con bash usando la ruta completa.`;
165
+ }
166
+ return `<skill name="${skill.name}" dir="${skill.dir}">\n${truncate(skill.body, MAX_BODY)}\n</skill>${extra}`;
167
+ }
168
+ // Un fichero de la skill (sin salir de su carpeta)
169
+ export function readSkillFile(skill, file) {
170
+ const abs = path.resolve(skill.dir, file);
171
+ let real = abs;
172
+ let base = skill.dir;
173
+ try {
174
+ real = fs.realpathSync(abs);
175
+ base = fs.realpathSync(skill.dir);
176
+ }
177
+ catch {
178
+ return `Error: la skill ${skill.name} no tiene el fichero ${file}.`;
179
+ }
180
+ if (real !== base && !real.startsWith(base + path.sep)) {
181
+ return `Error: ${file} está fuera de la carpeta de la skill.`;
182
+ }
183
+ if (fs.statSync(real).isDirectory()) {
184
+ const entries = safeReaddir(real).map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name));
185
+ return `Contenido de ${file}:\n${entries.join("\n") || "(vacía)"}`;
186
+ }
187
+ const buffer = fs.readFileSync(real);
188
+ if (buffer.subarray(0, 8000).includes(0)) {
189
+ return `${file} es un fichero binario (${buffer.length} bytes): úsalo desde bash con la ruta ${real}.`;
190
+ }
191
+ return `<fichero skill="${skill.name}" ruta="${file}">\n${truncate(buffer.toString("utf8"), MAX_BODY)}\n</fichero>`;
192
+ }
193
+ // En la lista que va en cada petición, descripciones cortas: los modelos gratis tienen poco contexto
194
+ const LISTED_DESCRIPTION = 250;
195
+ // Herramienta para que el agente cargue una skill cuando la tarea encaja con su descripción
196
+ export function skillTool(skills) {
197
+ const listed = skills.filter((skill) => skill.modelInvocable);
198
+ const short = (text) => (text.length > LISTED_DESCRIPTION ? `${text.slice(0, LISTED_DESCRIPTION - 1)}…` : text);
199
+ return {
200
+ name: "skill",
201
+ description: `Carga una skill: instrucciones y recursos para un tipo de tarea concreto. Si la tarea encaja con la descripción de alguna, cárgala antes de empezar y sigue sus instrucciones. Con "file" lees uno de sus ficheros (por ejemplo references/guia.md).\nSkills disponibles:\n${listed
202
+ .map((skill) => `- ${skill.name}: ${short(skill.description)}`)
203
+ .join("\n")}`,
204
+ parameters: {
205
+ type: "object",
206
+ properties: {
207
+ name: { type: "string", description: "Nombre de la skill" },
208
+ file: { type: "string", description: "Opcional: fichero de la skill que quieres leer, relativo a su carpeta" },
209
+ },
210
+ required: ["name"],
211
+ },
212
+ permission: "none",
213
+ readOnly: true,
214
+ async run(args) {
215
+ const wanted = normalizeSkillName(String(args.name || ""));
216
+ const skill = skills.find((item) => item.name === wanted);
217
+ if (!skill) {
218
+ return `Error: no hay ninguna skill llamada «${args.name}». Disponibles: ${listed.map((item) => item.name).join(", ") || "ninguna"}.`;
219
+ }
220
+ if (args.file) {
221
+ return readSkillFile(skill, String(args.file));
222
+ }
223
+ return skillContent(skill);
224
+ },
225
+ };
226
+ }
227
+ // Las carpetas de vipcode donde se instalan (las de Claude Code u opencode no se tocan)
228
+ export function installDir(root, project) {
229
+ if (project) {
230
+ return path.join(root, ".vipcode", "skills");
231
+ }
232
+ return GLOBAL_SKILLS_DIR;
233
+ }
234
+ // Carpetas con SKILL.md dentro de un directorio (ella misma o hasta 4 niveles más abajo)
235
+ function findSkillFolders(dir, depth = 0) {
236
+ if (skillFile(dir)) {
237
+ return [dir];
238
+ }
239
+ if (depth >= 4) {
240
+ return [];
241
+ }
242
+ const result = [];
243
+ for (const entry of safeReaddir(dir)) {
244
+ if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
245
+ result.push(...findSkillFolders(path.join(dir, entry.name), depth + 1));
246
+ }
247
+ }
248
+ return result;
249
+ }
250
+ // github.com/usuario/repo, …/tree/rama/carpeta, usuario/repo o cualquier URL de git
251
+ function parseGitSource(source) {
252
+ const github = /^https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?(?:\/(?:tree|blob)\/([^/\s]+)(?:\/(.+?))?)?\/?$/.exec(source);
253
+ if (github) {
254
+ let subdir = github[4];
255
+ // Un enlace a …/SKILL.md apunta a su carpeta
256
+ if (subdir && /(^|\/)skill\.md$/i.test(subdir)) {
257
+ subdir = path.posix.dirname(subdir);
258
+ }
259
+ return { url: `https://github.com/${github[1]}/${github[2]}.git`, ref: github[3], subdir };
260
+ }
261
+ if (/^[\w.-]+\/[\w.-]+$/.test(source)) {
262
+ return { url: `https://github.com/${source}.git` };
263
+ }
264
+ if (/^(https?:\/\/|git@|ssh:\/\/|file:\/\/).+/.test(source) || source.endsWith(".git")) {
265
+ return { url: source };
266
+ }
267
+ return null;
268
+ }
269
+ // Copia las skills de una carpeta o de un repositorio git a la carpeta de vipcode
270
+ export function installSkills(source, root, options = {}) {
271
+ const result = { installed: [], skipped: [] };
272
+ let base = path.resolve(root, source.replace(/^~(?=$|[\\/])/, os.homedir()));
273
+ let temp = null;
274
+ try {
275
+ if (!fs.existsSync(base)) {
276
+ const git = parseGitSource(source);
277
+ if (!git) {
278
+ throw new Error(`No encuentro ${source}: pon la ruta de una carpeta con SKILL.md o la dirección de un repositorio git.`);
279
+ }
280
+ temp = fs.mkdtempSync(path.join(os.tmpdir(), "vipcode-skill-"));
281
+ // Con «--» un origen que empiece por - no se toma como una opción de git (por ejemplo --upload-pack)
282
+ const args = ["clone", "--depth", "1", ...(git.ref ? [`--branch=${git.ref}`] : []), "--", git.url, temp];
283
+ const clone = spawnSync("git", args, { encoding: "utf8", timeout: 120000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
284
+ if (clone.error || clone.status !== 0) {
285
+ throw new Error(`No he podido descargar ${git.url}: ${(clone.stderr || clone.error?.message || "").trim().split("\n").pop()}`);
286
+ }
287
+ base = git.subdir ? path.join(temp, git.subdir) : temp;
288
+ if (!fs.existsSync(base)) {
289
+ throw new Error(`El repositorio no tiene la carpeta ${git.subdir}.`);
290
+ }
291
+ }
292
+ const folders = findSkillFolders(base);
293
+ if (folders.length === 0) {
294
+ throw new Error(`No hay ninguna skill (carpeta con SKILL.md) en ${source}.`);
295
+ }
296
+ const target = installDir(root, Boolean(options.project));
297
+ fs.mkdirSync(target, { recursive: true });
298
+ for (const folder of folders) {
299
+ const skill = readSkill(folder, "");
300
+ if (!skill) {
301
+ continue;
302
+ }
303
+ const dest = path.join(target, skill.name);
304
+ if (fs.existsSync(dest)) {
305
+ if (!options.force) {
306
+ result.skipped.push({ name: skill.name, reason: "ya existe (usa --force para sustituirla)" });
307
+ continue;
308
+ }
309
+ fs.rmSync(dest, { recursive: true, force: true });
310
+ }
311
+ fs.cpSync(folder, dest, { recursive: true, filter: (file) => path.basename(file) !== ".git" });
312
+ result.installed.push({ name: skill.name, dir: dest });
313
+ }
314
+ }
315
+ finally {
316
+ if (temp) {
317
+ fs.rmSync(temp, { recursive: true, force: true });
318
+ }
319
+ }
320
+ return result;
321
+ }
322
+ // Crea una skill nueva con una plantilla para rellenar
323
+ export function createSkill(name, root, project) {
324
+ const clean = normalizeSkillName(name);
325
+ if (!clean) {
326
+ throw new Error("El nombre solo puede tener letras, números y guiones.");
327
+ }
328
+ const dir = path.join(installDir(root, project), clean);
329
+ if (fs.existsSync(dir)) {
330
+ throw new Error(`Ya existe ${tildify(dir)}.`);
331
+ }
332
+ fs.mkdirSync(dir, { recursive: true });
333
+ const title = clean.replace(/-/g, " ").replace(/^./, (letter) => letter.toUpperCase());
334
+ fs.writeFileSync(path.join(dir, "SKILL.md"), `---
335
+ name: ${clean}
336
+ description: Explica en una o dos frases qué hace esta skill y cuándo usarla (por ejemplo «Úsala cuando el usuario pida…»). Con esta frase decide el agente si la carga.
337
+ ---
338
+
339
+ # ${title}
340
+
341
+ ## Cuándo usarla
342
+
343
+ - …
344
+
345
+ ## Instrucciones
346
+
347
+ 1. …
348
+ 2. …
349
+
350
+ ## Ejemplos
351
+
352
+ - …
353
+
354
+ <!-- Puedes añadir scripts/ y references/ en esta carpeta y nombrarlos aquí: el agente los lee cuando los necesita. -->
355
+ `);
356
+ return dir;
357
+ }
358
+ // Borra una skill de las carpetas de vipcode (las de otros programas no se tocan)
359
+ export function removeSkill(name, root, config) {
360
+ const clean = normalizeSkillName(name);
361
+ const own = [path.join(root, ".vipcode", "skills"), GLOBAL_SKILLS_DIR];
362
+ for (const dir of own) {
363
+ const skill = scanDir(dir, "").find((item) => item.name === clean);
364
+ if (skill) {
365
+ fs.rmSync(skill.dir, { recursive: true, force: true });
366
+ return skill.dir;
367
+ }
368
+ }
369
+ const elsewhere = loadSkills(root, config).find((item) => item.name === clean);
370
+ if (elsewhere) {
371
+ throw new Error(`La skill ${clean} está en ${elsewhere.where}, que no es de vipcode: bórrala desde allí o añádela a "skills.disabled" en la configuración.`);
372
+ }
373
+ throw new Error(`No hay ninguna skill llamada ${clean}.`);
374
+ }