@kuyper/harness 0.1.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.
@@ -0,0 +1,361 @@
1
+ import { readFile, readdir, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { parseDocument } from 'yaml';
4
+ import { isTempPath } from './atomicWrite.js';
5
+ import { isValidCapabilityName, normalizeLineEndings } from './lock.js';
6
+ export class CapabilityValidationError extends Error {
7
+ issues;
8
+ constructor(issues) {
9
+ super(issues.map((i) => `${i.path}: ${i.message}`).join('\n'));
10
+ this.issues = issues;
11
+ this.name = 'CapabilityValidationError';
12
+ }
13
+ }
14
+ /** `undefined` quando o texto não começa com `---` na primeira linha. */
15
+ export function splitFrontmatter(normalizedText) {
16
+ const lines = normalizedText.split('\n');
17
+ if (lines[0] !== '---')
18
+ return undefined;
19
+ let closeIndex = -1;
20
+ for (let i = 1; i < lines.length; i++) {
21
+ if (lines[i] === '---') {
22
+ closeIndex = i;
23
+ break;
24
+ }
25
+ }
26
+ if (closeIndex === -1)
27
+ return undefined;
28
+ return {
29
+ yamlText: lines.slice(1, closeIndex).join('\n'),
30
+ body: lines.slice(closeIndex + 1).join('\n'),
31
+ };
32
+ }
33
+ export function parseFrontmatterYaml(yamlText) {
34
+ const doc = parseDocument(yamlText);
35
+ if (doc.errors.length > 0) {
36
+ return { error: doc.errors[0]?.message ?? 'YAML malformado' };
37
+ }
38
+ const data = doc.toJS() ?? {};
39
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) {
40
+ return { error: 'frontmatter não é um mapa' };
41
+ }
42
+ return { data: data };
43
+ }
44
+ /**
45
+ * Normalização de corpo para a **materialização da rule** (SPEC §3): linhas
46
+ * vazias no início e no fim removidas, exatamente um LF no final. Não se
47
+ * aplica a skills — o `SKILL.md` é copiado inteiro (§4/§6), só com LF e um
48
+ * único newline final.
49
+ */
50
+ export function normalizeRuleBody(body) {
51
+ const lines = body.split('\n');
52
+ let start = 0;
53
+ while (start < lines.length && lines[start].trim() === '')
54
+ start++;
55
+ let end = lines.length;
56
+ while (end > start && lines[end - 1].trim() === '')
57
+ end--;
58
+ const trimmed = lines.slice(start, end).join('\n');
59
+ return trimmed.length > 0 ? `${trimmed}\n` : '';
60
+ }
61
+ /** Só LF, com exatamente um newline final — usado para copiar o `SKILL.md` inteiro. */
62
+ export function normalizeWholeFile(text) {
63
+ const normalized = normalizeLineEndings(text);
64
+ return `${normalized.replace(/\n+$/u, '')}\n`;
65
+ }
66
+ const RULE_FRONTMATTER_KEYS = new Set(['title', 'replaces']);
67
+ function parseRuleFile(sourcePath, name, origin, raw) {
68
+ const normalized = normalizeLineEndings(raw);
69
+ const split = splitFrontmatter(normalized);
70
+ if (!split) {
71
+ return { path: sourcePath, message: 'frontmatter ausente ou malformado — o arquivo precisa começar com "---"' };
72
+ }
73
+ const parsed = parseFrontmatterYaml(split.yamlText);
74
+ if ('error' in parsed) {
75
+ return { path: sourcePath, message: `frontmatter inválido: ${parsed.error}` };
76
+ }
77
+ const unknown = Object.keys(parsed.data).filter((k) => !RULE_FRONTMATTER_KEYS.has(k));
78
+ if (unknown.length > 0) {
79
+ return { path: sourcePath, message: `campo desconhecido: ${unknown.join(', ')}` };
80
+ }
81
+ const title = parsed.data['title'];
82
+ if (typeof title !== 'string' || title.trim().length === 0 || title.includes('\n')) {
83
+ return { path: sourcePath, message: 'title ausente, vazio ou com múltiplas linhas' };
84
+ }
85
+ const replacesRaw = parsed.data['replaces'];
86
+ let replaces;
87
+ if (replacesRaw !== undefined) {
88
+ if (origin === 'core') {
89
+ return { path: sourcePath, message: 'replaces não é permitido em rule do core' };
90
+ }
91
+ if (typeof replacesRaw !== 'string' || !/^core:[a-z0-9]+(-[a-z0-9]+)*$/.test(replacesRaw)) {
92
+ return { path: sourcePath, message: `replaces inválido: ${JSON.stringify(replacesRaw)} (esperado core:<nome>)` };
93
+ }
94
+ replaces = replacesRaw;
95
+ }
96
+ const body = normalizeRuleBody(split.body);
97
+ if (body.length === 0) {
98
+ return { path: sourcePath, message: 'corpo vazio' };
99
+ }
100
+ return { name, origin, title, replaces, body, sourcePath };
101
+ }
102
+ /** Descobre `<raiz>/rules/*.md`, um nível, ignorando `.kuyper-tmp`. */
103
+ export async function discoverRules(rulesDir, origin) {
104
+ const items = [];
105
+ const issues = [];
106
+ let entries;
107
+ try {
108
+ entries = await readdir(rulesDir, { withFileTypes: true });
109
+ }
110
+ catch (err) {
111
+ if (err.code === 'ENOENT')
112
+ return { items, issues };
113
+ throw err;
114
+ }
115
+ for (const entry of entries) {
116
+ if (isTempPath(entry.name))
117
+ continue;
118
+ const fullPath = join(rulesDir, entry.name);
119
+ if (!entry.isFile() || !entry.name.endsWith('.md')) {
120
+ issues.push({ path: fullPath, message: 'estrutura inesperada em rules/ — só arquivos <nome>.md são aceitos' });
121
+ continue;
122
+ }
123
+ const name = entry.name.slice(0, -'.md'.length);
124
+ if (!isValidCapabilityName(name)) {
125
+ issues.push({ path: fullPath, message: `nome inválido: ${name}` });
126
+ continue;
127
+ }
128
+ const raw = await readFile(fullPath, 'utf8');
129
+ const result = parseRuleFile(fullPath, name, origin, raw);
130
+ if ('name' in result)
131
+ items.push(result);
132
+ else
133
+ issues.push(result);
134
+ }
135
+ return { items, issues };
136
+ }
137
+ const SKILL_FRONTMATTER_KEYS = new Set(['name', 'description', 'replaces']);
138
+ const SKILL_MD = 'SKILL.md';
139
+ const SCRIPTS_DIR = 'scripts';
140
+ function isBinary(buf) {
141
+ const scanLength = Math.min(buf.length, 8000);
142
+ for (let i = 0; i < scanLength; i++) {
143
+ if (buf[i] === 0)
144
+ return true;
145
+ }
146
+ return false;
147
+ }
148
+ async function collectScripts(scriptsDir, prefix, issues) {
149
+ const out = [];
150
+ let entries;
151
+ try {
152
+ entries = await readdir(scriptsDir, { withFileTypes: true });
153
+ }
154
+ catch (err) {
155
+ if (err.code === 'ENOENT')
156
+ return out;
157
+ throw err;
158
+ }
159
+ for (const entry of entries) {
160
+ if (isTempPath(entry.name))
161
+ continue;
162
+ const relPath = `${prefix}${entry.name}`;
163
+ const fullPath = join(scriptsDir, entry.name);
164
+ if (entry.isSymbolicLink()) {
165
+ issues.push({ path: fullPath, message: 'symlink não é permitido dentro de scripts/' });
166
+ continue;
167
+ }
168
+ if (entry.isDirectory()) {
169
+ out.push(...(await collectScripts(fullPath, `${relPath}/`, issues)));
170
+ continue;
171
+ }
172
+ if (!entry.isFile()) {
173
+ issues.push({ path: fullPath, message: 'tipo de arquivo não suportado em scripts/' });
174
+ continue;
175
+ }
176
+ const stats = await stat(fullPath);
177
+ const raw = await readFile(fullPath);
178
+ if (isBinary(raw)) {
179
+ issues.push({ path: fullPath, message: 'arquivo binário não é permitido em scripts/' });
180
+ continue;
181
+ }
182
+ const normalized = normalizeWholeFile(raw.toString('utf8'));
183
+ out.push({ relPath, content: Buffer.from(normalized, 'utf8'), mode: stats.mode & 0o777 });
184
+ }
185
+ return out;
186
+ }
187
+ async function parseSkillDir(sourceDir, name, origin) {
188
+ const issues = [];
189
+ let entries;
190
+ try {
191
+ entries = await readdir(sourceDir, { withFileTypes: true });
192
+ }
193
+ catch (err) {
194
+ if (err.code === 'ENOENT')
195
+ return [{ path: sourceDir, message: 'diretório da skill não existe' }];
196
+ throw err;
197
+ }
198
+ let hasSkillMd = false;
199
+ for (const entry of entries) {
200
+ if (isTempPath(entry.name))
201
+ continue;
202
+ if (entry.name === SKILL_MD && entry.isFile()) {
203
+ hasSkillMd = true;
204
+ continue;
205
+ }
206
+ if (entry.name === SCRIPTS_DIR && entry.isDirectory())
207
+ continue;
208
+ issues.push({
209
+ path: join(sourceDir, entry.name),
210
+ message: `estrutura inesperada na skill — só ${SKILL_MD} e ${SCRIPTS_DIR}/ são aceitos (sensível a maiúsculas)`,
211
+ });
212
+ }
213
+ if (!hasSkillMd) {
214
+ issues.push({ path: join(sourceDir, SKILL_MD), message: `${SKILL_MD} ausente` });
215
+ }
216
+ if (issues.length > 0)
217
+ return issues;
218
+ const skillMdPath = join(sourceDir, SKILL_MD);
219
+ const raw = await readFile(skillMdPath, 'utf8');
220
+ const normalized = normalizeLineEndings(raw);
221
+ const split = splitFrontmatter(normalized);
222
+ if (!split) {
223
+ return [{ path: skillMdPath, message: 'frontmatter ausente ou malformado — o arquivo precisa começar com "---"' }];
224
+ }
225
+ const parsed = parseFrontmatterYaml(split.yamlText);
226
+ if ('error' in parsed) {
227
+ return [{ path: skillMdPath, message: `frontmatter inválido: ${parsed.error}` }];
228
+ }
229
+ const unknown = Object.keys(parsed.data).filter((k) => !SKILL_FRONTMATTER_KEYS.has(k));
230
+ if (unknown.length > 0) {
231
+ return [{ path: skillMdPath, message: `campo desconhecido: ${unknown.join(', ')}` }];
232
+ }
233
+ const skillNameRaw = parsed.data['name'];
234
+ const skillIssues = [];
235
+ if (typeof skillNameRaw !== 'string' || skillNameRaw.length < 1 || skillNameRaw.length > 64) {
236
+ skillIssues.push({ path: skillMdPath, message: 'name ausente ou fora de 1–64 caracteres' });
237
+ }
238
+ else if (skillNameRaw !== name) {
239
+ skillIssues.push({ path: skillMdPath, message: `name (${skillNameRaw}) diverge do diretório (${name})` });
240
+ }
241
+ const descriptionRaw = parsed.data['description'];
242
+ if (typeof descriptionRaw !== 'string' ||
243
+ descriptionRaw.trim().length === 0 ||
244
+ descriptionRaw.length > 1024 ||
245
+ descriptionRaw.includes('\n')) {
246
+ skillIssues.push({ path: skillMdPath, message: 'description ausente, vazia, multi-linha ou maior que 1024 caracteres' });
247
+ }
248
+ const replacesRaw = parsed.data['replaces'];
249
+ let replaces;
250
+ if (replacesRaw !== undefined) {
251
+ if (origin === 'core') {
252
+ skillIssues.push({ path: skillMdPath, message: 'replaces não é permitido em skill do core' });
253
+ }
254
+ else if (typeof replacesRaw !== 'string' || !isValidCapabilityName(replacesRaw)) {
255
+ skillIssues.push({ path: skillMdPath, message: `replaces inválido: ${JSON.stringify(replacesRaw)} (esperado o nome puro de uma skill, sem prefixo core:)` });
256
+ }
257
+ else {
258
+ replaces = replacesRaw;
259
+ }
260
+ }
261
+ const bodyTrimmed = split.body
262
+ .split('\n')
263
+ .filter((l) => l.trim() !== '')
264
+ .join('');
265
+ if (bodyTrimmed.length === 0) {
266
+ skillIssues.push({ path: skillMdPath, message: 'corpo Markdown vazio' });
267
+ }
268
+ if (skillIssues.length > 0)
269
+ return skillIssues;
270
+ const scripts = await collectScripts(join(sourceDir, SCRIPTS_DIR), `${SCRIPTS_DIR}/`, issues);
271
+ if (issues.length > 0)
272
+ return issues;
273
+ return {
274
+ name,
275
+ origin,
276
+ skillName: skillNameRaw,
277
+ description: descriptionRaw,
278
+ replaces,
279
+ normalizedSkillMd: normalizeWholeFile(raw),
280
+ scripts,
281
+ sourceDir,
282
+ };
283
+ }
284
+ /** Descobre `<raiz>/skills/<nome>/`, um nível, ignorando `.kuyper-tmp`. */
285
+ export async function discoverSkills(skillsDir, origin) {
286
+ const items = [];
287
+ const issues = [];
288
+ let entries;
289
+ try {
290
+ entries = await readdir(skillsDir, { withFileTypes: true });
291
+ }
292
+ catch (err) {
293
+ if (err.code === 'ENOENT')
294
+ return { items, issues };
295
+ throw err;
296
+ }
297
+ for (const entry of entries) {
298
+ if (isTempPath(entry.name))
299
+ continue;
300
+ const fullPath = join(skillsDir, entry.name);
301
+ if (!entry.isDirectory()) {
302
+ issues.push({ path: fullPath, message: 'estrutura inesperada em skills/ — só diretórios <nome>/ são aceitos' });
303
+ continue;
304
+ }
305
+ if (!isValidCapabilityName(entry.name)) {
306
+ issues.push({ path: fullPath, message: `nome inválido: ${entry.name}` });
307
+ continue;
308
+ }
309
+ const result = await parseSkillDir(fullPath, entry.name, origin);
310
+ if (Array.isArray(result))
311
+ issues.push(...result);
312
+ else
313
+ items.push(result);
314
+ }
315
+ return { items, issues };
316
+ }
317
+ // ---------------------------------------------------------------------------
318
+ // Materialização das rules (SPEC §5)
319
+ // ---------------------------------------------------------------------------
320
+ const INSTRUCTION_HEADER = '<!-- gerado por @kuyper/harness — não editar -->';
321
+ /**
322
+ * Filtra as rules do core apontadas por `replaces:`, ordena core e projeto
323
+ * separadamente por nome (ASCII, sem locale) e concatena core primeiro.
324
+ */
325
+ export function compileInstructionFile(coreRules, projectRules) {
326
+ const replaced = new Set(projectRules.map((r) => r.replaces).filter((r) => r !== undefined).map((r) => r.slice('core:'.length)));
327
+ const effectiveCore = coreRules.filter((r) => !replaced.has(r.name)).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
328
+ const effectiveProject = [...projectRules].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
329
+ const sections = [...effectiveCore, ...effectiveProject].map((r) => `## ${r.title}\n\n${r.body}`);
330
+ return `${INSTRUCTION_HEADER}\n\n${sections.join('\n')}`;
331
+ }
332
+ // ---------------------------------------------------------------------------
333
+ // Lista de roteamento de skills (SPEC §7) — sem fonte, computada toda vez.
334
+ // ---------------------------------------------------------------------------
335
+ const SKILL_ROUTING_TITLE = 'Quais skills usar';
336
+ /**
337
+ * A primeira frase da `description` — não a `description` inteira. O campo
338
+ * existe pensando em quem decide invocar a skill sozinha ("o que faz e
339
+ * quando usar", SPEC §4); a lista de roteamento é uma linha por skill, então
340
+ * a cláusula de "quando usar" (a segunda frase, por convenção) sobra: já é o
341
+ * próprio propósito da lista.
342
+ */
343
+ function firstSentence(description) {
344
+ const match = /^[^.]*\.?/u.exec(description);
345
+ return (match?.[0] ?? description).trim();
346
+ }
347
+ /**
348
+ * Uma linha por skill ativa. Skill de projeto com `replaces: <nome>` tira a
349
+ * linha da skill `<nome>` do core desta lista — a skill em si continua sendo
350
+ * copiada para os providers (§6); só a recomendação muda. Sem fonte em disco,
351
+ * então a validação de referência (R9/R10) fica em `checkReferences`
352
+ * (`generate.ts`), que já cobre o mesmo caso para rule.
353
+ */
354
+ export function compileSkillRoutingSection(coreSkills, projectSkills) {
355
+ const replacedNames = new Set(projectSkills.map((s) => s.replaces).filter((r) => r !== undefined));
356
+ const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
357
+ const effectiveCore = coreSkills.filter((s) => !replacedNames.has(s.name)).sort(byName);
358
+ const effectiveProject = [...projectSkills].sort(byName);
359
+ const lines = [...effectiveCore, ...effectiveProject].map((s) => `- ${firstSentence(s.description)} → skill \`${s.name}\``);
360
+ return `## ${SKILL_ROUTING_TITLE}\n\n${lines.join('\n')}\n`;
361
+ }
@@ -0,0 +1,285 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { cp, lstat, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { writeFileAtomic } from './atomicWrite.js';
7
+ import { KuyperRefusal } from './errors.js';
8
+ import { generate } from './generate.js';
9
+ import { getCoreHooksPath, setCoreHooksPath } from './hooks.js';
10
+ import { isValidCapabilityName } from './lock.js';
11
+ const execFileAsync = promisify(execFile);
12
+ const PROJECT_COPY_PATHS = ['.kuyper', '.claude', '.agents', 'CLAUDE.md', 'AGENTS.md'];
13
+ async function exists(path) {
14
+ try {
15
+ await lstat(path);
16
+ return true;
17
+ }
18
+ catch (err) {
19
+ if (err.code === 'ENOENT')
20
+ return false;
21
+ throw err;
22
+ }
23
+ }
24
+ function usage(kind, detail) {
25
+ throw new KuyperRefusal({
26
+ headline: `Uso incompleto de kuyper ${kind}.`,
27
+ details: [detail],
28
+ route: [
29
+ `pnpm exec kuyper ${kind} create <nome> --from <arquivo|-> [--dry-run]`,
30
+ `pnpm exec kuyper ${kind} edit <nome> [--from <arquivo|->] [--dry-run]`,
31
+ `pnpm exec kuyper ${kind} delete <nome> [--dry-run]`,
32
+ ],
33
+ });
34
+ }
35
+ function parseArgs(kind, args) {
36
+ const positionals = [];
37
+ let from;
38
+ let dryRun = false;
39
+ for (let index = 0; index < args.length; index++) {
40
+ const value = args[index];
41
+ if (value === '--dry-run') {
42
+ if (dryRun)
43
+ usage(kind, '--dry-run foi informado mais de uma vez.');
44
+ dryRun = true;
45
+ continue;
46
+ }
47
+ if (value === '--from') {
48
+ if (from !== undefined)
49
+ usage(kind, '--from foi informado mais de uma vez.');
50
+ const next = args[index + 1];
51
+ if (next === undefined || next.startsWith('--'))
52
+ usage(kind, '--from exige um arquivo ou "-" para stdin.');
53
+ from = next;
54
+ index++;
55
+ continue;
56
+ }
57
+ if (value.startsWith('--'))
58
+ usage(kind, `Opção desconhecida: ${value}`);
59
+ positionals.push(value);
60
+ }
61
+ const [actionRaw, name, ...extra] = positionals;
62
+ if (actionRaw !== 'create' && actionRaw !== 'edit' && actionRaw !== 'delete') {
63
+ usage(kind, 'A ação precisa ser create, edit ou delete.');
64
+ }
65
+ if (name === undefined || extra.length > 0)
66
+ usage(kind, 'Informe exatamente um nome de capacidade.');
67
+ if (actionRaw === 'create' && from === undefined)
68
+ usage(kind, 'create exige --from <arquivo|->.');
69
+ if (actionRaw === 'delete' && from !== undefined)
70
+ usage(kind, 'delete não aceita --from.');
71
+ return { action: actionRaw, name, from, dryRun };
72
+ }
73
+ function sourceRelPath(kind, origin, name) {
74
+ return kind === 'rule'
75
+ ? join('.kuyper', origin, 'rules', `${name}.md`)
76
+ : join('.kuyper', origin, 'skills', name);
77
+ }
78
+ function canonicalFilePath(kind, sourcePath) {
79
+ return kind === 'rule' ? sourcePath : join(sourcePath, 'SKILL.md');
80
+ }
81
+ function refuseInvalidName(kind, name) {
82
+ const extra = kind === 'skill' ? ' e ter no máximo 64 caracteres' : '';
83
+ throw new KuyperRefusal({
84
+ code: 'R25',
85
+ headline: 'O nome da capacidade é inválido.',
86
+ details: [`recebido: ${name}`, `esperado: ^[a-z0-9]+(-[a-z0-9]+)*$${extra}`],
87
+ route: [`Escolha outro nome e rode kuyper ${kind} novamente.`],
88
+ });
89
+ }
90
+ function refuseCore(action, kind, name, code) {
91
+ const verb = action === 'create' ? 'já pertence' : 'pertence';
92
+ throw new KuyperRefusal({
93
+ code,
94
+ headline: `core:${name} ${verb} ao Harness.`,
95
+ details: [
96
+ action === 'create'
97
+ ? `Não é possível criar uma ${kind} do projeto com o mesmo nome.`
98
+ : `Não é possível ${action === 'edit' ? 'editar' : 'remover'} uma capacidade core.`,
99
+ ],
100
+ route: [
101
+ kind === 'rule'
102
+ ? 'Crie uma rule do projeto com outro nome e declare replaces: core:<nome> no frontmatter.'
103
+ : 'Crie uma skill do projeto com outro nome; para trocar a recomendação, declare replaces: <nome> no frontmatter.',
104
+ ],
105
+ });
106
+ }
107
+ function refuseProjectExists(kind, name) {
108
+ throw new KuyperRefusal({
109
+ code: 'R9',
110
+ headline: `A ${kind} ${name} já existe no projeto.`,
111
+ route: [`Use kuyper ${kind} edit ${name} para revalidar ou substituir o arquivo canônico.`],
112
+ });
113
+ }
114
+ function refuseMissing(kind, name) {
115
+ throw new KuyperRefusal({
116
+ code: 'R13',
117
+ headline: `A ${kind} ${name} não existe.`,
118
+ route: [`Crie com kuyper ${kind} create ${name} --from <arquivo|->.`],
119
+ });
120
+ }
121
+ async function copyIfPresent(from, to) {
122
+ if (!(await exists(from)))
123
+ return;
124
+ await cp(from, to, { recursive: true, preserveTimestamps: true });
125
+ }
126
+ async function createShadow(projectRoot) {
127
+ const shadow = await mkdtemp(join(tmpdir(), 'kuyper-capability-'));
128
+ try {
129
+ for (const relPath of PROJECT_COPY_PATHS) {
130
+ await copyIfPresent(join(projectRoot, relPath), join(shadow, relPath));
131
+ }
132
+ await execFileAsync('git', ['init', '-q'], { cwd: shadow });
133
+ const hooksPath = await getCoreHooksPath(projectRoot);
134
+ if (hooksPath !== undefined)
135
+ await setCoreHooksPath(shadow, hooksPath);
136
+ return shadow;
137
+ }
138
+ catch (err) {
139
+ await rm(shadow, { recursive: true, force: true });
140
+ throw err;
141
+ }
142
+ }
143
+ async function readInput(kind, parsed, projectRoot, options) {
144
+ if (parsed.from === undefined)
145
+ return undefined;
146
+ if (parsed.from === '-') {
147
+ if (options.readStdin === undefined)
148
+ usage(kind, '--from - exige conteúdo em stdin.');
149
+ return options.readStdin();
150
+ }
151
+ try {
152
+ return await readFile(resolve(projectRoot, parsed.from), 'utf8');
153
+ }
154
+ catch (err) {
155
+ if (err.code === 'ENOENT') {
156
+ throw new KuyperRefusal({
157
+ headline: 'O arquivo informado em --from não existe.',
158
+ details: [parsed.from],
159
+ route: [`Crie o arquivo canônico completo e rode kuyper ${kind} novamente.`],
160
+ });
161
+ }
162
+ throw err;
163
+ }
164
+ }
165
+ async function applyMutation(root, kind, action, name, input) {
166
+ const source = join(root, sourceRelPath(kind, 'project', name));
167
+ if (action === 'delete') {
168
+ await rm(source, { recursive: true, force: true });
169
+ return;
170
+ }
171
+ if (input !== undefined) {
172
+ await writeFileAtomic(canonicalFilePath(kind, source), input);
173
+ }
174
+ }
175
+ function affectedPaths(report) {
176
+ return [...new Set([...report.written, ...report.removed])].sort();
177
+ }
178
+ function remapShadowRefusal(err, shadow, projectRoot) {
179
+ const remap = (value) => value.split(shadow).join(projectRoot);
180
+ return new KuyperRefusal({
181
+ ...(err.opts.code !== undefined ? { code: err.opts.code } : {}),
182
+ headline: remap(err.opts.headline),
183
+ ...(err.opts.details !== undefined ? { details: err.opts.details.map(remap) } : {}),
184
+ ...(err.opts.route !== undefined ? { route: err.opts.route.map(remap) } : {}),
185
+ });
186
+ }
187
+ function formatReport(kind, parsed, report) {
188
+ const paths = affectedPaths(report);
189
+ const actionText = parsed.action === 'create' ? 'criada' : parsed.action === 'edit' ? 'editada' : 'removida';
190
+ const prefix = parsed.dryRun ? '○ dry-run:' : '✓ ';
191
+ const verb = parsed.dryRun ? `seria ${actionText}` : actionText;
192
+ const noChange = parsed.action === 'edit' && paths.length === 0;
193
+ const lines = [noChange ? `${prefix} ${kind} ${parsed.name}: nada mudou.` : `${prefix} ${kind} ${parsed.name} ${verb}.`];
194
+ if (paths.length > 0) {
195
+ lines.push('');
196
+ for (const path of paths)
197
+ lines.push(` ${path}`);
198
+ }
199
+ if (parsed.dryRun)
200
+ lines.push('', ' achados: nenhum; nenhum arquivo foi alterado.');
201
+ if (parsed.action === 'create' && kind === 'skill') {
202
+ lines.push('', ` Para usar: "use a skill ${parsed.name} para ..."`);
203
+ }
204
+ return lines.join('\n');
205
+ }
206
+ async function rollbackSource(actualSource, backup, existedBefore) {
207
+ await rm(actualSource, { recursive: true, force: true });
208
+ if (existedBefore) {
209
+ await mkdir(dirname(actualSource), { recursive: true });
210
+ await cp(backup, actualSource, { recursive: true, preserveTimestamps: true });
211
+ }
212
+ }
213
+ /**
214
+ * PRD §3.7 / SPEC formato-canônico §8. A mutação é primeiro aplicada numa
215
+ * cópia de disco e passa pelo `generate` real. Só depois desse preflight a
216
+ * fonte verdadeira muda; o comando não mantém uma segunda implementação das
217
+ * validações de R7–R10/R25.
218
+ */
219
+ export async function runCapabilityCommand(kind, args, options = {}) {
220
+ const projectRoot = options.projectRoot ?? process.cwd();
221
+ const parsed = parseArgs(kind, args);
222
+ if (!isValidCapabilityName(parsed.name) || (kind === 'skill' && parsed.name.length > 64)) {
223
+ refuseInvalidName(kind, parsed.name);
224
+ }
225
+ const coreSource = join(projectRoot, sourceRelPath(kind, 'core', parsed.name));
226
+ const projectSource = join(projectRoot, sourceRelPath(kind, 'project', parsed.name));
227
+ const [coreExists, projectExists] = await Promise.all([exists(coreSource), exists(projectSource)]);
228
+ if (parsed.action === 'create') {
229
+ if (coreExists)
230
+ refuseCore(parsed.action, kind, parsed.name, 'R12');
231
+ if (projectExists)
232
+ refuseProjectExists(kind, parsed.name);
233
+ }
234
+ else {
235
+ if (!projectExists && coreExists)
236
+ refuseCore(parsed.action, kind, parsed.name, 'R11');
237
+ if (!projectExists)
238
+ refuseMissing(kind, parsed.name);
239
+ }
240
+ const input = await readInput(kind, parsed, projectRoot, options);
241
+ const shadow = await createShadow(projectRoot);
242
+ const backup = join(shadow, '.source-backup');
243
+ const print = options.print ?? console.log;
244
+ try {
245
+ await applyMutation(shadow, kind, parsed.action, parsed.name, input);
246
+ let preview;
247
+ try {
248
+ preview = await generate({
249
+ projectRoot: shadow,
250
+ ...(options.packageCoreDir !== undefined ? { packageCoreDir: options.packageCoreDir } : {}),
251
+ silent: true,
252
+ });
253
+ }
254
+ catch (err) {
255
+ if (err instanceof KuyperRefusal)
256
+ throw remapShadowRefusal(err, shadow, projectRoot);
257
+ throw err;
258
+ }
259
+ if (parsed.dryRun) {
260
+ print(formatReport(kind, parsed, preview));
261
+ return 0;
262
+ }
263
+ const existedBefore = await exists(projectSource);
264
+ if (existedBefore)
265
+ await cp(projectSource, backup, { recursive: true, preserveTimestamps: true });
266
+ await applyMutation(projectRoot, kind, parsed.action, parsed.name, input);
267
+ let report;
268
+ try {
269
+ report = await generate({
270
+ projectRoot,
271
+ ...(options.packageCoreDir !== undefined ? { packageCoreDir: options.packageCoreDir } : {}),
272
+ silent: true,
273
+ });
274
+ }
275
+ catch (err) {
276
+ await rollbackSource(projectSource, backup, existedBefore);
277
+ throw err;
278
+ }
279
+ print(formatReport(kind, parsed, report));
280
+ return 0;
281
+ }
282
+ finally {
283
+ await rm(shadow, { recursive: true, force: true });
284
+ }
285
+ }