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
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Never calls a skill "safe": the scan only reports what its rules saw.
|
|
2
|
+
export function formatScanReport(report, label) {
|
|
3
|
+
const count = severity => report.findings.filter(f => f.severity === severity).length;
|
|
4
|
+
const blocks = count('bloqueo');
|
|
5
|
+
const warnings = count('aviso');
|
|
6
|
+
const lines = [blocks
|
|
7
|
+
? `Escaneo de ${label}: BLOQUEADA (${blocks} bloqueos, ${warnings} avisos)`
|
|
8
|
+
: `Escaneo de ${label}: sin bloqueos; ${warnings} avisos`];
|
|
9
|
+
for (const f of report.findings) lines.push(` [${f.severity}] ${f.rule} · ${f.file}:${f.line} · ${f.excerpt}`);
|
|
10
|
+
const caps = Object.entries(report.capabilities).filter(([, on]) => on).map(([name]) => name);
|
|
11
|
+
lines.push(`Capacidades detectadas: ${caps.length ? caps.join(', ') : 'ninguna'}`);
|
|
12
|
+
lines.push('El escaneo no ejecuta la skill ni garantiza que sea útil o segura.');
|
|
13
|
+
return lines.join('\n') + '\n';
|
|
14
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readSkillFiles } from './skill-files.js';
|
|
3
|
+
import { SECRET_PATTERNS } from './sensitive-data.js';
|
|
4
|
+
|
|
5
|
+
// Offline content review of a skill. Reads bytes only: nothing here executes, imports or fetches the skill.
|
|
6
|
+
|
|
7
|
+
const SCRIPT_EXTENSIONS = new Set(['.sh', '.bash', '.zsh', '.js', '.mjs', '.cjs', '.ts', '.py', '.rb', '.ps1']);
|
|
8
|
+
const SEVERITY = {
|
|
9
|
+
'unicode-oculto': 'bloqueo', credencial: 'bloqueo', 'descarga-y-ejecuta': 'bloqueo', exfiltracion: 'bloqueo',
|
|
10
|
+
'instruccion-oculta': 'aviso', inyeccion: 'aviso', 'comando-peligroso': 'aviso', ofuscacion: 'aviso',
|
|
11
|
+
red: 'info'
|
|
12
|
+
};
|
|
13
|
+
// Lines longer than this skip the rules with open-ended wildcards: a crafted 512 KiB line must not hang the scan.
|
|
14
|
+
const MAX_RULE_LINE = 4096;
|
|
15
|
+
|
|
16
|
+
// Zero width, bidirectional controls, tag characters, Arabic letter mark, Mongolian vowel separator,
|
|
17
|
+
// Hangul fillers and variation selectors supplement. U+200D and U+FEFF have their own rules below.
|
|
18
|
+
const HIDDEN_CLASS = String.raw`---ᅟᅠㅤᅠ\u{E0000}-\u{E007F}\u{E0100}-\u{E01EF}`;
|
|
19
|
+
const HIDDEN = new RegExp(`[${HIDDEN_CLASS}]`, 'u');
|
|
20
|
+
const SHOWN_AS_CODEPOINT = new RegExp(`[${HIDDEN_CLASS}\\u200D\\uFEFF]`, 'gu');
|
|
21
|
+
const PICTO = /\p{Extended_Pictographic}/u;
|
|
22
|
+
const EMOJI_TAIL = /[️\u{1F3FB}-\u{1F3FF}]/u;
|
|
23
|
+
|
|
24
|
+
// Only concrete secret formats: the generic "password: ..." assignment fires on every code example in a skill.
|
|
25
|
+
const CREDENTIAL_PATTERNS = SECRET_PATTERNS.filter(({ name }) => name !== 'asignación sensible').map(({ pattern }) => pattern);
|
|
26
|
+
const URL_WITH_PASSWORD = /https?:\/\/[^\s/:@]{1,200}:[^\s/@]{1,200}@/i;
|
|
27
|
+
|
|
28
|
+
const FETCHER = String.raw`(?:curl|wget|iwr|irm|Invoke-WebRequest)`;
|
|
29
|
+
const INTERPRETER = String.raw`(?:(?:sudo(?:\s+-\S+){0,5}\s+)?(?:env\s+)?(?:(?:ba|z|da)?sh|python3?|node|perl|ruby|iex|Invoke-Expression)\b)`;
|
|
30
|
+
const DOWNLOAD_EXEC = [
|
|
31
|
+
new RegExp(String.raw`\b${FETCHER}\b[^\n]{0,400}\|\s*${INTERPRETER}`, 'i'),
|
|
32
|
+
new RegExp(String.raw`\bbase64\s+(?:-d|--decode|-D)\b[^\n]{0,400}\|\s*${INTERPRETER}`, 'i'),
|
|
33
|
+
new RegExp(String.raw`\b(?:(?:ba|z|da)?sh|python3?|node|perl|ruby|eval|source|iex)\b[^\n]{0,200}(?:\$\(|<\(|\x60)\s*${FETCHER}\b`, 'i')
|
|
34
|
+
];
|
|
35
|
+
// Linear fallback for long lines: a fetcher plus a pipe into a shell or a fetch inside a substitution.
|
|
36
|
+
const LONG_FETCHER = new RegExp(String.raw`\b${FETCHER}\b`, 'i');
|
|
37
|
+
const LONG_EXEC = new RegExp(String.raw`\|\s*(?:sudo\s+)?(?:ba|z|da)?sh\b|(?:\$\(|<\()\s*${FETCHER}\b`, 'i');
|
|
38
|
+
|
|
39
|
+
const SENSITIVE_READ = /~\/\.ssh\b|\$HOME\/\.ssh\b|\bid_rsa\b|\bid_ed25519\b|~\/\.aws\b|\.aws\/credentials|(?:^|[\s"'/])\.env(?!\.(?:example|sample|template|dist)\b)\b|\bKeychain\b|security\s+find-generic-password|process\.env\b|os\.environ\b|\bprintenv\b/i;
|
|
40
|
+
const NETWORK_SEND = /\bcurl\b[^\n]{0,400}(?:\s-d\b|\s--data|\s-F\b|\s--form|\s-T\b|\s--upload-file)|\bwget\b[^\n]{0,400}--post-(?:data|file)|\bfetch\s*\([^\n]{0,400}method\s*:\s*['"]POST|\brequests\.(?:post|put)\s*\(|\b(?:nc|ncat|netcat)\s+[\w.-]{1,200}\s+\d+|\baxios\.post\s*\(/i;
|
|
41
|
+
const NETWORK = /\bcurl\b|\bwget\b|\bfetch\s*\(|\brequests\.\w+\s*\(|\burllib\b|\bhttps?\.request\s*\(|\baxios\b|\bInvoke-WebRequest\b|\b(?:nc|ncat|netcat)\s+\S{1,200}\s+\d+/i;
|
|
42
|
+
const INJECTION = /\bignore\s+(?:all\s+)?(?:the\s+)?(?:previous|prior|above)\s+instructions\b|\bdisregard\s+(?:all\s+)?(?:previous|prior|above)\b|\bdo\s+not\s+(?:tell|inform|mention\s+(?:this\s+)?to)\s+the\s+user\b|\bsystem\s+prompt\b|\bignora\s+(?:todas\s+)?las\s+instrucciones\s+(?:anteriores|previas)\b|\bno\s+se\s+lo\s+(?:digas|cuentes|menciones)\s+al\s+usuario\b|\bsin\s+que\s+el\s+usuario\s+lo\s+sepa\b/i;
|
|
43
|
+
const DANGEROUS = /\brm\s+-[a-z]*r[a-z]*f[a-z]*\s+(?:\/(?:\s|$|\*)|~(?:\/?\s|\/?$)|"?\$HOME"?\/?(?:\s|$))|\brm\s+-[a-z]*f[a-z]*r[a-z]*\s+(?:\/(?:\s|$|\*)|~(?:\/?\s|\/?$)|"?\$HOME"?\/?(?:\s|$))|\bsudo\s+\S|\bchmod\s+(?:-R\s+)?777\b|>>?\s*~\/\.(?:bashrc|zshrc|bash_profile|profile|zprofile)\b|~\/\.ssh\/authorized_keys|\bcrontab\s+(?:-\s|-e|\S+\.(?:txt|cron))|\blaunchctl\s+(?:load|bootstrap|submit)\b/i;
|
|
44
|
+
const BASE64_BLOB = /[A-Za-z0-9+/]{200,}={0,2}/;
|
|
45
|
+
const EVAL_DECODED = /\b(?:eval|exec|Function)\s*\(\s*(?:atob|Buffer\.from|base64\.b64decode|bytes\.fromhex|unescape|decodeURIComponent)\b/;
|
|
46
|
+
const PROCESS = /\bchild_process\b|\bexecSync\s*\(|\bspawn\s*\(|\bsubprocess\b|\bos\.system\s*\(/;
|
|
47
|
+
const FILE_WRITE = /\bwriteFile(?:Sync)?\s*\(|\bfs\.(?:rm|unlink|rename|mkdir)\w*\s*\(|\bopen\s*\([^)\n]{0,200}['"][wa]\+?['"]|\brm\s+-|>\s*[~/\w.]/;
|
|
48
|
+
const FENCE = /^\s{0,3}(?:```|~~~)/;
|
|
49
|
+
// Files a skill copies for the user (templates, examples) are material, not code the skill runs.
|
|
50
|
+
const MATERIAL_DIR = /^(?:assets|templates|examples?)\//;
|
|
51
|
+
const runsCode = filePath => SCRIPT_EXTENSIONS.has(path.extname(filePath).toLowerCase()) && !MATERIAL_DIR.test(filePath);
|
|
52
|
+
|
|
53
|
+
const codepoint = ch => `<U+${ch.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}>`;
|
|
54
|
+
const excerptOf = line => {
|
|
55
|
+
const trimmed = line.trim();
|
|
56
|
+
const shown = (trimmed.length > 2000 ? trimmed.slice(0, 2000) : trimmed).replace(SHOWN_AS_CODEPOINT, codepoint);
|
|
57
|
+
return shown.length > 120 ? `${shown.slice(0, 117)}...` : shown;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function hiddenUnicodeIn(line, isFirstLine) {
|
|
61
|
+
if (HIDDEN.test(line)) return true;
|
|
62
|
+
if (!line.includes('') && !line.includes('')) return false;
|
|
63
|
+
const chars = [...line];
|
|
64
|
+
for (let i = 0; i < chars.length; i++) {
|
|
65
|
+
if (chars[i] === '' && !(isFirstLine && i === 0)) return true;
|
|
66
|
+
if (chars[i] === '') {
|
|
67
|
+
// A ZWJ joins two emoji; skin tones and the emoji presentation selector may sit before it.
|
|
68
|
+
let before = i - 1;
|
|
69
|
+
while (before >= 0 && EMOJI_TAIL.test(chars[before])) before--;
|
|
70
|
+
if (!(PICTO.test(chars[before] ?? '') && PICTO.test(chars[i + 1] ?? ''))) return true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Joins shell continuation lines ("\" at the end) so a pipe on the next line is seen with its command.
|
|
77
|
+
function logicalLines(lines) {
|
|
78
|
+
const result = [];
|
|
79
|
+
let current = null;
|
|
80
|
+
lines.forEach((line, index) => {
|
|
81
|
+
const text = current ? `${current.text} ${line.trim()}` : line;
|
|
82
|
+
const start = current ? current.start : index + 1;
|
|
83
|
+
if (/\\\s*$/.test(line) && text.length < MAX_RULE_LINE) current = { start, text: text.replace(/\\\s*$/, '') };
|
|
84
|
+
else { result.push({ start, text }); current = null; }
|
|
85
|
+
});
|
|
86
|
+
if (current) result.push(current);
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function scanText(filePath, text, push, capabilities) {
|
|
91
|
+
const isScript = SCRIPT_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
|
92
|
+
const runs = runsCode(filePath);
|
|
93
|
+
const isMarkdown = /\.(?:md|markdown|mdx)$/i.test(filePath);
|
|
94
|
+
const lines = text.split(/\r?\n/);
|
|
95
|
+
// Exfiltration needs a sensitive read and a network send in the same unit: the file for scripts,
|
|
96
|
+
// a fenced code block for Markdown, and a single line for prose and data rows (CSV, JSON), so
|
|
97
|
+
// unrelated documentation does not block.
|
|
98
|
+
const sensitiveUnits = new Set();
|
|
99
|
+
const sendUnits = new Map();
|
|
100
|
+
let fence = 0;
|
|
101
|
+
let inFence = false;
|
|
102
|
+
let networkSeen = false;
|
|
103
|
+
let commentStart = 0;
|
|
104
|
+
let commentText = '';
|
|
105
|
+
lines.forEach((line, index) => {
|
|
106
|
+
const n = index + 1;
|
|
107
|
+
const hit = rule => push(rule, filePath, n, line);
|
|
108
|
+
const long = line.length > MAX_RULE_LINE;
|
|
109
|
+
const fenceLine = isMarkdown && FENCE.test(line);
|
|
110
|
+
if (fenceLine) { inFence = !inFence; if (inFence) fence++; }
|
|
111
|
+
const unit = isScript ? 'file' : isMarkdown && inFence ? `fence-${fence}` : `line-${n}`;
|
|
112
|
+
if (hiddenUnicodeIn(line, index === 0)) hit('unicode-oculto');
|
|
113
|
+
if (CREDENTIAL_PATTERNS.some(re => re.test(line)) || (!long && URL_WITH_PASSWORD.test(line))) hit('credencial');
|
|
114
|
+
if (long && LONG_FETCHER.test(line) && LONG_EXEC.test(line)) hit('descarga-y-ejecuta');
|
|
115
|
+
if (INJECTION.test(line)) hit('inyeccion');
|
|
116
|
+
if (DANGEROUS.test(line)) hit('comando-peligroso');
|
|
117
|
+
if (long || BASE64_BLOB.test(line) || EVAL_DECODED.test(line) || (isScript && line.length > 1000)) hit('ofuscacion');
|
|
118
|
+
if (SENSITIVE_READ.test(line)) sensitiveUnits.add(unit);
|
|
119
|
+
if (!long && NETWORK_SEND.test(line) && !sendUnits.has(unit)) sendUnits.set(unit, n);
|
|
120
|
+
// Capabilities come only from code the skill runs; examples in documentation are reported as `red` info.
|
|
121
|
+
if (NETWORK.test(line)) {
|
|
122
|
+
if (runs) capabilities.network = true;
|
|
123
|
+
if (!networkSeen) { networkSeen = true; hit('red'); }
|
|
124
|
+
}
|
|
125
|
+
if (runs && PROCESS.test(line)) capabilities.processes = true;
|
|
126
|
+
if (runs && !long && FILE_WRITE.test(line)) capabilities.filesystem = true;
|
|
127
|
+
// An HTML comment inside a fenced code block is shown to the reader, so it is not hidden.
|
|
128
|
+
if (isMarkdown && (!inFence || commentStart) && !fenceLine) {
|
|
129
|
+
let rest = line;
|
|
130
|
+
while (rest.length) {
|
|
131
|
+
if (!commentStart) {
|
|
132
|
+
const open = rest.indexOf('<!--');
|
|
133
|
+
if (open < 0) break;
|
|
134
|
+
commentStart = n;
|
|
135
|
+
commentText = '';
|
|
136
|
+
rest = rest.slice(open + 4);
|
|
137
|
+
}
|
|
138
|
+
const close = rest.indexOf('-->');
|
|
139
|
+
if (close < 0) { commentText += rest; break; }
|
|
140
|
+
commentText += rest.slice(0, close);
|
|
141
|
+
if (commentText.trim()) push('instruccion-oculta', filePath, commentStart, lines[commentStart - 1]);
|
|
142
|
+
commentStart = 0;
|
|
143
|
+
rest = rest.slice(close + 3);
|
|
144
|
+
}
|
|
145
|
+
if (commentStart && n !== commentStart) commentText += ' ';
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
for (const { start, text: logical } of logicalLines(lines)) {
|
|
149
|
+
if (logical.length <= MAX_RULE_LINE && DOWNLOAD_EXEC.some(re => re.test(logical))) push('descarga-y-ejecuta', filePath, start, lines[start - 1]);
|
|
150
|
+
}
|
|
151
|
+
if (commentStart && commentText.trim()) push('instruccion-oculta', filePath, commentStart, lines[commentStart - 1]);
|
|
152
|
+
const exfil = [...sendUnits].find(([unit]) => sensitiveUnits.has(unit));
|
|
153
|
+
if (exfil) push('exfiltracion', filePath, exfil[1], lines[exfil[1] - 1]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function scanSkillFiles(files) {
|
|
157
|
+
const findings = [];
|
|
158
|
+
const capabilities = { filesystem: false, processes: false, network: false };
|
|
159
|
+
const push = (rule, file, line, text) => findings.push({ rule, severity: SEVERITY[rule], file, line, excerpt: excerptOf(text) });
|
|
160
|
+
// ignoreBOM keeps a leading U+FEFF in the text, so a second one right after it is still caught.
|
|
161
|
+
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
162
|
+
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
163
|
+
let text;
|
|
164
|
+
try {
|
|
165
|
+
text = decoder.decode(file.bytes);
|
|
166
|
+
} catch {
|
|
167
|
+
findings.push({ rule: 'ofuscacion', severity: 'aviso', file: file.path, line: 1, excerpt: 'archivo binario o no UTF-8' });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (runsCode(file.path)) capabilities.processes = true;
|
|
171
|
+
scanText(file.path, text, push, capabilities);
|
|
172
|
+
}
|
|
173
|
+
return { blocked: findings.some(f => f.severity === 'bloqueo'), findings, capabilities };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function scanSkillDirectory(dir) {
|
|
177
|
+
return scanSkillFiles(await readSkillFiles(path.resolve(dir), dir));
|
|
178
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// ~/.nexakit/nexakit: a small launcher that runs nexakit from its active root with a fixed node, so skills and the
|
|
2
|
+
// person can call nexakit without npx and without nexakit on the PATH. It is rewritten only when its content
|
|
3
|
+
// changes, and a link or a folder in its place is never replaced.
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { IS_WINDOWS } from './platform.js';
|
|
7
|
+
import { nexakitDir } from './stable-install.js';
|
|
8
|
+
|
|
9
|
+
const shellQuote = value => `'${value.replaceAll("'", "'\\''")}'`;
|
|
10
|
+
// The second line marks the file as nexakit's: a file without it is the person's and is never replaced.
|
|
11
|
+
const MARK = 'nexakit: orden estable (se reescribe sola)';
|
|
12
|
+
// In a batch file % expands variables even inside quotes; %% is a literal %.
|
|
13
|
+
const batchPath = value => value.replaceAll('%', '%%');
|
|
14
|
+
|
|
15
|
+
export function stableCommandContent({ nodePath, toolkitRoot, windows = IS_WINDOWS }) {
|
|
16
|
+
return windows
|
|
17
|
+
? `@rem ${MARK}\r\n@"${batchPath(nodePath)}" "${batchPath(toolkitRoot)}\\bin\\nexakit.js" %*\r\n`
|
|
18
|
+
: `#!/bin/sh\n# ${MARK}\nexec ${shellQuote(nodePath)} ${shellQuote(path.join(toolkitRoot, 'bin', 'nexakit.js'))} "$@"\n`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function planStableCommand({ homeDirectory, nodePath, toolkitRoot, windows = IS_WINDOWS }) {
|
|
22
|
+
const target = path.join(nexakitDir(homeDirectory), windows ? 'nexakit.cmd' : 'nexakit');
|
|
23
|
+
const content = stableCommandContent({ nodePath, toolkitRoot, windows });
|
|
24
|
+
let state;
|
|
25
|
+
try {
|
|
26
|
+
const info = await fs.lstat(target);
|
|
27
|
+
const current = info.isFile() ? await fs.readFile(target, 'utf8') : null;
|
|
28
|
+
state = current === null || !current.includes(MARK) ? 'conflicto' : current === content ? 'igual' : 'distinto';
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error.code !== 'ENOENT') throw error;
|
|
31
|
+
state = 'falta';
|
|
32
|
+
}
|
|
33
|
+
return { path: target, content, state, windows };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function writeStableCommand(plan, lock) {
|
|
37
|
+
if (plan.state === 'igual' || plan.state === 'conflicto') return false;
|
|
38
|
+
await lock.assertHeld();
|
|
39
|
+
await fs.mkdir(path.dirname(plan.path), { recursive: true, mode: 0o700 });
|
|
40
|
+
const temporary = `${plan.path}.${process.pid}.tmp`;
|
|
41
|
+
await fs.writeFile(temporary, plan.content, { mode: 0o755, flag: 'wx' });
|
|
42
|
+
try {
|
|
43
|
+
await fs.chmod(temporary, 0o755);
|
|
44
|
+
await lock.assertHeld();
|
|
45
|
+
// Read again right before replacing: something that became a link or a folder meanwhile is left alone.
|
|
46
|
+
const current = await fs.lstat(plan.path).catch(error => (error.code === 'ENOENT' ? null : Promise.reject(error)));
|
|
47
|
+
if (current && (!current.isFile() || !(await fs.readFile(plan.path, 'utf8')).includes(MARK))) return false;
|
|
48
|
+
await fs.rename(temporary, plan.path);
|
|
49
|
+
return true;
|
|
50
|
+
} finally {
|
|
51
|
+
await fs.rm(temporary, { force: true });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { LockError } from './install-lock.js';
|
|
6
|
+
import { IS_WINDOWS } from './platform.js';
|
|
7
|
+
|
|
8
|
+
const PACKAGE_NAME = 'nexakit';
|
|
9
|
+
const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/u;
|
|
10
|
+
const COMPLETE_MARKER = '.completa';
|
|
11
|
+
const PROBED_IMPORTS = ['ajv', 'zod', '@modelcontextprotocol/sdk/client/index.js'];
|
|
12
|
+
const NPM_TIMEOUT_MS = 10 * 60 * 1000;
|
|
13
|
+
const PROBE_TIMEOUT_MS = 30 * 1000;
|
|
14
|
+
|
|
15
|
+
export class StableInstallError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'StableInstallError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const nexakitDir = homeDirectory => path.join(homeDirectory, '.nexakit');
|
|
23
|
+
export const actualRoot = homeDirectory => path.join(nexakitDir(homeDirectory), 'actual');
|
|
24
|
+
|
|
25
|
+
const lstatOrNull = file => fs.lstat(file).catch(error => {
|
|
26
|
+
if (error.code === 'ENOENT') return null;
|
|
27
|
+
throw error;
|
|
28
|
+
});
|
|
29
|
+
const realpathOrNull = file => fs.realpath(file).catch(error => {
|
|
30
|
+
if (['ENOENT', 'ENOTDIR', 'ELOOP'].includes(error.code)) return null;
|
|
31
|
+
throw error;
|
|
32
|
+
});
|
|
33
|
+
const samePath = (a, b) => (IS_WINDOWS ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
34
|
+
const firstLine = text => (String(text ?? '').split('\n').map(line => line.trim()).find(Boolean) ?? 'sin detalle').slice(0, 300);
|
|
35
|
+
|
|
36
|
+
export function compareVersions(a, b) {
|
|
37
|
+
const left = VERSION_RE.exec(a), right = VERSION_RE.exec(b);
|
|
38
|
+
if (!left || !right) return 0;
|
|
39
|
+
for (let index = 1; index <= 3; index++) {
|
|
40
|
+
const difference = Number(left[index]) - Number(right[index]);
|
|
41
|
+
if (difference !== 0) return difference > 0 ? 1 : -1;
|
|
42
|
+
}
|
|
43
|
+
// A release is newer than its own prerelease.
|
|
44
|
+
if (left[4] === right[4]) return 0;
|
|
45
|
+
if (left[4] === undefined) return 1;
|
|
46
|
+
if (right[4] === undefined) return -1;
|
|
47
|
+
return left[4] > right[4] ? 1 : -1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function readActualVersion(homeDirectory) {
|
|
51
|
+
try {
|
|
52
|
+
const manifest = JSON.parse(await fs.readFile(path.join(actualRoot(homeDirectory), 'package.json'), 'utf8'));
|
|
53
|
+
return manifest?.name === PACKAGE_NAME && typeof manifest.version === 'string' && VERSION_RE.test(manifest.version)
|
|
54
|
+
? manifest.version : null;
|
|
55
|
+
} catch { return null; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The npm that launched us (`npx` sets npm_execpath) runs as `node npm-cli.js`, so no .cmd shim is involved.
|
|
59
|
+
// Without it POSIX falls back to `npm` on the PATH; Windows has no safe fallback and reports unavailable (null).
|
|
60
|
+
export function npmInvocation(args, { environment = process.env, platform = process.platform } = {}) {
|
|
61
|
+
const script = environment.npm_execpath;
|
|
62
|
+
if (typeof script === 'string' && path.isAbsolute(script) && /\.[cm]?js$/iu.test(script)) {
|
|
63
|
+
return { command: environment.npm_node_execpath || process.execPath, args: [script, ...args] };
|
|
64
|
+
}
|
|
65
|
+
return platform === 'win32' ? null : { command: 'npm', args };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function defaultRunNpm(args) {
|
|
69
|
+
const invocation = npmInvocation(args);
|
|
70
|
+
if (!invocation) return { status: 1, stderr: 'No se encontró npm. Ejecuta nexakit con `npx nexakit`.' };
|
|
71
|
+
const result = spawnSync(invocation.command, invocation.args, {
|
|
72
|
+
encoding: 'utf8', shell: false, timeout: NPM_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024, windowsHide: true
|
|
73
|
+
});
|
|
74
|
+
return { status: result.status ?? 1, stderr: result.stderr || result.error?.message || '' };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function validatePackageRoot(root, version) {
|
|
78
|
+
const fail = reason => { throw new StableInstallError(`La copia de nexakit ${version} no es válida (${reason}): ${root}`); };
|
|
79
|
+
let manifest;
|
|
80
|
+
try { manifest = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8')); } catch { fail('falta package.json'); }
|
|
81
|
+
if (manifest?.name !== PACKAGE_NAME) fail('el paquete no es nexakit');
|
|
82
|
+
if (manifest.version !== version) fail(`trae la versión ${manifest.version}`);
|
|
83
|
+
|
|
84
|
+
const mcp = await lstatOrNull(path.join(root, 'bin/agent-toolkit-mcp.js'));
|
|
85
|
+
if (!mcp?.isFile()) fail('falta bin/agent-toolkit-mcp.js');
|
|
86
|
+
|
|
87
|
+
for (const folder of ['skills', 'catalogs/skills']) {
|
|
88
|
+
let entries;
|
|
89
|
+
try { entries = await fs.readdir(path.join(root, folder), { withFileTypes: true }); } catch { fail(`falta ${folder}/`); }
|
|
90
|
+
for (const entry of entries.filter(item => item.isDirectory())) {
|
|
91
|
+
const file = await lstatOrNull(path.join(root, folder, entry.name, 'SKILL.md'));
|
|
92
|
+
if (!file?.isFile()) fail(`la skill ${entry.name} no tiene SKILL.md`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Importing (not just resolving) catches a dependency that is present but cannot load.
|
|
97
|
+
const probe = spawnSync(process.execPath, ['--input-type=module', '-e', `for (const name of ${JSON.stringify(PROBED_IMPORTS)}) await import(name);`], {
|
|
98
|
+
cwd: root, encoding: 'utf8', timeout: PROBE_TIMEOUT_MS, windowsHide: true
|
|
99
|
+
});
|
|
100
|
+
if (probe.status !== 0) {
|
|
101
|
+
fail(`no se cargan sus dependencias: ${firstLine((probe.stderr ?? '').split('\n').find(line => /Error/u.test(line)))}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function ensureRealDirectory(directory) {
|
|
106
|
+
const info = await lstatOrNull(directory);
|
|
107
|
+
if (!info) {
|
|
108
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
112
|
+
throw new StableInstallError(`${directory} no es una carpeta normal; muévela o bórrala para continuar.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function ensurePointerSlot(actual) {
|
|
117
|
+
const info = await lstatOrNull(actual);
|
|
118
|
+
if (info && !info.isSymbolicLink()) {
|
|
119
|
+
throw new StableInstallError(`${actual} no es un enlace de nexakit; muévelo para continuar.`);
|
|
120
|
+
}
|
|
121
|
+
return info;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function publishVersion({ stagingDir, versionDir, version, packageSpec, runNpm, lock }) {
|
|
125
|
+
// We hold the lock, so anything left in the staging folder is from a run that died.
|
|
126
|
+
for (const entry of await fs.readdir(stagingDir)) await fs.rm(path.join(stagingDir, entry), { recursive: true, force: true });
|
|
127
|
+
const staging = path.join(stagingDir, `${version}-${crypto.randomUUID()}`);
|
|
128
|
+
try {
|
|
129
|
+
const result = await runNpm(['install', '--prefix', staging, '--no-audit', '--no-fund', packageSpec]);
|
|
130
|
+
if (result?.status !== 0) throw new StableInstallError(`npm no pudo instalar ${packageSpec}: ${firstLine(result?.stderr)}`);
|
|
131
|
+
await validatePackageRoot(path.join(staging, 'node_modules', PACKAGE_NAME), version);
|
|
132
|
+
await fs.writeFile(path.join(staging, COMPLETE_MARKER), `${new Date().toISOString()}\n`, { flag: 'wx', mode: 0o600 });
|
|
133
|
+
await lock.assertHeld();
|
|
134
|
+
await fs.rename(staging, versionDir);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Puts the link `temporary` where `actual` is. Renaming over the old link is atomic; Windows refuses that for a
|
|
142
|
+
// junction, so there the old pointer is moved aside first and put back if the second rename fails: the pointer
|
|
143
|
+
// can be missing for an instant there, but it is never lost.
|
|
144
|
+
export async function replacePointer(temporary, actual, { windows = IS_WINDOWS, rename = fs.rename } = {}) {
|
|
145
|
+
try {
|
|
146
|
+
await rename(temporary, actual);
|
|
147
|
+
return;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (!windows || !['EPERM', 'EEXIST', 'ENOTEMPTY'].includes(error.code)) throw error;
|
|
150
|
+
}
|
|
151
|
+
const aside = `${actual}.anterior.${crypto.randomUUID()}`;
|
|
152
|
+
await rename(actual, aside);
|
|
153
|
+
try {
|
|
154
|
+
await rename(temporary, actual);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
await rename(aside, actual).catch(() => {});
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
await fs.unlink(aside).catch(() => {});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function movePointer({ base, actual, packageRoot, lock }) {
|
|
163
|
+
const current = await ensurePointerSlot(actual);
|
|
164
|
+
if (current) {
|
|
165
|
+
const [real, wanted] = await Promise.all([realpathOrNull(actual), fs.realpath(packageRoot)]);
|
|
166
|
+
if (real && samePath(real, wanted)) return 'igual';
|
|
167
|
+
}
|
|
168
|
+
// Build the new link beside the old one and rename over it: the pointer never goes missing.
|
|
169
|
+
const temporary = path.join(base, `actual.${crypto.randomUUID()}`);
|
|
170
|
+
await fs.symlink(packageRoot, temporary, IS_WINDOWS ? 'junction' : 'dir');
|
|
171
|
+
try {
|
|
172
|
+
await lock.assertHeld();
|
|
173
|
+
await replacePointer(temporary, actual);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
await fs.unlink(temporary).catch(() => {});
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
return 'movido';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Leaves `~/.nexakit/actual` pointing at a complete, validated copy of this version (or keeps a newer one).
|
|
182
|
+
// Must run under the install lock; `runNpm(args)` returns `{ status, stderr }` and may be async.
|
|
183
|
+
export async function ensureStableInstall({
|
|
184
|
+
homeDirectory, version, lock, runNpm = defaultRunNpm,
|
|
185
|
+
packageSpec = process.env.NEXAKIT_PAQUETE || `nexakit@${version}`
|
|
186
|
+
}) {
|
|
187
|
+
if (typeof version !== 'string' || !VERSION_RE.test(version)) {
|
|
188
|
+
throw new StableInstallError(`Versión de nexakit no válida: ${String(version)}`);
|
|
189
|
+
}
|
|
190
|
+
const base = nexakitDir(homeDirectory);
|
|
191
|
+
const versionsDir = path.join(base, 'versiones');
|
|
192
|
+
const stagingDir = path.join(base, 'preparacion');
|
|
193
|
+
const actual = actualRoot(homeDirectory);
|
|
194
|
+
try {
|
|
195
|
+
await lock.assertHeld();
|
|
196
|
+
for (const directory of [base, versionsDir, stagingDir]) await ensureRealDirectory(directory);
|
|
197
|
+
await ensurePointerSlot(actual);
|
|
198
|
+
|
|
199
|
+
const activeVersion = await readActualVersion(homeDirectory);
|
|
200
|
+
if (activeVersion && compareVersions(activeVersion, version) > 0) {
|
|
201
|
+
return { root: actual, version: activeVersion, reused: true, pointer: 'mayor-conservado' };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const versionDir = path.join(versionsDir, version);
|
|
205
|
+
const packageRoot = path.join(versionDir, 'node_modules', PACKAGE_NAME);
|
|
206
|
+
let reused = false;
|
|
207
|
+
if (await lstatOrNull(path.join(versionDir, COMPLETE_MARKER))) {
|
|
208
|
+
await validatePackageRoot(packageRoot, version);
|
|
209
|
+
reused = true;
|
|
210
|
+
} else {
|
|
211
|
+
if (await lstatOrNull(versionDir)) {
|
|
212
|
+
throw new StableInstallError(`La carpeta ${versionDir} existe pero está incompleta; bórrala para reinstalar.`);
|
|
213
|
+
}
|
|
214
|
+
await publishVersion({ stagingDir, versionDir, version, packageSpec, runNpm, lock });
|
|
215
|
+
}
|
|
216
|
+
const pointer = await movePointer({ base, actual, packageRoot, lock });
|
|
217
|
+
return { root: actual, version, reused, pointer };
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (error instanceof StableInstallError || error instanceof LockError || error?.code === 'perdido') throw error;
|
|
220
|
+
throw new StableInstallError(`No se pudo preparar la copia estable de nexakit: ${error.message}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Superpowers (obra/superpowers, MIT) is required: it carries the work process (brainstorming, plans, debugging,
|
|
2
|
+
// TDD, verification). Each agent installs it from the marketplace reviewed for it; nexakit only lists, installs or
|
|
3
|
+
// enables it through the agent's own CLI and never edits plugin folders by hand.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { constants } from 'node:fs';
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { resolveCliInvocation } from './cli-executable.js';
|
|
9
|
+
import { IS_WINDOWS } from './platform.js';
|
|
10
|
+
|
|
11
|
+
export const SUPERPOWERS = Object.freeze({
|
|
12
|
+
claude: Object.freeze({
|
|
13
|
+
command: 'claude',
|
|
14
|
+
id: 'superpowers@claude-plugins-official',
|
|
15
|
+
list: ['plugin', 'list', '--json'],
|
|
16
|
+
install: ['plugin', 'install', 'superpowers@claude-plugins-official'],
|
|
17
|
+
enable: ['plugin', 'enable', 'superpowers@claude-plugins-official'],
|
|
18
|
+
}),
|
|
19
|
+
codex: Object.freeze({
|
|
20
|
+
command: 'codex',
|
|
21
|
+
id: 'superpowers@openai-curated-remote',
|
|
22
|
+
list: ['plugin', 'list'],
|
|
23
|
+
install: ['plugin', 'add', 'superpowers@openai-curated-remote'],
|
|
24
|
+
// `codex plugin` has no enable: a disabled plugin is turned on from Codex itself (/plugins).
|
|
25
|
+
enable: null,
|
|
26
|
+
}),
|
|
27
|
+
antigravity: Object.freeze({
|
|
28
|
+
command: 'agy',
|
|
29
|
+
id: 'superpowers@antigravity-plugins-partners',
|
|
30
|
+
list: ['plugin', 'list', '--json'],
|
|
31
|
+
install: ['plugin', 'install', 'superpowers@antigravity-plugins-partners'],
|
|
32
|
+
enable: ['plugin', 'enable', 'superpowers'],
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const TIMEOUT_MS = 120_000;
|
|
37
|
+
const AGENT_NAMES = { claude: 'Claude Code', codex: 'Codex', antigravity: 'Antigravity' };
|
|
38
|
+
|
|
39
|
+
// 'activo' | 'desactivado' | 'falta' | 'desconocido'. Anything that cannot be read is unknown, never missing,
|
|
40
|
+
// so nexakit does not try to install over a list it did not understand.
|
|
41
|
+
export function parseSuperpowersState(agent, result) {
|
|
42
|
+
if (!result || result.status !== 0) return 'desconocido';
|
|
43
|
+
const text = String(result.stdout ?? '');
|
|
44
|
+
if (agent === 'codex') {
|
|
45
|
+
const row = text.split(/\r?\n/u).find((line) => /^superpowers@\S+\s/u.test(line.trim()));
|
|
46
|
+
if (!row) return 'desconocido';
|
|
47
|
+
if (/\bnot installed\b/u.test(row)) return 'falta';
|
|
48
|
+
if (/\bdisabled\b/u.test(row)) return 'desactivado';
|
|
49
|
+
return /\benabled\b/u.test(row) ? 'activo' : 'desconocido';
|
|
50
|
+
}
|
|
51
|
+
if (agent === 'antigravity' && /^\s*No plugins installed\.?\s*$/iu.test(text)) return 'falta';
|
|
52
|
+
let list;
|
|
53
|
+
try {
|
|
54
|
+
list = JSON.parse(text);
|
|
55
|
+
} catch {
|
|
56
|
+
return 'desconocido';
|
|
57
|
+
}
|
|
58
|
+
if (!Array.isArray(list)) list = list?.plugins;
|
|
59
|
+
if (!Array.isArray(list)) return 'desconocido';
|
|
60
|
+
const entry = list.find((item) =>
|
|
61
|
+
agent === 'claude' ? item?.id === SUPERPOWERS.claude.id : item?.name === 'superpowers',
|
|
62
|
+
);
|
|
63
|
+
if (!entry) return 'falta';
|
|
64
|
+
return entry.enabled === false ? 'desactivado' : 'activo';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function spawnCommand(command, args) {
|
|
68
|
+
// `codex plugin list` prints every remote marketplace (about 2 MB), above the 1 MB default buffer.
|
|
69
|
+
const result = spawnSync(command, args, { encoding: 'utf8', shell: false, timeout: TIMEOUT_MS, maxBuffer: 32 * 1024 * 1024 });
|
|
70
|
+
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '', errorCode: result.error?.code };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const notFound = () => ({ status: null, stdout: '', stderr: '', errorCode: 'ENOENT' });
|
|
74
|
+
|
|
75
|
+
// The CLI is looked up in the given environment's PATH, not this process's, so a test HOME never reaches the
|
|
76
|
+
// person's real agents.
|
|
77
|
+
async function defaultRunner(command, environment = process.env) {
|
|
78
|
+
if (!IS_WINDOWS) {
|
|
79
|
+
for (const folder of (environment.PATH ?? '').split(path.delimiter).filter(Boolean)) {
|
|
80
|
+
const file = path.join(folder, command);
|
|
81
|
+
try {
|
|
82
|
+
await fs.access(file, constants.X_OK);
|
|
83
|
+
if ((await fs.stat(file)).isFile()) return (_command, args) => spawnCommand(file, args);
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
return notFound;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const invocation = await resolveCliInvocation({ name: command, environment });
|
|
90
|
+
return (_command, args) => spawnCommand(invocation.executable, [...invocation.argsPrefix, ...args]);
|
|
91
|
+
} catch {
|
|
92
|
+
return notFound;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function inspectSuperpowers({ agentsFound, runCommand, environment } = {}) {
|
|
97
|
+
const states = {};
|
|
98
|
+
for (const agent of Object.keys(SUPERPOWERS)) {
|
|
99
|
+
if (!agentsFound?.[agent]) continue;
|
|
100
|
+
const { command, list } = SUPERPOWERS[agent];
|
|
101
|
+
const run = runCommand ?? (await defaultRunner(command, environment));
|
|
102
|
+
let result;
|
|
103
|
+
try {
|
|
104
|
+
result = await run(command, list);
|
|
105
|
+
} catch {
|
|
106
|
+
result = null;
|
|
107
|
+
}
|
|
108
|
+
states[agent] = parseSuperpowersState(agent, result);
|
|
109
|
+
}
|
|
110
|
+
return states;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The command a person can run by hand, for messages.
|
|
114
|
+
export function superpowersCommand(agent, state) {
|
|
115
|
+
const entry = SUPERPOWERS[agent];
|
|
116
|
+
const args = state === 'desactivado' ? entry.enable : entry.install;
|
|
117
|
+
return args ? `${entry.command} ${args.join(' ')}` : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function applySuperpowers(agent, state, { runCommand, environment } = {}) {
|
|
121
|
+
const entry = SUPERPOWERS[agent];
|
|
122
|
+
if (!entry) throw new Error(`Agente desconocido: ${agent}`);
|
|
123
|
+
if (state !== 'falta' && state !== 'desactivado') return false;
|
|
124
|
+
const args = state === 'falta' ? entry.install : entry.enable;
|
|
125
|
+
if (!args) throw new Error(`Superpowers está desactivado en ${AGENT_NAMES[agent]}; actívalo desde ${AGENT_NAMES[agent]} (/plugins).`);
|
|
126
|
+
const run = runCommand ?? (await defaultRunner(entry.command, environment));
|
|
127
|
+
const result = await run(entry.command, args);
|
|
128
|
+
if (result?.status !== 0) {
|
|
129
|
+
const detail = String(result?.stderr || result?.stdout || result?.errorCode || '').trim();
|
|
130
|
+
throw new Error(`No se pudo ${state === 'falta' ? 'instalar' : 'activar'} Superpowers en ${AGENT_NAMES[agent]}.${detail ? ` ${detail}` : ''}`);
|
|
131
|
+
}
|
|
132
|
+
return true;
|
|
133
|
+
}
|
package/src/sync-apply.js
CHANGED
|
@@ -161,7 +161,7 @@ function writeNotes({ vault, notes, before, capture, report }, context) {
|
|
|
161
161
|
try {
|
|
162
162
|
check();
|
|
163
163
|
const fd = io.openSync(lockPath, "wx", 0o600);
|
|
164
|
-
try { lock = fs.fstatSync(fd); } finally { fs.closeSync(fd); }
|
|
164
|
+
try { lock = fs.fstatSync(fd, { bigint: true }); } finally { fs.closeSync(fd); }
|
|
165
165
|
artifacts.push({ path: lockPath, stat: lock }); ignored.push(relativeGitPath(vault, lockPath));
|
|
166
166
|
check();
|
|
167
167
|
const pending = notes.filter((n) => !n.satisfied), temps = new Map();
|
|
@@ -171,7 +171,7 @@ function writeNotes({ vault, notes, before, capture, report }, context) {
|
|
|
171
171
|
const temp = path.join(path.dirname(note.file.path), `.project-memory-sync-apply-${randomUUID()}.tmp`);
|
|
172
172
|
const fd = io.openSync(temp, "wx", 0o600); let stat;
|
|
173
173
|
try {
|
|
174
|
-
stat = fs.fstatSync(fd); artifacts.push({ path: temp, stat }); ignored.push(relativeGitPath(vault, temp));
|
|
174
|
+
stat = fs.fstatSync(fd, { bigint: true }); artifacts.push({ path: temp, stat }); ignored.push(relativeGitPath(vault, temp));
|
|
175
175
|
io.writeFileSync(fd, note.bytes); io.fchmodSync(fd, note.file.mode); io.fsyncSync(fd);
|
|
176
176
|
} finally { fs.closeSync(fd); }
|
|
177
177
|
assertOwnedArtifact(temp, stat);
|
|
@@ -186,9 +186,12 @@ function writeNotes({ vault, notes, before, capture, report }, context) {
|
|
|
186
186
|
if (prepared.hash !== note.afterHash || prepared.mode !== note.file.mode) rejectFile("temporary-content", "Un temporal cambió antes de reemplazar.");
|
|
187
187
|
let renameError;
|
|
188
188
|
try { io.renameSync(temp.path, note.file.path); } catch (error) { renameError = error; }
|
|
189
|
-
let result;
|
|
190
|
-
try {
|
|
191
|
-
|
|
189
|
+
let result, installedIdentity;
|
|
190
|
+
try {
|
|
191
|
+
installedIdentity = fs.lstatSync(note.file.path, { bigint: true });
|
|
192
|
+
result = readSyncFile(note.file.path, APPLY_LIMITS.note);
|
|
193
|
+
} catch { /* An unconfirmable outcome is conservative, never rolled back. */ }
|
|
194
|
+
const installed = result && installedIdentity.dev === temp.stat.dev && installedIdentity.ino === temp.stat.ino && result.hash === note.afterHash && result.mode === note.file.mode;
|
|
192
195
|
if (installed) {
|
|
193
196
|
applied.push(note.path);
|
|
194
197
|
expected.files[expected.files.findIndex((f) => f.path === note.file.path)] = fileState(result);
|
package/src/sync-files.js
CHANGED
|
@@ -102,7 +102,8 @@ export function assertSameState(before, after) {
|
|
|
102
102
|
}
|
|
103
103
|
export function assertOwnedArtifact(file, expected) {
|
|
104
104
|
assertPhysicalPath(file);
|
|
105
|
-
|
|
105
|
+
// Inodes on Windows may exceed Number.MAX_SAFE_INTEGER. Never round ownership.
|
|
106
|
+
const stat = fs.lstatSync(file, { bigint: true });
|
|
106
107
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.dev !== expected.dev || stat.ino !== expected.ino) rejectFile("artifact-conflict", "Un artefacto fue sustituido; no se tocará.");
|
|
107
108
|
}
|
|
108
109
|
function identity(stat) { return [stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeMs, stat.ctimeMs]; }
|