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
package/src/init.js
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
const SOURCE_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
|
8
8
|
const TEMPLATE_ROOT = path.resolve(SOURCE_DIRECTORY, "../templates/project-memory");
|
|
9
9
|
const PROJECT_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
10
|
+
const KNOWN_AGENTS = ["codex", "claude", "antigravity"];
|
|
10
11
|
const BRANCH_PATTERN = /^(?!.*(?:\.\.|\/\/|@\{|\\))[A-Za-z0-9][A-Za-z0-9._/-]*[A-Za-z0-9]$|^[A-Za-z0-9]$/;
|
|
11
12
|
const SYNC_MODES = new Set(["none", "git-manual", "git-automated"]);
|
|
12
13
|
|
|
@@ -73,6 +74,7 @@ function normalizeOptions(options, context) {
|
|
|
73
74
|
memoryBranch: "main",
|
|
74
75
|
mcpName: options.mcpName?.trim() || `${options.projectId?.trim()}-memory`,
|
|
75
76
|
syncMode,
|
|
77
|
+
agents: options.agents ?? ["codex", "claude"],
|
|
76
78
|
date: formatDate(now),
|
|
77
79
|
templateRoot: path.resolve(context.templateRoot ?? TEMPLATE_ROOT)
|
|
78
80
|
};
|
|
@@ -89,6 +91,12 @@ function validateConfig(config) {
|
|
|
89
91
|
errors.push("--project-id debe usar minúsculas, números y guiones.");
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
if (!Array.isArray(config.agents) || config.agents.length === 0 ||
|
|
95
|
+
config.agents.some((agent) => !KNOWN_AGENTS.includes(agent)) ||
|
|
96
|
+
new Set(config.agents).size !== config.agents.length) {
|
|
97
|
+
errors.push("Los agentes deben ser codex, claude o antigravity, sin repetir.");
|
|
98
|
+
}
|
|
99
|
+
|
|
92
100
|
if (!config.projectName || config.projectName.length > 100) {
|
|
93
101
|
errors.push("--project-name es obligatorio y admite máximo 100 caracteres.");
|
|
94
102
|
}
|
|
@@ -261,7 +269,7 @@ function createManifest(config) {
|
|
|
261
269
|
},
|
|
262
270
|
source,
|
|
263
271
|
memory,
|
|
264
|
-
agents:
|
|
272
|
+
agents: config.agents.map((id) => ({
|
|
265
273
|
id,
|
|
266
274
|
enabled: true,
|
|
267
275
|
skillName: config.projectId
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const LOCK_FILE = 'bloqueo';
|
|
7
|
+
const MAX_RECOVERIES = 10;
|
|
8
|
+
|
|
9
|
+
export class LockError extends Error {
|
|
10
|
+
constructor(code, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'LockError';
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function defaultIsAlive(pid) {
|
|
18
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
22
|
+
const ignoreMissing = error => { if (error.code !== 'ENOENT') throw error; };
|
|
23
|
+
|
|
24
|
+
// The state is `missing`, `unreadable` (not valid JSON, wrong shape or not a regular file) or `ok`.
|
|
25
|
+
async function readOwner(file) {
|
|
26
|
+
let text;
|
|
27
|
+
try { text = await fs.readFile(file, 'utf8'); } catch (error) {
|
|
28
|
+
return error.code === 'ENOENT' ? { state: 'missing' } : { state: 'unreadable' };
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const data = JSON.parse(text);
|
|
32
|
+
if (Number.isInteger(data?.pid) && data.pid > 0 && typeof data.equipo === 'string' && typeof data.marca === 'string') {
|
|
33
|
+
return { state: 'ok', owner: data };
|
|
34
|
+
}
|
|
35
|
+
} catch { /* falls through */ }
|
|
36
|
+
return { state: 'unreadable' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Writes the record to a private file and hard-links it into place: the lock never exists half written.
|
|
40
|
+
async function createExclusive(file, record) {
|
|
41
|
+
const temporary = `${file}.nuevo-${crypto.randomUUID()}`;
|
|
42
|
+
await fs.writeFile(temporary, JSON.stringify(record), { mode: 0o600, flag: 'wx' });
|
|
43
|
+
try {
|
|
44
|
+
await fs.link(temporary, file);
|
|
45
|
+
return true;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error.code === 'EEXIST') return false;
|
|
48
|
+
throw error;
|
|
49
|
+
} finally {
|
|
50
|
+
await fs.unlink(temporary).catch(() => {});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Called only for an owner judged dead. The removal happens under a second guard, so two recoverers can never
|
|
55
|
+
// act at once. Under the guard only the dead lock can be at `file`: acquirers create it only when it is absent,
|
|
56
|
+
// and its dead owner will not release it. The guard itself is never removed by anyone but its holder: a guard
|
|
57
|
+
// left by a process that died in the middle of a recovery (a few milliseconds) must be removed by hand, because
|
|
58
|
+
// removing it by reading and deleting would reopen the same race. Returns 'recuperado' or 'ocupado'.
|
|
59
|
+
async function recover(file, judgedMark, { record }) {
|
|
60
|
+
const guard = `${file}.recuperando`;
|
|
61
|
+
if (!(await createExclusive(guard, record))) return 'ocupado';
|
|
62
|
+
try {
|
|
63
|
+
const again = await readOwner(file);
|
|
64
|
+
if (again.state === 'ok' && again.owner.marca === judgedMark) await fs.unlink(file).catch(ignoreMissing);
|
|
65
|
+
return 'recuperado';
|
|
66
|
+
} finally {
|
|
67
|
+
const current = await readOwner(guard);
|
|
68
|
+
if (current.state === 'ok' && current.owner.marca === record.marca) await fs.unlink(guard).catch(ignoreMissing);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const busy = (file, unreadable) => new LockError('ocupado', unreadable
|
|
73
|
+
? `No se puede leer el bloqueo de nexakit. Si no hay ninguna instalación en curso, bórralo a mano: ${file}`
|
|
74
|
+
: 'Otra instalación de nexakit está en curso; vuelve a intentarlo cuando termine.');
|
|
75
|
+
|
|
76
|
+
export async function acquireLock(nexakitDir, {
|
|
77
|
+
waitMs = 30_000, pollMs = 250, hostname = os.hostname(), pid = process.pid, isAlive = defaultIsAlive
|
|
78
|
+
} = {}) {
|
|
79
|
+
await fs.mkdir(nexakitDir, { recursive: true, mode: 0o700 });
|
|
80
|
+
const file = path.join(nexakitDir, LOCK_FILE);
|
|
81
|
+
const token = crypto.randomUUID();
|
|
82
|
+
const record = { pid, equipo: hostname, hora: new Date().toISOString(), marca: token };
|
|
83
|
+
const deadline = Date.now() + waitMs;
|
|
84
|
+
let recoveries = 0;
|
|
85
|
+
|
|
86
|
+
while (true) {
|
|
87
|
+
if (await createExclusive(file, record)) return heldLock(file, token);
|
|
88
|
+
|
|
89
|
+
const current = await readOwner(file);
|
|
90
|
+
if (current.state === 'missing') continue;
|
|
91
|
+
if (current.state === 'ok') {
|
|
92
|
+
const { owner } = current;
|
|
93
|
+
if (owner.equipo !== hostname) {
|
|
94
|
+
throw new LockError('otro-equipo', `El bloqueo de nexakit lo creó otro equipo (${owner.equipo}). Si no hay ninguna instalación en curso, bórralo a mano: ${file}`);
|
|
95
|
+
}
|
|
96
|
+
// Age never decides: a live owner keeps the lock however long it takes.
|
|
97
|
+
if (!isAlive(owner.pid) && await recover(file, owner.marca, { record }) === 'recuperado') {
|
|
98
|
+
if (++recoveries > MAX_RECOVERIES) throw busy(file, false);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (Date.now() >= deadline) {
|
|
103
|
+
const guard = await readOwner(`${file}.recuperando`);
|
|
104
|
+
if (guard.state === 'ok' && guard.owner.equipo === hostname && !isAlive(guard.owner.pid)) {
|
|
105
|
+
throw new LockError('ocupado', `Se interrumpió una recuperación anterior del bloqueo de nexakit. Si no hay ninguna instalación en curso, borra a mano: ${file}.recuperando`);
|
|
106
|
+
}
|
|
107
|
+
throw busy(file, current.state === 'unreadable');
|
|
108
|
+
}
|
|
109
|
+
await sleep(Math.max(1, Math.min(pollMs, deadline - Date.now())));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function heldLock(file, token) {
|
|
114
|
+
return {
|
|
115
|
+
token,
|
|
116
|
+
async assertHeld() {
|
|
117
|
+
const current = await readOwner(file);
|
|
118
|
+
if (!(current.state === 'ok' && current.owner.marca === token)) {
|
|
119
|
+
throw new LockError('perdido', 'Se perdió el bloqueo de nexakit; no se escribió nada más.');
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
async release() {
|
|
123
|
+
const current = await readOwner(file);
|
|
124
|
+
if (current.state === 'ok' && current.owner.marca === token) await fs.unlink(file).catch(ignoreMissing);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const REGISTRY_FILE = 'instalaciones.json';
|
|
6
|
+
const MAX_REGISTRY_BYTES = 4 * 1024 * 1024;
|
|
7
|
+
const FIELDS = ['destino', 'apunta', 'raiz', 'fecha'];
|
|
8
|
+
|
|
9
|
+
const damaged = file => new Error(`El registro de instalaciones de nexakit está dañado: ${file}`);
|
|
10
|
+
const isText = value => typeof value === 'string' && value.length > 0;
|
|
11
|
+
const empty = () => ({ version: 1, entradas: [] });
|
|
12
|
+
|
|
13
|
+
// The registry is the only proof that a broken link belongs to nexakit. `apunta` must be what `readlink`
|
|
14
|
+
// returns for the link, since that is the value compared later.
|
|
15
|
+
export async function readRegistry(nexakitDir) {
|
|
16
|
+
const file = path.join(nexakitDir, REGISTRY_FILE);
|
|
17
|
+
let info;
|
|
18
|
+
try { info = await fs.lstat(file); } catch (error) {
|
|
19
|
+
if (error.code === 'ENOENT') return empty();
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
if (!info.isFile() || info.size > MAX_REGISTRY_BYTES) throw damaged(file);
|
|
23
|
+
let data;
|
|
24
|
+
try { data = JSON.parse(await fs.readFile(file, 'utf8')); } catch { throw damaged(file); }
|
|
25
|
+
const valid = data?.version === 1 && Array.isArray(data.entradas) &&
|
|
26
|
+
data.entradas.every(entry => entry && typeof entry === 'object' && FIELDS.every(field => isText(entry[field])));
|
|
27
|
+
if (!valid) throw damaged(file);
|
|
28
|
+
return {
|
|
29
|
+
version: 1,
|
|
30
|
+
entradas: data.entradas.map(({ destino, apunta, raiz, fecha }) => ({ destino, apunta, raiz, fecha }))
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const isRegistered = (registry, target, literal) =>
|
|
35
|
+
registry.entradas.some(entry => entry.destino === target && entry.apunta === literal);
|
|
36
|
+
|
|
37
|
+
// Must run under the install lock: the lock is checked on entry and again right before the file is replaced.
|
|
38
|
+
export async function recordEntries(nexakitDir, entries, lock) {
|
|
39
|
+
if (!Array.isArray(entries) || !entries.every(entry => isText(entry?.destino) && isText(entry?.apunta) && isText(entry?.raiz))) {
|
|
40
|
+
throw new Error('Entrada del registro de instalaciones no válida.');
|
|
41
|
+
}
|
|
42
|
+
if (entries.length === 0) return;
|
|
43
|
+
await lock.assertHeld();
|
|
44
|
+
|
|
45
|
+
const registry = await readRegistry(nexakitDir);
|
|
46
|
+
const fecha = new Date().toISOString();
|
|
47
|
+
// A Map keeps the position of an entry that is replaced.
|
|
48
|
+
const byTarget = new Map(registry.entradas.map(entry => [entry.destino, entry]));
|
|
49
|
+
for (const { destino, apunta, raiz } of entries) byTarget.set(destino, { destino, apunta, raiz, fecha });
|
|
50
|
+
|
|
51
|
+
const file = path.join(nexakitDir, REGISTRY_FILE);
|
|
52
|
+
const temporary = `${file}.${crypto.randomUUID()}.tmp`;
|
|
53
|
+
try {
|
|
54
|
+
await fs.writeFile(temporary, `${JSON.stringify({ version: 1, entradas: [...byTarget.values()] }, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
55
|
+
await lock.assertHeld();
|
|
56
|
+
await fs.rename(temporary, file);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
await fs.unlink(temporary).catch(() => {});
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { acquireLock } from './install-lock.js';
|
|
5
|
+
import { recordEntries } from './install-registry.js';
|
|
6
|
+
import { relinkIfUnchanged } from './link-states.js';
|
|
7
|
+
import { actualRoot, ensureStableInstall, nexakitDir } from './stable-install.js';
|
|
8
|
+
import { TOOLKIT_ROOT, packageVersion, registrationCommand, resolveNodeExecutable, setupToolkit } from './toolkit-setup.js';
|
|
9
|
+
|
|
10
|
+
const samePath = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
11
|
+
const exists = file => fs.access(file).then(() => true, () => false);
|
|
12
|
+
|
|
13
|
+
// Finds the global links that point into a root that no longer exists, `from`, and that nexakit cannot prove
|
|
14
|
+
// are its own (no entry in the install registry). Only skill names this version ships are considered.
|
|
15
|
+
// Nothing is written here: the person has to confirm what this lists.
|
|
16
|
+
export async function planLegacyRepair({
|
|
17
|
+
from, agent = 'all', homeDirectory = os.homedir(), toolkitRoot = TOOLKIT_ROOT, environment = process.env, codexHome
|
|
18
|
+
} = {}) {
|
|
19
|
+
const dead = path.resolve(from);
|
|
20
|
+
const setup = await setupToolkit({ agent, homeDirectory, toolkitRoot, environment, ...(codexHome && { codexHome }) });
|
|
21
|
+
const links = setup.skills
|
|
22
|
+
.filter(skill => skill.state === 'roto-ajeno' && skill.root && samePath(skill.root, dead))
|
|
23
|
+
.map(({ client, name, target, literal }) => ({ client, name, target, literal }))
|
|
24
|
+
.sort((a, b) => a.client.localeCompare(b.client) || a.name.localeCompare(b.name));
|
|
25
|
+
const mcp = {};
|
|
26
|
+
for (const [client, state] of Object.entries(setup.mcp.states)) {
|
|
27
|
+
if (state.state === 'roto-ajeno' && state.root && samePath(state.root, dead)) mcp[client] = { name: state.name, literal: state.literal };
|
|
28
|
+
}
|
|
29
|
+
return { from: dead, agent, activeRoot: setup.activeRoot, ephemeral: setup.ephemeral && !setup.otherCopy, links, mcp };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Repoints the links of the plan to the copy of nexakit in use and records them as nexakit's own.
|
|
33
|
+
// Each link is read again right before writing: one that changed since the plan is left alone.
|
|
34
|
+
export async function applyLegacyRepair(plan, {
|
|
35
|
+
homeDirectory = os.homedir(), toolkitRoot = TOOLKIT_ROOT, environment = process.env, runNpm, packageSpec, lockOptions,
|
|
36
|
+
acquire = acquireLock
|
|
37
|
+
} = {}) {
|
|
38
|
+
const home = await fs.realpath(homeDirectory);
|
|
39
|
+
const lock = await acquire(nexakitDir(home), lockOptions);
|
|
40
|
+
try {
|
|
41
|
+
if (plan.ephemeral && plan.links.length) {
|
|
42
|
+
await ensureStableInstall({
|
|
43
|
+
homeDirectory: home, version: await packageVersion(await fs.realpath(toolkitRoot)), lock,
|
|
44
|
+
...(runNpm && { runNpm }), ...(packageSpec && { packageSpec })
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const entries = [], skipped = [];
|
|
48
|
+
let changed = 0;
|
|
49
|
+
for (const link of plan.links) {
|
|
50
|
+
const source = path.join(plan.activeRoot, 'skills', link.name);
|
|
51
|
+
if (!(await exists(path.join(source, 'SKILL.md')))) { skipped.push(link.name); continue; }
|
|
52
|
+
await lock.assertHeld();
|
|
53
|
+
if (await relinkIfUnchanged({ target: link.target, expectedLiteral: link.literal, source }) === 'cambio') { changed++; continue; }
|
|
54
|
+
entries.push({ destino: link.target, apunta: await fs.readlink(link.target), raiz: plan.activeRoot });
|
|
55
|
+
}
|
|
56
|
+
await recordEntries(nexakitDir(home), entries, lock);
|
|
57
|
+
|
|
58
|
+
// The MCP entry is not rewritten here: its client owns that file, so only the exact commands are given.
|
|
59
|
+
const mcpCommands = [];
|
|
60
|
+
const script = path.join(plan.activeRoot, 'bin/agent-toolkit-mcp.js');
|
|
61
|
+
if (Object.keys(plan.mcp ?? {}).length && await exists(script)) {
|
|
62
|
+
const nodePath = await resolveNodeExecutable(environment);
|
|
63
|
+
for (const [client, current] of Object.entries(plan.mcp)) {
|
|
64
|
+
mcpCommands.push(registrationCommand(client, { state: 'roto-ajeno', name: current.name }, nodePath, script));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { repaired: entries.length, changed, skipped, mcpCommands, stableRoot: actualRoot(home) };
|
|
68
|
+
} finally {
|
|
69
|
+
await lock.release();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { IS_WINDOWS } from './platform.js';
|
|
5
|
+
|
|
6
|
+
// Evaluated in this order; the first one that holds wins.
|
|
7
|
+
export const LINK_STATES = Object.freeze(['falta', 'conflicto', 'efimero', 'instalado', 'otra-copia', 'roto-propio', 'roto-ajeno', 'quitada']);
|
|
8
|
+
// What an apply acts on, and what counts as ready.
|
|
9
|
+
export const ACTIONABLE_LINK_STATES = Object.freeze(['falta', 'efimero', 'roto-propio']);
|
|
10
|
+
export const READY_LINK_STATES = Object.freeze(['instalado', 'otra-copia']);
|
|
11
|
+
// `quitada`: nexakit instaló el enlace y el usuario lo quitó. No es pendiente: se respeta hasta que pida `--reponer`.
|
|
12
|
+
export const REMOVED_BY_USER_STATE = 'quitada';
|
|
13
|
+
export const SETTLED_LINK_STATES = Object.freeze([...READY_LINK_STATES, REMOVED_BY_USER_STATE]);
|
|
14
|
+
|
|
15
|
+
const PACKAGE_NAMES = new Set(['nexakit', 'agent-toolkit']);
|
|
16
|
+
const MAX_PACKAGE_BYTES = 1_048_576;
|
|
17
|
+
const BROKEN_CODES = new Set(['ENOENT', 'ENOTDIR', 'ELOOP']);
|
|
18
|
+
|
|
19
|
+
const normalized = value => (IS_WINDOWS ? value.toLowerCase() : value);
|
|
20
|
+
const samePath = (a, b) => normalized(a) === normalized(b);
|
|
21
|
+
const lstatOrNull = file => fs.lstat(file).catch(error => {
|
|
22
|
+
if (error.code === 'ENOENT') return null;
|
|
23
|
+
throw error;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const isEphemeralPath = value => path.resolve(value).split(path.sep).includes('_npx');
|
|
27
|
+
|
|
28
|
+
// A copy of nexakit inside an npx cache: `…/_npx/<hash>/node_modules/nexakit` (or the old name, agent-toolkit).
|
|
29
|
+
// Anything else under `_npx` belongs to another package and is never treated as nexakit's.
|
|
30
|
+
export function isEphemeralNexakitRoot(root) {
|
|
31
|
+
const parts = path.resolve(root).split(path.sep);
|
|
32
|
+
const n = parts.length;
|
|
33
|
+
return n >= 4 && parts[n - 4] === '_npx' && parts[n - 2] === 'node_modules' && PACKAGE_NAMES.has(parts[n - 1]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function readLink(target) {
|
|
37
|
+
const info = await lstatOrNull(target);
|
|
38
|
+
if (!info) return { exists: false, isLink: false };
|
|
39
|
+
if (!info.isSymbolicLink()) return { exists: true, isLink: false };
|
|
40
|
+
const literal = await fs.readlink(target);
|
|
41
|
+
return { exists: true, isLink: true, literal, absolute: path.resolve(path.dirname(target), literal) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Strips `suffix` from the end of `value`; null when it does not end with it.
|
|
45
|
+
function rootFrom(value, suffix) {
|
|
46
|
+
const tail = path.normalize(suffix);
|
|
47
|
+
const resolved = path.resolve(value);
|
|
48
|
+
if (!normalized(resolved).endsWith(normalized(path.sep + tail))) return null;
|
|
49
|
+
return resolved.slice(0, resolved.length - tail.length - 1) || null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function recognizedRoot(resolvedPath, suffix) {
|
|
53
|
+
const root = rootFrom(resolvedPath, suffix);
|
|
54
|
+
if (!root) return null;
|
|
55
|
+
const file = path.join(root, 'package.json');
|
|
56
|
+
const info = await lstatOrNull(file);
|
|
57
|
+
if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_PACKAGE_BYTES) return null;
|
|
58
|
+
try {
|
|
59
|
+
const pkg = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
60
|
+
return PACKAGE_NAMES.has(pkg?.name) ? root : null;
|
|
61
|
+
} catch { return null; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function realpathOrNull(file) {
|
|
65
|
+
try { return await fs.realpath(file); } catch (error) {
|
|
66
|
+
if (BROKEN_CODES.has(error.code)) return null;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function classifyLink({ target, suffix, activeRoots = [], isOwned = () => false }) {
|
|
72
|
+
const link = await readLink(target);
|
|
73
|
+
if (!link.exists) return { target, suffix, state: 'falta' };
|
|
74
|
+
if (!link.isLink) return { target, suffix, state: 'conflicto' };
|
|
75
|
+
return classifyReference({ target, literal: link.literal, absolute: link.absolute, suffix, activeRoots, isOwned });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Classifies a path that points into a copy of nexakit without being a link itself, such as the script
|
|
79
|
+
// an MCP entry runs. `target` identifies the reference for `isOwned`; `absolute` defaults to the literal.
|
|
80
|
+
export async function classifyReference({ target, literal, absolute = path.resolve(literal), suffix, activeRoots = [], isOwned = () => false }) {
|
|
81
|
+
const base = { target, suffix };
|
|
82
|
+
const resolved = await realpathOrNull(absolute);
|
|
83
|
+
|
|
84
|
+
// Before `instalado`, so a link into the cache this run came from is never taken as good. It must point at
|
|
85
|
+
// exactly `<copy of nexakit in an npx cache>/<suffix>`; another package there is not nexakit's to move.
|
|
86
|
+
const ephemeralRoot = [absolute, resolved].filter(Boolean)
|
|
87
|
+
.map(candidate => rootFrom(candidate, suffix)).find(root => root && isEphemeralNexakitRoot(root));
|
|
88
|
+
if (ephemeralRoot) return { ...base, state: 'efimero', literal, root: ephemeralRoot };
|
|
89
|
+
|
|
90
|
+
if (resolved) {
|
|
91
|
+
const tail = path.normalize(suffix);
|
|
92
|
+
for (const candidate of activeRoots) {
|
|
93
|
+
if (isEphemeralPath(candidate)) continue;
|
|
94
|
+
const real = await realpathOrNull(candidate);
|
|
95
|
+
if (real && samePath(resolved, path.join(real, tail))) return { ...base, state: 'instalado', literal, root: real };
|
|
96
|
+
}
|
|
97
|
+
const other = await recognizedRoot(resolved, suffix);
|
|
98
|
+
if (other) return { ...base, state: 'otra-copia', literal, root: other };
|
|
99
|
+
return { ...base, state: 'conflicto', literal };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const owned = await isOwned(target, literal);
|
|
103
|
+
return { ...base, state: owned ? 'roto-propio' : 'roto-ajeno', literal, root: rootFrom(absolute, suffix) ?? undefined };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function chooseActiveRoot(items, fallbackRoot) {
|
|
107
|
+
const counts = new Map();
|
|
108
|
+
for (const item of items) {
|
|
109
|
+
if (item.state !== 'otra-copia' || !item.root || isEphemeralPath(item.root)) continue;
|
|
110
|
+
counts.set(item.root, (counts.get(item.root) ?? 0) + 1);
|
|
111
|
+
}
|
|
112
|
+
let best = null;
|
|
113
|
+
for (const [root, count] of counts) {
|
|
114
|
+
if (count * 2 > items.length && (!best || count > best.count)) best = { root, count };
|
|
115
|
+
}
|
|
116
|
+
return best?.root ?? fallbackRoot;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function legacyGroups(items, { skillNames, mcpDeadRoot } = {}) {
|
|
120
|
+
const groups = new Map();
|
|
121
|
+
for (const item of items) {
|
|
122
|
+
if (item.state !== 'roto-ajeno' || !item.root || !skillNames?.has(path.basename(item.target))) continue;
|
|
123
|
+
groups.set(item.root, [...(groups.get(item.root) ?? []), item.target]);
|
|
124
|
+
}
|
|
125
|
+
return [...groups]
|
|
126
|
+
.filter(([root, targets]) => targets.length >= 3 || (mcpDeadRoot !== undefined && root === mcpDeadRoot))
|
|
127
|
+
.map(([root, targets]) => ({ root, targets }));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// `expectedLiteral` is the readlink value the plan saw; null means the path must not exist yet.
|
|
131
|
+
// Re-reads right before writing and touches nothing when the link is no longer what the plan saw.
|
|
132
|
+
// `relative` writes the link relative to its own folder (Windows junctions are always absolute).
|
|
133
|
+
export async function relinkIfUnchanged({ target, expectedLiteral, source, relative = false }) {
|
|
134
|
+
const type = IS_WINDOWS ? 'junction' : 'dir';
|
|
135
|
+
const absoluteSource = path.resolve(source);
|
|
136
|
+
const linkValue = relative && !IS_WINDOWS ? path.relative(path.dirname(target), absoluteSource) : absoluteSource;
|
|
137
|
+
const info = await lstatOrNull(target);
|
|
138
|
+
|
|
139
|
+
if (expectedLiteral === null) {
|
|
140
|
+
if (info) return 'cambio';
|
|
141
|
+
try { await fs.symlink(linkValue, target, type); } catch (error) {
|
|
142
|
+
if (error.code === 'EEXIST') return 'cambio';
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
return 'hecho';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!info?.isSymbolicLink() || await fs.readlink(target) !== expectedLiteral) return 'cambio';
|
|
149
|
+
|
|
150
|
+
if (IS_WINDOWS) {
|
|
151
|
+
// A junction cannot be renamed over; this window is not atomic there.
|
|
152
|
+
await fs.unlink(target);
|
|
153
|
+
await fs.symlink(linkValue, target, type);
|
|
154
|
+
return 'hecho';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Build the new link beside the old one and rename over it, so the name is never missing.
|
|
158
|
+
const temporary = `${target}.nx-${crypto.randomUUID()}`;
|
|
159
|
+
await fs.symlink(linkValue, temporary, type);
|
|
160
|
+
try {
|
|
161
|
+
if (await fs.readlink(target) !== expectedLiteral) {
|
|
162
|
+
await fs.unlink(temporary);
|
|
163
|
+
return 'cambio';
|
|
164
|
+
}
|
|
165
|
+
await fs.rename(temporary, target);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
await fs.unlink(temporary).catch(() => {});
|
|
168
|
+
if (error.code === 'ENOENT' || error.code === 'EINVAL') return 'cambio';
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
return 'hecho';
|
|
172
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Finds the project memories this person already has, from the memory MCP servers registered in Claude Code, Codex
|
|
2
|
+
// and Antigravity, so a repository reuses its memory and a new one goes next to the others. Read-only. No new
|
|
3
|
+
// registry: the clients' own configuration is the source of truth.
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { getStaticTOMLValue, parseTOML } from 'toml-eslint-parser';
|
|
7
|
+
|
|
8
|
+
const MAX_CONFIG_BYTES = 2 * 1024 * 1024;
|
|
9
|
+
const MAX_MANIFEST_BYTES = 512 * 1024;
|
|
10
|
+
const CLIENT_ORDER = ['claude', 'codex', 'antigravity'];
|
|
11
|
+
const FILESYSTEM_PACKAGE = /^@modelcontextprotocol\/server-filesystem(?:@[^/]+)?$/u;
|
|
12
|
+
const MEMORY_SCRIPT = 'project-memory-mcp.js';
|
|
13
|
+
|
|
14
|
+
async function readBounded(file, maxBytes) {
|
|
15
|
+
try {
|
|
16
|
+
const info = await fs.lstat(file);
|
|
17
|
+
if (!info.isFile() || info.size > maxBytes) return null;
|
|
18
|
+
return await fs.readFile(file, 'utf8');
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function serversOf(client, { homeDirectory, codexHome }) {
|
|
25
|
+
try {
|
|
26
|
+
if (client === 'claude') {
|
|
27
|
+
const config = JSON.parse(await readBounded(path.join(homeDirectory, '.claude.json'), MAX_CONFIG_BYTES) ?? 'null');
|
|
28
|
+
const projects = Object.values(config?.projects ?? {}).map(project => project?.mcpServers ?? {});
|
|
29
|
+
return [config?.mcpServers ?? {}, ...projects].flatMap(Object.entries);
|
|
30
|
+
}
|
|
31
|
+
if (client === 'codex') {
|
|
32
|
+
const text = await readBounded(path.join(codexHome, 'config.toml'), MAX_CONFIG_BYTES);
|
|
33
|
+
return text ? Object.entries(getStaticTOMLValue(parseTOML(text, { tomlVersion: '1.0.0' }))?.mcp_servers ?? {}) : [];
|
|
34
|
+
}
|
|
35
|
+
const config = JSON.parse(await readBounded(path.join(homeDirectory, '.gemini', 'config', 'mcp_config.json'), MAX_CONFIG_BYTES) ?? 'null');
|
|
36
|
+
return Object.entries(config?.mcpServers ?? {});
|
|
37
|
+
} catch {
|
|
38
|
+
return []; // An unreadable configuration means no known memories from that client, never an error.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The manifest a server points at: the last argument of server-filesystem, or the --manifest of project-memory-mcp.
|
|
43
|
+
function manifestCandidate(server) {
|
|
44
|
+
const args = Array.isArray(server?.args) ? server.args.filter(arg => typeof arg === 'string') : [];
|
|
45
|
+
const script = args.findIndex(arg => arg.replaceAll('\\', '/').endsWith(`/${MEMORY_SCRIPT}`));
|
|
46
|
+
if (script >= 0 && args[script + 1] === '--manifest' && path.isAbsolute(args[script + 2] ?? '')) return args[script + 2];
|
|
47
|
+
if (args.some(arg => FILESYSTEM_PACKAGE.test(arg))) {
|
|
48
|
+
const last = args.at(-1);
|
|
49
|
+
if (last && path.isAbsolute(last)) return path.join(last, 'project-memory.json');
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function readManifest(manifestPath) {
|
|
55
|
+
const text = await readBounded(manifestPath, MAX_MANIFEST_BYTES); // lstat: a linked manifest is not accepted.
|
|
56
|
+
if (text === null) return null;
|
|
57
|
+
try {
|
|
58
|
+
const manifest = JSON.parse(text);
|
|
59
|
+
const id = manifest?.project?.id;
|
|
60
|
+
const repositoryPath = manifest?.source?.repositoryPath;
|
|
61
|
+
if (typeof id !== 'string' || typeof repositoryPath !== 'string') return null;
|
|
62
|
+
return manifest;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Every MCP server name registered in each agent, with the vault it serves when it is a memory (else null).
|
|
69
|
+
export async function registeredServers({ homeDirectory, codexHome = path.join(homeDirectory, '.codex') } = {}) {
|
|
70
|
+
const result = {};
|
|
71
|
+
for (const client of CLIENT_ORDER) {
|
|
72
|
+
result[client] = {};
|
|
73
|
+
for (const [name, server] of await serversOf(client, { homeDirectory, codexHome })) {
|
|
74
|
+
const candidate = manifestCandidate(server);
|
|
75
|
+
const vault = candidate ? await fs.realpath(path.dirname(candidate)).catch(() => null) : null;
|
|
76
|
+
const kind = !candidate ? null : (server.args ?? []).some(arg => typeof arg === 'string' && arg.replaceAll('\\', '/').endsWith(`/${MEMORY_SCRIPT}`)) ? 'guard' : 'filesystem';
|
|
77
|
+
result[client][name] = vault && { vault, kind };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function findKnownMemories({ homeDirectory, codexHome = path.join(homeDirectory, '.codex') } = {}) {
|
|
84
|
+
const byVault = new Map();
|
|
85
|
+
for (const client of CLIENT_ORDER) {
|
|
86
|
+
for (const [name, server] of await serversOf(client, { homeDirectory, codexHome })) {
|
|
87
|
+
const candidate = manifestCandidate(server);
|
|
88
|
+
if (!candidate) continue;
|
|
89
|
+
const manifest = await readManifest(candidate);
|
|
90
|
+
if (!manifest) continue;
|
|
91
|
+
const vaultPath = await fs.realpath(path.dirname(candidate)).catch(() => null);
|
|
92
|
+
if (!vaultPath) continue;
|
|
93
|
+
const entry = byVault.get(vaultPath) ?? {
|
|
94
|
+
vaultPath,
|
|
95
|
+
manifestPath: path.join(vaultPath, 'project-memory.json'),
|
|
96
|
+
projectId: manifest.project.id,
|
|
97
|
+
repositoryPath: manifest.source.repositoryPath,
|
|
98
|
+
mcpName: typeof manifest.memory?.mcpServerName === 'string' ? manifest.memory.mcpServerName : name,
|
|
99
|
+
clients: []
|
|
100
|
+
};
|
|
101
|
+
if (!entry.clients.includes(client)) entry.clients.push(client);
|
|
102
|
+
byVault.set(vaultPath, entry);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return [...byVault.values()].sort((a, b) => (a.vaultPath < b.vaultPath ? -1 : a.vaultPath > b.vaultPath ? 1 : 0));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function memoryForRepository(known, root) {
|
|
109
|
+
return known.find(entry => path.resolve(entry.repositoryPath) === path.resolve(root)) ?? null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Next to the memories already known when they share one parent folder; otherwise a folder of its own.
|
|
113
|
+
export function suggestMemoryLocation(known, { id, homeDirectory }) {
|
|
114
|
+
const parents = [...new Set(known.map(entry => path.dirname(entry.vaultPath)))];
|
|
115
|
+
return parents.length === 1
|
|
116
|
+
? path.join(parents[0], `${id.toUpperCase()}-MEMORY`)
|
|
117
|
+
: path.join(homeDirectory, 'nexakit-memorias', id);
|
|
118
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { isEphemeralPath } from './link-states.js';
|
|
4
|
+
|
|
5
|
+
// This file must stay free of third-party imports: it has to run exactly when they are missing.
|
|
6
|
+
const copyRoot = () => path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
7
|
+
|
|
8
|
+
// Loads a module of nexakit. If a third-party package is missing in a checkout, says how to install it instead
|
|
9
|
+
// of showing a stack trace. Any other failure is raised untouched, and so is a missing package under npx,
|
|
10
|
+
// where `npm ci` would be the wrong advice.
|
|
11
|
+
export async function loadWithDependencyCheck(load, { root = copyRoot(), write = text => process.stderr.write(text) } = {}) {
|
|
12
|
+
try {
|
|
13
|
+
return await load();
|
|
14
|
+
} catch (error) {
|
|
15
|
+
const missingPackage = error?.code === 'ERR_MODULE_NOT_FOUND' && /^Cannot find package '/u.test(error.message ?? '');
|
|
16
|
+
if (!missingPackage || isEphemeralPath(root)) throw error;
|
|
17
|
+
write(`Faltan las dependencias de esta copia. Ejecuta \`npm ci\` en ${root}.\n`);
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|