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.
Files changed (113) hide show
  1. package/.claude-plugin/marketplace.json +18 -1
  2. package/README.md +40 -30
  3. package/assets/brand/README.md +0 -2
  4. package/assets/runtime/package-lock.json +2 -2
  5. package/bin/agent-task.js +12 -10
  6. package/bin/agent-toolkit-gemini.js +11 -9
  7. package/bin/agent-toolkit-inbox.js +12 -10
  8. package/bin/agent-toolkit-mcp.js +14 -8
  9. package/bin/agent-toolkit.js +12 -9
  10. package/bin/nexakit.js +12 -9
  11. package/bin/project-memory-mcp.js +12 -9
  12. package/bin/project-memory.js +5 -3
  13. package/{skills/depurar → catalogs/skills/vercel-composition-patterns}/LICENSE.txt +6 -6
  14. package/catalogs/skills/vercel-react-best-practices/LICENSE.txt +27 -0
  15. package/catalogs/skills.json +70 -35
  16. package/docs/skills/evidencias/2026-10-28-recomendador-preparacion.md +1 -1
  17. package/package.json +5 -3
  18. package/plugins/nexakit-modelo/.claude-plugin/plugin.json +1 -0
  19. package/plugins/nexakit-modelo/hooks/hooks.json +1 -0
  20. package/plugins/nexakit-modelo/hooks/register.js +48 -0
  21. package/plugins/nexakit-modelo/hooks/status.js +8 -0
  22. package/plugins/nexakit-modelo/tests/register.test.ts +21 -0
  23. package/plugins/nexakit-panel/.claude-plugin/plugin.json +1 -0
  24. package/plugins/nexakit-panel/README.md +20 -0
  25. package/plugins/nexakit-panel/hooks/hooks.json +1 -0
  26. package/plugins/nexakit-panel/hooks/lienzo.js +382 -0
  27. package/plugins/nexakit-panel/hooks/panel.js +279 -0
  28. package/plugins/nexakit-panel/hooks/register.tsx +248 -0
  29. package/plugins/nexakit-panel/tests/lienzo.test.ts +142 -0
  30. package/plugins/nexakit-panel/tests/panel.test.ts +240 -0
  31. package/plugins/nexakit-panel/tests/register.test.ts +167 -0
  32. package/plugins/nexakit-panel/types/index.d.ts +41 -0
  33. package/plugins/nexakit-sesion/.claude-plugin/plugin.json +1 -0
  34. package/plugins/nexakit-sesion/README.md +12 -0
  35. package/plugins/nexakit-sesion/hooks/aviso.js +38 -0
  36. package/plugins/nexakit-sesion/hooks/hooks.json +1 -0
  37. package/plugins/nexakit-sesion/hooks/register.js +28 -0
  38. package/plugins/nexakit-sesion/tests/register.test.ts +96 -0
  39. package/schemas/skill-catalog.schema.json +9 -2
  40. package/skills/cerrar-memoria/SKILL.md +6 -2
  41. package/skills/cerrar-memoria/references/cierre.md +5 -1
  42. package/skills/elegir-modelo/SKILL.md +68 -0
  43. package/skills/elegir-modelo/modelos.mjs +108 -0
  44. package/skills/gemini/SKILL.md +1 -1
  45. package/skills/project-handoff/SKILL.md +39 -12
  46. package/skills/project-handoff/references/handoff-template.md +24 -17
  47. package/skills/quality-gate/SKILL.md +6 -12
  48. package/skills/revisar/SKILL.md +6 -16
  49. package/skills/{desarrollo-con-pruebas/LICENSE.txt → revisar-skill/LICENSE-find-skills.txt} +5 -5
  50. package/skills/revisar-skill/SKILL.md +94 -0
  51. package/skills/revision-cruzada/SKILL.md +53 -475
  52. package/skills/web-design-guidelines/AGENTS.md +156 -0
  53. package/skills/{spec-driven-development → web-design-guidelines}/LICENSE.txt +1 -6
  54. package/skills/web-design-guidelines/SKILL.md +20 -30
  55. package/src/agent-models.js +53 -13
  56. package/src/brand.js +117 -0
  57. package/src/cli-executable.js +5 -3
  58. package/src/cli.js +16 -9
  59. package/src/connect.js +56 -4
  60. package/src/init.js +9 -1
  61. package/src/install-lock.js +127 -0
  62. package/src/install-registry.js +61 -0
  63. package/src/legacy-repair.js +71 -0
  64. package/src/link-states.js +172 -0
  65. package/src/memory-locator.js +118 -0
  66. package/src/missing-dependencies.js +21 -0
  67. package/src/model-command.js +147 -0
  68. package/src/panel-cli.js +182 -0
  69. package/src/project-root.js +41 -0
  70. package/src/project-signals.js +339 -0
  71. package/src/recommend-report.js +77 -0
  72. package/src/review-instructions.js +211 -0
  73. package/src/review-policy.js +35 -0
  74. package/src/revision-adapters.js +157 -0
  75. package/src/revision-context.js +86 -0
  76. package/src/revision-isolation.js +222 -0
  77. package/src/revision-process.js +103 -0
  78. package/src/revision-provenance.js +89 -0
  79. package/src/revision-snapshot.js +120 -0
  80. package/src/revision.js +242 -0
  81. package/src/setup-apply.js +231 -0
  82. package/src/setup-plan.js +231 -0
  83. package/src/setup-state.js +277 -0
  84. package/src/setup-tui.js +555 -0
  85. package/src/skill-catalog-add.js +91 -0
  86. package/src/skill-catalog-audit.js +26 -27
  87. package/src/skill-files.js +39 -0
  88. package/src/skill-recommendations.js +321 -208
  89. package/src/skill-scan-report.js +14 -0
  90. package/src/skill-scan.js +178 -0
  91. package/src/stable-command.js +53 -0
  92. package/src/stable-install.js +222 -0
  93. package/src/superpowers.js +133 -0
  94. package/src/sync-apply.js +8 -5
  95. package/src/sync-files.js +2 -1
  96. package/src/terminal-text.js +68 -0
  97. package/src/toolkit-cli.js +397 -70
  98. package/src/toolkit-commands-guide.js +53 -39
  99. package/src/toolkit-setup.js +507 -110
  100. package/assets/brand/agent-toolkit-mark-v1.png +0 -0
  101. package/skills/depurar/SKILL.md +0 -69
  102. package/skills/depurar/agents/openai.yaml +0 -7
  103. package/skills/depurar/references/tecnicas-de-diagnostico.md +0 -37
  104. package/skills/desarrollo-con-pruebas/SKILL.md +0 -55
  105. package/skills/desarrollo-con-pruebas/agents/openai.yaml +0 -7
  106. package/skills/desarrollo-con-pruebas/references/buenas-pruebas.md +0 -33
  107. package/skills/estado/SKILL.md +0 -41
  108. package/skills/programar/SKILL.md +0 -19
  109. package/skills/spec-driven-development/SKILL.md +0 -48
  110. package/skills/spec-driven-development/agents/openai.yaml +0 -7
  111. package/skills/spec-driven-development/references/spec-template.md +0 -55
  112. package/skills/spec-driven-development/references/workflow.md +0 -70
  113. package/src/toolkit-tui.js +0 -961
@@ -6,158 +6,476 @@ import { fileURLToPath } from 'node:url';
6
6
  import { createRequire } from 'node:module';
7
7
  import { parseTOML, getStaticTOMLValue } from 'toml-eslint-parser';
8
8
  import { IS_WINDOWS } from './platform.js';
9
+ import { acquireLock } from './install-lock.js';
10
+ import { isRegistered, readRegistry, recordEntries } from './install-registry.js';
11
+ import {
12
+ ACTIONABLE_LINK_STATES,
13
+ READY_LINK_STATES,
14
+ REMOVED_BY_USER_STATE,
15
+ chooseActiveRoot,
16
+ classifyLink,
17
+ classifyReference,
18
+ isEphemeralPath,
19
+ legacyGroups,
20
+ recognizedRoot,
21
+ relinkIfUnchanged,
22
+ } from './link-states.js';
23
+ import { actualRoot, ensureStableInstall, nexakitDir } from './stable-install.js';
24
+ import { inspectSuperpowers } from './superpowers.js';
9
25
 
10
26
  import packageJson from '../package.json' with { type: 'json' };
11
27
 
12
28
  // The package declares a simple major-version floor (>=24).
13
29
  const minimumNodeMajor = Number(packageJson.engines.node.match(/^>=(\d+)$/u)[1]);
14
- export const isNodeSupported = version => Number(version.replace(/^v/u, '').split('.')[0]) >= minimumNodeMajor;
30
+ export const isNodeSupported = (version) =>
31
+ Number(version.replace(/^v/u, '').split('.')[0]) >= minimumNodeMajor;
15
32
 
16
33
  export const TOOLKIT_ROOT = fileURLToPath(new URL('../', import.meta.url));
17
- const clientsFor = agent => {
18
- if (!['codex', 'claude', 'all'].includes(agent)) throw new Error('El agente debe ser codex, claude o all.');
19
- return agent === 'all' ? ['codex', 'claude'] : [agent];
34
+ const SKILL_HOMES = {
35
+ codex: ['.agents', 'skills'],
36
+ claude: ['.claude', 'skills'],
37
+ antigravity: ['.gemini', 'antigravity-cli', 'skills'],
20
38
  };
21
- const quote = value => "'" + value.replaceAll("'", "'\\''") + "'";
22
- const normalizedPath = value => IS_WINDOWS ? value.toLowerCase() : value;
23
- const stat = file => fs.lstat(file).catch(e => { if (e.code === 'ENOENT') return null; throw e; });
39
+ // Only Claude and Codex run the coordination MCP; Antigravity does not have one in this phase.
40
+ const MCP_CLIENTS = ['codex', 'claude'];
41
+ // Skills nexakit no longer ships. Links it created are removed; anything else with the name is reported, never
42
+ // touched. The last three are now covered by Superpowers (brainstorming/writing-plans, systematic-debugging and
43
+ // test-driven-development), which is required.
44
+ export const RETIRED_SKILLS = Object.freeze(['programar', 'estado', 'spec-driven-development', 'depurar', 'desarrollo-con-pruebas']);
45
+ const MCP_SUFFIX = 'bin/agent-toolkit-mcp.js';
46
+ const COMMANDS = { codex: 'codex', claude: 'claude', antigravity: 'agy' };
47
+ export const globalSkillTarget = (home, client, name) => path.join(home, ...SKILL_HOMES[client], name);
48
+ // Registry key of an MCP entry: it has no path of its own in the home folder.
49
+ export const mcpRegistryTarget = (client, name) => `mcp:${client}:${name}`;
50
+
51
+ // `all` means Claude and Codex, plus Antigravity when `agy` is installed or its folder already exists:
52
+ // nothing is created for someone who does not use it.
53
+ async function clientsFor(agent, environment, home) {
54
+ if (!['codex', 'claude', 'antigravity', 'all'].includes(agent))
55
+ throw new Error('El agente debe ser codex, claude, antigravity o all.');
56
+ if (agent !== 'all') return [agent];
57
+ const antigravity =
58
+ (await executable('agy', environment)) ||
59
+ Boolean(await stat(path.join(home, '.gemini', 'antigravity-cli')));
60
+ return antigravity ? ['codex', 'claude', 'antigravity'] : ['codex', 'claude'];
61
+ }
62
+ const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
63
+ const stat = (file) =>
64
+ fs.lstat(file).catch((e) => {
65
+ if (e.code === 'ENOENT') return null;
66
+ throw e;
67
+ });
68
+
69
+ // Links point at the root in use, except when it is an npx cache: then they point at the stable copy.
70
+ export async function resolveRoots({ toolkitRoot, homeDirectory }) {
71
+ const sourceRoot = await fs.realpath(toolkitRoot);
72
+ const ephemeral = isEphemeralPath(sourceRoot);
73
+ return { sourceRoot, linkRoot: ephemeral ? actualRoot(homeDirectory) : sourceRoot, ephemeral };
74
+ }
75
+ // A node inside an npx cache disappears with it, so it is never the one registered, not even through a link.
76
+ const outsideNpxCache = async (file) =>
77
+ !isEphemeralPath(file) && !isEphemeralPath(await fs.realpath(file).catch(() => file));
78
+ export async function resolveNodeExecutable(environment) {
79
+ const stable = await findExecutable('node', environment, outsideNpxCache);
80
+ if (stable) return stable;
81
+ return (await outsideNpxCache(process.execPath)) ? process.execPath : 'node';
82
+ }
83
+ // Skills de catálogo para stacks concretos: el recomendador las instala por proyecto, así que no son base global.
84
+ // Los enlaces globales que ya existían no se tocan.
85
+ export const PROJECT_ONLY_SKILLS = Object.freeze(['frontend-design', 'web-design-guidelines', 'mcp-builder']);
86
+ async function baseSkillNames(root) {
87
+ const names = (await fs.readdir(path.join(root, 'skills'), { withFileTypes: true }))
88
+ .filter((e) => e.isDirectory() && /^[a-z0-9-]+$/u.test(e.name) && !PROJECT_ONLY_SKILLS.includes(e.name))
89
+ .map((e) => e.name)
90
+ .sort();
91
+ for (const name of names) {
92
+ const file = await stat(path.join(root, 'skills', name, 'SKILL.md'));
93
+ if (!file?.isFile() || file.isSymbolicLink()) throw new Error('Skill fuente inválida.');
94
+ }
95
+ return names;
96
+ }
97
+ async function catalogNames(root) {
98
+ try {
99
+ return JSON.parse(await fs.readFile(path.join(root, 'catalogs/skills.json'), 'utf8')).skills.map(
100
+ (s) => s.name,
101
+ );
102
+ } catch {
103
+ return [];
104
+ }
105
+ }
106
+ export async function packageVersion(root) {
107
+ return JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8')).version;
108
+ }
24
109
  async function parents(home, target, create = false) {
25
110
  let current = home;
26
111
  for (const part of path.relative(home, path.dirname(target)).split(path.sep)) {
27
112
  current = path.join(current, part);
28
113
  let info = await stat(current);
29
114
  if (!info && create) {
30
- await fs.mkdir(current, { mode: 0o700 }).catch(e => { if (e.code !== 'EEXIST') throw e; });
115
+ await fs.mkdir(current, { mode: 0o700 }).catch((e) => {
116
+ if (e.code !== 'EEXIST') throw e;
117
+ });
31
118
  info = await stat(current);
32
119
  }
33
- if (info && (!info.isDirectory() || info.isSymbolicLink())) throw new Error('Un directorio de instalación está ocupado o es un enlace.');
120
+ if (info && (!info.isDirectory() || info.isSymbolicLink()))
121
+ throw new Error('Un directorio de instalación está ocupado o es un enlace.');
34
122
  }
35
123
  }
36
- async function linkState(source, target) {
37
- const info = await stat(target);
38
- if (!info) return 'missing';
39
- if (!info.isSymbolicLink()) return 'conflict';
40
- try {
41
- const [actual, expected] = await Promise.all([fs.realpath(target), fs.realpath(source)]);
42
- return normalizedPath(actual) === normalizedPath(expected) ? 'installed' : 'replace';
43
- } catch (error) { if (error.code === 'ENOENT') return 'replace'; throw error; }
44
- }
45
- export async function setupToolkit({ agent = 'all', apply = false, homeDirectory = os.homedir(), toolkitRoot = TOOLKIT_ROOT,
46
- environment = process.env, codexHome = environment.CODEX_HOME ?? path.join(homeDirectory, '.codex'), codexProfile } = {}) {
47
- const clients = clientsFor(agent);
48
- const home = await fs.realpath(homeDirectory), root = await fs.realpath(toolkitRoot);
49
- const names = (await fs.readdir(path.join(root, 'skills'), { withFileTypes: true })).filter(e => e.isDirectory() && /^[a-z0-9-]+$/u.test(e.name)).map(e => e.name).sort();
124
+ export function registrationCommand(client, current, nodePath, entry) {
125
+ const add =
126
+ client === 'codex'
127
+ ? `codex mcp add agent-toolkit-workflow -- ${quote(nodePath)} ${quote(entry)}`
128
+ : `claude mcp add --scope user --transport stdio agent-toolkit-workflow -- ${quote(nodePath)} ${quote(entry)}`;
129
+ if (current.state === 'falta') return add;
130
+ // Moving or repairing an existing entry: remove it first under the name it has now.
131
+ return `${client === 'codex' ? `codex mcp remove ${current.name}` : `claude mcp remove ${current.name} -s user`} && ${add}`;
132
+ }
133
+
134
+ // Classifies every global skill link and the coordination MCP, and with `apply` creates what is missing and
135
+ // moves or repairs only what nexakit can prove is its own. Conflicts are skipped and reported, never replaced.
136
+ export async function setupToolkit({
137
+ agent = 'all',
138
+ apply = false,
139
+ homeDirectory = os.homedir(),
140
+ toolkitRoot = TOOLKIT_ROOT,
141
+ environment = process.env,
142
+ codexHome = environment.CODEX_HOME ?? path.join(homeDirectory, '.codex'),
143
+ codexProfile,
144
+ restore = [],
145
+ omit = [],
146
+ runNpm,
147
+ packageSpec,
148
+ lockOptions,
149
+ acquire = acquireLock,
150
+ } = {}) {
151
+ const home = await fs.realpath(homeDirectory);
152
+ const clients = await clientsFor(agent, environment, home);
153
+ const { sourceRoot, linkRoot, ephemeral } = await resolveRoots({ toolkitRoot, homeDirectory: home });
154
+ const names = await baseSkillNames(sourceRoot);
155
+ const registry = await readRegistry(nexakitDir(home));
156
+ const isOwned = (target, literal) => isRegistered(registry, target, literal);
157
+ const activeRoots = [linkRoot, sourceRoot, actualRoot(home)];
158
+ const retiredSkills = [];
159
+ for (const client of clients)
160
+ for (const name of RETIRED_SKILLS) {
161
+ const target = globalSkillTarget(home, client, name);
162
+ await parents(home, target);
163
+ const info = await stat(target);
164
+ if (!info) continue;
165
+ const literal = info.isSymbolicLink() ? await fs.readlink(target) : null;
166
+ // Registered, or pointing at `<nexakit copy>/skills/<name>`: links made before the registry existed
167
+ // are nexakit's too. A link into anything else is reported, never removed.
168
+ const owned = Boolean(
169
+ literal &&
170
+ (isOwned(target, literal) ||
171
+ (await recognizedRoot(path.resolve(path.dirname(target), literal), path.join('skills', name)))),
172
+ );
173
+ retiredSkills.push({ client, name, target, literal, owned });
174
+ }
175
+
50
176
  const skills = [];
51
- for (const client of clients) for (const name of names) {
52
- const source = path.join(root, 'skills', name), file = await stat(path.join(source, 'SKILL.md'));
53
- if (!file?.isFile() || file.isSymbolicLink()) throw new Error('Skill fuente inválida.');
54
- const target = path.join(home, client === 'codex' ? '.agents' : '.claude', 'skills', name);
55
- await parents(home, target);
56
- skills.push({ client, name, source, target, state: await linkState(source, target) });
57
- }
58
- const conflicts = skills.filter(s => s.state === 'conflict');
59
- if (apply && !conflicts.length) {
60
- for (const item of skills.filter(s => ['missing', 'replace'].includes(s.state))) {
61
- await parents(home, item.target, true);
62
- if (item.state === 'replace') {
63
- // Never recursively remove a junction: unlink removes only the link.
177
+ for (const client of clients)
178
+ for (const name of names) {
179
+ const target = globalSkillTarget(home, client, name);
180
+ await parents(home, target);
181
+ const { suffix, ...state } = await classifyLink({
182
+ target,
183
+ suffix: `skills/${name}`,
184
+ activeRoots,
185
+ isOwned,
186
+ });
187
+ skills.push({ client, name, ...state });
188
+ }
189
+ // Un enlace que falta pero que nexakit registró como suyo lo quitó el usuario: se respeta. Solo si esa IA aún
190
+ // conserva otros enlaces de nexakit; si se quedó sin ninguno (carpeta borrada), se reconstruye entera.
191
+ // `omit` declara lo mismo de forma explícita para enlaces antiguos que el registro no llegó a conocer; se anota
192
+ // en el registro al aplicar. `restore` gana sobre ambos.
193
+ const clientsWithLinks = new Set(skills.filter((s) => READY_LINK_STATES.includes(s.state)).map((s) => s.client));
194
+ const toRestore = new Set(restore);
195
+ const toOmit = new Set(omit);
196
+ const toRecord = new Map();
197
+ for (const item of skills) {
198
+ if (item.state !== 'falta' || toRestore.has(item.name)) continue;
199
+ if (registry.entradas.some((entry) => entry.destino === item.target)) {
200
+ if (clientsWithLinks.has(item.client)) item.state = REMOVED_BY_USER_STATE;
201
+ } else if (toOmit.has(item.name)) {
202
+ item.state = REMOVED_BY_USER_STATE;
203
+ toRecord.set(item.target, item);
204
+ }
205
+ }
206
+
207
+ // Origins are never mixed: if most links already use another valid copy, that copy stays the root to link to.
208
+ const activeRoot = chooseActiveRoot(skills, linkRoot);
209
+ const usingOtherCopy = activeRoot !== linkRoot;
210
+ const missingInActive = [];
211
+ if (usingOtherCopy) {
212
+ for (const name of names)
213
+ if (!(await stat(path.join(activeRoot, 'skills', name, 'SKILL.md')))) missingInActive.push(name);
214
+ }
215
+ for (const item of skills) item.source = path.join(activeRoot, 'skills', item.name);
216
+
217
+ const mcpStates = {};
218
+ for (const client of clients.filter((c) => MCP_CLIENTS.includes(c))) {
219
+ mcpStates[client] = await inspectMcp(client, home, codexHome, { activeRoots, isOwned });
220
+ }
221
+ const mcpDeadRoot = Object.values(mcpStates).find((s) => s.state === 'roto-ajeno')?.root;
222
+ const legacy = legacyGroups(skills, {
223
+ skillNames: new Set([...names, ...(await catalogNames(sourceRoot))]),
224
+ mcpDeadRoot,
225
+ });
226
+
227
+ const pending = skills.filter(
228
+ (s) => ACTIONABLE_LINK_STATES.includes(s.state) && !missingInActive.includes(s.name),
229
+ );
230
+ const mcpPending = Object.keys(mcpStates).filter((client) =>
231
+ ACTIONABLE_LINK_STATES.includes(mcpStates[client].state),
232
+ );
233
+ let applied = false,
234
+ changed = 0,
235
+ created = 0,
236
+ stableInstall;
237
+ if (apply) {
238
+ const lock = await acquire(nexakitDir(home), lockOptions);
239
+ try {
240
+ if (ephemeral && !usingOtherCopy && (pending.length || mcpPending.length)) {
241
+ stableInstall = await ensureStableInstall({
242
+ homeDirectory: home,
243
+ version: await packageVersion(sourceRoot),
244
+ lock,
245
+ ...(runNpm && { runNpm }),
246
+ ...(packageSpec && { packageSpec }),
247
+ });
248
+ }
249
+ for (const item of retiredSkills.filter((s) => s.owned)) {
250
+ await lock.assertHeld();
64
251
  const current = await stat(item.target);
65
- if (!current?.isSymbolicLink()) throw new Error('El destino cambió; no se reemplazan archivos ni carpetas.');
66
- await fs.unlink(item.target);
252
+ if (current?.isSymbolicLink() && (await fs.readlink(item.target)) === item.literal)
253
+ await fs.unlink(item.target);
254
+ }
255
+ const entries = [];
256
+ for (const item of pending) {
257
+ await parents(home, item.target, true);
258
+ await lock.assertHeld();
259
+ const outcome = await relinkIfUnchanged({
260
+ target: item.target,
261
+ expectedLiteral: item.state === 'falta' ? null : item.literal,
262
+ source: item.source,
263
+ });
264
+ if (outcome === 'cambio') {
265
+ changed++;
266
+ continue;
267
+ }
268
+ // The registry keeps what readlink returns, the value later compared.
269
+ const literal = await fs.readlink(item.target);
270
+ entries.push({ destino: item.target, apunta: literal, raiz: activeRoot });
271
+ Object.assign(item, { state: 'instalado', literal, root: activeRoot });
67
272
  }
68
- try { await fs.symlink(path.resolve(item.source), item.target, IS_WINDOWS ? 'junction' : 'dir'); }
69
- catch (e) { if (e.code !== 'EEXIST' || await linkState(item.source, item.target) !== 'installed') throw new Error('El destino cambió; se conservan los enlaces aplicados. Repite la vista previa.'); }
70
- item.state = 'installed';
273
+ created = entries.length;
274
+ for (const [destino, item] of toRecord)
275
+ entries.push({ destino, apunta: path.join(activeRoot, 'skills', item.name), raiz: activeRoot });
276
+ await recordEntries(nexakitDir(home), entries, lock);
277
+ applied = true;
278
+ } finally {
279
+ await lock.release();
71
280
  }
72
281
  }
73
- const entry = quote(path.join(root, 'bin/agent-toolkit-mcp.js'));
74
- const commands = Object.fromEntries(clients.map(client => [client, client === 'codex'
75
- ? `codex mcp add agent-toolkit-workflow -- ${quote(process.execPath)} ${entry}`
76
- : `claude mcp add --scope user --transport stdio agent-toolkit-workflow -- ${quote(process.execPath)} ${entry}`]));
77
- return { applied: Boolean(apply && !conflicts.length), skills, conflicts: conflicts.length,
78
- mcp: { applied: false, registrationCommands: commands }, restartRequired: Boolean(apply && !conflicts.length),
282
+
283
+ // A command is only suggested once the script it registers exists (after the stable copy is in place).
284
+ const nodePath = await resolveNodeExecutable(environment);
285
+ const entry = path.join(activeRoot, MCP_SUFFIX);
286
+ const entryExists = Boolean(await stat(entry));
287
+ const registrationCommands = {},
288
+ registrationPending = [];
289
+ for (const client of mcpPending) {
290
+ if (entryExists)
291
+ registrationCommands[client] = registrationCommand(client, mcpStates[client], nodePath, entry);
292
+ else registrationPending.push(client);
293
+ }
294
+
295
+ const obsolete = Object.entries(mcpStates)
296
+ .filter(([, state]) => state.name)
297
+ .map(([client, state]) => ({
298
+ message: 'Ya no se usa; si quieres quitarlo:',
299
+ command:
300
+ client === 'codex' ? `codex mcp remove ${state.name}` : `claude mcp remove ${state.name} -s user`,
301
+ }));
302
+ const agentsPath = path.join(codexHome, 'agents');
303
+ for (const name of await fs.readdir(agentsPath).catch(() => [])) {
304
+ if (!/^nivel-(ligero|normal|exigente|alto)\.(toml|md)$/u.test(name)) continue;
305
+ const file = path.join(agentsPath, name);
306
+ obsolete.push({
307
+ message: 'Ya no se usa; si quieres quitarlo:',
308
+ command: IS_WINDOWS
309
+ ? `PowerShell: Remove-Item -LiteralPath '${file.replaceAll("'", "''")}'`
310
+ : `rm -- ${quote(file)}`,
311
+ });
312
+ }
313
+ return {
314
+ applied,
315
+ skills,
316
+ retiredSkills,
317
+ obsolete,
318
+ conflicts:
319
+ skills.filter((s) => ['conflicto', 'roto-ajeno'].includes(s.state)).length +
320
+ retiredSkills.filter((s) => !s.owned).length,
321
+ changed,
322
+ clients,
323
+ linkRoot,
324
+ ephemeral,
325
+ activeRoot,
326
+ legacy,
327
+ ...(usingOtherCopy && { otherCopy: { root: activeRoot, missing: missingInActive } }),
328
+ ...(stableInstall && { stableInstall }),
329
+ mcp: {
330
+ applied: false,
331
+ states: mcpStates,
332
+ registrationCommands,
333
+ ...(registrationPending.length && { registrationPending }),
334
+ },
335
+ restartRequired: created > 0,
79
336
  ...(clients.includes('codex') && { codexSandbox: await codexSandbox(home, codexHome, codexProfile) }),
80
- superpowers: { included: false, required: false }, codexHome };
337
+ // Required, installed through each agent's plugin manager; `doctor` and `npx nexakit` check it.
338
+ superpowers: { included: false, required: true },
339
+ codexHome,
340
+ };
81
341
  }
82
342
  // Codex runs commands in a workspace-write sandbox by default, so `registrar` cannot write its reviewer record
83
343
  // under ~/.agent-toolkit unless that folder is inside a writable root. Only reported; the user runs the printed
84
344
  // command. What cannot be evaluated (permission profiles) is reported as unknown instead of asking for a change.
85
345
  // Resolves links through the deepest folder that exists, so a root written through a link still matches.
86
346
  async function canonical(target) {
87
- let current = path.resolve(target), rest = [];
347
+ let current = path.resolve(target),
348
+ rest = [];
88
349
  while (true) {
89
- try { return path.join(await fs.realpath(current), ...rest); }
90
- catch { const parent = path.dirname(current); if (parent === current) return path.resolve(target); rest.unshift(path.basename(current)); current = parent; }
350
+ try {
351
+ return path.join(await fs.realpath(current), ...rest);
352
+ } catch {
353
+ const parent = path.dirname(current);
354
+ if (parent === current) return path.resolve(target);
355
+ rest.unshift(path.basename(current));
356
+ current = parent;
357
+ }
91
358
  }
92
359
  }
93
360
  const PROFILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
94
361
  const layer = (base, top) => {
95
362
  const out = { ...base };
96
363
  for (const [key, value] of Object.entries(top)) {
97
- out[key] = value && typeof value === 'object' && !Array.isArray(value) && out[key] && typeof out[key] === 'object' && !Array.isArray(out[key])
98
- ? layer(out[key], value) : value;
364
+ out[key] =
365
+ value &&
366
+ typeof value === 'object' &&
367
+ !Array.isArray(value) &&
368
+ out[key] &&
369
+ typeof out[key] === 'object' &&
370
+ !Array.isArray(out[key])
371
+ ? layer(out[key], value)
372
+ : value;
99
373
  }
100
374
  return out;
101
375
  };
102
- const readToml = async file => { const bytes = await boundedConfig(file); return bytes ? getStaticTOMLValue(parseTOML(bytes.toString('utf8'), { tomlVersion: '1.0.0' })) : null; };
376
+ const readToml = async (file) => {
377
+ const bytes = await boundedConfig(file);
378
+ return bytes ? getStaticTOMLValue(parseTOML(bytes.toString('utf8'), { tomlVersion: '1.0.0' })) : null;
379
+ };
103
380
  async function codexSandbox(home, codexHome, codexProfile) {
104
- const state = path.join(home, '.agent-toolkit'), file = path.join(codexHome, 'config.toml');
381
+ const state = path.join(home, '.agent-toolkit'),
382
+ file = path.join(codexHome, 'config.toml');
105
383
  // A base64 payload keeps paths and JavaScript out of shell interpolation in cmd, PowerShell and POSIX shells.
106
- const script = `const fs = require('node:fs'); const file = ${JSON.stringify(file)}; ` +
384
+ const script =
385
+ `const fs = require('node:fs'); const file = ${JSON.stringify(file)}; ` +
107
386
  `let content = ''; try { content = fs.readFileSync(file, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; } ` +
108
387
  `if (/^\\[sandbox_workspace_write\\]/mu.test(content)) console.log('Ya existe la sección: añade la ruta a writable_roots a mano'); ` +
109
388
  `else fs.appendFileSync(file, ${JSON.stringify(`\n[sandbox_workspace_write]\nwritable_roots = [${JSON.stringify(state)}]\n`)});`;
110
389
  const command = `node -e "eval(Buffer.from('${Buffer.from(script).toString('base64')}','base64').toString())"`;
111
- const unknown = reason => ({ state: 'unknown', writableRoot: state,
112
- hint: `${reason}: no se puede saber si Codex deja escribir en ${state}. Si $revisar no consigue registrarse, permite esa carpeta.` });
113
- let config = {}, profileFiles = [];
390
+ const unknown = (reason) => ({
391
+ state: 'unknown',
392
+ writableRoot: state,
393
+ hint: `${reason}: no se puede saber si Codex deja escribir en ${state}. Si $revisar no consigue registrarse, permite esa carpeta.`,
394
+ });
395
+ let config = {},
396
+ profileFiles = [];
114
397
  try {
115
- config = await readToml(file) ?? {};
398
+ config = (await readToml(file)) ?? {};
116
399
  // `codex --profile <name>` layers $CODEX_HOME/<name>.config.toml over config.toml at launch time.
117
- profileFiles = (await fs.readdir(codexHome).catch(() => [])).filter(n => n.endsWith('.config.toml')).map(n => n.slice(0, -12)).filter(n => PROFILE.test(n)).sort();
118
- } catch { return { state: 'unreadable', writableRoot: state }; }
400
+ profileFiles = (await fs.readdir(codexHome).catch(() => []))
401
+ .filter((n) => n.endsWith('.config.toml'))
402
+ .map((n) => n.slice(0, -12))
403
+ .filter((n) => PROFILE.test(n))
404
+ .sort();
405
+ } catch {
406
+ return { state: 'unreadable', writableRoot: state };
407
+ }
119
408
  let effective = config;
120
409
  if (codexProfile !== undefined) {
121
410
  if (!PROFILE.test(codexProfile)) throw new Error('Nombre de perfil de Codex no válido.');
122
411
  let overlay;
123
- try { overlay = await readToml(path.join(codexHome, `${codexProfile}.config.toml`)); } catch { return { state: 'unreadable', writableRoot: state }; }
412
+ try {
413
+ overlay = await readToml(path.join(codexHome, `${codexProfile}.config.toml`));
414
+ } catch {
415
+ return { state: 'unreadable', writableRoot: state };
416
+ }
124
417
  if (!overlay) return unknown(`No existe ${codexProfile}.config.toml en ${codexHome}`);
125
418
  effective = layer(config, overlay);
126
419
  } else if (config.profile !== undefined) {
127
420
  // Older layout: a [profiles.<name>] table selected with `profile = "<name>"`.
128
421
  const profile = typeof config.profile === 'string' ? config.profiles?.[config.profile] : undefined;
129
- if (!profile || typeof profile !== 'object') return unknown('El perfil de configuración elegido no se encuentra');
422
+ if (!profile || typeof profile !== 'object')
423
+ return unknown('El perfil de configuración elegido no se encuentra');
130
424
  effective = layer(config, profile);
131
425
  }
132
- const result = value => codexProfile === undefined && profileFiles.length
133
- ? { ...value, profileFiles, profileHint: `Hay perfiles de Codex (${profileFiles.join(', ')}). Si abres Codex con --profile, revisa con --perfil-codex <nombre>.` }
134
- : value;
426
+ const result = (value) =>
427
+ codexProfile === undefined && profileFiles.length
428
+ ? {
429
+ ...value,
430
+ profileFiles,
431
+ profileHint: `Hay perfiles de Codex (${profileFiles.join(', ')}). Si abres Codex con --profile, revisa con --perfil-codex <nombre>.`,
432
+ }
433
+ : value;
135
434
  return result(await sandboxState(effective, home, state, file, command, unknown));
136
435
  }
137
436
  async function sandboxState(effective, home, state, file, command, unknown) {
138
437
  const mode = effective.sandbox_mode;
139
438
  // When sandbox_mode is present, Codex uses the legacy options and ignores default_permissions.
140
439
  if (mode === undefined && effective.default_permissions !== undefined) {
141
- if (effective.default_permissions === ':danger-full-access') return { state: 'not-needed', writableRoot: state };
142
- if (effective.default_permissions === ':read-only') return { state: 'read-only', writableRoot: state,
143
- hint: `El perfil de permisos :read-only no deja escribir: $revisar no puede registrarse. Usa sandbox_mode = "workspace-write" con ${state} en writable_roots.` };
440
+ if (effective.default_permissions === ':danger-full-access')
441
+ return { state: 'not-needed', writableRoot: state };
442
+ if (effective.default_permissions === ':read-only')
443
+ return {
444
+ state: 'read-only',
445
+ writableRoot: state,
446
+ hint: `El perfil de permisos :read-only no deja escribir: $revisar no puede registrarse. Usa sandbox_mode = "workspace-write" con ${state} en writable_roots.`,
447
+ };
144
448
  return unknown('Codex usa un perfil de permisos propio');
145
449
  }
146
- if (mode === undefined && effective.permissions !== undefined) return unknown('Codex usa perfiles de permisos');
450
+ if (mode === undefined && effective.permissions !== undefined)
451
+ return unknown('Codex usa perfiles de permisos');
147
452
  if (mode === 'danger-full-access') return { state: 'not-needed', writableRoot: state };
148
- if (mode === 'read-only') return { state: 'read-only', writableRoot: state,
149
- hint: `Codex está en solo lectura: $revisar no puede registrarse. Usa sandbox_mode = "workspace-write" con ${state} en writable_roots.` };
150
- if (mode !== undefined && mode !== 'workspace-write') return unknown(`sandbox_mode = ${JSON.stringify(mode)} no es conocido`);
453
+ if (mode === 'read-only')
454
+ return {
455
+ state: 'read-only',
456
+ writableRoot: state,
457
+ hint: `Codex está en solo lectura: $revisar no puede registrarse. Usa sandbox_mode = "workspace-write" con ${state} en writable_roots.`,
458
+ };
459
+ if (mode !== undefined && mode !== 'workspace-write')
460
+ return unknown(`sandbox_mode = ${JSON.stringify(mode)} no es conocido`);
151
461
  const roots = effective.sandbox_workspace_write?.writable_roots;
152
462
  const target = await canonical(state);
153
463
  for (const root of Array.isArray(roots) ? roots : []) {
154
464
  if (typeof root !== 'string' || !root) continue;
155
465
  const relative = path.relative(await canonical(root.replace(/^~(?=$|\/)/u, home)), target);
156
- if (relative === '' || (relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative))) {
466
+ if (
467
+ relative === '' ||
468
+ (relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative))
469
+ ) {
157
470
  return { state: 'allowed', writableRoot: state };
158
471
  }
159
472
  }
160
- return effective.sandbox_workspace_write ? { state: 'missing-root', writableRoot: state, hint: `Añade ${JSON.stringify(state)} a writable_roots de [sandbox_workspace_write] en ${file}.` }
473
+ return effective.sandbox_workspace_write
474
+ ? {
475
+ state: 'missing-root',
476
+ writableRoot: state,
477
+ hint: `Añade ${JSON.stringify(state)} a writable_roots de [sandbox_workspace_write] en ${file}.`,
478
+ }
161
479
  : { state: 'missing', writableRoot: state, command };
162
480
  }
163
481
  async function boundedConfig(file) {
@@ -166,52 +484,131 @@ async function boundedConfig(file) {
166
484
  if (!info.isFile() || info.isSymbolicLink() || info.size > 1_048_576) throw new Error('config');
167
485
  return fs.readFile(file);
168
486
  }
169
- async function inspectMcp(client, home, codexHome, root) {
487
+ // The entry is recognized by the script it runs (`…/bin/agent-toolkit-mcp.js` under `node`), then classified like
488
+ // a link: installed only if that script exists in a root in use.
489
+ async function inspectMcp(client, home, codexHome, { activeRoots, isOwned }) {
490
+ let servers;
170
491
  try {
171
- const bytes = await boundedConfig(client === 'codex' ? path.join(codexHome, 'config.toml') : path.join(home, '.claude.json'));
172
- if (!bytes) return { state: 'missing' };
173
- const names = ['agent-toolkit-workflow', 'agent-toolkit'];
174
- for (const name of names) {
175
- let server;
176
- if (client === 'codex') {
177
- server = getStaticTOMLValue(parseTOML(bytes.toString('utf8'), { tomlVersion: '1.0.0' })).mcp_servers?.[name];
178
- } else server = JSON.parse(bytes.toString('utf8')).mcpServers?.[name];
179
- if (!server) continue;
180
- const expected = path.join(root, 'bin/agent-toolkit-mcp.js');
181
- const recognized = Array.isArray(server.args) && server.args.includes(expected) &&
182
- (server.command === 'node' || server.command === process.execPath);
183
- return { state: server.enabled === false ? 'disabled' : recognized ? 'configured' : 'different-checkout', name };
184
- }
185
- return { state: 'missing' };
186
- } catch { return { state: 'unreadable' }; }
492
+ const bytes = await boundedConfig(
493
+ client === 'codex' ? path.join(codexHome, 'config.toml') : path.join(home, '.claude.json'),
494
+ );
495
+ if (!bytes) return { state: 'falta' };
496
+ servers =
497
+ client === 'codex'
498
+ ? getStaticTOMLValue(parseTOML(bytes.toString('utf8'), { tomlVersion: '1.0.0' })).mcp_servers
499
+ : JSON.parse(bytes.toString('utf8')).mcpServers;
500
+ } catch {
501
+ return { state: 'unreadable' };
502
+ }
503
+ for (const name of ['agent-toolkit-workflow', 'agent-toolkit']) {
504
+ const server = servers?.[name];
505
+ if (!server) continue;
506
+ if (server.enabled === false) return { state: 'disabled', name };
507
+ const entry = Array.isArray(server.args)
508
+ ? server.args.find(
509
+ (arg) => typeof arg === 'string' && arg.replaceAll('\\', '/').endsWith(`/${MCP_SUFFIX}`),
510
+ )
511
+ : undefined;
512
+ const runsNode =
513
+ typeof server.command === 'string' && /^node(\.exe)?$/iu.test(path.basename(server.command));
514
+ if (!entry || !runsNode) return { state: 'conflicto', name };
515
+ const { state, root } = await classifyReference({
516
+ target: mcpRegistryTarget(client, name),
517
+ literal: entry,
518
+ suffix: MCP_SUFFIX,
519
+ activeRoots,
520
+ isOwned,
521
+ });
522
+ return { state, name, literal: entry, ...(root && { root }) };
523
+ }
524
+ return { state: 'falta' };
187
525
  }
188
- export async function executable(name, environment) {
526
+ async function findExecutable(name, environment, accept = () => true) {
189
527
  const extensions = IS_WINDOWS
190
- ? ['', ...(environment.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean).map(extension => extension.toLowerCase())]
528
+ ? [
529
+ '',
530
+ ...(environment.PATHEXT || '.COM;.EXE;.BAT;.CMD')
531
+ .split(';')
532
+ .filter(Boolean)
533
+ .map((extension) => extension.toLowerCase()),
534
+ ]
191
535
  : [''];
192
536
  for (const folder of (environment.PATH ?? '').split(path.delimiter).filter(Boolean)) {
193
537
  for (const extension of extensions) {
194
- try { const file = path.join(folder, name + extension); await fs.access(file, constants.X_OK); if ((await fs.stat(file)).isFile()) return true; } catch {}
538
+ try {
539
+ const file = path.join(folder, name + extension);
540
+ await fs.access(file, constants.X_OK);
541
+ if ((await fs.stat(file)).isFile() && (await accept(file))) return file;
542
+ } catch {}
195
543
  }
196
544
  }
197
- return false;
545
+ return null;
198
546
  }
199
- export async function doctorToolkit({ agent = 'all', homeDirectory = os.homedir(), toolkitRoot = TOOLKIT_ROOT, environment = process.env,
200
- codexHome = environment.CODEX_HOME ?? path.join(homeDirectory, '.codex'), codexProfile } = {}) {
201
- const setup = await setupToolkit({ agent, homeDirectory, toolkitRoot, environment, codexHome, codexProfile });
202
- const clients = {}, mcp = {};
203
- for (const name of clientsFor(agent)) {
204
- clients[name] = { available: await executable(name, environment) };
205
- mcp[name] = await inspectMcp(name, homeDirectory, codexHome, await fs.realpath(toolkitRoot));
206
- }
547
+ export async function executable(name, environment) {
548
+ return Boolean(await findExecutable(name, environment));
549
+ }
550
+ export async function doctorToolkit({
551
+ agent = 'all',
552
+ homeDirectory = os.homedir(),
553
+ toolkitRoot = TOOLKIT_ROOT,
554
+ environment = process.env,
555
+ codexHome = environment.CODEX_HOME ?? path.join(homeDirectory, '.codex'),
556
+ codexProfile,
557
+ runCommand,
558
+ } = {}) {
559
+ const setup = await setupToolkit({
560
+ agent,
561
+ homeDirectory,
562
+ toolkitRoot,
563
+ environment,
564
+ codexHome,
565
+ codexProfile,
566
+ });
567
+ const clients = {};
568
+ for (const name of setup.clients)
569
+ clients[name] = { available: await executable(COMMANDS[name], environment) };
570
+ const mcp = setup.mcp.states;
207
571
  const dependencies = {};
208
572
  const require = createRequire(path.join(toolkitRoot, 'package.json'));
209
- for (const name of ['@modelcontextprotocol/server', '@modelcontextprotocol/sdk', 'zod', 'ajv', 'toml-eslint-parser']) {
210
- try { require.resolve(name === '@modelcontextprotocol/sdk' ? name + '/client/index.js' : name); dependencies[name] = true; } catch { dependencies[name] = false; }
573
+ for (const name of [
574
+ '@modelcontextprotocol/server',
575
+ '@modelcontextprotocol/sdk',
576
+ 'zod',
577
+ 'ajv',
578
+ 'toml-eslint-parser',
579
+ ]) {
580
+ try {
581
+ require.resolve(name === '@modelcontextprotocol/sdk' ? name + '/client/index.js' : name);
582
+ dependencies[name] = true;
583
+ } catch {
584
+ dependencies[name] = false;
585
+ }
211
586
  }
212
- return { node: { version: process.version, supported: isNodeSupported(process.versions.node) }, dependencies, clients, mcp,
213
- skills: setup.skills, registrationCommands: setup.mcp.registrationCommands,
587
+ return {
588
+ node: { version: process.version, supported: isNodeSupported(process.versions.node) },
589
+ dependencies,
590
+ clients,
591
+ mcp,
592
+ skills: setup.skills,
593
+ obsolete: setup.obsolete,
594
+ retiredSkills: setup.retiredSkills,
595
+ registrationCommands: setup.mcp.registrationCommands,
596
+ ...(setup.mcp.registrationPending && { registrationPending: setup.mcp.registrationPending }),
597
+ activeRoot: setup.activeRoot,
598
+ legacy: setup.legacy,
599
+ ...(setup.otherCopy && { otherCopy: setup.otherCopy }),
214
600
  ...(setup.codexSandbox && { codexSandbox: setup.codexSandbox }),
215
601
  gemini: { available: await executable('agy', environment), provider: 'antigravity', apiFallback: false },
216
- superpowers: { included: false, required: false, detection: 'check-client-plugin-manager' }, nativeConnectionVerified: false };
602
+ superpowers: {
603
+ included: false,
604
+ required: true,
605
+ detection: 'client-plugin-cli',
606
+ states: await inspectSuperpowers({
607
+ agentsFound: Object.fromEntries(Object.entries(clients).map(([name, c]) => [name, c.available])),
608
+ environment,
609
+ ...(runCommand && { runCommand }),
610
+ }),
611
+ },
612
+ nativeConnectionVerified: false,
613
+ };
217
614
  }