@tavaressan/vetor 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.
Files changed (78) hide show
  1. package/README.md +42 -0
  2. package/bin/vetor.js +6 -0
  3. package/lib/banner.js +35 -0
  4. package/lib/commands/install.js +71 -0
  5. package/lib/commands/status.js +59 -0
  6. package/lib/commands/uninstall.js +119 -0
  7. package/lib/commands/update.js +63 -0
  8. package/lib/installer/command-exists.js +30 -0
  9. package/lib/installer/cursor-hooks.js +181 -0
  10. package/lib/installer/detector.js +79 -0
  11. package/lib/installer/manifest.js +76 -0
  12. package/lib/installer/prompts.js +97 -0
  13. package/lib/installer/writer.js +382 -0
  14. package/lib/router.js +50 -0
  15. package/package.json +39 -0
  16. package/templates/.gitkeep +0 -0
  17. package/templates/agents/code-review/agent.json +27 -0
  18. package/templates/agents/code-review/codex.toml +37 -0
  19. package/templates/agents/code-review.md +99 -0
  20. package/templates/agents/issue-worker/agent.json +33 -0
  21. package/templates/agents/issue-worker/codex.toml +57 -0
  22. package/templates/agents/issue-worker.md +112 -0
  23. package/templates/hooks/hooks-codex.json +48 -0
  24. package/templates/hooks/hooks.json +62 -0
  25. package/templates/opencode/agent/code-review.md +73 -0
  26. package/templates/opencode/agent/issue-coordinator.md +521 -0
  27. package/templates/opencode/agent/issue-worker.md +64 -0
  28. package/templates/opencode/mcp.jsonc +39 -0
  29. package/templates/opencode/plugin/vetor.ts +207 -0
  30. package/templates/opencode/scripts/agent-registration_test.ts +92 -0
  31. package/templates/opencode/scripts/check-edit.ts +147 -0
  32. package/templates/opencode/scripts/ensure-external-directory-permission.ts +110 -0
  33. package/templates/opencode/scripts/ensure-external-directory-permission_test.ts +142 -0
  34. package/templates/opencode/scripts/lib/guard.ts +45 -0
  35. package/templates/opencode/scripts/lib/model-health.ts +133 -0
  36. package/templates/opencode/scripts/lib/model-health_test.ts +181 -0
  37. package/templates/opencode/scripts/lib/project.ts +240 -0
  38. package/templates/opencode/scripts/lib/project_test.ts +45 -0
  39. package/templates/opencode/scripts/lib/status.ts +69 -0
  40. package/templates/opencode/scripts/lib/worktree.ts +41 -0
  41. package/templates/opencode/scripts/model-health.ts +50 -0
  42. package/templates/opencode/scripts/model-health_test.ts +80 -0
  43. package/templates/opencode/scripts/resolve-model.ts +112 -0
  44. package/templates/opencode/scripts/resolve-model_test.ts +185 -0
  45. package/templates/opencode/scripts/safety-check.ts +203 -0
  46. package/templates/opencode/scripts/vetor-checks.sh +217 -0
  47. package/templates/opencode/scripts/vetor-status.sh +99 -0
  48. package/templates/skills/architecture-review/SKILL.md +187 -0
  49. package/templates/skills/backlog-ideator/SKILL.md +277 -0
  50. package/templates/skills/design/SKILL.md +468 -0
  51. package/templates/skills/design/examples/design-contract-example.md +46 -0
  52. package/templates/skills/design/examples/prototype-handoff-example.md +142 -0
  53. package/templates/skills/fix-loop-agent/SKILL.md +255 -0
  54. package/templates/skills/guardian/SKILL.md +343 -0
  55. package/templates/skills/issue-coordinator/SKILL.md +596 -0
  56. package/templates/skills/retro/SKILL.md +156 -0
  57. package/templates/skills/shared/references/agent-status.template.md +68 -0
  58. package/templates/skills/shared/references/codebase-design-vocabulary.md +54 -0
  59. package/templates/skills/shared/references/conflict-resolution.md +94 -0
  60. package/templates/skills/shared/references/delegate-to-runtime.md +239 -0
  61. package/templates/skills/shared/references/design-vocabulary.md +508 -0
  62. package/templates/skills/shared/references/evidence-state.md +365 -0
  63. package/templates/skills/shared/references/frontend-design-enforcement.md +33 -0
  64. package/templates/skills/shared/references/grilling-conventions.md +64 -0
  65. package/templates/skills/shared/references/knowledge-provider-contract.md +150 -0
  66. package/templates/skills/shared/references/mcp-availability.md +104 -0
  67. package/templates/skills/shared/references/module-test-map.template.md +72 -0
  68. package/templates/skills/shared/references/planning-conventions.md +97 -0
  69. package/templates/skills/shared/references/project-conventions.md +63 -0
  70. package/templates/skills/shared/references/tdd-conventions.md +81 -0
  71. package/templates/skills/shared/references/touched-files-cache.md +30 -0
  72. package/templates/skills/spec/SKILL.md +524 -0
  73. package/templates/skills/spec-validate/SKILL.md +195 -0
  74. package/templates/skills/spec-validate/references/traceability.md +169 -0
  75. package/templates/skills/stack-practices/SKILL.md +151 -0
  76. package/templates/skills/vetor/SKILL.md +174 -0
  77. package/templates/skills/worktree-create/SKILL.md +142 -0
  78. package/templates/skills/worktree-ship/SKILL.md +394 -0
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const { commandExists } = require('./command-exists.js');
7
+
8
+ function isDirectory(targetPath) {
9
+ try {
10
+ return fs.statSync(targetPath).isDirectory();
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+
16
+ function isFile(targetPath) {
17
+ try {
18
+ return fs.statSync(targetPath).isFile();
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ // Escopo da issue #254: só as 4 engines já suportadas pelo Vetor hoje (ver wiki/Compatibilidade-*.md
25
+ // para os arquivos-âncora de cada uma). Cursor foi investigado e adicionado na issue #256.
26
+ //
27
+ // Premissa declarada: não há convenção de arquivo-âncora de projeto documentada para Antigravity
28
+ // neste repositório (sem equivalente a `.claude/`, `AGENTS.md` ou `.opencode/` encontrado em
29
+ // wiki/Compatibilidade-Antigravity.md nem na estrutura do repo) — a detecção de Antigravity usa
30
+ // somente o comando `agy` no PATH (nome confirmado em scripts/lib/delegation-runtime.ts).
31
+ const ENGINES = [
32
+ {
33
+ id: 'claude-code',
34
+ name: 'Claude Code',
35
+ detect: (root, env) => isDirectory(path.join(root, '.claude')) || commandExists('claude', env),
36
+ },
37
+ {
38
+ id: 'codex',
39
+ name: 'Codex',
40
+ detect: (root, env) => isFile(path.join(root, 'AGENTS.md')) || commandExists('codex', env),
41
+ },
42
+ {
43
+ id: 'opencode',
44
+ name: 'OpenCode',
45
+ detect: (root, env) =>
46
+ isDirectory(path.join(root, '.opencode')) || commandExists('opencode', env),
47
+ },
48
+ {
49
+ id: 'antigravity',
50
+ name: 'Antigravity',
51
+ detect: (_root, env) => commandExists('agy', env),
52
+ },
53
+ // Issue #256 (ver wiki/Compatibilidade-Cursor.md): `.cursor/` é a âncora de projeto
54
+ // confirmada contra a doc oficial (rules/skills/agents/hooks vivem todos ali). O comando
55
+ // de PATH usado é `cursor-agent`, não `agent` — `agent` é o nome "primary" hoje na doc do
56
+ // CLI, mas é genérico demais e colide com facilidade com binários não relacionados ao
57
+ // Cursor; `cursor-agent` é mantido como symlink "legacy" pelo próprio script oficial de
58
+ // instalação (`cursor.com/install`, verificado nesta investigação) e carrega o mesmo sinal
59
+ // com risco de falso positivo muito menor.
60
+ {
61
+ id: 'cursor',
62
+ name: 'Cursor',
63
+ detect: (root, env) =>
64
+ isDirectory(path.join(root, '.cursor')) || commandExists('cursor-agent', env),
65
+ },
66
+ ];
67
+
68
+ /**
69
+ * Detecta engines suportadas no projeto-alvo: arquivo/diretório-âncora já existente,
70
+ * ou comando disponível no PATH. Nunca lança erro — ausência total de sinal apenas
71
+ * resulta em `detected: false` para todas as entradas.
72
+ *
73
+ * `env` é injetável para testes determinísticos (ex.: PATH vazio).
74
+ */
75
+ function detectEngines(root = process.cwd(), env = process.env) {
76
+ return ENGINES.map(({ id, name, detect }) => ({ id, name, detected: detect(root, env) }));
77
+ }
78
+
79
+ module.exports = { detectEngines, ENGINES };
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+
7
+ // Correção pós-code-review da PR #282 (issue #256): o manifesto vivia em
8
+ // `.claude/vetor/install-manifest.json`, mas `writeManifest`/`readManifest` fazem
9
+ // `mkdirSync(dirname(manifestPath), { recursive: true })` — criar `.claude/` como efeito
10
+ // colateral de QUALQUER instalação (mesmo uma "Cursor-exclusiva", sem Claude Code
11
+ // envolvido) polui `detectEngines()`: uma segunda execução de `vetor install` passaria a
12
+ // reportar `claude-code: detected: true` falsamente, só por causa do diretório-âncora que
13
+ // o próprio manifesto criou. `.vetor/` na raiz do projeto-alvo (fora de `.claude/`) não é
14
+ // âncora de detecção de nenhuma engine hoje (ver `detector.js`) nem previsivelmente no
15
+ // futuro, então não contamina a detecção de nenhuma delas.
16
+ //
17
+ // Decisão de migração (issue #285): este já é o segundo path usado por
18
+ // MANIFEST_RELATIVE_PATH nesta mesma feature (o primeiro, `.claude/vetor/...` acima, durou
19
+ // só até a PR #282) — sem nenhuma lógica de migração implementada para quem tivesse
20
+ // instalado com o path antigo. Avaliado e decidido não implementar migração automática
21
+ // (opção b, não a): até a data desta issue, `vetor install`/`installFiles` nunca foi
22
+ // publicado em nenhum release/pacote npm — não existe usuário real com manifesto gravado
23
+ // no path antigo para migrar. A primeira versão publicada já nasce com este path como
24
+ // definitivo. Se `MANIFEST_RELATIVE_PATH` precisar mudar de novo DEPOIS de uma versão
25
+ // publicada, essa migração (ler o path antigo se o novo não existir, ou equivalente) passa
26
+ // a ser obrigatória — o que não se aplicou nas duas mudanças anteriores continua não se
27
+ // aplicando por acidente.
28
+ const MANIFEST_RELATIVE_PATH = path.join('.vetor', 'install-manifest.json');
29
+
30
+ function manifestPathFor(projectRoot) {
31
+ return path.join(projectRoot, MANIFEST_RELATIVE_PATH);
32
+ }
33
+
34
+ function hashFile(filePath) {
35
+ return hashContent(fs.readFileSync(filePath));
36
+ }
37
+
38
+ /** Hash de conteúdo já em memória (ex.: bytes traduzidos, nunca lidos de volta do disco antes
39
+ * de gravar) — usado quando o arquivo gravado no destino não é uma cópia byte-a-byte da fonte
40
+ * (ex.: `.cursor/hooks.json` traduzido, ver `writer.js`/`cursor-hooks.js`, issue #284). */
41
+ function hashContent(content) {
42
+ return crypto.createHash('sha256').update(content).digest('hex');
43
+ }
44
+
45
+ /**
46
+ * Lê o manifesto de instalação do projeto-alvo. Ausência do arquivo (primeira instalação)
47
+ * não é erro — retorna manifesto vazio.
48
+ */
49
+ function readManifest(projectRoot) {
50
+ const manifestPath = manifestPathFor(projectRoot);
51
+ try {
52
+ const raw = fs.readFileSync(manifestPath, 'utf8');
53
+ const parsed = JSON.parse(raw);
54
+ return { version: 1, files: {}, ...parsed };
55
+ } catch (error) {
56
+ if (error.code === 'ENOENT') {
57
+ return { version: 1, files: {} };
58
+ }
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ function writeManifest(projectRoot, manifest) {
64
+ const manifestPath = manifestPathFor(projectRoot);
65
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
66
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
67
+ }
68
+
69
+ module.exports = {
70
+ MANIFEST_RELATIVE_PATH,
71
+ manifestPathFor,
72
+ hashFile,
73
+ hashContent,
74
+ readManifest,
75
+ writeManifest,
76
+ };
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ const readline = require('node:readline');
4
+
5
+ /**
6
+ * Deriva o estado inicial do checkbox a partir da detecção: engine detectada nasce
7
+ * pré-marcada, não detectada nasce desmarcada. Função pura — o usuário ainda pode alterar
8
+ * cada item na interação; a detecção é só sugestão inicial, nunca decide a instalação sozinha.
9
+ */
10
+ function buildEngineChoices(engines) {
11
+ return engines.map((engine) => ({ ...engine, checked: engine.detected }));
12
+ }
13
+
14
+ function renderChoices(choices) {
15
+ return choices
16
+ .map((choice, index) => {
17
+ const box = choice.checked ? '[x]' : '[ ]';
18
+ const suffix = choice.detected ? ' (detectada)' : '';
19
+ return ` ${index + 1}. ${box} ${choice.name}${suffix}`;
20
+ })
21
+ .join('\n');
22
+ }
23
+
24
+ /**
25
+ * Prompt interativo de seleção de engines: checkbox por número (sem dependência externa),
26
+ * engines detectadas pré-marcadas. Usuário digita números para alternar marcação e confirma
27
+ * com Enter (linha vazia) — exige ao menos uma engine marcada para aceitar a confirmação.
28
+ *
29
+ * Em sessão não-interativa (sem TTY) não há como obter confirmação explícita do usuário:
30
+ * imprime a detecção e resolve com seleção vazia, sem marcar nenhuma engine por padrão.
31
+ */
32
+ function runInstallPrompts(
33
+ engines,
34
+ { input = process.stdin, output = process.stdout, isTTY } = {},
35
+ ) {
36
+ const choices = buildEngineChoices(engines);
37
+ const interactive = isTTY ?? input.isTTY ?? false;
38
+
39
+ output.write('Selecione as engines para instalar:\n');
40
+ output.write(renderChoices(choices) + '\n');
41
+
42
+ if (!interactive) {
43
+ output.write(
44
+ '\nSessão não-interativa: nenhuma engine selecionada automaticamente ' +
45
+ '(confirmação explícita é obrigatória).\n',
46
+ );
47
+ return Promise.resolve([]);
48
+ }
49
+
50
+ const rl = readline.createInterface({ input, output });
51
+
52
+ return new Promise((resolve) => {
53
+ function prompt() {
54
+ rl.question(
55
+ '\nDigite números para marcar/desmarcar (ex.: 1,3), Enter para confirmar, ' +
56
+ '"q" para cancelar: ',
57
+ (answer) => {
58
+ const trimmed = answer.trim();
59
+
60
+ if (trimmed.toLowerCase() === 'q') {
61
+ rl.close();
62
+ resolve([]);
63
+ return;
64
+ }
65
+
66
+ if (trimmed === '') {
67
+ const selected = choices.filter((choice) => choice.checked);
68
+ if (selected.length === 0) {
69
+ output.write('Selecione pelo menos uma engine antes de confirmar.\n');
70
+ prompt();
71
+ return;
72
+ }
73
+ rl.close();
74
+ resolve(selected);
75
+ return;
76
+ }
77
+
78
+ const indexes = trimmed
79
+ .split(',')
80
+ .map((part) => Number.parseInt(part.trim(), 10) - 1)
81
+ .filter((index) => Number.isInteger(index) && index >= 0 && index < choices.length);
82
+
83
+ for (const index of indexes) {
84
+ choices[index].checked = !choices[index].checked;
85
+ }
86
+
87
+ output.write('\n' + renderChoices(choices) + '\n');
88
+ prompt();
89
+ },
90
+ );
91
+ }
92
+
93
+ prompt();
94
+ });
95
+ }
96
+
97
+ module.exports = { buildEngineChoices, runInstallPrompts };
@@ -0,0 +1,382 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const { hashFile, hashContent, readManifest, writeManifest } = require('./manifest.js');
7
+ const { translateHooksForCursor } = require('./cursor-hooks.js');
8
+
9
+ // Decisão de escopo (issue #255, revisada na #283): destino nativo por engine é o
10
+ // diretório-âncora já usado pela própria detecção (detector.js usa `.claude`/`.opencode`/
11
+ // `.cursor` como âncora; `.codex` segue a mesma convenção por consistência, mesmo sem
12
+ // detecção por diretório própria hoje).
13
+ //
14
+ // Cursor (#256, ver wiki/Compatibilidade-Cursor.md): `.cursor/skills/` e `.cursor/agents/`
15
+ // são descobertos nativamente pelo Cursor sem tradução de formato (SKILL.md e agents/*.md já
16
+ // são agnósticos de engine desde #251) — cópia direta funciona de verdade para essas duas
17
+ // pastas. `hooks/` é tratado à parte (ver `installCursorHooks` abaixo, issue #284): o Cursor
18
+ // só carrega hooks de projeto em `.cursor/hooks.json` (arquivo único na raiz, schema próprio
19
+ // em camelCase), nunca `.cursor/hooks/hooks.json` (diretório, o formato que este writer copia
20
+ // para as demais engines) — por isso `hooks/hooks.json` é traduzido em memória
21
+ // (`cursor-hooks.js`) e gravado como arquivo único em vez de entrar no loop genérico de
22
+ // `SOURCE_DIRS` abaixo.
23
+ //
24
+ // Redundância conhecida e aceita (YAGNI, achado #4 do code-review da PR #282): quando
25
+ // Claude Code E Cursor estão ambos selecionados, `skills/`/`agents/` são copiados tanto
26
+ // para `.claude/` quanto para `.cursor/`, mesmo a wiki confirmando que o Cursor já lê
27
+ // `.claude/skills/`/`.claude/agents/` nativamente para compatibilidade (ver
28
+ // wiki/Compatibilidade-Cursor.md). Deduplicar exigiria decidir qual engine é a "fonte da
29
+ // verdade" em disco e como reagir a edição de só uma das cópias — complexidade/risco maior
30
+ // que o custo de ter uma cópia extra em disco. Não implementado de propósito.
31
+ //
32
+ // Antigravity (#283): removida de ENGINE_DEST_DIR. `.antigravity` era uma convenção
33
+ // assumida sem confirmação (ver detector.js, que já não usa esse diretório como âncora de
34
+ // detecção — só o comando `agy` no PATH). Investigação nesta issue com o CLI `agy` real
35
+ // instalado (`agy plugin validate .`, `agy plugin list`) confirma que a distribuição de
36
+ // plugin do Antigravity não é "copiar skills/agents/hooks para um diretório de projeto":
37
+ // é `agy plugin install/import` lendo um `plugin.json` na RAIZ do plugin (mesmo schema
38
+ // `antigravity.google/schemas/v1/plugin.json` já usado por este repo) e registrando o
39
+ // import num manifesto global do usuário (`~/.gemini/antigravity-cli/settings.json`), não
40
+ // num diretório de projeto-alvo. Não existe hoje um segundo mecanismo de "instalação
41
+ // project-local" documentado ou observado para o Antigravity equivalente a `.claude/` do
42
+ // Claude Code — copiar arquivos para um `.antigravity/` inventado produziria uma pasta que
43
+ // o Antigravity nunca lê. Engine sem destino conhecido cai em `enginesSkipped` (ver
44
+ // `installFiles`), reportado ao usuário em vez de silenciosamente não fazer nada.
45
+ const ENGINE_DEST_DIR = {
46
+ 'claude-code': '.claude',
47
+ codex: '.codex',
48
+ opencode: '.opencode',
49
+ cursor: '.cursor',
50
+ };
51
+
52
+ const SOURCE_DIRS = ['skills', 'agents', 'hooks'];
53
+
54
+ // Diretórios de SOURCE_DIRS que não devem ser copiados (cópia genérica byte-a-byte) para o
55
+ // destino de uma engine específica, mesmo existindo na fonte, porque o resultado é
56
+ // comprovadamente inerte.
57
+ //
58
+ // - Cursor: `hooks/` NÃO entra aqui — virou tradução dedicada (`installCursorHooks`, issue
59
+ // #284) em vez de exclusão pura. O loop genérico de SOURCE_DIRS trata `cursor`+`hooks`
60
+ // como caso especial (ver o `continue` dedicado abaixo) antes mesmo de chegar a esta
61
+ // lista, produzindo `.cursor/hooks.json` de verdade — colocar `cursor` aqui excluiria
62
+ // `hooks` antes desse caso especial rodar.
63
+ // - OpenCode (#283): `skills/`, `agents/`, `hooks/` inteiros, porque o OpenCode tem
64
+ // árvore-fonte própria já traduzida para o seu formato (ver `ENGINE_NATIVE_SOURCE_DIR`
65
+ // abaixo) — copiar os genéricos produziria skills inertes (referenciam
66
+ // `$CLAUDE_PLUGIN_ROOT`, variável que o OpenCode não define), um diretório `agents/`
67
+ // (plural) que o OpenCode não escaneia (ele usa `agent/`, singular) e hooks em JSON
68
+ // declarativo onde o OpenCode espera plugin TS (`tool.execute.before/after`). Ver
69
+ // wiki/Compatibilidade-OpenCode.md.
70
+ const ENGINE_EXCLUDED_SOURCE_DIRS = {
71
+ opencode: ['skills', 'agents', 'hooks'],
72
+ };
73
+
74
+ // OpenCode (#283): em vez dos SOURCE_DIRS agnósticos, copia a árvore `opencode/` (já
75
+ // adaptada ao formato nativo — `opencode/agent/*.md`, `opencode/skills/*/SKILL.md`,
76
+ // `opencode/plugin/*.ts`, `opencode/scripts/*`) para a raiz do destino, achatada (não
77
+ // aninhada em `.opencode/opencode/...`). Mesmo mecanismo de instalação manual documentado
78
+ // em wiki/Compatibilidade-OpenCode.md (`cp -r opencode/. <projeto>/.opencode/`), só que
79
+ // via `vetor install` com o mesmo controle de manifesto/update seguro das demais engines.
80
+ // `opencode/mcp.jsonc` é copiado como referência; o merge do bloco `mcp` em
81
+ // `opencode.json` do projeto-alvo continua manual (documentado na wiki) — automatizar
82
+ // merge de JSON de config alheio está fora de escopo (YAGNI).
83
+ const ENGINE_NATIVE_SOURCE_DIR = {
84
+ opencode: 'opencode',
85
+ };
86
+
87
+ // Formatos de subagente coexistem em `agents/`: `agents/<nome>.md` (Claude Code e Cursor —
88
+ // agnóstico de engine desde #251), `agents/<nome>/agent.json` (Antigravity) e
89
+ // `agents/<nome>/codex.toml` (Codex — ver wiki/Compatibilidade-Codex.md, ".codex/agents/
90
+ // (projeto)"). Cada engine só reconhece o seu formato; copiar os outros junto (comportamento
91
+ // anterior à #283) produz arquivo inerte no destino. Este mapa filtra e, quando necessário,
92
+ // traduz o path (achata `<nome>/codex.toml` para `<nome>.toml`, que é o path plano que o
93
+ // Codex espera em `.codex/agents/`). Engine sem entrada aqui mantém o path original (só
94
+ // `SOURCE_DIRS` decide se `agents/` entra em jogo para ela).
95
+ const ENGINE_AGENT_FILE_MAP = {
96
+ 'claude-code': (relPath) => (relPath.endsWith('.md') ? relPath : null),
97
+ cursor: (relPath) => (relPath.endsWith('.md') ? relPath : null),
98
+ codex: (relPath) => {
99
+ const match = relPath.match(/^([^/\\]+)[/\\]codex\.toml$/);
100
+ return match ? `${match[1]}.toml` : null;
101
+ },
102
+ };
103
+
104
+ // cli/lib/installer/writer.js -> cli/lib -> cli (raiz do pacote, tanto em dev quanto no
105
+ // pacote npm publicado, onde "cli/" é achatado para a raiz do pacote).
106
+ //
107
+ // Decide entre a raiz do monorepo e `templates/` (populado por
108
+ // `cli/scripts/sync-templates.js` via hook `prepack` do npm, ver #255 redespacho) por um
109
+ // marcador, não por "templates/ tem conteúdo": `plugin.json` só existe na raiz do monorepo
110
+ // Vetor, nunca no pacote publicado nem em node_modules de um projeto-alvo qualquer. Isso
111
+ // evita dois problemas:
112
+ // - Falso-positivo: checar só "skills/ existe um nível acima" arriscaria ler o `skills/`
113
+ // do próprio projeto-alvo do usuário como se fosse a fonte do Vetor.
114
+ // - Deriva em dev: se a decisão fosse "usa templates/ quando tiver conteúdo", um
115
+ // dev editando `skills/` na raiz e rodando `vetor install` logo depois de qualquer
116
+ // `npm test` (que já roda o sync via prepack) leria o snapshot congelado de
117
+ // templates/, não a edição viva — o mesmo tipo de deriva que a automação do sync
118
+ // existe para evitar, só que realocada para o runtime do installer.
119
+ // Em dev, a raiz do monorepo (sempre viva) vence. Só no pacote publicado, sem o marcador,
120
+ // cai para `templates/`.
121
+ //
122
+ // `packageRoot` é injetável só para teste (evita depender do `cli/templates/` real, que
123
+ // outros testes também sincronizam via prepack — ver sync-templates.test.js).
124
+ function defaultSourceRoot({ packageRoot = path.join(__dirname, '..', '..') } = {}) {
125
+ const monorepoRoot = path.join(packageRoot, '..');
126
+ const isMonorepoCheckout = fs.existsSync(path.join(monorepoRoot, 'plugin.json'));
127
+ if (isMonorepoCheckout) return monorepoRoot;
128
+
129
+ return path.join(packageRoot, 'templates');
130
+ }
131
+
132
+ function listFilesRecursive(dir) {
133
+ const result = [];
134
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
135
+ const fullPath = path.join(dir, entry.name);
136
+ if (entry.isDirectory()) {
137
+ result.push(...listFilesRecursive(fullPath));
138
+ } else if (entry.isFile()) {
139
+ result.push(fullPath);
140
+ }
141
+ }
142
+ return result;
143
+ }
144
+
145
+ // Copia um único arquivo para o destino aplicando a mesma política de update seguro para
146
+ // todas as engines/origens (SOURCE_DIRS agnósticos ou árvore nativa de uma engine):
147
+ // arquivo ausente copia e manifesta; presente e íntegro (hash bate com o manifesto)
148
+ // sincroniza; presente sem entrada no manifesto ou com hash divergente nunca é
149
+ // sobrescrito, só reportado em `skipped`.
150
+ function copyManagedFile({ sourceFile, destFile, manifestKey, manifest, engineId, copied, skipped }) {
151
+ const existingEntry = manifest.files[manifestKey];
152
+
153
+ if (fs.existsSync(destFile)) {
154
+ if (!existingEntry) {
155
+ // Arquivo existe no destino mas não consta no manifesto: não foi este
156
+ // instalador que o gerou. Nunca sobrescreve nem deleta.
157
+ skipped.push({ path: manifestKey, reason: 'unmanaged' });
158
+ return;
159
+ }
160
+
161
+ const destHash = hashFile(destFile);
162
+ if (destHash !== existingEntry.sha256) {
163
+ // Editado pelo usuário desde a última instalação: nunca sobrescreve sem sinalizar.
164
+ skipped.push({ path: manifestKey, reason: 'user-modified' });
165
+ return;
166
+ }
167
+ }
168
+
169
+ fs.mkdirSync(path.dirname(destFile), { recursive: true });
170
+ fs.copyFileSync(sourceFile, destFile);
171
+ manifest.files[manifestKey] = { sha256: hashFile(sourceFile), engine: engineId };
172
+ copied.push(manifestKey);
173
+ }
174
+
175
+ /**
176
+ * Núcleo de idempotência compartilhado por cópia genérica de arquivo e por
177
+ * `installCursorHooks` (que grava conteúdo *traduzido*, não uma cópia byte-a-byte da fonte):
178
+ * - Arquivo ausente no destino: escreve e manifesta.
179
+ * - Arquivo presente e já manifestado com o mesmo hash do destino atual: seguro
180
+ * sobrescrever (não foi editado pelo usuário desde a última instalação) — sincroniza
181
+ * com a fonte/tradução, mesmo que o conteúdo produzido tenha mudado (isso é o "update
182
+ * seguro").
183
+ * - Arquivo presente mas SEM entrada no manifesto, ou com hash divergente da entrada
184
+ * manifestada: não foi gerado por este instalador ou foi editado pelo usuário — NUNCA
185
+ * sobrescreve, só reporta como `skipped`.
186
+ *
187
+ * `contentHash` é o hash do conteúdo que `write()` vai efetivamente gravar (não
188
+ * necessariamente o hash dos bytes da fonte — ver `installCursorHooks`), para que uma segunda
189
+ * execução compare o destino contra o que este instalador realmente produziu.
190
+ */
191
+ function writeManaged({ destFile, manifestKey, contentHash, write, engineId, manifest, copied, skipped }) {
192
+ const existingEntry = manifest.files[manifestKey];
193
+
194
+ if (fs.existsSync(destFile)) {
195
+ if (!existingEntry) {
196
+ // Arquivo existe no destino mas não consta no manifesto: não foi este
197
+ // instalador que o gerou. Nunca sobrescreve nem deleta.
198
+ skipped.push({ path: manifestKey, reason: 'unmanaged' });
199
+ return;
200
+ }
201
+
202
+ const destHash = hashFile(destFile);
203
+ if (destHash !== existingEntry.sha256) {
204
+ // Editado pelo usuário desde a última instalação: nunca sobrescreve sem sinalizar.
205
+ skipped.push({ path: manifestKey, reason: 'user-modified' });
206
+ return;
207
+ }
208
+ }
209
+
210
+ fs.mkdirSync(path.dirname(destFile), { recursive: true });
211
+ write();
212
+ manifest.files[manifestKey] = { sha256: contentHash, engine: engineId };
213
+ copied.push(manifestKey);
214
+ }
215
+
216
+ /**
217
+ * Traduz `<sourceRoot>/hooks/hooks.json` (formato Claude Code) para o schema nativo do Cursor
218
+ * e grava como arquivo único em `<destRootName>/hooks.json` (não `<destRootName>/hooks/...`,
219
+ * que o Cursor não descobre — ver `cursor-hooks.js` e wiki/Compatibilidade-Cursor.md, issue
220
+ * #284). Silenciosamente no-op se a fonte não existir (mesmo comportamento do loop genérico
221
+ * para uma `sourceDir` ausente). Outros arquivos de `hooks/` (ex.: `hooks-codex.json`) não são
222
+ * tocados — só `hooks.json` tem tradução para o Cursor.
223
+ */
224
+ function installCursorHooks({ sourceRoot, projectRoot, destRootName, manifest, copied, skipped, warnings }) {
225
+ const sourceFile = path.join(sourceRoot, 'hooks', 'hooks.json');
226
+ if (!fs.existsSync(sourceFile)) return;
227
+
228
+ const destFile = path.join(projectRoot, destRootName, 'hooks.json');
229
+ const manifestKey = path.relative(projectRoot, destFile).split(path.sep).join('/');
230
+
231
+ let sourceJson;
232
+ try {
233
+ sourceJson = JSON.parse(fs.readFileSync(sourceFile, 'utf8'));
234
+ } catch (error) {
235
+ // JSON inválido na fonte não pode derrubar toda a instalação (outras engines/dirs já
236
+ // processados no mesmo loop perderiam a entrada no manifesto, ver writeManifest só no
237
+ // final de installFiles) — reporta e segue.
238
+ skipped.push({ path: manifestKey, reason: 'invalid-source' });
239
+ warnings.push(`${manifestKey}: hooks/hooks.json não é JSON válido (${error.message})`);
240
+ return;
241
+ }
242
+
243
+ const { hooks: translated, dropped } = translateHooksForCursor(sourceJson);
244
+ const content = `${JSON.stringify(translated, null, 2)}\n`;
245
+
246
+ for (const { event, reason } of dropped) {
247
+ const description = reason === 'no-cursor-equivalent'
248
+ ? `evento "${event}" sem equivalente no Cursor — não incluído em ${manifestKey}`
249
+ : `matcher de "${event}" não é traduzível com fidelidade — hook mantido em ${manifestKey}, mas sem matcher (roda para todos os casos do evento)`;
250
+ warnings.push(`${description} — ver wiki/Compatibilidade-Cursor.md`);
251
+ }
252
+
253
+ writeManaged({
254
+ destFile,
255
+ manifestKey,
256
+ contentHash: hashContent(content),
257
+ write: () => fs.writeFileSync(destFile, content, 'utf8'),
258
+ engineId: 'cursor',
259
+ manifest,
260
+ copied,
261
+ skipped,
262
+ });
263
+ }
264
+
265
+ /**
266
+ * Copia `skills/`, `agents/`, `hooks/` (fonte agnóstica de engine, #251) para o destino
267
+ * nativo de cada engine selecionada — com tradução de formato/path por engine quando
268
+ * necessário (ver `ENGINE_AGENT_FILE_MAP`, `ENGINE_NATIVE_SOURCE_DIR`,
269
+ * `ENGINE_EXCLUDED_SOURCE_DIRS`) — gravando manifesto de hash SHA-256 por arquivo copiado
270
+ * em `.vetor/install-manifest.json` no projeto-alvo. `hooks/` para o Cursor é tratado à
271
+ * parte por `installCursorHooks` (issue #284): não é uma cópia byte-a-byte, é uma tradução
272
+ * de schema + caminho de destino.
273
+ *
274
+ * Idempotente e seguro para update (ver `copyManagedFile`/`writeManaged`).
275
+ *
276
+ * Engine sem destino conhecido (ex.: Antigravity, #283 — sem âncora de projeto confirmada)
277
+ * não é silenciosamente ignorada: entra em `enginesSkipped` para o chamador reportar ao
278
+ * usuário.
279
+ *
280
+ * Retorna `{ copied, skipped, warnings, enginesSkipped }` com os paths relativos ao
281
+ * projeto-alvo. `warnings` cobre perdas parciais que não impedem a instalação (ex.: evento
282
+ * de hook sem tradução fiel para uma engine, ver `installCursorHooks`) — vazio quando não há
283
+ * nada a avisar.
284
+ */
285
+ function installFiles({ projectRoot, engines, sourceRoot = defaultSourceRoot() } = {}) {
286
+ if (!projectRoot) {
287
+ throw new Error('installFiles: "projectRoot" é obrigatório.');
288
+ }
289
+
290
+ const manifest = readManifest(projectRoot);
291
+ const copied = [];
292
+ const skipped = [];
293
+ const warnings = [];
294
+ const enginesSkipped = [];
295
+
296
+ for (const engine of engines ?? []) {
297
+ const destRootName = ENGINE_DEST_DIR[engine.id];
298
+ if (!destRootName) {
299
+ enginesSkipped.push({
300
+ id: engine.id,
301
+ name: engine.name,
302
+ reason: 'no-verified-project-anchor',
303
+ });
304
+ continue;
305
+ }
306
+
307
+ const nativeSourceDirName = ENGINE_NATIVE_SOURCE_DIR[engine.id];
308
+ if (nativeSourceDirName) {
309
+ // Engine com árvore-fonte própria (ex.: OpenCode): copia tudo, achatado, sem passar
310
+ // pelos SOURCE_DIRS agnósticos abaixo.
311
+ const nativeSourceDir = path.join(sourceRoot, nativeSourceDirName);
312
+ if (fs.existsSync(nativeSourceDir)) {
313
+ for (const sourceFile of listFilesRecursive(nativeSourceDir)) {
314
+ const relativeToSourceDir = path.relative(nativeSourceDir, sourceFile);
315
+ const destFile = path.join(projectRoot, destRootName, relativeToSourceDir);
316
+ const manifestKey = path.relative(projectRoot, destFile).split(path.sep).join('/');
317
+ copyManagedFile({
318
+ sourceFile,
319
+ destFile,
320
+ manifestKey,
321
+ manifest,
322
+ engineId: engine.id,
323
+ copied,
324
+ skipped,
325
+ });
326
+ }
327
+ }
328
+ continue;
329
+ }
330
+
331
+ const excludedSourceDirs = ENGINE_EXCLUDED_SOURCE_DIRS[engine.id] ?? [];
332
+ const mapAgentFile = ENGINE_AGENT_FILE_MAP[engine.id];
333
+
334
+ for (const sourceDirName of SOURCE_DIRS) {
335
+ if (excludedSourceDirs.includes(sourceDirName)) continue;
336
+
337
+ if (engine.id === 'cursor' && sourceDirName === 'hooks') {
338
+ installCursorHooks({ sourceRoot, projectRoot, destRootName, manifest, copied, skipped, warnings });
339
+ continue;
340
+ }
341
+
342
+ const sourceDir = path.join(sourceRoot, sourceDirName);
343
+ if (!fs.existsSync(sourceDir)) continue;
344
+
345
+ for (const sourceFile of listFilesRecursive(sourceDir)) {
346
+ const relativeToSourceDir = path.relative(sourceDir, sourceFile);
347
+
348
+ let destRelativePath = relativeToSourceDir;
349
+ if (sourceDirName === 'agents' && mapAgentFile) {
350
+ const mapped = mapAgentFile(relativeToSourceDir);
351
+ if (!mapped) continue; // formato de subagente que esta engine não reconhece
352
+ destRelativePath = mapped;
353
+ }
354
+
355
+ const destFile = path.join(projectRoot, destRootName, sourceDirName, destRelativePath);
356
+ const manifestKey = path.relative(projectRoot, destFile).split(path.sep).join('/');
357
+ copyManagedFile({
358
+ sourceFile,
359
+ destFile,
360
+ manifestKey,
361
+ manifest,
362
+ engineId: engine.id,
363
+ copied,
364
+ skipped,
365
+ });
366
+ }
367
+ }
368
+ }
369
+
370
+ writeManifest(projectRoot, manifest);
371
+ return { copied, skipped, warnings, enginesSkipped };
372
+ }
373
+
374
+ module.exports = {
375
+ installFiles,
376
+ ENGINE_DEST_DIR,
377
+ SOURCE_DIRS,
378
+ ENGINE_EXCLUDED_SOURCE_DIRS,
379
+ ENGINE_NATIVE_SOURCE_DIR,
380
+ ENGINE_AGENT_FILE_MAP,
381
+ defaultSourceRoot,
382
+ };