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/setup-tui.js
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
// `npx nexakit`: three questions with the answer already suggested (skills, memory, agents) and one confirmation,
|
|
2
|
+
// in the style of `npm create vite`. Answered questions fold into one ◇ line; the active one uses ◆. The model,
|
|
3
|
+
// the drawing and the keys are pure, so every screen is tested as text; runSetupTui is the only part with I/O.
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { applySetup } from './setup-apply.js';
|
|
7
|
+
import { AGENTS, AGENT_NAMES, buildConfirmation, joinNames, shortPath, suggestedAnswers } from './setup-plan.js';
|
|
8
|
+
import { gatherSetupState, inspectMemoryPath, memoryConnections } from './setup-state.js';
|
|
9
|
+
import { applyToolkitDetach, planToolkitDetach } from './skill-recommendations.js';
|
|
10
|
+
import { LOGO_WIDTH, colorLevel, levelOf, paint, renderLogo } from './brand.js';
|
|
11
|
+
import { cellWidth, fitLine, wrapText } from './terminal-text.js';
|
|
12
|
+
|
|
13
|
+
const KEYS = Object.freeze({
|
|
14
|
+
up: ['\x1b[A', 'k'], down: ['\x1b[B', 'j'], toggle: [' '], enter: ['\r', '\n'],
|
|
15
|
+
back: ['\x7f', '\x08', '\x1b[D'], left: ['\x1b[D'], right: ['\x1b[C'], quit: ['q', 'Q', '\x03']
|
|
16
|
+
});
|
|
17
|
+
const is = (key, name) => KEYS[name].includes(key);
|
|
18
|
+
const MENU = Object.freeze([
|
|
19
|
+
['exit', 'No, salir'], ['skills', 'Añadir o quitar skills'], ['agents', 'Cambiar agentes'], ['detach', 'Quitar nexakit de este proyecto']
|
|
20
|
+
]);
|
|
21
|
+
const MAX_DETECTED = 4;
|
|
22
|
+
// Languages and generic signals go after the frameworks the person will recognise.
|
|
23
|
+
const GENERIC_SIGNALS = new Set(['nodejs', 'python', 'rust', 'rest-api', 'vitest', 'mcp-sdk']);
|
|
24
|
+
const detectedLabels = signals => [...signals]
|
|
25
|
+
.sort((a, b) => GENERIC_SIGNALS.has(a.id) - GENERIC_SIGNALS.has(b.id) || a.label.localeCompare(b.label))
|
|
26
|
+
.map(signal => signal.label);
|
|
27
|
+
|
|
28
|
+
// ── Model ────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function memoryOptions(model) {
|
|
31
|
+
const { state } = model;
|
|
32
|
+
if (!state.project.git) return ['later'];
|
|
33
|
+
return [state.memory.existing ? 'existing' : 'suggested', 'other', 'later'];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Skills shown before "ver N más…": the suggested ones and any already chosen or installed.
|
|
37
|
+
function visibleSkills(model) {
|
|
38
|
+
const { state, answers, expanded } = model;
|
|
39
|
+
if (expanded) return state.skills;
|
|
40
|
+
return state.skills.filter(skill => skill.suggested || answers.skills.chosen.includes(skill.name) ||
|
|
41
|
+
Object.values(skill.links ?? {}).includes('installed'));
|
|
42
|
+
}
|
|
43
|
+
// What the info view says about trust: untested skills say so, and review observations are shown as written.
|
|
44
|
+
function skillTrust(skill) {
|
|
45
|
+
const lines = [];
|
|
46
|
+
if (skill.tested === false) lines.push('', ...wrapText('No probada: nadie ha comprobado aún que mejore el trabajo.', 60));
|
|
47
|
+
if (skill.reviewNotes) lines.push(...wrapText(`Observaciones: ${skill.reviewNotes}`, 60));
|
|
48
|
+
return lines;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function skillRows(model) {
|
|
52
|
+
const visible = visibleSkills(model);
|
|
53
|
+
const hidden = model.state.skills.length - visible.length;
|
|
54
|
+
return hidden > 0 ? [...visible.map(skill => ({ skill })), { more: hidden }] : visible.map(skill => ({ skill }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createSetupModel(state) {
|
|
58
|
+
const answers = suggestedAnswers(state);
|
|
59
|
+
const confirmation = buildConfirmation(state, answers);
|
|
60
|
+
const model = {
|
|
61
|
+
state, answers, step: confirmation.allReady ? 'menu' : 'skills', flow: confirmation.allReady ? 'menu' : 'full',
|
|
62
|
+
cursor: 0, expanded: false, info: false, detail: false, confirmYes: true,
|
|
63
|
+
memoryInput: '', memoryError: null, progress: [], result: null, detachPlan: null, message: null
|
|
64
|
+
};
|
|
65
|
+
model.memoryChoice = answers.memory.decision === 'later' ? 'later'
|
|
66
|
+
: answers.memory.path === (state.memory.existing?.vaultPath ?? state.memory.suggestedPath) ? memoryOptions(model)[0] : 'other';
|
|
67
|
+
return model;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Leaving the skills question: a suggestion left unchecked is remembered as discarded, so it is not offered again.
|
|
71
|
+
function closeSkills(model) {
|
|
72
|
+
const chosen = model.state.skills.filter(skill => model.answers.skills.chosen.includes(skill.name)).map(skill => skill.name);
|
|
73
|
+
const discarded = new Set(model.answers.skills.discarded);
|
|
74
|
+
for (const skill of model.state.skills) {
|
|
75
|
+
if (chosen.includes(skill.name)) discarded.delete(skill.name);
|
|
76
|
+
else if (skill.suggested) discarded.add(skill.name);
|
|
77
|
+
}
|
|
78
|
+
return { ...model.answers, skills: { chosen, discarded: model.state.skills.map(s => s.name).filter(name => discarded.has(name)) } };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function memoryAnswer(model, choice) {
|
|
82
|
+
const { state } = model;
|
|
83
|
+
if (choice === 'later') return { decision: 'later' };
|
|
84
|
+
if (choice === 'existing') return { decision: 'path', path: state.memory.existing.vaultPath };
|
|
85
|
+
if (choice === 'suggested') return { decision: 'path', path: state.memory.suggestedPath };
|
|
86
|
+
return model.answers.memory;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const go = (model, step, extra = {}) => ({ ...model, step, cursor: 0, info: false, detail: false, message: null, ...extra });
|
|
90
|
+
const nextAfter = (model, step) => {
|
|
91
|
+
if (model.flow === 'menu') return 'confirm';
|
|
92
|
+
return { skills: 'memory', memory: 'agents', 'memory-path': 'agents', agents: 'confirm' }[step];
|
|
93
|
+
};
|
|
94
|
+
const previousOf = (model, step) => {
|
|
95
|
+
if (model.flow === 'menu') return step === 'confirm' ? model.menuEntry : 'menu';
|
|
96
|
+
return { memory: 'skills', 'memory-path': 'memory', agents: 'memory', confirm: 'agents' }[step];
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export function handleSetupKey(model, key) {
|
|
100
|
+
if (key === '\x03') return { model, effect: 'exit' };
|
|
101
|
+
const { step } = model;
|
|
102
|
+
|
|
103
|
+
if (step === 'memory-path') {
|
|
104
|
+
if (is(key, 'enter')) {
|
|
105
|
+
const raw = model.memoryInput.trim();
|
|
106
|
+
const expanded = raw === '~' || raw.startsWith('~/') ? path.join(model.state.homeDirectory, raw.slice(1)) : raw;
|
|
107
|
+
if (!path.isAbsolute(expanded)) return { model: { ...model, memoryError: 'Escribe una ruta completa, por ejemplo ~/memorias/mi-proyecto.' } };
|
|
108
|
+
return { model: { ...model, memoryError: null }, effect: 'check-path', path: path.resolve(expanded) };
|
|
109
|
+
}
|
|
110
|
+
if (key === '\x1b') return { model: go(model, 'memory', { cursor: memoryOptions(model).indexOf('other') }) };
|
|
111
|
+
if (key === '\x7f' || key === '\x08') return { model: { ...model, memoryInput: [...model.memoryInput].slice(0, -1).join('') } };
|
|
112
|
+
if (/^[^\x00-\x1f\x7f]+$/u.test(key) && !key.startsWith('\x1b')) return { model: { ...model, memoryInput: model.memoryInput + key, memoryError: null } };
|
|
113
|
+
return { model };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (is(key, 'quit') && !['applying'].includes(step)) return { model, effect: 'exit' };
|
|
117
|
+
|
|
118
|
+
if (step === 'done' || step === 'detached') return { model, effect: 'exit' };
|
|
119
|
+
if (step === 'applying') return { model };
|
|
120
|
+
|
|
121
|
+
if (step === 'menu') {
|
|
122
|
+
if (is(key, 'up')) return { model: { ...model, cursor: Math.max(0, model.cursor - 1) } };
|
|
123
|
+
if (is(key, 'down')) return { model: { ...model, cursor: Math.min(MENU.length - 1, model.cursor + 1) } };
|
|
124
|
+
if (is(key, 'enter')) {
|
|
125
|
+
const [choice] = MENU[model.cursor];
|
|
126
|
+
if (choice === 'exit') return { model, effect: 'exit' };
|
|
127
|
+
if (choice === 'detach') return { model, effect: 'detach-preview' };
|
|
128
|
+
return { model: go(model, choice, { menuEntry: choice, expanded: choice === 'skills' }) };
|
|
129
|
+
}
|
|
130
|
+
return { model };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (step === 'detach-confirm') {
|
|
134
|
+
if (is(key, 'left') || is(key, 'right') || is(key, 'toggle')) return { model: { ...model, confirmYes: !model.confirmYes } };
|
|
135
|
+
if (is(key, 'enter')) return model.confirmYes ? { model, effect: 'detach-apply' } : { model: go(model, 'menu') };
|
|
136
|
+
if (key === '\x7f' || key === '\x08') return { model: go(model, 'menu') };
|
|
137
|
+
return { model };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (step === 'skills') {
|
|
141
|
+
const rows = skillRows(model);
|
|
142
|
+
if (is(key, 'up')) return { model: { ...model, cursor: Math.max(0, model.cursor - 1), info: false } };
|
|
143
|
+
if (is(key, 'down')) return { model: { ...model, cursor: Math.min(rows.length - 1, model.cursor + 1), info: false } };
|
|
144
|
+
if (key === 'i') return { model: { ...model, info: !model.info } };
|
|
145
|
+
const row = rows[model.cursor];
|
|
146
|
+
if (is(key, 'toggle') || (is(key, 'enter') && row?.more)) {
|
|
147
|
+
if (row?.more) return { model: { ...model, expanded: true } };
|
|
148
|
+
const chosen = model.answers.skills.chosen.includes(row.skill.name)
|
|
149
|
+
? model.answers.skills.chosen.filter(name => name !== row.skill.name)
|
|
150
|
+
: [...model.answers.skills.chosen, row.skill.name];
|
|
151
|
+
return { model: { ...model, answers: { ...model.answers, skills: { ...model.answers.skills, chosen } } } };
|
|
152
|
+
}
|
|
153
|
+
if (is(key, 'enter')) return { model: go({ ...model, answers: closeSkills(model) }, nextAfter(model, 'skills')) };
|
|
154
|
+
if (is(key, 'back') && model.flow === 'menu') return { model: go(model, 'menu') };
|
|
155
|
+
return { model };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (step === 'memory') {
|
|
159
|
+
const options = memoryOptions(model);
|
|
160
|
+
const cursor = model.cursor;
|
|
161
|
+
if (is(key, 'up')) return { model: { ...model, cursor: Math.max(0, cursor - 1) } };
|
|
162
|
+
if (is(key, 'down')) return { model: { ...model, cursor: Math.min(options.length - 1, cursor + 1) } };
|
|
163
|
+
if (is(key, 'enter')) {
|
|
164
|
+
const choice = options[cursor];
|
|
165
|
+
if (choice === 'other') return { model: go(model, 'memory-path', { memoryChoice: 'other' }) };
|
|
166
|
+
return { model: go({ ...model, memoryChoice: choice, answers: { ...model.answers, memory: memoryAnswer(model, choice) } }, nextAfter(model, 'memory')) };
|
|
167
|
+
}
|
|
168
|
+
if (is(key, 'back')) return { model: go(model, previousOf(model, 'memory')) };
|
|
169
|
+
return { model };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (step === 'agents') {
|
|
173
|
+
if (is(key, 'up')) return { model: { ...model, cursor: Math.max(0, model.cursor - 1) } };
|
|
174
|
+
if (is(key, 'down')) return { model: { ...model, cursor: Math.min(AGENTS.length - 1, model.cursor + 1) } };
|
|
175
|
+
if (is(key, 'toggle')) {
|
|
176
|
+
const agent = AGENTS[model.cursor];
|
|
177
|
+
const agents = model.answers.agents.includes(agent) ? model.answers.agents.filter(a => a !== agent) : [...model.answers.agents, agent];
|
|
178
|
+
return { model: { ...model, message: null, answers: { ...model.answers, agents: AGENTS.filter(a => agents.includes(a)) } } };
|
|
179
|
+
}
|
|
180
|
+
if (is(key, 'enter')) {
|
|
181
|
+
if (!model.answers.agents.length) return { model: { ...model, message: 'Marca al menos una IA.' } };
|
|
182
|
+
return { model: go(model, nextAfter(model, 'agents')) };
|
|
183
|
+
}
|
|
184
|
+
if (is(key, 'back')) return { model: go(model, previousOf(model, 'agents')) };
|
|
185
|
+
return { model };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (step === 'confirm') {
|
|
189
|
+
if (key === 'd') return { model: { ...model, detail: !model.detail } };
|
|
190
|
+
if (is(key, 'left') || is(key, 'right') || is(key, 'toggle')) return { model: { ...model, confirmYes: !model.confirmYes } };
|
|
191
|
+
if (is(key, 'enter')) {
|
|
192
|
+
if (!model.confirmYes) return { model, effect: 'exit' };
|
|
193
|
+
return buildConfirmation(model.state, model.answers).blocks.length ? { model, effect: 'apply' } : { model, effect: 'exit' };
|
|
194
|
+
}
|
|
195
|
+
if (key === '\x7f' || key === '\x08') return { model: go(model, previousOf(model, 'confirm')) };
|
|
196
|
+
return { model };
|
|
197
|
+
}
|
|
198
|
+
return { model };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The answer to "otra carpeta…" once its path was checked (an existing memory, a new one, or something not touched).
|
|
202
|
+
export function applyMemoryPathCheck(model, target, kind, connections) {
|
|
203
|
+
if (kind === 'invalid') return { ...model, memoryError: 'Esa carpeta no está vacía y no es una memoria; elige otra.' };
|
|
204
|
+
if (kind === 'foreign') return { ...model, memoryError: 'Esa es la memoria de otro proyecto; elige otra carpeta.' };
|
|
205
|
+
const memory = { ...model.state.memory, kinds: { ...model.state.memory.kinds, [target]: kind },
|
|
206
|
+
connections: { ...model.state.memory.connections, ...(connections && { [target]: connections }) } };
|
|
207
|
+
return go({ ...model, state: { ...model.state, memory }, answers: { ...model.answers, memory: { decision: 'path', path: target } } }, nextAfter(model, 'memory'));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Drawing ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
function folded(model) {
|
|
213
|
+
const { state, answers } = model;
|
|
214
|
+
const lines = [`Proyecto: ${state.project.displayName}${state.project.fromSubfolder ? ' (repositorio completo)' : ''}`];
|
|
215
|
+
const order = ['skills', 'memory', 'memory-path', 'agents', 'confirm'];
|
|
216
|
+
const at = order.indexOf(model.step);
|
|
217
|
+
if (model.step === 'skills' || at < 0) {
|
|
218
|
+
if (state.signals.length && model.step === 'skills') {
|
|
219
|
+
const labels = detectedLabels(state.signals);
|
|
220
|
+
lines.push(`Detecté: ${labels.slice(0, MAX_DETECTED).join(' · ')}${labels.length > MAX_DETECTED ? ` · y ${labels.length - MAX_DETECTED} más` : ''}`);
|
|
221
|
+
}
|
|
222
|
+
return lines;
|
|
223
|
+
}
|
|
224
|
+
if (model.flow === 'full' || model.menuEntry === 'skills') lines.push(`Skills: ${answers.skills.chosen.length} ${answers.skills.chosen.length === 1 ? 'elegida' : 'elegidas'}`);
|
|
225
|
+
if (model.flow === 'full' && at > order.indexOf('memory-path')) {
|
|
226
|
+
lines.push(answers.memory.decision === 'later' ? 'Memoria: ahora no' : `Memoria: ${shortPath(answers.memory.path, state.homeDirectory)}`);
|
|
227
|
+
}
|
|
228
|
+
if ((model.flow === 'full' && at > order.indexOf('agents')) || model.menuEntry === 'agents' && model.step === 'confirm') {
|
|
229
|
+
lines.push(`IA: ${joinNames(answers.agents.map(agent => AGENT_NAMES[agent]))}`);
|
|
230
|
+
}
|
|
231
|
+
return lines;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function question(model) {
|
|
235
|
+
const { state, answers } = model;
|
|
236
|
+
const home = state.homeDirectory;
|
|
237
|
+
switch (model.step) {
|
|
238
|
+
case 'skills': {
|
|
239
|
+
const rows = skillRows(model);
|
|
240
|
+
const width = Math.min(28, Math.max(...rows.filter(r => r.skill).map(r => r.skill.name.length), 10));
|
|
241
|
+
const body = rows.map((row, index) => ({
|
|
242
|
+
cursor: index === model.cursor,
|
|
243
|
+
text: row.more ? `◻ ver ${row.more} más…` : `${answers.skills.chosen.includes(row.skill.name) ? '◼' : '◻'} ${row.skill.name.padEnd(width)} ${row.skill.reason ?? ''}${row.skill.tested === false ? ' · no probada' : ''}`.trimEnd()
|
|
244
|
+
}));
|
|
245
|
+
const current = rows[model.cursor]?.skill;
|
|
246
|
+
const external = state.externalSkills?.length
|
|
247
|
+
? ['', ...wrapText(`Se instalan aparte: ${state.externalSkills.map(ref => ref.name).join(', ')}`, 60)]
|
|
248
|
+
: [];
|
|
249
|
+
const missing = state.uncoveredTech?.length
|
|
250
|
+
? ['', ...wrapText(`Sin skill en el catálogo: ${joinNames(state.uncoveredTech.map(item => item.label))}. Para buscar una, pide a tu IA «busca una skill para ${state.uncoveredTech[0].label}».`, 60)]
|
|
251
|
+
: [];
|
|
252
|
+
const info = [...(model.info && current ? ['', ...wrapText(current.summary, 60), ...skillTrust(current)] : []), ...external, ...missing];
|
|
253
|
+
return { title: '¿Qué skills quieres?', body, info, keys: 'espacio marcar · i qué hace · Enter seguir · q salir' };
|
|
254
|
+
}
|
|
255
|
+
case 'memory': {
|
|
256
|
+
const labels = {
|
|
257
|
+
existing: `Usar la memoria existente en ${shortPath(state.memory.existing?.vaultPath ?? '', home)}`,
|
|
258
|
+
suggested: `Sí, en ${shortPath(state.memory.suggestedPath, home)}`,
|
|
259
|
+
other: 'Sí, en otra carpeta…',
|
|
260
|
+
later: 'Ahora no'
|
|
261
|
+
};
|
|
262
|
+
const options = memoryOptions(model);
|
|
263
|
+
const body = options.map((option, index) => ({ cursor: index === model.cursor, text: `${index === model.cursor ? '●' : '○'} ${labels[option]}` }));
|
|
264
|
+
const info = state.project.git
|
|
265
|
+
? ['', 'Guarda decisiones, arquitectura y entregas.', 'Se abre en Obsidian y la leen tus IA.']
|
|
266
|
+
: ['', 'La memoria necesita un repositorio Git; ejecuta `git init` para activarla.'];
|
|
267
|
+
return { title: state.memory.existing ? '¿Usar la memoria del proyecto?' : '¿Crear la memoria del proyecto?', body, info, keys: 'Enter seguir · ← volver · q salir' };
|
|
268
|
+
}
|
|
269
|
+
case 'memory-path':
|
|
270
|
+
return {
|
|
271
|
+
title: '¿En qué carpeta?', body: [{ cursor: true, text: `${model.memoryInput}▏` }],
|
|
272
|
+
info: model.memoryError ? ['', model.memoryError] : ['', 'Una carpeta vacía o que aún no existe, o una memoria tuya.'],
|
|
273
|
+
keys: 'Enter seguir · Esc volver'
|
|
274
|
+
};
|
|
275
|
+
case 'agents': {
|
|
276
|
+
const body = AGENTS.map((agent, index) => ({
|
|
277
|
+
cursor: index === model.cursor,
|
|
278
|
+
text: `${answers.agents.includes(agent) ? '◼' : '◻'} ${AGENT_NAMES[agent].padEnd(12)} ${state.agentsFound[agent] ? 'encontrado' : 'no encontrado'}`
|
|
279
|
+
}));
|
|
280
|
+
const info = [];
|
|
281
|
+
if (answers.agents.length >= 2) info.push('', 'Desde cualquiera puedes pedir revisión a otra.');
|
|
282
|
+
const unchecked = (state.savedAnswers?.agents ?? []).filter(agent => !answers.agents.includes(agent));
|
|
283
|
+
if (unchecked.length) info.push('', 'Desmarcar no desconecta nada; para quitarlo usa `npx nexakit quitar`.');
|
|
284
|
+
if (model.message) info.push('', model.message);
|
|
285
|
+
return { title: '¿Con qué IA trabajas?', body, info, keys: 'espacio marcar · Enter seguir · ← volver · q salir' };
|
|
286
|
+
}
|
|
287
|
+
case 'confirm': {
|
|
288
|
+
const confirmation = buildConfirmation(state, answers);
|
|
289
|
+
if (!confirmation.blocks.length) {
|
|
290
|
+
return { title: 'No hay nada que preparar', body: confirmation.lines.map(line => ({ text: ` ${line.mark} ${line.text}` })), info: [], keys: 'Enter salir · ← volver' };
|
|
291
|
+
}
|
|
292
|
+
const body = model.detail ? detailLines(model, confirmation) : confirmation.lines.map(line => ({ text: ` ${line.mark} ${line.text}` }));
|
|
293
|
+
const info = ['', 'No toco archivos tuyos. Se puede deshacer con', '`npx nexakit quitar`.', '',
|
|
294
|
+
`¿Lo preparo? ${model.confirmYes ? '● Sí / ○ No' : '○ Sí / ● No'}`];
|
|
295
|
+
return { title: 'Voy a preparar:', body, info, keys: 'Enter elegir · ←/→ cambiar · d detalle · Retroceso volver' };
|
|
296
|
+
}
|
|
297
|
+
default:
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function detailLines(model, confirmation) {
|
|
303
|
+
const { state, answers } = model;
|
|
304
|
+
const home = state.homeDirectory;
|
|
305
|
+
const lines = [`Proyecto: ${shortPath(state.project.root, home)}`, `nexakit: ${shortPath(state.internal?.linkRoot ?? '', home)}`];
|
|
306
|
+
for (const name of answers.skills.chosen) {
|
|
307
|
+
const skill = state.skills.find(entry => entry.name === name);
|
|
308
|
+
lines.push(`skill ${name}${skill?.license ? ` (${skill.license})` : ''}: .claude ${skill?.links.claude} · .agents ${skill?.links.codex}`);
|
|
309
|
+
}
|
|
310
|
+
if (answers.memory.decision === 'path') lines.push(`memoria: ${shortPath(answers.memory.path, home)}`);
|
|
311
|
+
lines.push(`bloques: ${confirmation.blocks.map(block => block.id).join(', ')}`);
|
|
312
|
+
return lines.map(text => ({ text: ` ${text}` }));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function finalLines(model) {
|
|
316
|
+
const { state, answers, result } = model;
|
|
317
|
+
const lines = model.progress.map(item => `${item.phase === 'failed' ? '✗' : item.phase === 'done' ? '◇' : '◆'} ${item.label}${item.phase === 'start' ? '…' : ''}`);
|
|
318
|
+
if (result?.finalCheck) lines.push(`${result.finalCheck.ok ? '◇' : '!'} Comprobación final: ${result.finalCheck.message}`);
|
|
319
|
+
if (model.step !== 'done') return { lines, end: [] };
|
|
320
|
+
if (!result.ok) {
|
|
321
|
+
const reason = result.failed?.message ?? `Comprobación final: ${result.finalCheck?.message ?? ''}`;
|
|
322
|
+
return { lines, end: [result.failed ? 'Se detuvo aquí.' : 'Terminó, pero algo no quedó listo.', ...wrapText(reason, 60)] };
|
|
323
|
+
}
|
|
324
|
+
const names = joinNames(answers.agents.map(agent => AGENT_NAMES[agent]));
|
|
325
|
+
const end = ['Listo. Ahora:', ` 1. Cierra y abre ${names}`];
|
|
326
|
+
if (answers.memory.decision === 'path') {
|
|
327
|
+
end.push(' 2. Pídele: «lee la memoria y resume el proyecto»',
|
|
328
|
+
'Abre la memoria en Obsidian: «Abrir carpeta como bóveda»', `→ ${shortPath(answers.memory.path, state.homeDirectory)}`);
|
|
329
|
+
} else {
|
|
330
|
+
end.push(' 2. Pídele lo que necesites: las skills ya están en el proyecto');
|
|
331
|
+
}
|
|
332
|
+
return { lines, end };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function renderBody(model, { columns, level }) {
|
|
336
|
+
const c = paint(level);
|
|
337
|
+
const width = Math.max(30, columns - 1);
|
|
338
|
+
const out = [];
|
|
339
|
+
const push = (text, paint = value => value) => out.push(paint(fitLine(text, width)));
|
|
340
|
+
// Rows that carry a path or a sentence wrap instead of being cut; continuation lines keep the │ gutter.
|
|
341
|
+
const pushWrapped = (prefix, text, paint = value => value, continuation = `│${' '.repeat(prefix.length - 1)}`) => {
|
|
342
|
+
const lines = wrapText(text, Math.max(10, width - Math.max(cellWidth(prefix), cellWidth(continuation))));
|
|
343
|
+
lines.forEach((line, index) => out.push(paint(`${index === 0 ? prefix : continuation}${line}`)));
|
|
344
|
+
};
|
|
345
|
+
const { state } = model;
|
|
346
|
+
|
|
347
|
+
if (model.step === 'menu') {
|
|
348
|
+
push(`◇ Proyecto: ${state.project.displayName}`, c.dim);
|
|
349
|
+
push('│');
|
|
350
|
+
const ready = buildConfirmation(state, model.answers).lines.filter(line => line.mark === '✓').map(line => line.text);
|
|
351
|
+
push('◇ Todo está listo', c.green);
|
|
352
|
+
for (const text of ready) push(`│ ${text}`, c.dim);
|
|
353
|
+
push('│');
|
|
354
|
+
push('◆ ¿Quieres cambiar algo?', c.accent);
|
|
355
|
+
MENU.forEach(([, label], index) => push(`│ ${index === model.cursor ? '❯' : ' '} ${index === model.cursor ? '●' : '○'} ${label}`, index === model.cursor ? c.accent : undefined));
|
|
356
|
+
push('└ Enter elegir · q salir', c.dim);
|
|
357
|
+
return out.join('\n');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (['applying', 'done'].includes(model.step)) {
|
|
361
|
+
const agents = joinNames(model.answers.agents.map(agent => AGENT_NAMES[agent]));
|
|
362
|
+
const memory = model.answers.memory.decision === 'path' ? ' · memoria' : '';
|
|
363
|
+
push(`◇ ${state.project.displayName} · ${model.answers.skills.chosen.length} ${model.answers.skills.chosen.length === 1 ? 'skill' : 'skills'}${memory} · ${agents}`, c.dim);
|
|
364
|
+
push('│');
|
|
365
|
+
const { lines, end } = finalLines(model);
|
|
366
|
+
for (const line of lines) push(line, line.startsWith('✗') ? c.red : line.startsWith('◆') ? c.accent : undefined);
|
|
367
|
+
if (end.length) {
|
|
368
|
+
push('│');
|
|
369
|
+
push(`└ ${end[0]}`, model.result?.ok ? c.green : c.red);
|
|
370
|
+
for (const line of end.slice(1)) push(` ${line}`);
|
|
371
|
+
}
|
|
372
|
+
return out.join('\n');
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (model.step === 'detach-confirm' || model.step === 'detached') {
|
|
376
|
+
push(`◇ Proyecto: ${state.project.displayName}`, c.dim);
|
|
377
|
+
push('│');
|
|
378
|
+
const plan = model.detachPlan;
|
|
379
|
+
const removable = (plan?.items ?? []).filter(item => item.action !== 'conflict').length;
|
|
380
|
+
const kept = (plan?.items ?? []).filter(item => item.action === 'conflict').length;
|
|
381
|
+
if (model.step === 'detached') {
|
|
382
|
+
push('◇ nexakit quitado de este proyecto', c.green);
|
|
383
|
+
push(`└ Se quitaron ${removable} enlaces. La memoria y tus IA no se tocaron.`);
|
|
384
|
+
return out.join('\n');
|
|
385
|
+
}
|
|
386
|
+
push('◆ ¿Quitar nexakit de este proyecto?', c.accent);
|
|
387
|
+
push(`│ − ${removable} skills del proyecto y su bloque en .gitignore`);
|
|
388
|
+
if (kept) push(`│ ! ${kept} carpetas tuyas se quedan como están`);
|
|
389
|
+
push('│ La memoria y la configuración de tus IA no se tocan.');
|
|
390
|
+
push('│');
|
|
391
|
+
push(`│ ${model.confirmYes ? '● Sí / ○ No' : '○ Sí / ● No'}`);
|
|
392
|
+
push('└ Enter elegir · ←/→ cambiar', c.dim);
|
|
393
|
+
return out.join('\n');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
for (const line of folded(model)) {
|
|
397
|
+
if (level !== 'none' && line.startsWith('Detecté: ')) {
|
|
398
|
+
const labels = line.slice('Detecté: '.length).split(' · ');
|
|
399
|
+
if (cellWidth(`◇ Detecté: ${labels.map((label) => ` ${label} `).join(' ')}`) <= width) {
|
|
400
|
+
out.push(`${c.dim('◇ Detecté:')} ${labels.map((label) => (/^y \d+ más$/u.test(label) ? c.dim(label) : c.chip(label))).join(' ')}`);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
pushWrapped('◇ ', line, c.dim, ' ');
|
|
405
|
+
}
|
|
406
|
+
push('│');
|
|
407
|
+
const q = question(model);
|
|
408
|
+
push(`◆ ${q.title}`, c.accent);
|
|
409
|
+
for (const row of q.body) {
|
|
410
|
+
const text = row.text.startsWith(' ') ? row.text.slice(1) : row.text;
|
|
411
|
+
const marker = row.text.startsWith(' ') && /^ [+−↻!✓] /u.test(row.text) ? `│ ${text.slice(0, 2)}` : `│ ${row.cursor ? '❯' : ' '} `;
|
|
412
|
+
const shown = marker.startsWith('│ ') && /^[+−↻!✓] /u.test(text) ? text.slice(2) : text;
|
|
413
|
+
// A checkbox or radio row continues under its label, not under the box.
|
|
414
|
+
const continuation = /^[◼◻●○] /u.test(shown) ? `│${' '.repeat(marker.length + 1)}` : undefined;
|
|
415
|
+
pushWrapped(marker, shown, row.cursor ? c.accent : undefined, continuation);
|
|
416
|
+
}
|
|
417
|
+
for (const line of q.info) {
|
|
418
|
+
if (!line) push('│');
|
|
419
|
+
else pushWrapped('│ ', line, line.startsWith('¿Lo preparo') ? c.bold : c.dim);
|
|
420
|
+
}
|
|
421
|
+
pushWrapped('└ ', q.keys, c.dim, ' ');
|
|
422
|
+
return out.join('\n');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ── Loop ─────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
const STEP_NUMBER = Object.freeze({ skills: 1, memory: 2, 'memory-path': 2, agents: 3, confirm: 4 });
|
|
428
|
+
const STEP_COUNT = 4;
|
|
429
|
+
const TAGLINE = 'Tus skills. Tu memoria. Tus IA trabajando juntas.';
|
|
430
|
+
const TAGLINE_SHORT = 'Skills, memoria e IA, juntas.';
|
|
431
|
+
|
|
432
|
+
// El logo solo sale cuando hay color, ancho y alto de sobra; si no, queda la línea de siempre con el paso.
|
|
433
|
+
function renderHeader(model, { columns, rows, level, bodyRows }) {
|
|
434
|
+
const c = paint(level);
|
|
435
|
+
const width = Math.max(30, columns - 1);
|
|
436
|
+
const version = model.state.version ? ` v${model.state.version}` : '';
|
|
437
|
+
const number = model.flow === 'full' ? STEP_NUMBER[model.step] : undefined;
|
|
438
|
+
const steps = number ? `Paso ${number} de ${STEP_COUNT}` : '';
|
|
439
|
+
const dots = number ? Array.from({ length: STEP_COUNT }, (_, index) => (index < number ? '●' : '○')).join('') : '';
|
|
440
|
+
const celebrates = model.step === 'done' && model.result?.ok;
|
|
441
|
+
|
|
442
|
+
if (level !== 'none' && columns >= LOGO_WIDTH + 4 && (['skills', 'menu'].includes(model.step) || celebrates)) {
|
|
443
|
+
const tagline = fitLine(width - 2 >= cellWidth(TAGLINE) ? TAGLINE : TAGLINE_SHORT, width - 2);
|
|
444
|
+
const sub = celebrates ? `✓ Todo listo · ${version.trim()}` : `${version.trim()}${steps ? ` · ${steps} ${dots}` : ''}`;
|
|
445
|
+
const fitted = fitLine(sub, width - 2);
|
|
446
|
+
const painted = fitted === sub && steps && !celebrates
|
|
447
|
+
? `${c.dim(`${version.trim()} · ${steps} `)}${c.accent(dots.slice(0, number))}${c.dim(dots.slice(number))}`
|
|
448
|
+
: celebrates ? c.green(fitted) : c.dim(fitted);
|
|
449
|
+
const block = ['', ...renderLogo(level).map((line) => ` ${line}`), '', ` ${c.soft(tagline)}`, ` ${painted}`, ''];
|
|
450
|
+
if (!rows || block.length + bodyRows <= rows - 1) return block;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const plain = `◆ nexakit${version}${steps ? ` · ${steps} ${dots}` : ''}`;
|
|
454
|
+
if (cellWidth(plain) > width) return [c.bold(fitLine(plain, width))];
|
|
455
|
+
const tail = steps ? `${c.dim(` · ${steps} `)}${c.accent(dots.slice(0, number))}${c.dim(dots.slice(number))}` : '';
|
|
456
|
+
return [`${c.accent('◆ nexakit')}${c.dim(version)}${tail}`];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function renderSetupFrame(model, { columns = 80, rows, color = true } = {}) {
|
|
460
|
+
const level = levelOf(color);
|
|
461
|
+
const body = renderBody(model, { columns, level });
|
|
462
|
+
const header = renderHeader(model, { columns, rows, level, bodyRows: body.split('\n').length });
|
|
463
|
+
return `${header.join('\n')}\n${body}`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export async function runSetupTui({ input = process.stdin, output = process.stdout, options = {}, applyOptions = {} } = {}) {
|
|
467
|
+
const level = colorLevel(output);
|
|
468
|
+
const home = options.homeDirectory ?? os.homedir();
|
|
469
|
+
output.write('Revisando el proyecto…\n');
|
|
470
|
+
let model = createSetupModel(await gatherSetupState(options));
|
|
471
|
+
let exitCode = 0;
|
|
472
|
+
// The questions live on the alternate screen, where moving home and erasing redraws in place: clearing the
|
|
473
|
+
// normal screen pushes a copy of every frame into the scrollback in most terminals. On exit the last frame is
|
|
474
|
+
// printed once on the normal screen, so the result stays readable.
|
|
475
|
+
const frame = () => renderSetupFrame(model, { columns: output.columns || 80, rows: output.rows, color: level });
|
|
476
|
+
const render = () => output.write(`\x1b[H\x1b[J${frame()}\n`);
|
|
477
|
+
|
|
478
|
+
return new Promise(resolve => {
|
|
479
|
+
let closed = false;
|
|
480
|
+
let chain = Promise.resolve();
|
|
481
|
+
const cleanup = code => {
|
|
482
|
+
if (closed) return;
|
|
483
|
+
closed = true;
|
|
484
|
+
input.off?.('data', onData);
|
|
485
|
+
output.off?.('resize', render);
|
|
486
|
+
try { if (typeof input.setRawMode === 'function') input.setRawMode(false); } catch {}
|
|
487
|
+
try { input.pause?.(); } catch {}
|
|
488
|
+
output.write(`\x1b[?1049l${frame()}\n\x1b[?25h`);
|
|
489
|
+
resolve(code);
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const runEffect = async (effect, extra) => {
|
|
493
|
+
if (effect === 'exit') return cleanup(exitCode);
|
|
494
|
+
if (effect === 'check-path') {
|
|
495
|
+
const kind = await inspectMemoryPath(extra.path, { repositoryRoot: model.state.project.root });
|
|
496
|
+
const connections = ['memory', 'new'].includes(kind)
|
|
497
|
+
? await memoryConnections(extra.path, { homeDirectory: home, codexHome: model.state.internal.codexHome, runCommand: options.runCommand,
|
|
498
|
+
nodePath: model.state.internal.nodePath, toolkitRoot: model.state.internal.linkRoot, agentsFound: model.state.agentsFound,
|
|
499
|
+
kind, mcpName: `${model.state.project.id}-memory` })
|
|
500
|
+
: null;
|
|
501
|
+
model = applyMemoryPathCheck(model, extra.path, kind, connections);
|
|
502
|
+
} else if (effect === 'apply') {
|
|
503
|
+
const labels = Object.fromEntries(buildConfirmation(model.state, model.answers).blocks.map(block => [block.id, block.label]));
|
|
504
|
+
model = { ...model, step: 'applying', progress: [] };
|
|
505
|
+
render();
|
|
506
|
+
const result = await applySetup(options, model.answers, {
|
|
507
|
+
...applyOptions,
|
|
508
|
+
onProgress: (id, phase) => {
|
|
509
|
+
const progress = model.progress.filter(item => item.id !== id);
|
|
510
|
+
model = { ...model, progress: [...progress, { id, label: labels[id] ?? id, phase }] };
|
|
511
|
+
render();
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
exitCode = result.ok ? 0 : 1;
|
|
515
|
+
model = { ...model, step: 'done', result };
|
|
516
|
+
} else if (effect === 'detach-preview') {
|
|
517
|
+
const { catalog, toolkitRoot } = model.state.internal;
|
|
518
|
+
const plan = await planToolkitDetach({ projectRoot: model.state.project.root, catalog, toolkitRoot, homeDirectory: home });
|
|
519
|
+
model = { ...model, step: 'detach-confirm', detachPlan: plan, confirmYes: false };
|
|
520
|
+
} else if (effect === 'detach-apply') {
|
|
521
|
+
await applyToolkitDetach(model.detachPlan);
|
|
522
|
+
model = { ...model, step: 'detached' };
|
|
523
|
+
}
|
|
524
|
+
render();
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const handle = async key => {
|
|
528
|
+
if (closed) return;
|
|
529
|
+
const { model: next, effect, ...extra } = handleSetupKey(model, key);
|
|
530
|
+
model = next;
|
|
531
|
+
if (effect) await runEffect(effect, extra);
|
|
532
|
+
else render();
|
|
533
|
+
};
|
|
534
|
+
const onData = chunk => {
|
|
535
|
+
const key = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
536
|
+
if (key === '\x03' && model.step !== 'applying') return cleanup(exitCode);
|
|
537
|
+
chain = chain.then(() => handle(key)).catch(error => {
|
|
538
|
+
model = { ...model, step: 'done', result: { ok: false, failed: { message: error instanceof Error ? error.message : String(error) } } };
|
|
539
|
+
exitCode = 1;
|
|
540
|
+
render();
|
|
541
|
+
});
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
try {
|
|
545
|
+
output.write('\x1b[?1049h\x1b[?25l');
|
|
546
|
+
if (typeof input.setRawMode === 'function') input.setRawMode(true);
|
|
547
|
+
input.resume?.();
|
|
548
|
+
input.on('data', onData);
|
|
549
|
+
output.on?.('resize', render);
|
|
550
|
+
render();
|
|
551
|
+
} catch {
|
|
552
|
+
cleanup(1);
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { loadSkillCatalog, resolveCatalogResource, SkillCatalogError, validateSkillCatalog } from './skill-catalog.js';
|
|
5
|
+
import { auditSkillCatalog } from './skill-catalog-audit.js';
|
|
6
|
+
import { hashSkillFiles, readSkillFiles } from './skill-files.js';
|
|
7
|
+
import { scanSkillFiles } from './skill-scan.js';
|
|
8
|
+
|
|
9
|
+
// The judgment fields come from the reviewer; hashes, paths and scenarios are always computed here.
|
|
10
|
+
const JUDGMENT_FIELDS = ['name', 'summary', 'repository', 'sourcePath', 'revision', 'license', 'matching', 'overlapsWith', 'review'];
|
|
11
|
+
|
|
12
|
+
const today = () => {
|
|
13
|
+
const d = new Date();
|
|
14
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function diffFiles(before, after) {
|
|
18
|
+
const old = new Map(before.map(f => [f.path, f.sha256]));
|
|
19
|
+
const next = new Map(after.map(f => [f.path, f.sha256]));
|
|
20
|
+
return {
|
|
21
|
+
added: after.filter(f => !old.has(f.path)).map(f => f.path),
|
|
22
|
+
changed: after.filter(f => old.has(f.path) && old.get(f.path) !== f.sha256).map(f => f.path),
|
|
23
|
+
removed: before.filter(f => !next.has(f.path)).map(f => f.path)
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function addCatalogSkill({ toolkitRoot, sourceDir, entry, apply = false, audit = auditSkillCatalog }) {
|
|
28
|
+
const extra = Object.keys(entry ?? {}).filter(key => !JUDGMENT_FIELDS.includes(key));
|
|
29
|
+
if (extra.length) throw new SkillCatalogError(`La entrada no admite el campo ${extra[0]}: se calcula al registrar.`);
|
|
30
|
+
if (!/^[a-z0-9-]+$/u.test(entry.name ?? '')) throw new SkillCatalogError('El nombre de la skill solo admite minúsculas, números y guiones.');
|
|
31
|
+
if (!/^[0-9a-f]{40}$/u.test(entry.revision ?? '')) throw new SkillCatalogError('La revisión debe ser un commit de 40 caracteres hexadecimales.');
|
|
32
|
+
|
|
33
|
+
const sourceFiles = await readSkillFiles(path.resolve(sourceDir), sourceDir);
|
|
34
|
+
const scan = scanSkillFiles(sourceFiles);
|
|
35
|
+
if (scan.blocked) {
|
|
36
|
+
const first = scan.findings.find(f => f.severity === 'bloqueo');
|
|
37
|
+
throw new SkillCatalogError(`El escáner encontró un bloqueo (${first.rule} en ${first.file}:${first.line}); no se registra.`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const catalogPath = await resolveCatalogResource(toolkitRoot, 'catalogs/skills.json');
|
|
41
|
+
const catalog = structuredClone(await loadSkillCatalog({ catalogUrl: pathToFileURL(catalogPath) }));
|
|
42
|
+
const index = catalog.skills.findIndex(skill => skill.name === entry.name);
|
|
43
|
+
const previous = index >= 0 ? catalog.skills[index] : null;
|
|
44
|
+
const files = hashSkillFiles(sourceFiles);
|
|
45
|
+
const changes = diffFiles(previous?.files ?? [], files);
|
|
46
|
+
const unchanged = previous && !changes.added.length && !changes.changed.length && !changes.removed.length;
|
|
47
|
+
const installedPath = `catalogs/skills/${entry.name}`;
|
|
48
|
+
const next = {
|
|
49
|
+
...entry,
|
|
50
|
+
bundled: true,
|
|
51
|
+
installedPath,
|
|
52
|
+
files,
|
|
53
|
+
scenarios: unchanged ? previous.scenarios : []
|
|
54
|
+
};
|
|
55
|
+
if (index >= 0) catalog.skills[index] = next;
|
|
56
|
+
else catalog.skills.push(next);
|
|
57
|
+
catalog.updatedAt = today();
|
|
58
|
+
validateSkillCatalog(catalog);
|
|
59
|
+
|
|
60
|
+
const result = { action: previous ? 'update' : 'create', files: changes, scan, written: false };
|
|
61
|
+
if (!apply) return result;
|
|
62
|
+
|
|
63
|
+
const target = path.join(toolkitRoot, installedPath);
|
|
64
|
+
const catalogBefore = await fs.readFile(catalogPath);
|
|
65
|
+
const hadTarget = await fs.lstat(target).then(() => true, () => false);
|
|
66
|
+
// The backup lives at the toolkit root, outside catalogs/, so an interrupted run never ships it in the package.
|
|
67
|
+
const backupRoot = hadTarget ? await fs.mkdtemp(path.join(toolkitRoot, '.catalog-add-')) : null;
|
|
68
|
+
const backup = backupRoot && path.join(backupRoot, entry.name);
|
|
69
|
+
if (hadTarget) await fs.rename(target, backup);
|
|
70
|
+
try {
|
|
71
|
+
for (const file of sourceFiles) {
|
|
72
|
+
await fs.mkdir(path.dirname(path.join(target, file.path)), { recursive: true });
|
|
73
|
+
await fs.writeFile(path.join(target, file.path), file.bytes);
|
|
74
|
+
}
|
|
75
|
+
await fs.writeFile(catalogPath, JSON.stringify(catalog, null, 2) + '\n');
|
|
76
|
+
const report = await audit({ toolkitRoot });
|
|
77
|
+
if (!report.ok) throw new SkillCatalogError(`La validación del catálogo falló: ${report.errors.join(' ')}`);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// Each restore step runs even if an earlier one fails; the catalog first, and the original error wins.
|
|
80
|
+
const failures = [];
|
|
81
|
+
const attempt = async (label, step) => { try { await step(); } catch (cause) { failures.push(`${label}: ${cause.message}`); } };
|
|
82
|
+
await attempt('catálogo', () => fs.writeFile(catalogPath, catalogBefore));
|
|
83
|
+
await attempt('carpeta nueva', () => fs.rm(target, { recursive: true, force: true }));
|
|
84
|
+
if (hadTarget) await attempt('carpeta anterior', () => fs.rename(backup, target));
|
|
85
|
+
if (backupRoot && !failures.length) await attempt('respaldo', () => fs.rm(backupRoot, { recursive: true, force: true }));
|
|
86
|
+
if (failures.length) error.message += ` No se pudo restaurar todo (${failures.join('; ')})${backupRoot ? `; el respaldo sigue en ${backupRoot}` : ''}.`;
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
if (backupRoot) await fs.rm(backupRoot, { recursive: true, force: true });
|
|
90
|
+
return { ...result, written: true };
|
|
91
|
+
}
|