@ariasbruno/skillbase 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/AGENTS.md ADDED
@@ -0,0 +1,41 @@
1
+ # AGENTS.md
2
+
3
+ Guía operativa para agentes que trabajen en este repositorio.
4
+
5
+ ## Alcance
6
+ Estas instrucciones aplican a todo el árbol del proyecto (`skillbase`).
7
+
8
+ ## Objetivo del proyecto
9
+ `skillbase` es una CLI para gestionar skills locales/remotas:
10
+ - store global por defecto: `~/.skillbase/skills`
11
+ - skills en proyecto: `.agents/skills`
12
+ - manifiesto del proyecto: `skillbase.json`
13
+
14
+ ## Reglas de implementación
15
+ 1. Mantener el código en JavaScript ESM (`"type": "module"`).
16
+ 2. Evitar dependencias externas salvo necesidad clara.
17
+ 3. Toda lógica de negocio va en `src/core.js`; `src/cli.js` sólo parsea/coordina comandos.
18
+ 4. Mantener mensajes de CLI en español (consistentes con README actual).
19
+ 5. Si agregas o cambias comandos, actualiza:
20
+ - ayuda en `src/cli.js`
21
+ - sección de comandos en `README.md`
22
+
23
+ ## Flujo de recomendaciones (init)
24
+ - Usar integración propia (sin dependencias externas) para detectar stack.
25
+ - Basarse en señales del proyecto (`package.json`, `requirements.txt`, etc).
26
+ - Si cambias este comportamiento, documenta el motivo en README.
27
+
28
+ ## Migración
29
+ - `skillbase migrate` migra desde `~/.agents/skills` a `~/.skillbase/skills`.
30
+ - `skillbase migrate --project` migra desde `.agents/skills` del cwd al mismo destino global.
31
+ - Mantener `--force` para sobrescritura explícita.
32
+
33
+ ## Calidad mínima
34
+ Antes de cerrar cambios ejecutar:
35
+ ```bash
36
+ npm run lint
37
+ node bin/skillbase.js -h
38
+ ```
39
+
40
+ ## Publicación
41
+ Si cambias comportamiento visible, actualiza README con ejemplos.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bruno Arias
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # 🧠 skillbase
2
+
3
+ **Gestor de skills locales para entornos de desarrollo con IA.**
4
+
5
+ `skillbase` es un CLI de Node.js que te permite administrar las skills de tus agentes de Inteligencia Artificial utilizando enlaces simbólicos (symlinks) o instalaciones locales por cada workspace, evitando la saturación del contexto.
6
+
7
+ ---
8
+
9
+ ## 🤔 El Problema: Saturación de Contexto (Context Bloat)
10
+
11
+ Cuando desarrollas asistido por IA (usando herramientas como Cursor, Copilot o agentes personalizados), tener un directorio global con cientos de skills `.agents/skills/` destruye la precisión del modelo:
12
+
13
+ * **Alucinaciones:** El LLM se confunde al tener demasiadas herramientas irrelevantes a su disposición.
14
+ * **Consumo de Tokens:** Enviar el manifiesto de cientos de skills en cada prompt es lento y costoso.
15
+ * **Falta de foco:** Un proyecto no necesita las mismas skills que otro.
16
+
17
+ ## 💡 La Solución
18
+
19
+ `skillbase` mantiene un **único registro global** en tu máquina (`~/.skillbase/skills`) y permite hacer una instalación en tu proyecto actual de **solo lo que necesitas**.
20
+
21
+ Imagínate que estás trabajando en el frontend de un e-commerce. No necesitas que la IA vea herramientas de DevOps o Backend; solo necesitas tus skills de React y quizás algunas de SEO. Con `skillbase`, le das a tu agente exactamente ese contexto.
22
+
23
+ ---
24
+
25
+ ## 🚀 Instalación
26
+
27
+ Instala el paquete globalmente:
28
+
29
+ ```bash
30
+ npm install -g @ariasbruno/skillbase
31
+ # O clona y linkea localmente:
32
+ # npm link
33
+ ```
34
+
35
+ ## 💻 Uso Rápido (Quickstart)
36
+
37
+ **1. Inicializa tu proyecto**
38
+ Detecta tecnologías y sugiere skills compatibles:
39
+ ```bash
40
+ skillbase init
41
+ ```
42
+
43
+ **2. Añade las skills necesarias**
44
+ ```bash
45
+ skillbase add seo-analyzer
46
+ # O abre el selector interactivo:
47
+ skillbase add
48
+ ```
49
+
50
+ **3. Instala desde el manifiesto**
51
+ Si ya tienes un `skillbase.json`, recrea el entorno:
52
+ ```bash
53
+ skillbase install
54
+ ```
55
+
56
+ ---
57
+
58
+ ## 🛠️ Referencia de Comandos
59
+
60
+ | Comando | Alias | Descripción |
61
+ | :--- | :--- | :--- |
62
+ | `skillbase ls` | `l` | Lista skills instaladas globalmente en `~/.skillbase/skills`. |
63
+ | `skillbase init [--hard]` | | Detecta tecnologías y sugiere skills (usa `--hard` para analizar `tags` en `skill.json`). |
64
+ | `skillbase add [<skill>] [-s]` | `a` | Instalar skill global. Sin nombre, abre el **selector interactivo**. `-s` crea symlink. |
65
+ | `skillbase install` | `i` | Instala desde el manifiesto `skillbase.json`. Soporta `-r` (remotas) y `-f` (forzar). |
66
+ | `skillbase install <ref> -r` | `i -r` | Instala skill remota (URL o GitHub). Requiere `-k <name>` si es un repo Git. |
67
+ | `skillbase remove <skill> [--g]` | `rm` | Elimina skill del proyecto. Con `--g` la elimina del registro global. |
68
+ | `skillbase check [-r]` | `c` | Busca actualizaciones. Con `-r` busca solo en fuentes remotas. |
69
+ | `skillbase update [<skill>] [-r] [-f]` | `up` | Actualiza una o todas las skills. `-r` para remotas, `-f` para forzar. |
70
+ | `skillbase migrate [-p] [-f]` | `m` | Migra desde el antiguo `.agents`. `-p` usa el dir del proyecto, `-f` sobrescribe. |
71
+
72
+ ### 🚩 Flags Detalladas
73
+ - `-h`, `--help`: Muestra la ayuda detallada.
74
+ - `-s`, `--sym`: Crea un enlace simbólico en lugar de copiar los archivos (útil para desarrollo).
75
+ - `-r`, `--remote`: Indica que la operación debe consultar fuentes externas (GitHub o API de skills.sh).
76
+ - `-f`, `--force`: Ignora errores de "ya existe" y sobrescribe archivos/configuraciones.
77
+ - `-k`, `--skill`: Nombre de la skill específica a extraer cuando se instala desde un repositorio GitHub.
78
+ - `-p`, `--project`: En migraciones, indica que el origen es `.agents/skills` del proyecto actual.
79
+ - `--g`: Flag específica de `remove` para borrar una skill del registro global.
80
+
81
+ ### ⌨️ Aliases de comandos
82
+ Para mayor velocidad, puedes usar las iniciales:
83
+ `l` (ls), `a` (add), `i` (install), `rm` (remove), `c` (check), `up` (update), `m` (migrate).
84
+
85
+ ---
86
+
87
+ ## 📂 ¿Cómo funciona bajo el capó?
88
+
89
+ `skillbase` organiza tus herramientas de forma eficiente:
90
+
91
+ ```text
92
+ Tu Computadora
93
+ ├── ~/.skillbase/skills/ <-- (Tu registro global físico)
94
+ │ ├── seo-analyzer/ <-- (Código fuente real)
95
+ │ └── react-helper/
96
+
97
+ └── /Proyectos/mi-gran-app/ <-- (Tu workspace actual)
98
+ ├── skillbase.json <-- (El manifiesto del proyecto)
99
+ └── .agents/skills/ <-- (Contexto limpio para la IA)
100
+ └── seo-analyzer/ <-- (Enlace o copia local)
101
+ ```
102
+
103
+ Puedes cambiar la ubicación del registro global configurando la variable de entorno `SKILLBASE_HOME`.
104
+
105
+ ---
106
+
107
+ ## 🌐 Fuentes Remotas
108
+
109
+ Instala skills directamente desde repositorios de GitHub:
110
+ ```bash
111
+ skillbase install <repo-url> --remote
112
+ # Ejemplo:
113
+ skillbase install https://github.com/usuario/my-skills --remote --skill analyzer
114
+ ```
115
+
116
+ ---
117
+
118
+ ## 🤝 Contribuciones
119
+
120
+ ¡Las contribuciones son bienvenidas! Si tienes ideas para mejorar la gestión del contexto, abre un Issue o un Pull Request.
121
+
122
+ ## 📄 Licencia
123
+
124
+ MIT
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { runCLI } from '../src/cli.js';
3
+
4
+ runCLI(process.argv).catch((error) => {
5
+ console.error(`Error: ${error.message}`);
6
+ process.exitCode = 1;
7
+ });
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@ariasbruno/skillbase",
3
+ "version": "0.1.0",
4
+ "description": "CLI para administrar skills locales y remotas entre proyectos",
5
+ "type": "module",
6
+ "bin": {
7
+ "skillbase": "bin/skillbase.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/skillbase.js -h",
11
+ "lint": "node --check bin/skillbase.js && node --check src/cli.js && node --check src/core.js"
12
+ },
13
+ "license": "MIT",
14
+ "engines": {
15
+ "node": ">=18.0.0"
16
+ },
17
+ "files": [
18
+ "bin/",
19
+ "src/",
20
+ "README.md",
21
+ "AGENTS.md",
22
+ "LICENSE"
23
+ ],
24
+ "keywords": [
25
+ "skills",
26
+ "cli",
27
+ "agents",
28
+ "ai",
29
+ "context",
30
+ "bloat",
31
+ "developer-tools"
32
+ ]
33
+ }
package/src/cli.js ADDED
@@ -0,0 +1,253 @@
1
+ import {
2
+ addSkillsInteractive,
3
+ addSkill,
4
+ checkUpdates,
5
+ getGlobalSkillsDir,
6
+ initProject,
7
+ installFromManifest,
8
+ installRemoteSkillRef,
9
+ listGlobalSkills,
10
+ migrateAgentsSkillsToSkillbase,
11
+ readManifest,
12
+ removeSkill,
13
+ updateSkills
14
+ } from './core.js';
15
+
16
+ function printHelp() {
17
+ console.log(`skillbase - Gestor de skills locales/remotas
18
+
19
+ Uso:
20
+ skillbase -h
21
+ skillbase h
22
+ skillbase ls
23
+ skillbase l
24
+ skillbase init [--hard]
25
+ skillbase add <skill> [--sym]
26
+ skillbase add
27
+ skillbase a <skill> [-s]
28
+ skillbase install
29
+ skillbase i
30
+ skillbase install <skill|repo-url> --remote [--skill <name>] [--force]
31
+ skillbase remove <skill> [--g]
32
+ skillbase rm <skill> [--g]
33
+ skillbase check [--remote]
34
+ skillbase c [-r]
35
+ skillbase update [<skill>] [--remote] [--force]
36
+ skillbase up [<skill>] [-r] [-f]
37
+ skillbase migrate [--project] [--force]
38
+ skillbase m [--project] [-f]
39
+
40
+ Descripción breve de comandos:
41
+ ls Lista skills instaladas globalmente en ~/.skillbase/skills.
42
+ init Sugiere skills globales según tecnologías del proyecto.
43
+ add Instala una skill global (o abre selector interactivo sin argumentos).
44
+ install Instala desde skillbase.json o una skill remota con --remote.
45
+ remove Elimina una skill del proyecto o global con --g.
46
+ check Revisa si hay versiones más nuevas disponibles.
47
+ update Actualiza skills del manifiesto (todas o una puntual).
48
+ migrate Migra skills desde .agents hacia ~/skillbase/skills.
49
+
50
+ Aliases:
51
+ l=ls, h=-h, a=add, i=install, rm=remove, c=check, up=update, m=migrate
52
+
53
+ Short flags:
54
+ -s=--sym, -r=--remote, -f=--force, -k=--skill, -p=--project
55
+
56
+ Notas:
57
+ - Carpeta global por defecto: ~/.skillbase/skills
58
+ - Puedes cambiarla con SKILLBASE_HOME
59
+ - Las skills de proyecto viven en .agents/skills
60
+ - El manifiesto del proyecto es skillbase.json
61
+ `);
62
+ }
63
+
64
+ function printCommandList() {
65
+ console.log(`Comandos disponibles:
66
+ - skillbase ls
67
+ - skillbase l
68
+ - skillbase init [--hard]
69
+ - skillbase add
70
+ - skillbase add <skill> [--sym]
71
+ - skillbase a <skill> [-s]
72
+ - skillbase install | skillbase i
73
+ - skillbase install <skill|repo-url> --remote [--skill <name>] [--force]
74
+ - skillbase remove <skill> [--g]
75
+ - skillbase rm <skill> [--g]
76
+ - skillbase check [--remote]
77
+ - skillbase c [-r]
78
+ - skillbase update [<skill>] [--remote] [--force]
79
+ - skillbase up [<skill>] [-r] [-f]
80
+ - skillbase migrate [--project] [--force]
81
+ - skillbase m [--project] [-f]
82
+
83
+ Usa "skillbase -h" para ver detalles.`);
84
+ }
85
+
86
+ function hasFlag(args, ...flags) {
87
+ return args.some((arg) => flags.includes(arg));
88
+ }
89
+
90
+ function getFlagValue(args, ...flags) {
91
+ for (let i = 0; i < args.length; i += 1) {
92
+ if (flags.includes(args[i])) return args[i + 1] ?? null;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ export async function runCLI(argv) {
98
+ const args = argv.slice(2);
99
+ const [rawCommand, maybeSkill] = args;
100
+ const commandAliases = {
101
+ h: '-h',
102
+ l: 'ls',
103
+ a: 'add',
104
+ i: 'install',
105
+ rm: 'remove',
106
+ c: 'check',
107
+ up: 'update',
108
+ m: 'migrate'
109
+ };
110
+ const command = commandAliases[rawCommand] ?? rawCommand;
111
+
112
+ if (!command) {
113
+ printCommandList();
114
+ return;
115
+ }
116
+
117
+ if (command === '-h' || command === '--help') {
118
+ printHelp();
119
+ return;
120
+ }
121
+
122
+ switch (command) {
123
+ case 'ls': {
124
+ const skills = await listGlobalSkills();
125
+ if (!skills.length) {
126
+ console.log(`No hay skills globales instaladas en ${getGlobalSkillsDir()}`);
127
+ } else {
128
+ console.log(`Skills globales (${getGlobalSkillsDir()}):`);
129
+ skills.forEach((skill) => console.log(`- ${skill}`));
130
+ }
131
+ return;
132
+ }
133
+
134
+ case 'init': {
135
+ const result = await initProject({ hard: hasFlag(args, '--hard') });
136
+ if (!result.technologies.length) {
137
+ console.log('No se detectaron tecnologías en el proyecto.');
138
+ return;
139
+ }
140
+ console.log(`Tecnologías detectadas: ${result.technologies.join(', ')}`);
141
+ if (!result.suggested.length) {
142
+ console.log('No se encontraron skills compatibles en ~/.skillbase/skills.');
143
+ return;
144
+ }
145
+ console.log(`Skills sugeridas: ${result.suggested.join(', ')}`);
146
+ if (result.cancelled) {
147
+ console.log('Selección cancelada.');
148
+ return;
149
+ }
150
+ if (result.installed.length) {
151
+ console.log(`Skills instaladas: ${result.installed.join(', ')}`);
152
+ } else {
153
+ console.log('No se seleccionaron skills para instalar.');
154
+ }
155
+ return;
156
+ }
157
+
158
+ case 'add': {
159
+ if (!maybeSkill) {
160
+ const result = await addSkillsInteractive({ sym: hasFlag(args, '--sym', '-s') });
161
+ if (result.cancelled) return;
162
+ if (!result.selected.length) {
163
+ console.log('No se seleccionaron skills para instalar.');
164
+ return;
165
+ }
166
+ console.log(`Skills instaladas: ${result.selected.join(', ')}`);
167
+ return;
168
+ }
169
+ await addSkill(maybeSkill, { sym: hasFlag(args, '--sym', '-s') });
170
+ console.log(`Skill "${maybeSkill}" instalada en el proyecto.`);
171
+ return;
172
+ }
173
+
174
+ case 'install': {
175
+ if (maybeSkill && hasFlag(args, '--remote', '-r')) {
176
+ await installRemoteSkillRef(maybeSkill, {
177
+ force: hasFlag(args, '--force', '-f'),
178
+ skill: getFlagValue(args, '--skill', '-k')
179
+ });
180
+ console.log(`Skill remota "${maybeSkill}" instalada.`);
181
+ return;
182
+ }
183
+ if (maybeSkill && !hasFlag(args, '--remote', '-r')) {
184
+ throw new Error('Para instalar una skill puntual usa "skillbase add <skill>" (global) o "skillbase install <skill|repo-url> --remote".');
185
+ }
186
+ await installFromManifest({ remote: hasFlag(args, '--remote', '-r'), force: hasFlag(args, '--force', '-f') });
187
+ const manifest = await readManifest();
188
+ console.log(`Instaladas ${manifest.skills.length} skills desde skillbase.json.`);
189
+ return;
190
+ }
191
+
192
+ case 'remove': {
193
+ if (!maybeSkill) throw new Error('Debes indicar una skill: skillbase remove <skill>');
194
+ await removeSkill(maybeSkill, { global: hasFlag(args, '--g') });
195
+ if (hasFlag(args, '--g')) {
196
+ console.log(`Skill global "${maybeSkill}" eliminada.`);
197
+ } else {
198
+ console.log(`Skill "${maybeSkill}" eliminada del proyecto.`);
199
+ }
200
+ return;
201
+ }
202
+
203
+ case 'check': {
204
+ const updates = await checkUpdates({ remoteOnly: hasFlag(args, '--remote', '-r') });
205
+ if (!updates.length) {
206
+ console.log('No hay actualizaciones disponibles.');
207
+ return;
208
+ }
209
+ console.log('Actualizaciones disponibles:');
210
+ updates.forEach((item) => {
211
+ console.log(`- ${item.name}: ${item.current ?? 'desconocida'} -> ${item.latest} (${item.source})`);
212
+ });
213
+ return;
214
+ }
215
+
216
+ case 'update': {
217
+ const skill = maybeSkill && !maybeSkill.startsWith('-') ? maybeSkill : null;
218
+ await updateSkills({
219
+ skillName: skill,
220
+ remoteOnly: hasFlag(args, '--remote', '-remote', '-r'),
221
+ force: hasFlag(args, '--force', '-f')
222
+ });
223
+ if (skill) {
224
+ console.log(`Skill "${skill}" actualizada.`);
225
+ } else {
226
+ console.log('Skills actualizadas.');
227
+ }
228
+ return;
229
+ }
230
+
231
+ case 'migrate': {
232
+ const fromProject = hasFlag(args, '--project', '-p');
233
+ const result = await migrateAgentsSkillsToSkillbase({
234
+ force: hasFlag(args, '--force', '-f'),
235
+ fromProject
236
+ });
237
+ console.log(`Origen de migración: ${result.sourceRoot}/skills`);
238
+ console.log(`Skills encontradas: ${result.totalFound}`);
239
+ if (result.migrated.length) {
240
+ console.log(`Migradas a ${getGlobalSkillsDir()}:`);
241
+ result.migrated.forEach((skill) => console.log(`- ${skill}`));
242
+ }
243
+ if (result.skipped.length) {
244
+ console.log('Omitidas (ya existen globalmente, usa --force para sobrescribir):');
245
+ result.skipped.forEach((skill) => console.log(`- ${skill}`));
246
+ }
247
+ return;
248
+ }
249
+
250
+ default:
251
+ throw new Error(`Comando desconocido: ${command}. Usa skillbase -h`);
252
+ }
253
+ }
package/src/config.js ADDED
@@ -0,0 +1,29 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ export const MANIFEST_FILE = 'skillbase.json';
5
+ export const PROJECT_AGENTS_DIR = '.agents';
6
+ export const PROJECT_SKILLS_DIR = 'skills';
7
+ export const DEFAULT_GLOBAL_ROOTNAME = '.skillbase';
8
+
9
+ export function getGlobalRootDir() {
10
+ const fromEnv = process.env.SKILLBASE_HOME;
11
+ if (fromEnv) return path.resolve(fromEnv);
12
+ return path.join(os.homedir(), DEFAULT_GLOBAL_ROOTNAME);
13
+ }
14
+
15
+ export function getGlobalSkillsDir() {
16
+ return path.join(getGlobalRootDir(), PROJECT_SKILLS_DIR);
17
+ }
18
+
19
+ export function getProjectRoot(cwd = process.cwd()) {
20
+ return cwd;
21
+ }
22
+
23
+ export function getProjectSkillsDir(cwd = process.cwd()) {
24
+ return path.join(getProjectRoot(cwd), PROJECT_AGENTS_DIR, PROJECT_SKILLS_DIR);
25
+ }
26
+
27
+ export function getManifestPath(cwd = process.cwd()) {
28
+ return path.join(getProjectRoot(cwd), MANIFEST_FILE);
29
+ }
package/src/core.js ADDED
@@ -0,0 +1,581 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import * as readline from 'node:readline';
5
+ import { stdin as input, stdout as output } from 'node:process';
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
8
+ import { getGlobalSkillsDir, getProjectRoot, getProjectSkillsDir, PROJECT_AGENTS_DIR, PROJECT_SKILLS_DIR } from './config.js';
9
+ import { readManifest, removeSkillFromManifest, upsertSkill, writeJson, writeManifest } from './manifest.js';
10
+ import { detectProjectTechnologies } from './recommendations.js';
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ function nowISO() {
14
+ return new Date().toISOString();
15
+ }
16
+
17
+ export { getGlobalSkillsDir, getProjectRoot, getProjectSkillsDir, readManifest };
18
+
19
+ export async function ensureDir(dir) {
20
+ await fs.mkdir(dir, { recursive: true });
21
+ }
22
+
23
+ async function exists(target) {
24
+ try {
25
+ await fs.access(target);
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ export async function listGlobalSkills() {
33
+ const globalDir = getGlobalSkillsDir();
34
+ await ensureDir(globalDir);
35
+ const entries = await fs.readdir(globalDir, { withFileTypes: true });
36
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
37
+ }
38
+
39
+ async function copyDir(src, dst) {
40
+ await fs.cp(src, dst, { recursive: true, force: true });
41
+ }
42
+
43
+ async function readSkillVersion(skillPath) {
44
+ const skillMeta = path.join(skillPath, 'skill.json');
45
+ if (!(await exists(skillMeta))) return null;
46
+ const data = JSON.parse(await fs.readFile(skillMeta, 'utf8'));
47
+ return typeof data.version === 'string' ? data.version : null;
48
+ }
49
+
50
+ function compareVersion(a, b) {
51
+ if (!a || !b) return 0;
52
+ const aa = a.split('.').map((x) => Number.parseInt(x, 10) || 0);
53
+ const bb = b.split('.').map((x) => Number.parseInt(x, 10) || 0);
54
+ for (let i = 0; i < Math.max(aa.length, bb.length); i += 1) {
55
+ const av = aa[i] ?? 0;
56
+ const bv = bb[i] ?? 0;
57
+ if (av > bv) return 1;
58
+ if (av < bv) return -1;
59
+ }
60
+ return 0;
61
+ }
62
+
63
+ export async function addSkill(skillName, { sym = false, cwd = process.cwd() } = {}) {
64
+ const globalPath = path.join(getGlobalSkillsDir(), skillName);
65
+ if (!(await exists(globalPath))) {
66
+ throw new Error(`La skill global "${skillName}" no existe en ${getGlobalSkillsDir()}`);
67
+ }
68
+
69
+ const projectSkillsDir = getProjectSkillsDir(cwd);
70
+ await ensureDir(projectSkillsDir);
71
+ const target = path.join(projectSkillsDir, skillName);
72
+
73
+ if (await exists(target)) {
74
+ await fs.rm(target, { recursive: true, force: true });
75
+ }
76
+
77
+ if (sym) await fs.symlink(globalPath, target, 'dir');
78
+ else await copyDir(globalPath, target);
79
+
80
+ const manifest = await readManifest(cwd);
81
+ const version = await readSkillVersion(globalPath);
82
+ upsertSkill(manifest, {
83
+ name: skillName,
84
+ source: 'global',
85
+ linked: sym,
86
+ version,
87
+ installedAt: nowISO()
88
+ });
89
+ await writeManifest(manifest, cwd);
90
+ }
91
+
92
+ export async function addSkillsInteractive({ cwd = process.cwd(), sym = false } = {}) {
93
+ const skills = await listGlobalSkills();
94
+ if (!skills.length) return { selected: [], cancelled: false };
95
+ const selection = await selectSkillsFromList(skills, {
96
+ title: 'Selecciona skills para instalar',
97
+ requireTTYMessage: 'La selección interactiva requiere una terminal TTY. Usa: skillbase add <skill>.'
98
+ });
99
+ if (selection.cancelled) return { selected: [], cancelled: true };
100
+ const selectedSkills = selection.selected;
101
+ for (const skill of selectedSkills) {
102
+ await addSkill(skill, { cwd, sym });
103
+ }
104
+ output.write(`\nInstaladas: ${selectedSkills.join(', ') || 'ninguna'}\n`);
105
+ return { selected: selectedSkills, cancelled: false };
106
+ }
107
+
108
+ async function selectSkillsFromList(skills, { title, requireTTYMessage } = {}) {
109
+ if (!input.isTTY || !output.isTTY) {
110
+ throw new Error(requireTTYMessage || 'La selección interactiva requiere una terminal TTY.');
111
+ }
112
+
113
+ const selected = new Set();
114
+ let cursor = 0;
115
+ let offset = 0;
116
+ let renderedLines = 0;
117
+ let firstRender = true;
118
+
119
+ // Tamaño de página adaptativo (mínimo 5, máximo 15 por defecto)
120
+ const getPageSize = () => {
121
+ const terminalRows = output.rows || 24;
122
+ const reservedRows = 8; // Header (5) + Footer (2) + Margen (1)
123
+ return Math.max(5, Math.min(15, terminalRows - reservedRows));
124
+ };
125
+
126
+ readline.emitKeypressEvents(input);
127
+ input.resume();
128
+ if (typeof input.setRawMode === 'function') input.setRawMode(true);
129
+
130
+ const render = () => {
131
+ const pageSize = getPageSize();
132
+
133
+ // Ajustar ventana (offset) según el cursor
134
+ if (cursor < offset) {
135
+ offset = cursor;
136
+ } else if (cursor >= offset + pageSize) {
137
+ offset = cursor - pageSize + 1;
138
+ }
139
+
140
+ const lines = [
141
+ '\x1b[36m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m',
142
+ `\x1b[1m${title || 'Selecciona skills'}\x1b[0m`,
143
+ '\x1b[2m↑/↓ navegar · espacio seleccionar · enter confirmar · esc cancelar\x1b[0m',
144
+ '\x1b[36m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m',
145
+ ''
146
+ ];
147
+
148
+ // Indicador superior si hay más arriba
149
+ if (offset > 0) {
150
+ lines.push('\x1b[2m ▲ y más...\x1b[0m');
151
+ } else {
152
+ lines.push('');
153
+ }
154
+
155
+ const visibleSkills = skills.slice(offset, offset + pageSize);
156
+ visibleSkills.forEach((skill, index) => {
157
+ const realIndex = index + offset;
158
+ const isSelected = selected.has(skill);
159
+ const isCursor = realIndex === cursor;
160
+ const mark = isSelected ? '[x]' : '[ ]';
161
+ const pointer = isCursor ? '\x1b[32m❯\x1b[0m' : ' ';
162
+ const line = `${mark} ${skill}`;
163
+ lines.push(isCursor ? `${pointer} \x1b[1m${line}\x1b[0m` : `${pointer} ${line}`);
164
+ });
165
+
166
+ // Rellenar hasta pageSize para mantener altura constante (evita parpadeos)
167
+ for (let i = visibleSkills.length; i < pageSize; i += 1) {
168
+ lines.push('');
169
+ }
170
+
171
+ // Indicador inferior si hay más abajo
172
+ if (offset + pageSize < skills.length) {
173
+ lines.push('\x1b[2m ▼ y más...\x1b[0m');
174
+ } else {
175
+ lines.push('');
176
+ }
177
+
178
+ if (!firstRender) {
179
+ readline.moveCursor(output, 0, -renderedLines);
180
+ }
181
+
182
+ for (let i = 0; i < lines.length; i += 1) {
183
+ readline.clearLine(output, 0);
184
+ readline.cursorTo(output, 0);
185
+ output.write(lines[i]);
186
+ if (i < lines.length - 1) output.write('\n');
187
+ }
188
+
189
+ // Limpiar líneas residuales si el tamaño de renderizado cambió (vía redimensionado de terminal)
190
+ if (!firstRender && renderedLines > lines.length) {
191
+ for (let i = lines.length; i < renderedLines; i += 1) {
192
+ output.write('\n');
193
+ readline.clearLine(output, 0);
194
+ }
195
+ }
196
+
197
+ output.write('\n');
198
+ renderedLines = lines.length + 1;
199
+ firstRender = false;
200
+ };
201
+
202
+ render();
203
+ let onKeypress;
204
+ const outcome = await new Promise((resolve) => {
205
+ onKeypress = (_, key) => {
206
+ if (key.name === 'up') {
207
+ cursor = (cursor - 1 + skills.length) % skills.length;
208
+ render();
209
+ return;
210
+ }
211
+ if (key.name === 'down') {
212
+ cursor = (cursor + 1) % skills.length;
213
+ render();
214
+ return;
215
+ }
216
+ if (key.name === 'space') {
217
+ const skill = skills[cursor];
218
+ if (selected.has(skill)) selected.delete(skill);
219
+ else selected.add(skill);
220
+ render();
221
+ return;
222
+ }
223
+ if (key.name === 'return' || key.name === 'enter') {
224
+ resolve({ cancelled: false });
225
+ return;
226
+ }
227
+ if (key.name === 'escape' || (key.name === 'c' && key.ctrl)) {
228
+ resolve({ cancelled: true });
229
+ }
230
+ };
231
+ input.on('keypress', onKeypress);
232
+ });
233
+
234
+ if (onKeypress) input.off('keypress', onKeypress);
235
+ if (typeof input.setRawMode === 'function') input.setRawMode(false);
236
+ input.pause();
237
+ output.write('\n');
238
+
239
+ if (outcome.cancelled) {
240
+ output.write('\nSelección cancelada.\n');
241
+ return { selected: [], cancelled: true };
242
+ }
243
+
244
+ return { selected: Array.from(selected), cancelled: false };
245
+ }
246
+
247
+ async function tryFetchJson(url) {
248
+ const response = await fetch(url);
249
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
250
+ return response.json();
251
+ }
252
+
253
+ function parseRemoteSkillRef(skillRef) {
254
+ const trimmed = skillRef.trim();
255
+ if (!trimmed) return { canonicalName: '', lookupKeys: [] };
256
+
257
+ if (/^https?:\/\//i.test(trimmed)) {
258
+ try {
259
+ const url = new URL(trimmed);
260
+ const parts = url.pathname.split('/').filter(Boolean);
261
+ // Formato esperado en skills.sh: /<owner>/skills/<skill>
262
+ const skillIdx = parts.indexOf('skills');
263
+ if (skillIdx > 0 && parts[skillIdx + 1]) {
264
+ const owner = parts[skillIdx - 1];
265
+ const name = parts[skillIdx + 1];
266
+ const scoped = `${owner}/${name}`;
267
+ return { canonicalName: scoped, lookupKeys: [scoped, name] };
268
+ }
269
+ const last = parts.at(-1) ?? trimmed;
270
+ return { canonicalName: last, lookupKeys: [last] };
271
+ } catch {
272
+ return { canonicalName: trimmed, lookupKeys: [trimmed] };
273
+ }
274
+ }
275
+
276
+ return { canonicalName: trimmed, lookupKeys: [trimmed] };
277
+ }
278
+
279
+ async function fetchRemoteMetadata(skillRef) {
280
+ const parsed = parseRemoteSkillRef(skillRef);
281
+ if (!parsed.lookupKeys.length) {
282
+ throw new Error('Debes indicar una skill remota válida.');
283
+ }
284
+
285
+ for (const key of parsed.lookupKeys) {
286
+ const encoded = encodeURIComponent(key);
287
+ const escapedPath = key.split('/').map(encodeURIComponent).join('/');
288
+ const candidates = [
289
+ `https://skills.sh/api/skills/${encoded}.json`,
290
+ `https://skills.sh/${escapedPath}/skill.json`,
291
+ `https://skills.sh/${escapedPath}/skills/${encodeURIComponent(parsed.canonicalName.split('/').at(-1) ?? key)}/skill.json`
292
+ ];
293
+
294
+ for (const url of candidates) {
295
+ try {
296
+ const data = await tryFetchJson(url);
297
+ return { ...data, _fetchedFrom: url, _resolvedName: parsed.canonicalName };
298
+ } catch {
299
+ // next
300
+ }
301
+ }
302
+ }
303
+
304
+ throw new Error(
305
+ `No se pudo obtener metadata remota para "${skillRef}". Usa slug tipo "owner/skill" o URL de skills.sh.`
306
+ );
307
+ }
308
+
309
+ async function downloadSkillFromRemote(skillRef, tmpDir) {
310
+ const metadata = await fetchRemoteMetadata(skillRef);
311
+ const resolvedName = metadata._resolvedName || skillRef;
312
+ const localName = resolvedName.split('/').at(-1) || resolvedName;
313
+ const sourceUrl = metadata.downloadUrl || metadata.sourceUrl || metadata.repo || metadata.url;
314
+
315
+ if (!sourceUrl) {
316
+ throw new Error(`La metadata remota de "${skillRef}" no contiene downloadUrl/sourceUrl/repo/url`);
317
+ }
318
+
319
+ if (metadata.archiveUrl) {
320
+ const archiveResponse = await fetch(metadata.archiveUrl);
321
+ if (!archiveResponse.ok) throw new Error(`No se pudo descargar archiveUrl (${archiveResponse.status})`);
322
+ const archivePath = path.join(tmpDir, `${localName}.tgz`);
323
+ const buffer = Buffer.from(await archiveResponse.arrayBuffer());
324
+ await fs.writeFile(archivePath, buffer);
325
+ throw new Error('archiveUrl descargado, pero falta extractor .tgz (pendiente de implementación)');
326
+ }
327
+
328
+ const skillPath = path.join(tmpDir, localName);
329
+ await ensureDir(skillPath);
330
+ await writeJson(path.join(skillPath, 'skill.json'), {
331
+ name: resolvedName,
332
+ version: metadata.version ?? '0.0.0',
333
+ sourceUrl,
334
+ fetchedFrom: metadata._fetchedFrom
335
+ });
336
+ return { path: skillPath, metadata, localName, resolvedName };
337
+ }
338
+
339
+ export async function installRemoteSkill(skillName, { cwd = process.cwd(), force = false } = {}) {
340
+ const projectSkillsDir = getProjectSkillsDir(cwd);
341
+ await ensureDir(projectSkillsDir);
342
+
343
+ if (/^https?:\/\/github\.com\//i.test(skillName)) {
344
+ throw new Error('Para instalar desde GitHub usa: skillbase install <repo-url> --remote --skill <nombre-skill>.');
345
+ }
346
+
347
+ const tempBase = await fs.mkdtemp(path.join(os.tmpdir(), 'skillbase-'));
348
+ try {
349
+ const downloaded = await downloadSkillFromRemote(skillName, tempBase);
350
+ const target = path.join(projectSkillsDir, downloaded.localName);
351
+ if ((await exists(target)) && !force) {
352
+ throw new Error(`La skill "${downloaded.localName}" ya existe en el proyecto. Usa --force para reinstalar.`);
353
+ }
354
+ if (await exists(target)) await fs.rm(target, { recursive: true, force: true });
355
+ await copyDir(downloaded.path, target);
356
+
357
+ const manifest = await readManifest(cwd);
358
+ upsertSkill(manifest, {
359
+ name: downloaded.resolvedName,
360
+ localName: downloaded.localName,
361
+ source: 'remote',
362
+ linked: false,
363
+ version: downloaded.metadata.version ?? '0.0.0',
364
+ remoteUrl: downloaded.metadata.sourceUrl || downloaded.metadata.repo || downloaded.metadata.url || null,
365
+ installedAt: nowISO()
366
+ });
367
+ await writeManifest(manifest, cwd);
368
+ } finally {
369
+ await fs.rm(tempBase, { recursive: true, force: true });
370
+ }
371
+ }
372
+
373
+ async function installRemoteFromGitHub(repoUrl, selectedSkill, { cwd = process.cwd(), force = false } = {}) {
374
+ if (!selectedSkill) {
375
+ throw new Error('Falta --skill <nombre>. Ejemplo: skillbase install <repo-url> --remote --skill find-skills');
376
+ }
377
+
378
+ const projectSkillsDir = getProjectSkillsDir(cwd);
379
+ await ensureDir(projectSkillsDir);
380
+
381
+ const tempBase = await fs.mkdtemp(path.join(os.tmpdir(), 'skillbase-git-'));
382
+ const repoPath = path.join(tempBase, 'repo');
383
+ try {
384
+ await execFileAsync('git', ['clone', '--depth', '1', repoUrl, repoPath], { maxBuffer: 1024 * 1024 * 10 });
385
+ const candidates = [path.join(repoPath, 'skills', selectedSkill), path.join(repoPath, selectedSkill)];
386
+ let sourcePath = null;
387
+ for (const candidate of candidates) {
388
+ if (await exists(candidate)) {
389
+ sourcePath = candidate;
390
+ break;
391
+ }
392
+ }
393
+ if (!sourcePath) {
394
+ throw new Error(`No se encontró la skill "${selectedSkill}" en el repo remoto.`);
395
+ }
396
+
397
+ const target = path.join(projectSkillsDir, selectedSkill);
398
+ if ((await exists(target)) && !force) {
399
+ throw new Error(`La skill "${selectedSkill}" ya existe en el proyecto. Usa --force para reinstalar.`);
400
+ }
401
+ if (await exists(target)) await fs.rm(target, { recursive: true, force: true });
402
+ await copyDir(sourcePath, target);
403
+
404
+ const manifest = await readManifest(cwd);
405
+ const version = await readSkillVersion(sourcePath);
406
+ upsertSkill(manifest, {
407
+ name: selectedSkill,
408
+ source: 'remote',
409
+ linked: false,
410
+ version: version ?? '0.0.0',
411
+ remoteUrl: repoUrl,
412
+ installedAt: nowISO()
413
+ });
414
+ await writeManifest(manifest, cwd);
415
+ } finally {
416
+ await fs.rm(tempBase, { recursive: true, force: true });
417
+ }
418
+ }
419
+
420
+ export async function installRemoteSkillRef(skillRef, options = {}) {
421
+ if (/^https?:\/\/github\.com\//i.test(skillRef)) {
422
+ return installRemoteFromGitHub(skillRef, options.skill, options);
423
+ }
424
+ return installRemoteSkill(skillRef, options);
425
+ }
426
+
427
+ export async function installFromManifest({ cwd = process.cwd(), remote = false, force = false } = {}) {
428
+ const manifest = await readManifest(cwd);
429
+ if (!manifest.skills.length) {
430
+ throw new Error('No hay skills en skillbase.json. Usa "skillbase add <skill>" o "skillbase install <skill> --remote".');
431
+ }
432
+ for (const skill of manifest.skills) {
433
+ if (remote || skill.source === 'remote') await installRemoteSkill(skill.name, { cwd, force });
434
+ else await addSkill(skill.name, { cwd, sym: Boolean(skill.linked) });
435
+ }
436
+ }
437
+
438
+ export async function removeSkill(skillName, { cwd = process.cwd(), global = false } = {}) {
439
+ if (global) {
440
+ await fs.rm(path.join(getGlobalSkillsDir(), skillName), { recursive: true, force: true });
441
+ return;
442
+ }
443
+
444
+ await fs.rm(path.join(getProjectSkillsDir(cwd), skillName), { recursive: true, force: true });
445
+ const manifest = await readManifest(cwd);
446
+ removeSkillFromManifest(manifest, skillName);
447
+ await writeManifest(manifest, cwd);
448
+ }
449
+
450
+ async function getRemoteVersion(skillName) {
451
+ const metadata = await fetchRemoteMetadata(skillName);
452
+ return metadata.version ?? null;
453
+ }
454
+
455
+ export async function checkUpdates({ cwd = process.cwd(), remoteOnly = false } = {}) {
456
+ const manifest = await readManifest(cwd);
457
+ const updates = [];
458
+
459
+ for (const skill of manifest.skills) {
460
+ if (remoteOnly && skill.source !== 'remote') continue;
461
+
462
+ if (skill.source === 'remote') {
463
+ const latest = await getRemoteVersion(skill.name);
464
+ if (latest && compareVersion(latest, skill.version) > 0) {
465
+ updates.push({ name: skill.name, current: skill.version, latest, source: 'remote' });
466
+ }
467
+ continue;
468
+ }
469
+
470
+ const globalPath = path.join(getGlobalSkillsDir(), skill.name);
471
+ if (!(await exists(globalPath))) continue;
472
+ const latest = await readSkillVersion(globalPath);
473
+ if (latest && compareVersion(latest, skill.version) > 0) {
474
+ updates.push({ name: skill.name, current: skill.version, latest, source: 'global' });
475
+ }
476
+ }
477
+
478
+ return updates;
479
+ }
480
+
481
+ export async function updateSkills({ cwd = process.cwd(), skillName = null, remoteOnly = false } = {}) {
482
+ const manifest = await readManifest(cwd);
483
+ const selected = manifest.skills.filter((skill) => {
484
+ if (skillName && skill.name !== skillName) return false;
485
+ if (remoteOnly && skill.source !== 'remote') return false;
486
+ return true;
487
+ });
488
+
489
+ for (const skill of selected) {
490
+ if (skill.source === 'remote') {
491
+ await installRemoteSkill(skill.name, { cwd, force: true });
492
+ } else {
493
+ await addSkill(skill.name, { cwd, sym: Boolean(skill.linked) });
494
+ const globalVersion = await readSkillVersion(path.join(getGlobalSkillsDir(), skill.name));
495
+ const nextManifest = await readManifest(cwd);
496
+ upsertSkill(nextManifest, { ...skill, version: globalVersion || skill.version, installedAt: nowISO() });
497
+ await writeManifest(nextManifest, cwd);
498
+ }
499
+ }
500
+ }
501
+
502
+ async function getSkillTags(skillDir) {
503
+ const metaPath = path.join(skillDir, 'skill.json');
504
+ if (!(await exists(metaPath))) return [];
505
+ try {
506
+ const meta = JSON.parse(await fs.readFile(metaPath, 'utf8'));
507
+ if (Array.isArray(meta.tags)) return meta.tags.map((tag) => String(tag).toLowerCase());
508
+ if (typeof meta.tags === 'string') return meta.tags.split(',').map((tag) => tag.trim().toLowerCase()).filter(Boolean);
509
+ return [];
510
+ } catch {
511
+ return [];
512
+ }
513
+ }
514
+
515
+ export async function initProject({ cwd = process.cwd(), hard = false } = {}) {
516
+ const technologies = await detectProjectTechnologies(cwd, { hard });
517
+ if (!technologies.length) return { technologies: [], suggested: [], installed: [], cancelled: false };
518
+
519
+ const globalSkills = await listGlobalSkills();
520
+ const suggested = [];
521
+ for (const skill of globalSkills) {
522
+ const skillLower = skill.toLowerCase();
523
+ const matchesByName = technologies.some((tech) => skillLower.includes(tech));
524
+ if (matchesByName) {
525
+ suggested.push(skill);
526
+ continue;
527
+ }
528
+ if (!hard) continue;
529
+ const tags = await getSkillTags(path.join(getGlobalSkillsDir(), skill));
530
+ if (tags.some((tag) => technologies.some((tech) => tag.includes(tech)))) {
531
+ suggested.push(skill);
532
+ }
533
+ }
534
+
535
+ if (!suggested.length) return { technologies, suggested: [], installed: [], cancelled: false };
536
+
537
+ const selection = await selectSkillsFromList(suggested, {
538
+ title: hard
539
+ ? 'Init --hard: selecciona skills recomendadas por nombre y tags'
540
+ : 'Init: selecciona skills recomendadas por nombre'
541
+ });
542
+ if (selection.cancelled) return { technologies, suggested, installed: [], cancelled: true };
543
+
544
+ for (const skill of selection.selected) {
545
+ await addSkill(skill, { cwd, sym: false });
546
+ }
547
+
548
+ return { technologies, suggested, installed: selection.selected, cancelled: false };
549
+ }
550
+
551
+ export async function migrateAgentsSkillsToSkillbase({ cwd = process.cwd(), force = false, fromProject = false } = {}) {
552
+ const globalDir = getGlobalSkillsDir();
553
+ await ensureDir(globalDir);
554
+
555
+ const agentsDir = fromProject ? path.join(getProjectRoot(cwd), PROJECT_AGENTS_DIR) : path.join(os.homedir(), '.agents');
556
+ const agentsSkillsDir = path.join(agentsDir, PROJECT_SKILLS_DIR);
557
+ const toMigrate = new Map();
558
+
559
+ if (await exists(agentsSkillsDir)) {
560
+ const entries = await fs.readdir(agentsSkillsDir, { withFileTypes: true });
561
+ for (const entry of entries) {
562
+ if (entry.isDirectory()) toMigrate.set(entry.name, path.join(agentsSkillsDir, entry.name));
563
+ }
564
+ }
565
+
566
+ const migrated = [];
567
+ const skipped = [];
568
+
569
+ for (const [skillName, sourcePath] of toMigrate.entries()) {
570
+ const targetPath = path.join(globalDir, skillName);
571
+ if ((await exists(targetPath)) && !force) {
572
+ skipped.push(skillName);
573
+ continue;
574
+ }
575
+ if (await exists(targetPath)) await fs.rm(targetPath, { recursive: true, force: true });
576
+ await copyDir(sourcePath, targetPath);
577
+ migrated.push(skillName);
578
+ }
579
+
580
+ return { migrated, skipped, totalFound: toMigrate.size, sourceRoot: agentsDir };
581
+ }
@@ -0,0 +1,41 @@
1
+ import fs from 'node:fs/promises';
2
+ import { getManifestPath } from './config.js';
3
+
4
+ async function exists(target) {
5
+ try {
6
+ await fs.access(target);
7
+ return true;
8
+ } catch {
9
+ return false;
10
+ }
11
+ }
12
+
13
+ async function safeReadJson(file, fallback) {
14
+ if (!(await exists(file))) return fallback;
15
+ const raw = await fs.readFile(file, 'utf8');
16
+ return JSON.parse(raw);
17
+ }
18
+
19
+ export async function writeJson(file, data) {
20
+ await fs.writeFile(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
21
+ }
22
+
23
+ export async function readManifest(cwd = process.cwd()) {
24
+ const manifest = await safeReadJson(getManifestPath(cwd), { skills: [] });
25
+ if (!Array.isArray(manifest.skills)) manifest.skills = [];
26
+ return manifest;
27
+ }
28
+
29
+ export async function writeManifest(manifest, cwd = process.cwd()) {
30
+ await writeJson(getManifestPath(cwd), manifest);
31
+ }
32
+
33
+ export function upsertSkill(manifest, record) {
34
+ const idx = manifest.skills.findIndex((skill) => skill.name === record.name);
35
+ if (idx === -1) manifest.skills.push(record);
36
+ else manifest.skills[idx] = { ...manifest.skills[idx], ...record };
37
+ }
38
+
39
+ export function removeSkillFromManifest(manifest, skillName) {
40
+ manifest.skills = manifest.skills.filter((skill) => skill.name !== skillName);
41
+ }
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ async function exists(target) {
5
+ try {
6
+ await fs.access(target);
7
+ return true;
8
+ } catch {
9
+ return false;
10
+ }
11
+ }
12
+
13
+ async function readJsonIfExists(file) {
14
+ if (!(await exists(file))) return null;
15
+ return JSON.parse(await fs.readFile(file, 'utf8'));
16
+ }
17
+
18
+ async function readTextIfExists(file) {
19
+ if (!(await exists(file))) return '';
20
+ return fs.readFile(file, 'utf8');
21
+ }
22
+
23
+ /**
24
+ * Detecta tecnologías del proyecto. En modo hard hace una inspección más profunda.
25
+ */
26
+ export async function detectProjectTechnologies(cwd = process.cwd(), { hard = false } = {}) {
27
+ const technologies = new Set();
28
+
29
+ const add = (...values) => values.filter(Boolean).forEach((value) => technologies.add(String(value).toLowerCase()));
30
+ const packageJson = await readJsonIfExists(path.join(cwd, 'package.json'));
31
+ if (packageJson) {
32
+ add('javascript', 'node', 'npm');
33
+ const deps = {
34
+ ...(packageJson.dependencies ?? {}),
35
+ ...(packageJson.devDependencies ?? {})
36
+ };
37
+ if (deps.react) add('react');
38
+ if (deps.vue) add('vue');
39
+ if (deps.svelte) add('svelte');
40
+ if (deps.next) add('nextjs', 'next');
41
+ if (deps.express || deps.fastify || deps.koa) add('api', 'backend');
42
+ if (deps.typescript) add('typescript', 'ts');
43
+ if (deps.tailwindcss) add('tailwind', 'css');
44
+ }
45
+
46
+ if (await exists(path.join(cwd, 'requirements.txt'))) add('python');
47
+ if (await exists(path.join(cwd, 'pyproject.toml'))) add('python');
48
+ if (await exists(path.join(cwd, 'go.mod'))) add('go', 'golang');
49
+ if (await exists(path.join(cwd, 'Cargo.toml'))) add('rust');
50
+ if (await exists(path.join(cwd, 'Dockerfile'))) add('docker', 'devops');
51
+ if (await exists(path.join(cwd, 'docker-compose.yml'))) add('docker', 'compose', 'devops');
52
+
53
+ if (hard) {
54
+ const readme = await readTextIfExists(path.join(cwd, 'README.md'));
55
+ const content = readme.toLowerCase();
56
+ if (content.includes('kubernetes') || content.includes('k8s')) add('kubernetes', 'k8s');
57
+ if (content.includes('postgres')) add('postgres', 'postgresql');
58
+ if (content.includes('mysql')) add('mysql');
59
+ if (content.includes('mongodb')) add('mongodb');
60
+ }
61
+
62
+ return Array.from(technologies);
63
+ }