nexakit 0.1.0 → 0.2.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/.claude-plugin/marketplace.json +18 -1
- package/README.md +40 -30
- package/assets/brand/README.md +0 -2
- package/assets/runtime/package-lock.json +2 -2
- package/bin/agent-task.js +12 -10
- package/bin/agent-toolkit-gemini.js +11 -9
- package/bin/agent-toolkit-inbox.js +12 -10
- package/bin/agent-toolkit-mcp.js +14 -8
- package/bin/agent-toolkit.js +12 -9
- package/bin/nexakit.js +12 -9
- package/bin/project-memory-mcp.js +12 -9
- package/bin/project-memory.js +5 -3
- package/{skills/depurar → catalogs/skills/vercel-composition-patterns}/LICENSE.txt +6 -6
- package/catalogs/skills/vercel-react-best-practices/LICENSE.txt +27 -0
- package/catalogs/skills.json +70 -35
- package/docs/skills/evidencias/2026-10-28-recomendador-preparacion.md +1 -1
- package/package.json +5 -3
- package/plugins/nexakit-modelo/.claude-plugin/plugin.json +1 -0
- package/plugins/nexakit-modelo/hooks/hooks.json +1 -0
- package/plugins/nexakit-modelo/hooks/register.js +48 -0
- package/plugins/nexakit-modelo/hooks/status.js +8 -0
- package/plugins/nexakit-modelo/tests/register.test.ts +21 -0
- package/plugins/nexakit-panel/.claude-plugin/plugin.json +1 -0
- package/plugins/nexakit-panel/README.md +20 -0
- package/plugins/nexakit-panel/hooks/hooks.json +1 -0
- package/plugins/nexakit-panel/hooks/lienzo.js +382 -0
- package/plugins/nexakit-panel/hooks/panel.js +279 -0
- package/plugins/nexakit-panel/hooks/register.tsx +248 -0
- package/plugins/nexakit-panel/tests/lienzo.test.ts +142 -0
- package/plugins/nexakit-panel/tests/panel.test.ts +240 -0
- package/plugins/nexakit-panel/tests/register.test.ts +167 -0
- package/plugins/nexakit-panel/types/index.d.ts +41 -0
- package/plugins/nexakit-sesion/.claude-plugin/plugin.json +1 -0
- package/plugins/nexakit-sesion/README.md +12 -0
- package/plugins/nexakit-sesion/hooks/aviso.js +38 -0
- package/plugins/nexakit-sesion/hooks/hooks.json +1 -0
- package/plugins/nexakit-sesion/hooks/register.js +28 -0
- package/plugins/nexakit-sesion/tests/register.test.ts +96 -0
- package/schemas/skill-catalog.schema.json +9 -2
- package/skills/cerrar-memoria/SKILL.md +6 -2
- package/skills/cerrar-memoria/references/cierre.md +5 -1
- package/skills/elegir-modelo/SKILL.md +68 -0
- package/skills/elegir-modelo/modelos.mjs +108 -0
- package/skills/gemini/SKILL.md +1 -1
- package/skills/project-handoff/SKILL.md +39 -12
- package/skills/project-handoff/references/handoff-template.md +24 -17
- package/skills/quality-gate/SKILL.md +6 -12
- package/skills/revisar/SKILL.md +6 -16
- package/skills/{desarrollo-con-pruebas/LICENSE.txt → revisar-skill/LICENSE-find-skills.txt} +5 -5
- package/skills/revisar-skill/SKILL.md +94 -0
- package/skills/revision-cruzada/SKILL.md +53 -475
- package/skills/web-design-guidelines/AGENTS.md +156 -0
- package/skills/{spec-driven-development → web-design-guidelines}/LICENSE.txt +1 -6
- package/skills/web-design-guidelines/SKILL.md +20 -30
- package/src/agent-models.js +53 -13
- package/src/brand.js +117 -0
- package/src/cli-executable.js +5 -3
- package/src/cli.js +16 -9
- package/src/connect.js +56 -4
- package/src/init.js +9 -1
- package/src/install-lock.js +127 -0
- package/src/install-registry.js +61 -0
- package/src/legacy-repair.js +71 -0
- package/src/link-states.js +172 -0
- package/src/memory-locator.js +118 -0
- package/src/missing-dependencies.js +21 -0
- package/src/model-command.js +147 -0
- package/src/panel-cli.js +182 -0
- package/src/project-root.js +41 -0
- package/src/project-signals.js +339 -0
- package/src/recommend-report.js +77 -0
- package/src/review-instructions.js +211 -0
- package/src/review-policy.js +35 -0
- package/src/revision-adapters.js +157 -0
- package/src/revision-context.js +86 -0
- package/src/revision-isolation.js +222 -0
- package/src/revision-process.js +103 -0
- package/src/revision-provenance.js +89 -0
- package/src/revision-snapshot.js +120 -0
- package/src/revision.js +242 -0
- package/src/setup-apply.js +231 -0
- package/src/setup-plan.js +231 -0
- package/src/setup-state.js +277 -0
- package/src/setup-tui.js +555 -0
- package/src/skill-catalog-add.js +91 -0
- package/src/skill-catalog-audit.js +26 -27
- package/src/skill-files.js +39 -0
- package/src/skill-recommendations.js +321 -208
- package/src/skill-scan-report.js +14 -0
- package/src/skill-scan.js +178 -0
- package/src/stable-command.js +53 -0
- package/src/stable-install.js +222 -0
- package/src/superpowers.js +133 -0
- package/src/sync-apply.js +8 -5
- package/src/sync-files.js +2 -1
- package/src/terminal-text.js +68 -0
- package/src/toolkit-cli.js +397 -70
- package/src/toolkit-commands-guide.js +53 -39
- package/src/toolkit-setup.js +507 -110
- package/assets/brand/agent-toolkit-mark-v1.png +0 -0
- package/skills/depurar/SKILL.md +0 -69
- package/skills/depurar/agents/openai.yaml +0 -7
- package/skills/depurar/references/tecnicas-de-diagnostico.md +0 -37
- package/skills/desarrollo-con-pruebas/SKILL.md +0 -55
- package/skills/desarrollo-con-pruebas/agents/openai.yaml +0 -7
- package/skills/desarrollo-con-pruebas/references/buenas-pruebas.md +0 -33
- package/skills/estado/SKILL.md +0 -41
- package/skills/programar/SKILL.md +0 -19
- package/skills/spec-driven-development/SKILL.md +0 -48
- package/skills/spec-driven-development/agents/openai.yaml +0 -7
- package/skills/spec-driven-development/references/spec-template.md +0 -55
- package/skills/spec-driven-development/references/workflow.md +0 -70
- package/src/toolkit-tui.js +0 -961
|
@@ -1,10 +1,17 @@
|
|
|
1
|
+
import { ownedReviewInstructions, removeReviewInstructions } from './review-instructions.js';
|
|
1
2
|
import { createHash } from 'node:crypto';
|
|
2
3
|
import fs from 'node:fs/promises';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
6
|
+
import { acquireLock } from './install-lock.js';
|
|
7
|
+
import { isRegistered, readRegistry, recordEntries } from './install-registry.js';
|
|
8
|
+
import { classifyLink, relinkIfUnchanged } from './link-states.js';
|
|
5
9
|
import { IS_WINDOWS } from './platform.js';
|
|
10
|
+
import { detectSignals, SIGNAL_LABELS } from './project-signals.js';
|
|
11
|
+
import { resolveProjectRoot } from './project-root.js';
|
|
6
12
|
import { loadSkillCatalog } from './skill-catalog.js';
|
|
7
|
-
import {
|
|
13
|
+
import { actualRoot, ensureStableInstall, nexakitDir } from './stable-install.js';
|
|
14
|
+
import { TOOLKIT_ROOT, globalSkillTarget, resolveRoots } from './toolkit-setup.js';
|
|
8
15
|
|
|
9
16
|
const MAX_MANIFEST_BYTES = 512 * 1024;
|
|
10
17
|
const MAX_SKILL_FILES = 256;
|
|
@@ -13,6 +20,14 @@ const VALID_CLIENTS = new Set(['codex', 'claude']);
|
|
|
13
20
|
const VALID_INSTALL_MODES = new Set(['symlink', 'copy']);
|
|
14
21
|
const VALID_GIT_IGNORE = new Set(['gitignore', 'git-exclude', 'none']);
|
|
15
22
|
const SKILL_NAME_RE = /^[a-z0-9-]+$/u;
|
|
23
|
+
// Antigravity reads project skills from `.agents/skills`, the folder Codex uses: one link serves both.
|
|
24
|
+
const projectClientsFor = agent => (agent === 'all' ? ['codex', 'claude'] : [agent === 'antigravity' ? 'codex' : agent]);
|
|
25
|
+
const servesFor = (client, agent) => (client === 'claude' ? ['claude'] : agent === 'all' ? ['codex', 'antigravity'] : [agent === 'antigravity' ? 'antigravity' : 'codex']);
|
|
26
|
+
// The coarse vocabulary the rest of the code reads; `linkState` keeps the detailed one.
|
|
27
|
+
const COARSE_STATE = Object.freeze({
|
|
28
|
+
falta: 'missing', efimero: 'replace', 'roto-propio': 'replace', instalado: 'installed',
|
|
29
|
+
'otra-copia': 'installed', conflicto: 'conflict', 'roto-ajeno': 'conflict'
|
|
30
|
+
});
|
|
16
31
|
|
|
17
32
|
export const PROJECT_TOOLKIT_DIR = '.agent-toolkit';
|
|
18
33
|
const PROJECT_MANIFEST_FILE = 'manifest.json';
|
|
@@ -22,75 +37,7 @@ const IGNORE_BLOCK_END = '# <<< agent-toolkit (managed) <<<';
|
|
|
22
37
|
const LEVEL_AGENT_HEADER = '# Generado por `agent-toolkit-inbox agentes`.';
|
|
23
38
|
const LEVEL_AGENT_NAMES = ['nivel_ligero.toml', 'nivel_normal.toml', 'nivel_exigente.toml', 'nivel_alto.toml'];
|
|
24
39
|
|
|
25
|
-
|
|
26
|
-
nodejs: 'Node.js',
|
|
27
|
-
typescript: 'TypeScript',
|
|
28
|
-
'mcp-sdk': 'MCP SDK',
|
|
29
|
-
react: 'React',
|
|
30
|
-
nextjs: 'Next.js',
|
|
31
|
-
tailwindcss: 'Tailwind CSS',
|
|
32
|
-
astro: 'Astro',
|
|
33
|
-
vue: 'Vue',
|
|
34
|
-
svelte: 'Svelte',
|
|
35
|
-
supabase: 'Supabase',
|
|
36
|
-
postgres: 'PostgreSQL',
|
|
37
|
-
express: 'Express',
|
|
38
|
-
fastify: 'Fastify',
|
|
39
|
-
hono: 'Hono',
|
|
40
|
-
playwright: 'Playwright',
|
|
41
|
-
vitest: 'Vitest'
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
const PACKAGE_TO_SIGNAL = Object.freeze({
|
|
45
|
-
'@modelcontextprotocol/sdk': 'mcp-sdk',
|
|
46
|
-
'@modelcontextprotocol/server': 'mcp-sdk',
|
|
47
|
-
'@modelcontextprotocol/client': 'mcp-sdk',
|
|
48
|
-
fastmcp: 'mcp-sdk',
|
|
49
|
-
next: 'nextjs',
|
|
50
|
-
react: 'react',
|
|
51
|
-
'react-dom': 'react',
|
|
52
|
-
tailwindcss: 'tailwindcss',
|
|
53
|
-
'@tailwindcss/postcss': 'tailwindcss',
|
|
54
|
-
'@tailwindcss/vite': 'tailwindcss',
|
|
55
|
-
typescript: 'typescript',
|
|
56
|
-
astro: 'astro',
|
|
57
|
-
vue: 'vue',
|
|
58
|
-
nuxt: 'vue',
|
|
59
|
-
svelte: 'svelte',
|
|
60
|
-
'@sveltejs/kit': 'svelte',
|
|
61
|
-
'@supabase/supabase-js': 'supabase',
|
|
62
|
-
'@supabase/ssr': 'supabase',
|
|
63
|
-
pg: 'postgres',
|
|
64
|
-
postgres: 'postgres',
|
|
65
|
-
'@neondatabase/serverless': 'postgres',
|
|
66
|
-
'drizzle-orm': 'postgres',
|
|
67
|
-
'@prisma/client': 'postgres',
|
|
68
|
-
express: 'express',
|
|
69
|
-
fastify: 'fastify',
|
|
70
|
-
hono: 'hono',
|
|
71
|
-
'@hono/node-server': 'hono',
|
|
72
|
-
playwright: 'playwright',
|
|
73
|
-
'@playwright/test': 'playwright',
|
|
74
|
-
vitest: 'vitest'
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
const FRAMEWORK_CONFIG_HINTS = Object.freeze([
|
|
78
|
-
['next.config.js', 'nextjs'],
|
|
79
|
-
['next.config.mjs', 'nextjs'],
|
|
80
|
-
['next.config.ts', 'nextjs'],
|
|
81
|
-
['tailwind.config.js', 'tailwindcss'],
|
|
82
|
-
['tailwind.config.ts', 'tailwindcss'],
|
|
83
|
-
['astro.config.mjs', 'astro'],
|
|
84
|
-
['svelte.config.js', 'svelte']
|
|
85
|
-
]);
|
|
86
|
-
|
|
87
|
-
const OUT_OF_COVERAGE_MANIFESTS = Object.freeze([
|
|
88
|
-
'Cargo.toml',
|
|
89
|
-
'pyproject.toml',
|
|
90
|
-
'go.mod',
|
|
91
|
-
'composer.json',
|
|
92
|
-
'Gemfile'
|
|
93
|
-
]);
|
|
40
|
+
export { SIGNAL_LABELS };
|
|
94
41
|
|
|
95
42
|
const normalizedPath = value => (IS_WINDOWS ? value.toLowerCase() : value);
|
|
96
43
|
const lstatOrNull = target => fs.lstat(target).catch(err => {
|
|
@@ -234,84 +181,36 @@ async function inspectProjectSkills(projectRoot, toolkitRoot, catalogByName, cli
|
|
|
234
181
|
}));
|
|
235
182
|
}
|
|
236
183
|
|
|
237
|
-
|
|
184
|
+
async function rootPackageName(root) {
|
|
185
|
+
const info = await lstatOrNull(path.join(root, 'package.json'));
|
|
186
|
+
if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_MANIFEST_BYTES) return null;
|
|
187
|
+
try {
|
|
188
|
+
const name = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8'))?.name;
|
|
189
|
+
return typeof name === 'string' && name.trim() ? name.trim() : null;
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function inspectSkillProject(projectRoot, { client, toolkitRoot = TOOLKIT_ROOT, catalog, homeDirectory } = {}) {
|
|
238
196
|
if (client !== undefined && !VALID_CLIENTS.has(client)) {
|
|
239
197
|
throw new Error('El filtro --client solo admite codex o claude.');
|
|
240
198
|
}
|
|
241
|
-
|
|
199
|
+
// The project is the whole Git repository, even when nexakit runs from one of its subfolders.
|
|
200
|
+
const project = await resolveProjectRoot(projectRoot, homeDirectory ? { homeDirectory } : {});
|
|
201
|
+
const root = project.root;
|
|
242
202
|
const resolvedToolkit = await fs.realpath(toolkitRoot);
|
|
243
203
|
const activeCatalog = catalog ?? await loadSkillCatalog();
|
|
244
204
|
const catalogByName = new Map(activeCatalog.skills.map(s => [s.name, s]));
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
const addSignal = (id, source) => {
|
|
248
|
-
const list = signalSources.get(id) ?? [];
|
|
249
|
-
if (!list.includes(source)) list.push(source);
|
|
250
|
-
signalSources.set(id, list);
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
const limits = [];
|
|
254
|
-
let projectName = path.basename(root);
|
|
255
|
-
|
|
256
|
-
const pkgPath = path.join(root, 'package.json');
|
|
257
|
-
const pkgInfo = await lstatOrNull(pkgPath);
|
|
258
|
-
if (pkgInfo) {
|
|
259
|
-
if (!pkgInfo.isFile() || pkgInfo.isSymbolicLink() || pkgInfo.size > MAX_MANIFEST_BYTES) {
|
|
260
|
-
limits.push('package.json excede el límite o no es un archivo regular; no se inspeccionó.');
|
|
261
|
-
} else {
|
|
262
|
-
let pkg = null;
|
|
263
|
-
try {
|
|
264
|
-
pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8'));
|
|
265
|
-
} catch {
|
|
266
|
-
limits.push('package.json no contiene JSON válido; no se dedujeron dependencias Node.');
|
|
267
|
-
}
|
|
268
|
-
if (pkg && typeof pkg === 'object' && !Array.isArray(pkg)) {
|
|
269
|
-
if (typeof pkg.name === 'string' && pkg.name.trim()) projectName = pkg.name.trim();
|
|
270
|
-
addSignal('nodejs', 'package.json');
|
|
271
|
-
for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
|
|
272
|
-
const deps = pkg[field];
|
|
273
|
-
if (!deps || typeof deps !== 'object' || Array.isArray(deps)) continue;
|
|
274
|
-
for (const depName of Object.keys(deps)) {
|
|
275
|
-
const mapped = PACKAGE_TO_SIGNAL[depName];
|
|
276
|
-
if (mapped) addSignal(mapped, `package.json (${depName})`);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const tsconfigInfo = await lstatOrNull(path.join(root, 'tsconfig.json'));
|
|
284
|
-
if (tsconfigInfo?.isFile() && !tsconfigInfo.isSymbolicLink()) {
|
|
285
|
-
addSignal('typescript', 'tsconfig.json');
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
for (const [configFile, expectedSignal] of FRAMEWORK_CONFIG_HINTS) {
|
|
289
|
-
const info = await lstatOrNull(path.join(root, configFile));
|
|
290
|
-
if (info?.isFile() && !info.isSymbolicLink() && !signalSources.has(expectedSignal)) {
|
|
291
|
-
limits.push(`Configuración observada sin dependencia confirmada en package.json: ${configFile}.`);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
for (const foreignManifest of OUT_OF_COVERAGE_MANIFESTS) {
|
|
296
|
-
const info = await lstatOrNull(path.join(root, foreignManifest));
|
|
297
|
-
if (info && !info.isSymbolicLink()) {
|
|
298
|
-
limits.push(`Stack fuera de cobertura automática observada: ${foreignManifest}.`);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
if (!pkgInfo && signalSources.size === 0 && limits.length === 0) {
|
|
303
|
-
limits.push('No se detectó package.json en la raíz; cobertura automática inicial enfocada en Node.js.');
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
const signals = [...signalSources.entries()]
|
|
307
|
-
.map(([id, sources]) => ({ id, label: SIGNAL_LABELS[id] ?? id, sources }))
|
|
308
|
-
.sort((a, b) => a.id.localeCompare(b.id));
|
|
205
|
+
const { signals, limits } = await detectSignals(root, { git: project.git });
|
|
206
|
+
const projectName = await rootPackageName(root) ?? project.displayName;
|
|
309
207
|
|
|
310
208
|
const installed = await inspectProjectSkills(root, resolvedToolkit, catalogByName, client);
|
|
311
209
|
const coupling = await inspectProjectCoupling(root, resolvedToolkit);
|
|
312
210
|
|
|
313
211
|
return {
|
|
314
212
|
projectRoot: root,
|
|
213
|
+
project,
|
|
315
214
|
projectName,
|
|
316
215
|
signals,
|
|
317
216
|
installed,
|
|
@@ -595,6 +494,7 @@ export function recommendSkills({ catalog, inspection, profile, client, clients
|
|
|
595
494
|
revision: skill.revision,
|
|
596
495
|
installedPath: skill.installedPath,
|
|
597
496
|
reasons,
|
|
497
|
+
matchedSignals,
|
|
598
498
|
overlapsWith: [...skill.overlapsWith],
|
|
599
499
|
reviewStatus: skill.review.status,
|
|
600
500
|
reviewNotes: skill.review.notes,
|
|
@@ -627,6 +527,10 @@ export function recommendSkills({ catalog, inspection, profile, client, clients
|
|
|
627
527
|
}
|
|
628
528
|
|
|
629
529
|
optional.sort((a, b) => a.name.localeCompare(b.name));
|
|
530
|
+
// The skills that match more of what was detected come first; ties by name.
|
|
531
|
+
const byMatches = (a, b) => b.matchedSignals.length - a.matchedSignals.length || a.name.localeCompare(b.name);
|
|
532
|
+
recommended.sort(byMatches);
|
|
533
|
+
candidates.sort(byMatches);
|
|
630
534
|
|
|
631
535
|
const externalCandidates = [];
|
|
632
536
|
for (const ref of catalog.externalReferences) {
|
|
@@ -701,8 +605,8 @@ async function projectTargetState(source, target, mode, catalogEntry) {
|
|
|
701
605
|
throw err;
|
|
702
606
|
}
|
|
703
607
|
}
|
|
704
|
-
// mode === 'copy'
|
|
705
|
-
if (info.isSymbolicLink()) return '
|
|
608
|
+
// mode === 'copy': links are classified by the planner; here one is never assumed replaceable.
|
|
609
|
+
if (info.isSymbolicLink()) return 'conflict';
|
|
706
610
|
if (info.isDirectory() && catalogEntry) {
|
|
707
611
|
const integrity = await verifyCatalogIntegrity(target, catalogEntry);
|
|
708
612
|
return integrity === 'verified' ? 'installed' : 'conflict';
|
|
@@ -717,10 +621,12 @@ export async function planProjectSkillInstall({
|
|
|
717
621
|
mode = 'symlink',
|
|
718
622
|
gitIgnore = 'gitignore',
|
|
719
623
|
catalog,
|
|
720
|
-
toolkitRoot = TOOLKIT_ROOT
|
|
624
|
+
toolkitRoot = TOOLKIT_ROOT,
|
|
625
|
+
linkRoot,
|
|
626
|
+
homeDirectory = os.homedir()
|
|
721
627
|
} = {}) {
|
|
722
|
-
if (!['codex', 'claude', 'all'].includes(agent)) {
|
|
723
|
-
throw new Error('El agente debe ser codex, claude o all.');
|
|
628
|
+
if (!['codex', 'claude', 'antigravity', 'all'].includes(agent)) {
|
|
629
|
+
throw new Error('El agente debe ser codex, claude, antigravity o all.');
|
|
724
630
|
}
|
|
725
631
|
if (!VALID_INSTALL_MODES.has(mode)) {
|
|
726
632
|
throw new Error('El modo de instalación debe ser symlink o copy.');
|
|
@@ -728,13 +634,33 @@ export async function planProjectSkillInstall({
|
|
|
728
634
|
if (!VALID_GIT_IGNORE.has(gitIgnore)) {
|
|
729
635
|
throw new Error('La estrategia gitIgnore debe ser gitignore, git-exclude o none.');
|
|
730
636
|
}
|
|
731
|
-
const clients = agent
|
|
637
|
+
const clients = projectClientsFor(agent);
|
|
732
638
|
const root = await fs.realpath(projectRoot);
|
|
733
639
|
const resolvedToolkit = await fs.realpath(toolkitRoot);
|
|
640
|
+
const home = await fs.realpath(homeDirectory).catch(() => path.resolve(homeDirectory));
|
|
641
|
+
const roots = await resolveRoots({ toolkitRoot: resolvedToolkit, homeDirectory: home });
|
|
642
|
+
// An explicit linkRoot means the caller chose where to link, so no stable copy is made.
|
|
643
|
+
const linkRootPath = linkRoot ? path.resolve(linkRoot) : roots.linkRoot;
|
|
644
|
+
const ephemeral = !linkRoot && roots.ephemeral;
|
|
734
645
|
const activeCatalog = catalog ?? await loadSkillCatalog();
|
|
735
646
|
const catalogByName = new Map(activeCatalog.skills.map(s => [s.name, s]));
|
|
736
647
|
const uniqueSkills = [...new Set(skillNames ?? [])].sort();
|
|
737
648
|
|
|
649
|
+
// A broken link is nexakit's only with proof: the install registry (the exact link), or this project's
|
|
650
|
+
// manifest. An entry written by this version records the exact link too; one from 0.1.0 only has the path,
|
|
651
|
+
// so its link must at least have nexakit's own shape: `<root>/<where this skill lives in nexakit>`.
|
|
652
|
+
const registry = await readRegistry(nexakitDir(home));
|
|
653
|
+
const previousManifest = await readProjectToolkitManifest(root);
|
|
654
|
+
const manifested = new Map((previousManifest?.entries ?? []).filter(entry => entry?.relativePath).map(entry => [entry.relativePath, entry]));
|
|
655
|
+
const ownedBy = suffix => (target, literal) => {
|
|
656
|
+
if (isRegistered(registry, target, literal)) return true;
|
|
657
|
+
const entry = manifested.get(path.relative(root, target).split(path.sep).join('/'));
|
|
658
|
+
if (!entry) return false;
|
|
659
|
+
if (typeof entry.apunta === 'string') return entry.apunta === literal;
|
|
660
|
+
return path.resolve(path.dirname(target), literal).endsWith(path.sep + path.normalize(suffix));
|
|
661
|
+
};
|
|
662
|
+
const activeRoots = [linkRootPath, resolvedToolkit, actualRoot(home)];
|
|
663
|
+
|
|
738
664
|
const links = [];
|
|
739
665
|
const verifiedCatalogEntries = new Map();
|
|
740
666
|
|
|
@@ -759,8 +685,29 @@ export async function planProjectSkillInstall({
|
|
|
759
685
|
for (const client of clients) {
|
|
760
686
|
const target = path.join(root, client === 'codex' ? '.agents' : '.claude', 'skills', skillName);
|
|
761
687
|
await ensureSafeProjectParents(root, target, false);
|
|
688
|
+
const serves = servesFor(client, agent);
|
|
689
|
+
const suffix = path.relative(resolvedToolkit, source);
|
|
690
|
+
if (mode === 'symlink') {
|
|
691
|
+
const info = await classifyLink({ target, suffix, activeRoots, isOwned: ownedBy(suffix) });
|
|
692
|
+
links.push({
|
|
693
|
+
client, serves, name: skillName, source: path.join(linkRootPath, suffix), target, mode,
|
|
694
|
+
state: COARSE_STATE[info.state], linkState: info.state,
|
|
695
|
+
...(info.literal && { literal: info.literal }), ...(info.root && { root: info.root })
|
|
696
|
+
});
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
if ((await lstatOrNull(target))?.isSymbolicLink()) {
|
|
700
|
+
// A link where a copy goes is replaced only when it is provably nexakit's; any other link is the user's.
|
|
701
|
+
const info = await classifyLink({ target, suffix, activeRoots, isOwned: ownedBy(suffix) });
|
|
702
|
+
const ours = ['instalado', 'otra-copia', 'efimero', 'roto-propio'].includes(info.state);
|
|
703
|
+
links.push({
|
|
704
|
+
client, serves, name: skillName, source, target, mode, state: ours ? 'replace' : 'conflict',
|
|
705
|
+
linkState: info.state, ...(info.literal && { literal: info.literal })
|
|
706
|
+
});
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
762
709
|
const state = await projectTargetState(source, target, mode, catalogEntry);
|
|
763
|
-
links.push({ client, name: skillName, source, target, state, mode });
|
|
710
|
+
links.push({ client, serves, name: skillName, source, target, state, mode });
|
|
764
711
|
}
|
|
765
712
|
}
|
|
766
713
|
|
|
@@ -800,6 +747,10 @@ export async function planProjectSkillInstall({
|
|
|
800
747
|
gitIgnore,
|
|
801
748
|
projectRoot: root,
|
|
802
749
|
toolkitRoot: resolvedToolkit,
|
|
750
|
+
linkRoot: linkRootPath,
|
|
751
|
+
ephemeral,
|
|
752
|
+
homeDirectory: home,
|
|
753
|
+
agent,
|
|
803
754
|
links,
|
|
804
755
|
stateDirPath,
|
|
805
756
|
manifestPath,
|
|
@@ -822,7 +773,8 @@ export async function planProjectSkillInstall({
|
|
|
822
773
|
};
|
|
823
774
|
}
|
|
824
775
|
|
|
825
|
-
async function copyVerifiedSkillDirectory(sourceDir, targetDir, catalogEntry) {
|
|
776
|
+
async function copyVerifiedSkillDirectory(sourceDir, targetDir, catalogEntry, lock) {
|
|
777
|
+
await lock?.assertHeld();
|
|
826
778
|
await fs.mkdir(targetDir, { recursive: true, mode: 0o755 });
|
|
827
779
|
if (catalogEntry) {
|
|
828
780
|
for (const file of catalogEntry.files) {
|
|
@@ -833,15 +785,18 @@ async function copyVerifiedSkillDirectory(sourceDir, targetDir, catalogEntry) {
|
|
|
833
785
|
if (actualHash !== file.sha256) {
|
|
834
786
|
throw new Error(`Integridad SHA-256 rechazada durante la copia de ${catalogEntry.name} (${file.path}).`);
|
|
835
787
|
}
|
|
788
|
+
await lock?.assertHeld();
|
|
836
789
|
await fs.mkdir(path.dirname(dstFile), { recursive: true, mode: 0o755 });
|
|
837
790
|
await fs.writeFile(dstFile, bytes, { mode: 0o644 });
|
|
838
791
|
}
|
|
839
792
|
} else {
|
|
793
|
+
await lock?.assertHeld();
|
|
840
794
|
await fs.cp(sourceDir, targetDir, { recursive: true, dereference: false });
|
|
841
795
|
}
|
|
842
796
|
}
|
|
843
797
|
|
|
844
|
-
async function writeSkillsLockfile(lockfilePath, activeEntriesBySkill) {
|
|
798
|
+
async function writeSkillsLockfile(lockfilePath, activeEntriesBySkill, lock) {
|
|
799
|
+
await lock?.assertHeld();
|
|
845
800
|
await fs.mkdir(path.dirname(lockfilePath), { recursive: true, mode: 0o755 });
|
|
846
801
|
const existingInfo = await lstatOrNull(lockfilePath);
|
|
847
802
|
if (existingInfo && (!existingInfo.isFile() || existingInfo.isSymbolicLink())) {
|
|
@@ -882,6 +837,7 @@ async function writeSkillsLockfile(lockfilePath, activeEntriesBySkill) {
|
|
|
882
837
|
generatedBy: 'nexakit',
|
|
883
838
|
skills: sortedSkills
|
|
884
839
|
};
|
|
840
|
+
await lock?.assertHeld();
|
|
885
841
|
await fs.writeFile(lockfilePath, JSON.stringify(payload, null, 2) + '\n', { mode: 0o644 });
|
|
886
842
|
return true;
|
|
887
843
|
}
|
|
@@ -891,13 +847,16 @@ async function writeProjectManifestAndIgnore({
|
|
|
891
847
|
mode,
|
|
892
848
|
gitIgnore,
|
|
893
849
|
prevManifest,
|
|
894
|
-
coupledEntriesMap
|
|
850
|
+
coupledEntriesMap,
|
|
851
|
+
lock,
|
|
852
|
+
answers = prevManifest?.answers
|
|
895
853
|
}) {
|
|
896
854
|
const stateDir = path.join(projectRoot, PROJECT_TOOLKIT_DIR);
|
|
897
855
|
const stateStat = await lstatOrNull(stateDir);
|
|
898
856
|
if (stateStat && (!stateStat.isDirectory() || stateStat.isSymbolicLink())) {
|
|
899
857
|
return { manifestWritten: false, gitIgnoreUpdated: false };
|
|
900
858
|
}
|
|
859
|
+
await lock?.assertHeld();
|
|
901
860
|
await fs.mkdir(stateDir, { recursive: true, mode: 0o755 });
|
|
902
861
|
|
|
903
862
|
const entries = [...coupledEntriesMap.values()].sort((a, b) =>
|
|
@@ -910,17 +869,20 @@ async function writeProjectManifestAndIgnore({
|
|
|
910
869
|
if (gitIgnore !== 'none') {
|
|
911
870
|
const info = await inspectGitIgnoreTarget(projectRoot, gitIgnore);
|
|
912
871
|
if (!info.conflict && !info.unavailable && info.targetPath) {
|
|
872
|
+
await lock?.assertHeld();
|
|
913
873
|
await fs.mkdir(path.dirname(info.targetPath), { recursive: true, mode: 0o755 });
|
|
914
874
|
const currentStat = await lstatOrNull(info.targetPath);
|
|
915
875
|
if (!currentStat) {
|
|
916
876
|
if (gitIgnore === 'gitignore') createdIgnoreFile = true;
|
|
917
877
|
const nextText = upsertManagedIgnoreBlock('', entries.map(e => e.relativePath));
|
|
878
|
+
await lock?.assertHeld();
|
|
918
879
|
await fs.writeFile(info.targetPath, nextText, { mode: 0o644 });
|
|
919
880
|
gitIgnoreUpdated = true;
|
|
920
881
|
} else if (currentStat.isFile() && !currentStat.isSymbolicLink()) {
|
|
921
882
|
const prevText = await fs.readFile(info.targetPath, 'utf8');
|
|
922
883
|
const nextText = upsertManagedIgnoreBlock(prevText, entries.map(e => e.relativePath));
|
|
923
884
|
if (nextText !== prevText) {
|
|
885
|
+
await lock?.assertHeld();
|
|
924
886
|
await fs.writeFile(info.targetPath, nextText, { mode: 0o644 });
|
|
925
887
|
}
|
|
926
888
|
gitIgnoreUpdated = true;
|
|
@@ -935,6 +897,7 @@ async function writeProjectManifestAndIgnore({
|
|
|
935
897
|
const otherText = await fs.readFile(otherInfo.targetPath, 'utf8');
|
|
936
898
|
const cleaned = removeManagedIgnoreBlock(otherText);
|
|
937
899
|
if (cleaned.changed) {
|
|
900
|
+
await lock?.assertHeld();
|
|
938
901
|
if (!cleaned.content && otherStrategy === 'gitignore' && prevManifest?.createdIgnoreFile) {
|
|
939
902
|
await fs.unlink(otherInfo.targetPath).catch(() => {});
|
|
940
903
|
createdIgnoreFile = false;
|
|
@@ -953,20 +916,71 @@ async function writeProjectManifestAndIgnore({
|
|
|
953
916
|
mode,
|
|
954
917
|
gitIgnoreStrategy: gitIgnore,
|
|
955
918
|
createdIgnoreFile,
|
|
956
|
-
entries
|
|
919
|
+
entries,
|
|
920
|
+
...(answers && { answers })
|
|
957
921
|
};
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
922
|
+
// A manifest that is a link or not a regular file is not nexakit's: it is never written through.
|
|
923
|
+
const manifestPath = path.join(stateDir, PROJECT_MANIFEST_FILE);
|
|
924
|
+
const manifestStat = await lstatOrNull(manifestPath);
|
|
925
|
+
if (manifestStat && (!manifestStat.isFile() || manifestStat.isSymbolicLink())) {
|
|
926
|
+
return { manifestWritten: false, gitIgnoreUpdated };
|
|
927
|
+
}
|
|
928
|
+
const temporary = `${manifestPath}.${process.pid}.tmp`;
|
|
929
|
+
await fs.writeFile(temporary, JSON.stringify(manifestPayload, null, 2) + '\n', { mode: 0o644, flag: 'wx' });
|
|
930
|
+
try {
|
|
931
|
+
await lock?.assertHeld();
|
|
932
|
+
await fs.rename(temporary, manifestPath);
|
|
933
|
+
} finally {
|
|
934
|
+
await fs.rm(temporary, { force: true }); // ours: created exclusively above
|
|
935
|
+
}
|
|
963
936
|
return { manifestWritten: true, gitIgnoreUpdated };
|
|
964
937
|
}
|
|
965
938
|
|
|
966
|
-
|
|
939
|
+
// Links are written under the install lock: it prepares the stable copy when running from an npx cache,
|
|
940
|
+
// re-checks the hashes where the links will point, and records every link so a broken one can be proven ours.
|
|
941
|
+
export async function applyProjectSkillInstall(plan, { runNpm, packageSpec, lockOptions, acquire = acquireLock } = {}) {
|
|
967
942
|
if (!plan || !Array.isArray(plan.links) || !plan.projectRoot) {
|
|
968
943
|
throw new Error('Se requiere una vista previa válida de instalación de skills.');
|
|
969
944
|
}
|
|
945
|
+
const mode = plan.mode ?? 'symlink';
|
|
946
|
+
const gitIgnore = plan.gitIgnore ?? 'gitignore';
|
|
947
|
+
// Anything that writes (links, copies, the manifest, ignore rules or the lockfile) runs under the lock.
|
|
948
|
+
// With nothing to write, nothing is written, so a run that changes nothing never creates ~/.nexakit.
|
|
949
|
+
const pending = plan.links.some(l => ['missing', 'replace'].includes(l.state));
|
|
950
|
+
const previous = await readProjectToolkitManifest(plan.projectRoot);
|
|
951
|
+
const settingsChanged = Boolean(previous) && (previous.mode !== mode || previous.gitIgnoreStrategy !== gitIgnore);
|
|
952
|
+
if (!pending && !settingsChanged) return applyProjectSkillInstallLocked(plan, { registryEntries: [], lock: null, writeMetadata: false });
|
|
953
|
+
|
|
954
|
+
const home = plan.homeDirectory ?? os.homedir();
|
|
955
|
+
const lock = await acquire(nexakitDir(home), lockOptions);
|
|
956
|
+
const registryEntries = [];
|
|
957
|
+
try {
|
|
958
|
+
if (mode === 'symlink' && pending) {
|
|
959
|
+
if (plan.ephemeral) {
|
|
960
|
+
const { version } = JSON.parse(await fs.readFile(path.join(plan.toolkitRoot ?? TOOLKIT_ROOT, 'package.json'), 'utf8'));
|
|
961
|
+
await ensureStableInstall({
|
|
962
|
+
homeDirectory: home, version, lock, ...(runNpm && { runNpm }), ...(packageSpec && { packageSpec })
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
const linkRoot = plan.linkRoot ?? plan.toolkitRoot ?? TOOLKIT_ROOT;
|
|
966
|
+
if (path.resolve(linkRoot) !== path.resolve(plan.toolkitRoot ?? TOOLKIT_ROOT)) {
|
|
967
|
+
// The files that get linked are the ones in linkRoot, so their hashes must hold there too.
|
|
968
|
+
for (const entry of Object.values(plan.verifiedCatalogEntries ?? {})) {
|
|
969
|
+
const check = await verifyBundledSkillHashes({ skillEntry: entry, toolkitRoot: linkRoot });
|
|
970
|
+
if (!check.ok) throw new Error(check.reason);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
const result = await applyProjectSkillInstallLocked(plan, { registryEntries, lock, writeMetadata: true });
|
|
975
|
+
await recordEntries(nexakitDir(home), registryEntries, lock);
|
|
976
|
+
return result;
|
|
977
|
+
} finally {
|
|
978
|
+
await lock.release();
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// `lock` is null only when nothing will be written; every write checks it still holds the lock just before.
|
|
983
|
+
async function applyProjectSkillInstallLocked(plan, { registryEntries, lock, writeMetadata }) {
|
|
970
984
|
const mode = plan.mode ?? 'symlink';
|
|
971
985
|
const gitIgnore = plan.gitIgnore ?? 'gitignore';
|
|
972
986
|
const catalogEntries = plan.verifiedCatalogEntries ?? {};
|
|
@@ -1008,35 +1022,49 @@ export async function applyProjectSkillInstall(plan) {
|
|
|
1008
1022
|
continue;
|
|
1009
1023
|
}
|
|
1010
1024
|
|
|
1025
|
+
await lock?.assertHeld();
|
|
1011
1026
|
await ensureSafeProjectParents(plan.projectRoot, item.target, true);
|
|
1012
|
-
|
|
1013
|
-
if (
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
installedClientsBySkill.set(item.name, list);
|
|
1022
|
-
}
|
|
1023
|
-
updatedLinks.push({ ...item, state: 'installed' });
|
|
1024
|
-
continue;
|
|
1025
|
-
}
|
|
1026
|
-
if (currentState === 'replace') {
|
|
1027
|
-
const currentInfo = await lstatOrNull(item.target);
|
|
1028
|
-
if (!currentInfo?.isSymbolicLink()) {
|
|
1029
|
-
updatedLinks.push({ ...item, state: 'conflict' });
|
|
1027
|
+
let literal;
|
|
1028
|
+
if (mode === 'symlink') {
|
|
1029
|
+
// Re-reads the link right before writing: if it is no longer what the plan saw, it is left alone.
|
|
1030
|
+
await lock?.assertHeld();
|
|
1031
|
+
const outcome = await relinkIfUnchanged({
|
|
1032
|
+
target: item.target, expectedLiteral: item.linkState === 'falta' ? null : item.literal, source: item.source, relative: true
|
|
1033
|
+
});
|
|
1034
|
+
if (outcome === 'cambio') {
|
|
1035
|
+
updatedLinks.push({ ...item, state: 'conflict', changed: true });
|
|
1030
1036
|
continue;
|
|
1031
1037
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
if (mode === 'symlink') {
|
|
1036
|
-
const linkTarget = IS_WINDOWS ? path.resolve(item.source) : path.relative(path.dirname(item.target), item.source);
|
|
1037
|
-
await fs.symlink(linkTarget, item.target, IS_WINDOWS ? 'junction' : 'dir');
|
|
1038
|
+
// The registry and the manifest keep what readlink returns, the value that is compared later.
|
|
1039
|
+
literal = await fs.readlink(item.target);
|
|
1040
|
+
registryEntries.push({ destino: item.target, apunta: literal, raiz: plan.linkRoot ?? plan.toolkitRoot });
|
|
1038
1041
|
} else {
|
|
1039
|
-
await
|
|
1042
|
+
const current = await lstatOrNull(item.target);
|
|
1043
|
+
if (current?.isSymbolicLink()) {
|
|
1044
|
+
// Only a link the plan proved to be nexakit's becomes a copy, and only if it did not change since.
|
|
1045
|
+
if (item.state !== 'replace' || !item.literal || await fs.readlink(item.target) !== item.literal) {
|
|
1046
|
+
updatedLinks.push({ ...item, state: 'conflict', changed: true });
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
await lock?.assertHeld();
|
|
1050
|
+
await fs.unlink(item.target);
|
|
1051
|
+
} else {
|
|
1052
|
+
const currentState = await projectTargetState(item.source, item.target, mode, catalogEntry);
|
|
1053
|
+
if (currentState === 'conflict') {
|
|
1054
|
+
updatedLinks.push({ ...item, state: 'conflict' });
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
if (currentState === 'installed') {
|
|
1058
|
+
if (coupledEntriesMap.has(`${item.client}:${item.name}`)) {
|
|
1059
|
+
const list = installedClientsBySkill.get(item.name) ?? [];
|
|
1060
|
+
list.push(item.client);
|
|
1061
|
+
installedClientsBySkill.set(item.name, list);
|
|
1062
|
+
}
|
|
1063
|
+
updatedLinks.push({ ...item, state: 'installed' });
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
await copyVerifiedSkillDirectory(item.source, item.target, catalogEntry, lock);
|
|
1040
1068
|
}
|
|
1041
1069
|
|
|
1042
1070
|
created++;
|
|
@@ -1045,6 +1073,7 @@ export async function applyProjectSkillInstall(plan) {
|
|
|
1045
1073
|
name: item.name,
|
|
1046
1074
|
relativePath: relPath,
|
|
1047
1075
|
mode,
|
|
1076
|
+
...(literal && { apunta: literal }),
|
|
1048
1077
|
...(catalogEntry?.revision && { revision: catalogEntry.revision })
|
|
1049
1078
|
});
|
|
1050
1079
|
const list = installedClientsBySkill.get(item.name) ?? [];
|
|
@@ -1054,7 +1083,7 @@ export async function applyProjectSkillInstall(plan) {
|
|
|
1054
1083
|
}
|
|
1055
1084
|
|
|
1056
1085
|
let lockfileWritten = false;
|
|
1057
|
-
if (mode === 'copy' && !plan.stateDirConflict && !plan.lockfileConflict && installedClientsBySkill.size > 0) {
|
|
1086
|
+
if (writeMetadata && mode === 'copy' && !plan.stateDirConflict && !plan.lockfileConflict && installedClientsBySkill.size > 0) {
|
|
1058
1087
|
const activeMap = new Map();
|
|
1059
1088
|
for (const [skillName, clients] of installedClientsBySkill.entries()) {
|
|
1060
1089
|
const catalogEntry = catalogEntries[skillName];
|
|
@@ -1063,20 +1092,22 @@ export async function applyProjectSkillInstall(plan) {
|
|
|
1063
1092
|
if (activeMap.size > 0) {
|
|
1064
1093
|
lockfileWritten = await writeSkillsLockfile(
|
|
1065
1094
|
plan.lockfilePath ?? path.join(plan.projectRoot, PROJECT_TOOLKIT_DIR, PROJECT_LOCKFILE_NAME),
|
|
1066
|
-
activeMap
|
|
1095
|
+
activeMap,
|
|
1096
|
+
lock
|
|
1067
1097
|
);
|
|
1068
1098
|
}
|
|
1069
1099
|
}
|
|
1070
1100
|
|
|
1071
1101
|
let manifestWritten = false;
|
|
1072
1102
|
let gitIgnoreUpdated = false;
|
|
1073
|
-
if (!plan.stateDirConflict && (coupledEntriesMap.size > 0 || lockfileWritten)) {
|
|
1103
|
+
if (writeMetadata && !plan.stateDirConflict && (coupledEntriesMap.size > 0 || lockfileWritten)) {
|
|
1074
1104
|
const res = await writeProjectManifestAndIgnore({
|
|
1075
1105
|
projectRoot: plan.projectRoot,
|
|
1076
1106
|
mode,
|
|
1077
1107
|
gitIgnore,
|
|
1078
1108
|
prevManifest,
|
|
1079
|
-
coupledEntriesMap
|
|
1109
|
+
coupledEntriesMap,
|
|
1110
|
+
lock
|
|
1080
1111
|
});
|
|
1081
1112
|
manifestWritten = res.manifestWritten;
|
|
1082
1113
|
gitIgnoreUpdated = res.gitIgnoreUpdated;
|
|
@@ -1108,6 +1139,38 @@ export async function applyProjectSkillLinks(plan) {
|
|
|
1108
1139
|
return applyProjectSkillInstall({ ...plan, mode: 'symlink' });
|
|
1109
1140
|
}
|
|
1110
1141
|
|
|
1142
|
+
// The answers given to `npx nexakit` (skills chosen and discarded, agents, memory), kept in the project manifest
|
|
1143
|
+
// so a second run knows what was decided and does not ask again.
|
|
1144
|
+
const isNameList = value => Array.isArray(value) && value.every(item => typeof item === 'string' && SKILL_NAME_RE.test(item));
|
|
1145
|
+
function validSetupAnswers(answers) {
|
|
1146
|
+
return Boolean(answers && answers.version === 1 &&
|
|
1147
|
+
isNameList(answers.skills?.chosen) && isNameList(answers.skills?.discarded) &&
|
|
1148
|
+
Array.isArray(answers.agents) && answers.agents.every(agent => ['claude', 'codex', 'antigravity'].includes(agent)) &&
|
|
1149
|
+
(answers.memory?.decision === 'later' ||
|
|
1150
|
+
(answers.memory?.decision === 'path' && typeof answers.memory.path === 'string' && path.isAbsolute(answers.memory.path))));
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
export async function readSetupAnswers(projectRoot) {
|
|
1154
|
+
const manifest = await readProjectToolkitManifest(projectRoot);
|
|
1155
|
+
return validSetupAnswers(manifest?.answers) ? manifest.answers : null;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
export async function writeSetupAnswers(projectRoot, answers, lock) {
|
|
1159
|
+
if (!validSetupAnswers(answers)) throw new Error('Respuestas de nexakit no válidas.');
|
|
1160
|
+
await lock.assertHeld();
|
|
1161
|
+
const prevManifest = await readProjectToolkitManifest(projectRoot);
|
|
1162
|
+
const result = await writeProjectManifestAndIgnore({
|
|
1163
|
+
projectRoot,
|
|
1164
|
+
mode: prevManifest?.mode ?? 'symlink',
|
|
1165
|
+
gitIgnore: prevManifest?.gitIgnoreStrategy ?? 'gitignore',
|
|
1166
|
+
prevManifest,
|
|
1167
|
+
coupledEntriesMap: new Map((prevManifest?.entries ?? []).map(entry => [entry.relativePath, entry])),
|
|
1168
|
+
lock,
|
|
1169
|
+
answers
|
|
1170
|
+
});
|
|
1171
|
+
if (!result.manifestWritten) throw new Error(`${PROJECT_TOOLKIT_DIR} no es una carpeta normal; no se guardaron las respuestas.`);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1111
1174
|
export async function planToolkitDetach({
|
|
1112
1175
|
projectRoot,
|
|
1113
1176
|
includeGlobal = false,
|
|
@@ -1115,13 +1178,21 @@ export async function planToolkitDetach({
|
|
|
1115
1178
|
catalog,
|
|
1116
1179
|
toolkitRoot = TOOLKIT_ROOT,
|
|
1117
1180
|
homeDirectory = os.homedir(),
|
|
1118
|
-
codexHome
|
|
1181
|
+
codexHome,
|
|
1182
|
+
environment = {},
|
|
1183
|
+
skillNames
|
|
1119
1184
|
} = {}) {
|
|
1120
|
-
if (!['codex', 'claude', 'all'].includes(agent)) {
|
|
1121
|
-
throw new Error('El agente debe ser codex, claude o all.');
|
|
1185
|
+
if (!['codex', 'claude', 'antigravity', 'all'].includes(agent)) {
|
|
1186
|
+
throw new Error('El agente debe ser codex, claude, antigravity o all.');
|
|
1122
1187
|
}
|
|
1123
|
-
const clients = agent === 'all' ? ['codex', 'claude'] : [agent];
|
|
1188
|
+
const clients = agent === 'all' ? ['codex', 'claude', 'antigravity'] : [agent];
|
|
1189
|
+
// Antigravity shares Codex's project folder, so removing one's project links removes the other's.
|
|
1190
|
+
const projectClients = [...new Set(clients.map(c => (c === 'antigravity' ? 'codex' : c)))];
|
|
1124
1191
|
const resolvedToolkit = await fs.realpath(toolkitRoot);
|
|
1192
|
+
const homeReal = await fs.realpath(homeDirectory).catch(() => path.resolve(homeDirectory));
|
|
1193
|
+
// Links made from an npx run point into the stable copy, which is nexakit's too.
|
|
1194
|
+
const stableVersions = path.join(nexakitDir(homeReal), 'versiones');
|
|
1195
|
+
const registry = await readRegistry(nexakitDir(homeReal));
|
|
1125
1196
|
const activeCatalog = catalog ?? await loadSkillCatalog();
|
|
1126
1197
|
const catalogByName = new Map(activeCatalog.skills.map(s => [s.name, s]));
|
|
1127
1198
|
|
|
@@ -1141,17 +1212,19 @@ export async function planToolkitDetach({
|
|
|
1141
1212
|
|
|
1142
1213
|
let candidateEntries = [];
|
|
1143
1214
|
if (manifest) {
|
|
1144
|
-
|
|
1145
|
-
|
|
1215
|
+
// With skillNames only those skills go; the rest of the entries stay in the manifest.
|
|
1216
|
+
const removing = e => projectClients.includes(e.client) && (!skillNames || skillNames.includes(e.name));
|
|
1217
|
+
candidateEntries = manifest.entries.filter(removing);
|
|
1218
|
+
remainingManifestEntries = manifest.entries.filter(e => !removing(e));
|
|
1146
1219
|
} else {
|
|
1147
|
-
const extLinks = await findFallbackExternalToolkitLinks(root, resolvedToolkit,
|
|
1220
|
+
const extLinks = await findFallbackExternalToolkitLinks(root, resolvedToolkit, projectClients, homeDirectory);
|
|
1148
1221
|
const seenKeys = new Set(extLinks.map(e => `${e.client}:${e.name}`));
|
|
1149
1222
|
candidateEntries = [...extLinks];
|
|
1150
1223
|
if (legacyLock) {
|
|
1151
1224
|
for (const [skillName, meta] of Object.entries(legacyLock.skills ?? {})) {
|
|
1152
1225
|
const lockClients = Array.isArray(meta?.clients) ? meta.clients : ['codex', 'claude'];
|
|
1153
1226
|
for (const client of lockClients) {
|
|
1154
|
-
if (!
|
|
1227
|
+
if (!projectClients.includes(client) || seenKeys.has(`${client}:${skillName}`)) continue;
|
|
1155
1228
|
candidateEntries.push({
|
|
1156
1229
|
client,
|
|
1157
1230
|
name: skillName,
|
|
@@ -1163,6 +1236,7 @@ export async function planToolkitDetach({
|
|
|
1163
1236
|
}
|
|
1164
1237
|
}
|
|
1165
1238
|
|
|
1239
|
+
if (skillNames) candidateEntries = candidateEntries.filter(entry => skillNames.includes(entry.name));
|
|
1166
1240
|
for (const entry of candidateEntries) {
|
|
1167
1241
|
if (!SKILL_NAME_RE.test(entry.name)) continue;
|
|
1168
1242
|
const target = path.join(root, entry.client === 'codex' ? '.agents' : '.claude', 'skills', entry.name);
|
|
@@ -1174,7 +1248,7 @@ export async function planToolkitDetach({
|
|
|
1174
1248
|
let safeToUnlink = Boolean(manifest);
|
|
1175
1249
|
try {
|
|
1176
1250
|
const real = await fs.realpath(target);
|
|
1177
|
-
safeToUnlink = isWithinRoot(real, resolvedToolkit) && !isWithinRoot(real, root);
|
|
1251
|
+
safeToUnlink = (isWithinRoot(real, resolvedToolkit) || isWithinRoot(real, stableVersions)) && !isWithinRoot(real, root);
|
|
1178
1252
|
} catch (err) {
|
|
1179
1253
|
if (err.code !== 'ENOENT') throw err;
|
|
1180
1254
|
}
|
|
@@ -1249,7 +1323,7 @@ export async function planToolkitDetach({
|
|
|
1249
1323
|
const manualCommands = [];
|
|
1250
1324
|
if (includeGlobal) {
|
|
1251
1325
|
const home = await fs.realpath(homeDirectory);
|
|
1252
|
-
const resolvedCodexHome = codexHome ?? path.join(home, '.codex');
|
|
1326
|
+
const resolvedCodexHome = codexHome ?? environment.CODEX_HOME ?? path.join(home, '.codex');
|
|
1253
1327
|
const bundledSkillsDir = path.join(resolvedToolkit, 'skills');
|
|
1254
1328
|
const bundledNames = (await fs.readdir(bundledSkillsDir, { withFileTypes: true }).catch(() => []))
|
|
1255
1329
|
.filter(e => e.isDirectory() && SKILL_NAME_RE.test(e.name))
|
|
@@ -1258,24 +1332,25 @@ export async function planToolkitDetach({
|
|
|
1258
1332
|
|
|
1259
1333
|
for (const client of clients) {
|
|
1260
1334
|
for (const name of bundledNames) {
|
|
1261
|
-
const
|
|
1262
|
-
const target = path.join(home, client === 'codex' ? '.agents' : '.claude', 'skills', name);
|
|
1335
|
+
const target = globalSkillTarget(home, client, name);
|
|
1263
1336
|
const info = await lstatOrNull(target);
|
|
1264
1337
|
if (!info?.isSymbolicLink()) continue;
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1338
|
+
// Only links that are provably nexakit's: into this copy, the stable copy, an npx cache, or recorded broken.
|
|
1339
|
+
const link = await classifyLink({
|
|
1340
|
+
target, suffix: `skills/${name}`, activeRoots: [resolvedToolkit, actualRoot(home)],
|
|
1341
|
+
isOwned: (at, literal) => isRegistered(registry, at, literal)
|
|
1342
|
+
});
|
|
1343
|
+
if (['instalado', 'efimero', 'roto-propio'].includes(link.state)) {
|
|
1344
|
+
items.push({
|
|
1345
|
+
scope: 'global',
|
|
1346
|
+
client,
|
|
1347
|
+
name,
|
|
1348
|
+
target,
|
|
1349
|
+
relativePath: target,
|
|
1350
|
+
action: 'remove-global-link',
|
|
1351
|
+
reason: 'enlace global de Agent Toolkit'
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1279
1354
|
}
|
|
1280
1355
|
}
|
|
1281
1356
|
|
|
@@ -1305,6 +1380,16 @@ export async function planToolkitDetach({
|
|
|
1305
1380
|
}
|
|
1306
1381
|
|
|
1307
1382
|
return {
|
|
1383
|
+
reviewInstructions: includeGlobal && !skillNames
|
|
1384
|
+
? await ownedReviewInstructions({
|
|
1385
|
+
homeDirectory: homeReal,
|
|
1386
|
+
codexHome: codexHome ?? environment.CODEX_HOME ?? path.join(homeReal, '.codex'),
|
|
1387
|
+
agents: clients
|
|
1388
|
+
})
|
|
1389
|
+
: [],
|
|
1390
|
+
homeDirectory: homeReal,
|
|
1391
|
+
reviewCodexHome: codexHome ?? environment.CODEX_HOME ?? path.join(homeReal, ".codex"),
|
|
1392
|
+
reviewAgents: clients,
|
|
1308
1393
|
applied: false,
|
|
1309
1394
|
projectRoot: root,
|
|
1310
1395
|
toolkitRoot: resolvedToolkit,
|
|
@@ -1363,6 +1448,18 @@ export async function applyToolkitDetach(plan) {
|
|
|
1363
1448
|
throw new Error('Se requiere una vista previa válida de desacoplado.');
|
|
1364
1449
|
}
|
|
1365
1450
|
|
|
1451
|
+
if (plan.reviewInstructions?.length) {
|
|
1452
|
+
const lock = await acquireLock(nexakitDir(plan.homeDirectory));
|
|
1453
|
+
try {
|
|
1454
|
+
await removeReviewInstructions({
|
|
1455
|
+
homeDirectory: plan.homeDirectory,
|
|
1456
|
+
codexHome: plan.reviewCodexHome,
|
|
1457
|
+
agents: plan.reviewAgents
|
|
1458
|
+
}, lock);
|
|
1459
|
+
} finally {
|
|
1460
|
+
await lock.release();
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1366
1463
|
let removedCount = 0;
|
|
1367
1464
|
let conflicts = 0;
|
|
1368
1465
|
const updatedItems = [];
|
|
@@ -1556,5 +1653,21 @@ export function formatDetachReport(report) {
|
|
|
1556
1653
|
for (const cmd of report.manualCommands) lines.push(` $ ${cmd}`);
|
|
1557
1654
|
}
|
|
1558
1655
|
|
|
1656
|
+
if (report.reviewInstructions?.length) lines.push(` - Quitar solo el bloque de revisión de ${report.reviewInstructions.join(', ')}`);
|
|
1559
1657
|
return lines.join('\n') + '\n';
|
|
1560
1658
|
}
|
|
1659
|
+
|
|
1660
|
+
// Generic signals: almost every project has them, and the useful skill is the framework's.
|
|
1661
|
+
const GENERIC_SIGNALS = new Set(['nodejs', 'typescript']);
|
|
1662
|
+
|
|
1663
|
+
// The technologies found in the project that no catalog skill (nor external reference) covers. Only reported:
|
|
1664
|
+
// finding a skill for one is a request the person makes to revisar-skill, never a search nexakit starts alone.
|
|
1665
|
+
export function uncoveredSignals({ signals = [], catalog }) {
|
|
1666
|
+
const covered = new Set([
|
|
1667
|
+
...(catalog?.skills ?? []).flatMap((skill) => skill.matching?.anySignals ?? []),
|
|
1668
|
+
...(catalog?.externalReferences ?? []).flatMap((ref) => ref.anySignals ?? []),
|
|
1669
|
+
]);
|
|
1670
|
+
return signals
|
|
1671
|
+
.filter((signal) => !GENERIC_SIGNALS.has(signal.id) && !covered.has(signal.id))
|
|
1672
|
+
.map((signal) => ({ id: signal.id, label: signal.label }));
|
|
1673
|
+
}
|