@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
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # Vetor
2
+
3
+ <div align="center">
4
+ <picture>
5
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Tavaressan/Vetor/master/assets/logo-dark.png">
6
+ <img src="https://raw.githubusercontent.com/Tavaressan/Vetor/master/assets/logo.png" alt="Vetor Logo" width="320" />
7
+ </picture>
8
+ </div>
9
+
10
+ [![npm version](https://img.shields.io/npm/v/@tavaressan/vetor.svg)](https://www.npmjs.com/package/@tavaressan/vetor)
11
+ [![license](https://img.shields.io/npm/l/@tavaressan/vetor.svg)](https://github.com/Tavaressan/Vetor/blob/master/LICENSE)
12
+
13
+ Instalador do **Vetor**: plugin de skills para automação de workflow de desenvolvimento no Claude Code, Codex e outras engines de agente.
14
+
15
+ Este pacote é o instalador de linha de comando. As skills em si (ideação, coordenação de issues, fix loop, ship, guardian) vivem no [repositório principal](https://github.com/Tavaressan/Vetor) — leia o README de lá para o que o Vetor faz.
16
+
17
+ ## Instalação
18
+
19
+ ```
20
+ npm install -g @tavaressan/vetor
21
+ vetor install
22
+ ```
23
+
24
+ **Pré-requisitos:** [Deno](https://deno.com) e `gh` CLI autenticado no PATH, Git com suporte a `git worktree`.
25
+
26
+ ## Uso
27
+
28
+ ```
29
+ vetor install # instala o Vetor no projeto atual
30
+ vetor update # sincroniza a instalação existente com a fonte
31
+ vetor status # mostra o status da instalação por engine
32
+ vetor uninstall # remove os arquivos instalados pelo Vetor (pede confirmação)
33
+ vetor --help # lista os comandos disponíveis
34
+ ```
35
+
36
+ ## Documentação
37
+
38
+ Skills, arquitetura, configuração e compatibilidade multi-engine: [wiki do repositório principal](https://github.com/Tavaressan/Vetor/tree/master/wiki).
39
+
40
+ ## Licença
41
+
42
+ [MIT](https://github.com/Tavaressan/Vetor/blob/master/LICENSE) © 2026 Vitor Tavares Chaves.
package/bin/vetor.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { run } = require('../lib/router.js');
5
+
6
+ run(process.argv.slice(2));
package/lib/banner.js ADDED
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ const LOGO_LINES = [
4
+ '██╗ ██╗███████╗████████╗ ██████╗ ██████╗',
5
+ '██║ ██║██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗',
6
+ '██║ ██║█████╗ ██║ ██║ ██║██████╔╝',
7
+ '╚██╗ ██╔╝██╔══╝ ██║ ██║ ██║██╔══██╗',
8
+ ' ╚████╔╝ ███████╗ ██║ ╚██████╔╝██║ ██║',
9
+ ' ╚═══╝ ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝',
10
+ ];
11
+
12
+ const LOGO_COLOR = '\x1b[36m';
13
+ const RESET = '\x1b[0m';
14
+
15
+ // Só aplica cor quando stdout é um terminal e a convenção NO_COLOR
16
+ // (https://no-color.org/) não está definida. Isso evita poluir saídas
17
+ // redirecionadas para arquivo/pipe (CI, `vetor --help > log`).
18
+ function shouldUseColor() {
19
+ return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
20
+ }
21
+
22
+ function printBanner() {
23
+ const useColor = shouldUseColor();
24
+
25
+ for (const line of LOGO_LINES) {
26
+ console.info(useColor ? `${LOGO_COLOR}${line}${RESET}` : line);
27
+ }
28
+
29
+ console.info('');
30
+ }
31
+
32
+ module.exports = {
33
+ printBanner,
34
+ LOGO_LINES,
35
+ };
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ const { detectEngines: defaultDetectEngines } = require('../installer/detector.js');
4
+ const { runInstallPrompts: defaultRunInstallPrompts } = require('../installer/prompts.js');
5
+ const { installFiles: defaultInstallFiles } = require('../installer/writer.js');
6
+
7
+ /**
8
+ * Comando `install`: detecta engines suportadas no projeto-alvo (ver `ENGINES` em
9
+ * `installer/detector.js`) e oferece seleção interativa
10
+ * com as engines detectadas pré-marcadas. A detecção é só sugestão inicial: nenhuma
11
+ * engine é instalada sem confirmação explícita do usuário via `runInstallPrompts`.
12
+ *
13
+ * Após a confirmação, copia `skills/`/`agents/`/`hooks/` (ou a árvore nativa da engine,
14
+ * quando existir) para o destino de cada engine selecionada via `installFiles` (writer,
15
+ * issue #255, tradução de formato por engine revisada na #283), gravando manifesto de hash
16
+ * por arquivo para updates seguros mais tarde. Engine sem destino de arquivo confirmado
17
+ * (`enginesSkipped`) é reportada ao usuário em vez de silenciosamente ignorada.
18
+ *
19
+ * `detectEngines`/`runInstallPrompts`/`installFiles`/`input`/`output` são injetáveis para
20
+ * testes.
21
+ */
22
+ async function install(cwd = process.cwd(), options = {}) {
23
+ const detectEngines = options.detectEngines ?? defaultDetectEngines;
24
+ const runInstallPrompts = options.runInstallPrompts ?? defaultRunInstallPrompts;
25
+ const installFiles = options.installFiles ?? defaultInstallFiles;
26
+ const input = options.input ?? process.stdin;
27
+ const output = options.output ?? process.stdout;
28
+
29
+ const engines = detectEngines(cwd);
30
+ const detectedNames = engines.filter((engine) => engine.detected).map((engine) => engine.name);
31
+
32
+ if (detectedNames.length > 0) {
33
+ console.info(`Engines detectadas: ${detectedNames.join(', ')}.`);
34
+ } else {
35
+ console.info('Nenhuma engine detectada no diretório atual.');
36
+ }
37
+
38
+ const selected = await runInstallPrompts(engines, { input, output });
39
+
40
+ if (selected.length === 0) {
41
+ console.info('Nenhuma engine selecionada. Instalação cancelada.');
42
+ return;
43
+ }
44
+
45
+ console.info(`Engines selecionadas: ${selected.map((engine) => engine.name).join(', ')}.`);
46
+
47
+ const {
48
+ copied,
49
+ skipped,
50
+ warnings = [],
51
+ enginesSkipped = [],
52
+ } = installFiles({ projectRoot: cwd, engines: selected });
53
+ console.info(`${copied.length} arquivo(s) copiado(s).`);
54
+ if (skipped.length > 0) {
55
+ console.info(
56
+ `${skipped.length} arquivo(s) não sobrescrito(s) (editado(s) pelo usuário ou não gerado(s) pelo instalador).`,
57
+ );
58
+ }
59
+ for (const warning of warnings) {
60
+ console.info(`Aviso: ${warning}`);
61
+ }
62
+ // Issue #283: engine selecionada sem destino de arquivo confirmado (ex.: Antigravity) não
63
+ // falha nem copia nada — mas precisa ser visível para o usuário, não silenciosa.
64
+ for (const engine of enginesSkipped) {
65
+ console.info(
66
+ `${engine.name}: nenhum arquivo instalado (sem convenção de projeto confirmada para esta engine ainda).`,
67
+ );
68
+ }
69
+ }
70
+
71
+ module.exports = { install };
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const { ENGINES } = require('../installer/detector.js');
7
+ const { readManifest: defaultReadManifest, hashFile: defaultHashFile } = require('../installer/manifest.js');
8
+
9
+ const ENGINE_NAME_BY_ID = new Map(ENGINES.map((engine) => [engine.id, engine.name]));
10
+
11
+ const STATE_LABEL = {
12
+ ok: '[ok]',
13
+ missing: '[ausente]',
14
+ modified: '[modificado]',
15
+ };
16
+
17
+ function classify(cwd, key, entry, hashFile) {
18
+ const filePath = path.join(cwd, ...key.split('/'));
19
+ if (!fs.existsSync(filePath)) return 'missing';
20
+ return hashFile(filePath) === entry.sha256 ? 'ok' : 'modified';
21
+ }
22
+
23
+ /**
24
+ * Comando `status` (issue #302): classifica cada arquivo do manifesto de instalação
25
+ * (`.vetor/install-manifest.json`) comparando o hash atual em disco com o hash gravado —
26
+ * `ok` (íntegro), `modificado` (hash diverge do manifesto) ou `ausente` (arquivo sumiu do
27
+ * disco). Somente leitura: nenhum arquivo é criado, alterado ou apagado.
28
+ *
29
+ * `readManifest`/`hashFile` são injetáveis para testes.
30
+ */
31
+ function status(cwd = process.cwd(), options = {}) {
32
+ const readManifest = options.readManifest ?? defaultReadManifest;
33
+ const hashFile = options.hashFile ?? defaultHashFile;
34
+
35
+ const manifest = readManifest(cwd);
36
+ const entries = Object.entries(manifest.files);
37
+
38
+ if (entries.length === 0) {
39
+ console.info('Vetor não está instalado neste projeto.');
40
+ return;
41
+ }
42
+
43
+ const byEngine = new Map();
44
+ for (const [key, entry] of entries) {
45
+ const engineName = ENGINE_NAME_BY_ID.get(entry.engine) ?? entry.engine;
46
+ const state = classify(cwd, key, entry, hashFile);
47
+ if (!byEngine.has(engineName)) byEngine.set(engineName, []);
48
+ byEngine.get(engineName).push({ key, state });
49
+ }
50
+
51
+ for (const [engineName, files] of byEngine) {
52
+ console.info(`${engineName}:`);
53
+ for (const { key, state } of files) {
54
+ console.info(` ${STATE_LABEL[state]} ${key}`);
55
+ }
56
+ }
57
+ }
58
+
59
+ module.exports = { status };
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const readline = require('node:readline');
6
+
7
+ const {
8
+ manifestPathFor,
9
+ readManifest: defaultReadManifest,
10
+ writeManifest: defaultWriteManifest,
11
+ hashFile: defaultHashFile,
12
+ } = require('../installer/manifest.js');
13
+
14
+ /**
15
+ * Confirmação simples (s/N) antes da remoção. Sessão não-interativa (sem TTY) não tem como
16
+ * obter confirmação explícita — cancela por padrão, mesma postura de `runInstallPrompts`
17
+ * (installer/prompts.js) para seleção vazia em ambiente não-interativo.
18
+ */
19
+ function confirmUninstall(message, { input = process.stdin, output = process.stdout, isTTY } = {}) {
20
+ const interactive = isTTY ?? input.isTTY ?? false;
21
+
22
+ if (!interactive) {
23
+ output.write(`${message} Sessão não-interativa: cancelado (confirmação explícita é obrigatória).\n`);
24
+ return Promise.resolve(false);
25
+ }
26
+
27
+ const rl = readline.createInterface({ input, output });
28
+ return new Promise((resolve) => {
29
+ rl.question(`${message} [s/N]: `, (answer) => {
30
+ rl.close();
31
+ resolve(answer.trim().toLowerCase() === 's');
32
+ });
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Comando `uninstall` (issue #303): remove do projeto-alvo os arquivos gerenciados pelo
38
+ * manifesto de instalação (`.vetor/install-manifest.json`) — única operação destrutiva dos
39
+ * três comandos pendentes de `router.js`, por isso pede confirmação antes de apagar algo.
40
+ *
41
+ * Só remove entrada cujo hash atual em disco ainda bate com o manifestado (não editada pelo
42
+ * usuário desde a instalação). Entrada com hash divergente, ou cujo path resolvido cai fora
43
+ * de `projectRoot` (manifesto corrompido/adulterado), é preservada — nunca apagada
44
+ * silenciosamente — e continua registrada no manifesto. Entrada já ausente do disco é só
45
+ * descartada do manifesto (nada físico para preservar).
46
+ *
47
+ * Se nenhuma entrada sobrar após a rodada, o próprio arquivo de manifesto é removido — não
48
+ * um manifesto com `files: {}` — para que `status`/`update` voltem a reportar "não
49
+ * instalado" em vez de uma instalação vazia.
50
+ *
51
+ * `readManifest`/`writeManifest`/`hashFile`/`confirm`/`input`/`output` são injetáveis para
52
+ * testes.
53
+ */
54
+ async function uninstall(cwd = process.cwd(), options = {}) {
55
+ const readManifest = options.readManifest ?? defaultReadManifest;
56
+ const writeManifest = options.writeManifest ?? defaultWriteManifest;
57
+ const hashFile = options.hashFile ?? defaultHashFile;
58
+ const confirm = options.confirm ?? confirmUninstall;
59
+ const input = options.input ?? process.stdin;
60
+ const output = options.output ?? process.stdout;
61
+
62
+ const manifest = readManifest(cwd);
63
+ const entries = Object.entries(manifest.files);
64
+
65
+ if (entries.length === 0) {
66
+ console.info('Nada para desinstalar: Vetor não está instalado neste projeto.');
67
+ return;
68
+ }
69
+
70
+ console.info(`${entries.length} arquivo(s) gerenciado(s) encontrado(s).`);
71
+ const confirmed = await confirm('Remover todos os arquivos instalados pelo Vetor?', { input, output });
72
+ if (!confirmed) {
73
+ console.info('Desinstalação cancelada.');
74
+ return;
75
+ }
76
+
77
+ const removed = [];
78
+ const preserved = [];
79
+ const remainingFiles = {};
80
+
81
+ for (const [key, entry] of entries) {
82
+ const destFile = path.resolve(cwd, ...key.split('/'));
83
+ const relative = path.relative(cwd, destFile);
84
+ const isInsideProject = relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
85
+
86
+ if (!isInsideProject) {
87
+ preserved.push({ path: key, reason: 'outside-project-root' });
88
+ remainingFiles[key] = entry;
89
+ continue;
90
+ }
91
+
92
+ if (!fs.existsSync(destFile)) {
93
+ preserved.push({ path: key, reason: 'already-missing' });
94
+ continue;
95
+ }
96
+
97
+ if (hashFile(destFile) !== entry.sha256) {
98
+ preserved.push({ path: key, reason: 'user-modified' });
99
+ remainingFiles[key] = entry;
100
+ continue;
101
+ }
102
+
103
+ fs.unlinkSync(destFile);
104
+ removed.push(key);
105
+ }
106
+
107
+ console.info(`${removed.length} arquivo(s) removido(s).`);
108
+ if (preserved.length > 0) {
109
+ console.info(`${preserved.length} arquivo(s) preservado(s) (editado(s) pelo usuário ou já ausente(s)).`);
110
+ }
111
+
112
+ if (Object.keys(remainingFiles).length > 0) {
113
+ writeManifest(cwd, { ...manifest, files: remainingFiles });
114
+ } else {
115
+ fs.rmSync(manifestPathFor(cwd), { force: true });
116
+ }
117
+ }
118
+
119
+ module.exports = { uninstall, confirmUninstall };
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ const { ENGINES } = require('../installer/detector.js');
4
+ const { installFiles: defaultInstallFiles } = require('../installer/writer.js');
5
+ const { readManifest: defaultReadManifest } = require('../installer/manifest.js');
6
+
7
+ const ENGINE_NAME_BY_ID = new Map(ENGINES.map((engine) => [engine.id, engine.name]));
8
+
9
+ /**
10
+ * Comando `update`: sincroniza a instalação existente sem repetir a detecção/seleção de
11
+ * engines do `install` (issue #301) — as engines a atualizar são derivadas do próprio
12
+ * manifesto (`.vetor/install-manifest.json`, campo `engine` de cada entrada), nunca de
13
+ * `detectEngines()`, para não arriscar instalar numa engine que o usuário nunca selecionou
14
+ * (ou deixar de atualizar uma que ele selecionou).
15
+ *
16
+ * A política de "update seguro" por arquivo (ausente copia, hash bate sincroniza,
17
+ * unmanaged/user-modified nunca sobrescreve) já vive em `installFiles`/`copyManagedFile` —
18
+ * este comando só monta os argumentos e reporta o resultado.
19
+ *
20
+ * Arquivo que constava no manifesto antes desta rodada e não aparece em `copied` nem
21
+ * `skipped` depois é órfão (fonte removeu o arquivo, ex.: skill descontinuada): reportado,
22
+ * nunca apagado — deleção fica reservada ao comando `uninstall`.
23
+ *
24
+ * `readManifest`/`installFiles` são injetáveis para testes.
25
+ */
26
+ function update(cwd = process.cwd(), options = {}) {
27
+ const readManifest = options.readManifest ?? defaultReadManifest;
28
+ const installFiles = options.installFiles ?? defaultInstallFiles;
29
+
30
+ const manifestBefore = readManifest(cwd);
31
+ const keysBefore = Object.keys(manifestBefore.files);
32
+
33
+ if (keysBefore.length === 0) {
34
+ console.info('Nenhuma instalação encontrada neste projeto. Rode "vetor install" primeiro.');
35
+ return;
36
+ }
37
+
38
+ const engineIds = [...new Set(Object.values(manifestBefore.files).map((entry) => entry.engine))];
39
+ const engines = engineIds.map((id) => ({ id, name: ENGINE_NAME_BY_ID.get(id) ?? id }));
40
+
41
+ console.info(`Atualizando engines: ${engines.map((engine) => engine.name).join(', ')}.`);
42
+
43
+ const { copied, skipped, warnings = [] } = installFiles({ projectRoot: cwd, engines });
44
+
45
+ console.info(`${copied.length} arquivo(s) sincronizado(s).`);
46
+ if (skipped.length > 0) {
47
+ console.info(
48
+ `${skipped.length} arquivo(s) não sobrescrito(s) (editado(s) pelo usuário ou não gerado(s) pelo instalador).`,
49
+ );
50
+ }
51
+ for (const warning of warnings) {
52
+ console.info(`Aviso: ${warning}`);
53
+ }
54
+
55
+ const touched = new Set([...copied, ...skipped.map((entry) => entry.path)]);
56
+ for (const key of keysBefore) {
57
+ if (!touched.has(key)) {
58
+ console.info(`${key}: não existe mais na fonte (não removido — use "vetor uninstall" se quiser limpar).`);
59
+ }
60
+ }
61
+ }
62
+
63
+ module.exports = { update };
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ /**
7
+ * Verifica se um comando existe em algum diretório do PATH, sem executá-lo — apenas
8
+ * resolve candidatos no filesystem (evita spawn/child_process e efeitos colaterais).
9
+ * No Windows, cruza cada diretório do PATH com as extensões de PATHEXT (`.cmd`, `.exe`, ...),
10
+ * já que CLIs instaladas via npm (`claude`, `codex`, `opencode`) viram shims `.cmd`.
11
+ *
12
+ * `env` é injetável para testes determinísticos (ex.: PATH vazio → nenhum comando encontrado).
13
+ */
14
+ function commandExists(command, env = process.env) {
15
+ const pathEnv = env.PATH ?? env.Path ?? '';
16
+ const dirs = pathEnv.split(path.delimiter).filter(Boolean);
17
+ const extensions = process.platform === 'win32'
18
+ ? (env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';')
19
+ : [''];
20
+
21
+ for (const dir of dirs) {
22
+ for (const ext of extensions) {
23
+ const candidate = path.join(dir, command + ext);
24
+ if (fs.existsSync(candidate)) return true;
25
+ }
26
+ }
27
+ return false;
28
+ }
29
+
30
+ module.exports = { commandExists };
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ // Tradução de hooks/hooks.json (formato Claude Code, fonte agnóstica deste repositório) para
4
+ // o schema nativo do Cursor (`.cursor/hooks.json`), confirmado contra a documentação oficial:
5
+ // - cursor.com/docs/hooks (schema, `version`, `hooks.<evento>[]` com `command`/`matcher`/
6
+ // `timeout`, lista de eventos suportados)
7
+ // - cursor.com/docs/reference/third-party-hooks (mapeamento de nomes de evento e de tool entre
8
+ // Claude Code e Cursor)
9
+ // Ver wiki/Compatibilidade-Cursor.md, seção Hooks, para o achado completo (issue #284).
10
+ //
11
+ // Cobre só os eventos usados por hooks/hooks.json deste repositório hoje (YAGNI) — não é um
12
+ // tradutor genérico para qualquer hooks.json arbitrário de terceiros.
13
+
14
+ // cursor.com/docs/reference/third-party-hooks#hook-step-mapping — só os eventos que essa
15
+ // tabela documenta como suportados; eventos sem linha na tabela (ex.: WorktreeCreate, que nem
16
+ // existe no Claude Code como conceito de hook nativo do produto, só neste plugin) não têm
17
+ // equivalente e são reportados em `dropped`.
18
+ const EVENT_NAME_MAP = {
19
+ PreToolUse: 'preToolUse',
20
+ PostToolUse: 'postToolUse',
21
+ UserPromptSubmit: 'beforeSubmitPrompt',
22
+ Stop: 'stop',
23
+ SubagentStop: 'subagentStop',
24
+ SessionStart: 'sessionStart',
25
+ SessionEnd: 'sessionEnd',
26
+ PreCompact: 'preCompact',
27
+ };
28
+
29
+ // cursor.com/docs/reference/third-party-hooks#tool-name-mapping. Edit e Write mapeiam para o
30
+ // mesmo tool Cursor ("Write") — o Cursor não distingui edição em lugar de escrita nova.
31
+ const TOOL_NAME_MAP = {
32
+ Bash: 'Shell',
33
+ Read: 'Read',
34
+ Write: 'Write',
35
+ Edit: 'Write',
36
+ Grep: 'Grep',
37
+ Task: 'Task',
38
+ };
39
+
40
+ // Eventos cujo `matcher` do Claude Code filtra por NOME de tool — únicos onde o vocabulário de
41
+ // TOOL_NAME_MAP se aplica. Outros eventos com matcher (ex.: SubagentStop, que no Claude Code
42
+ // filtra por nome customizado de subagente) têm semântica diferente da do Cursor (que filtra
43
+ // por `subagent_type` fixo: generalPurpose/explore/shell) e não são traduzíveis — ver
44
+ // `translateHooksForCursor`.
45
+ const TOOL_MATCHER_EVENTS = new Set(['PreToolUse', 'PostToolUse']);
46
+
47
+ // Lista completa de eventos documentados em cursor.com/docs/hooks (Agent hooks + Tab hooks +
48
+ // App lifecycle hook) — usada por `validateCursorHooksSchema` para rejeitar nomes de evento
49
+ // inventados/não suportados, não só os que este tradutor emite.
50
+ const CURSOR_EVENT_NAMES = new Set([
51
+ 'sessionStart',
52
+ 'sessionEnd',
53
+ 'preToolUse',
54
+ 'postToolUse',
55
+ 'postToolUseFailure',
56
+ 'subagentStart',
57
+ 'subagentStop',
58
+ 'beforeShellExecution',
59
+ 'afterShellExecution',
60
+ 'beforeMCPExecution',
61
+ 'afterMCPExecution',
62
+ 'beforeReadFile',
63
+ 'afterFileEdit',
64
+ 'beforeSubmitPrompt',
65
+ 'preCompact',
66
+ 'stop',
67
+ 'afterAgentResponse',
68
+ 'afterAgentThought',
69
+ 'beforeTabFileRead',
70
+ 'afterTabFileEdit',
71
+ 'workspaceOpen',
72
+ ]);
73
+
74
+ /**
75
+ * Traduz o `matcher` de PreToolUse/PostToolUse (nomes de tool do Claude Code, string "|"-
76
+ * separated) para o vocabulário de tool do Cursor, removendo duplicatas geradas por tools que
77
+ * colapsam no mesmo tool Cursor (Edit/Write -> Write). Tokens fora de TOOL_NAME_MAP passam
78
+ * inalterados (ex.: um nome de tool MCP customizado).
79
+ */
80
+ function translateToolMatcher(matcher) {
81
+ const tokens = matcher.split('|').map((token) => TOOL_NAME_MAP[token] ?? token);
82
+ return [...new Set(tokens)].join('|');
83
+ }
84
+
85
+ /** Achata uma entrada `{matcher?, hooks:[{type,command,timeout}]}` do Claude Code em uma ou mais entradas planas `{command,timeout?,matcher?}` do Cursor. */
86
+ function translateHookEntry(event, entry, canTranslateMatcher) {
87
+ const matcher = entry.matcher;
88
+ return (entry.hooks ?? []).map((inner) => {
89
+ const translated = { command: inner.command };
90
+ if (typeof inner.timeout === 'number') translated.timeout = inner.timeout;
91
+ if (matcher && canTranslateMatcher) {
92
+ translated.matcher = translateToolMatcher(matcher);
93
+ }
94
+ return translated;
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Traduz `hooks/hooks.json` (formato Claude Code) para o schema nativo do Cursor
100
+ * (`.cursor/hooks.json`). Retorna `{ hooks, dropped }`:
101
+ * - `hooks`: objeto pronto para `JSON.stringify` em `.cursor/hooks.json` (com `version: 1`).
102
+ * - `dropped`: eventos/matchers da fonte sem equivalente fiel no Cursor — nunca lançado como
103
+ * erro (perda de cobertura parcial não deve travar a instalação), mas sempre reportado para
104
+ * quem for decidir o que fazer com o gap (ver wiki/Compatibilidade-Cursor.md).
105
+ */
106
+ function translateHooksForCursor(sourceHooksJson) {
107
+ const sourceEvents = sourceHooksJson?.hooks ?? {};
108
+ const translatedHooks = {};
109
+ const dropped = [];
110
+
111
+ for (const [event, entries] of Object.entries(sourceEvents)) {
112
+ const cursorEvent = EVENT_NAME_MAP[event];
113
+ if (!cursorEvent) {
114
+ dropped.push({ event, reason: 'no-cursor-equivalent' });
115
+ continue;
116
+ }
117
+
118
+ const canTranslateMatcher = TOOL_MATCHER_EVENTS.has(event);
119
+ const hasUntranslatableMatcher = entries.some(
120
+ (entry) => entry.matcher && !canTranslateMatcher,
121
+ );
122
+ if (hasUntranslatableMatcher) {
123
+ dropped.push({ event, reason: 'matcher-not-translatable' });
124
+ }
125
+
126
+ translatedHooks[cursorEvent] = entries.flatMap((entry) =>
127
+ translateHookEntry(event, entry, canTranslateMatcher),
128
+ );
129
+ }
130
+
131
+ return { hooks: { version: 1, hooks: translatedHooks }, dropped };
132
+ }
133
+
134
+ /**
135
+ * Validação mínima do `.cursor/hooks.json` gerado contra o schema documentado em
136
+ * cursor.com/docs/hooks (nomes de evento válidos, `version` numérica, `command` obrigatório
137
+ * por entrada). Não é um validador de JSON Schema genérico — cobre só os campos que este
138
+ * tradutor emite e os erros mais prováveis de regressão (evento inventado, entrada sem
139
+ * `command`), suficiente para o critério de aceite da issue #284 sem depender de uma lib
140
+ * externa (este pacote não tem dependências — ver cli/package.json).
141
+ */
142
+ function validateCursorHooksSchema(hooksJson) {
143
+ const errors = [];
144
+
145
+ if (typeof hooksJson?.version !== 'number') {
146
+ errors.push('"version" ausente ou não-numérico — cursor.com/docs/hooks exige um inteiro positivo (ex.: 1)');
147
+ }
148
+
149
+ const hooks = hooksJson?.hooks;
150
+ if (hooks && typeof hooks === 'object') {
151
+ for (const [event, entries] of Object.entries(hooks)) {
152
+ if (!CURSOR_EVENT_NAMES.has(event)) {
153
+ errors.push(`evento "${event}" não está na lista documentada em cursor.com/docs/hooks`);
154
+ continue;
155
+ }
156
+ for (const [index, entry] of (entries ?? []).entries()) {
157
+ if (typeof entry?.command !== 'string' || entry.command.length === 0) {
158
+ errors.push(`hooks.${event}[${index}] sem "command" (campo obrigatório)`);
159
+ }
160
+ if ('timeout' in entry && typeof entry.timeout !== 'number') {
161
+ errors.push(`hooks.${event}[${index}].timeout precisa ser numérico`);
162
+ }
163
+ if ('matcher' in entry && typeof entry.matcher !== 'string') {
164
+ errors.push(`hooks.${event}[${index}].matcher precisa ser string`);
165
+ }
166
+ }
167
+ }
168
+ } else {
169
+ errors.push('"hooks" ausente ou não é um objeto');
170
+ }
171
+
172
+ return { valid: errors.length === 0, errors };
173
+ }
174
+
175
+ module.exports = {
176
+ translateHooksForCursor,
177
+ validateCursorHooksSchema,
178
+ EVENT_NAME_MAP,
179
+ TOOL_NAME_MAP,
180
+ CURSOR_EVENT_NAMES,
181
+ };