@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.
- package/README.md +42 -0
- package/bin/vetor.js +6 -0
- package/lib/banner.js +35 -0
- package/lib/commands/install.js +71 -0
- package/lib/commands/status.js +59 -0
- package/lib/commands/uninstall.js +119 -0
- package/lib/commands/update.js +63 -0
- package/lib/installer/command-exists.js +30 -0
- package/lib/installer/cursor-hooks.js +181 -0
- package/lib/installer/detector.js +79 -0
- package/lib/installer/manifest.js +76 -0
- package/lib/installer/prompts.js +97 -0
- package/lib/installer/writer.js +382 -0
- package/lib/router.js +50 -0
- package/package.json +39 -0
- package/templates/.gitkeep +0 -0
- package/templates/agents/code-review/agent.json +27 -0
- package/templates/agents/code-review/codex.toml +37 -0
- package/templates/agents/code-review.md +99 -0
- package/templates/agents/issue-worker/agent.json +33 -0
- package/templates/agents/issue-worker/codex.toml +57 -0
- package/templates/agents/issue-worker.md +112 -0
- package/templates/hooks/hooks-codex.json +48 -0
- package/templates/hooks/hooks.json +62 -0
- package/templates/opencode/agent/code-review.md +73 -0
- package/templates/opencode/agent/issue-coordinator.md +521 -0
- package/templates/opencode/agent/issue-worker.md +64 -0
- package/templates/opencode/mcp.jsonc +39 -0
- package/templates/opencode/plugin/vetor.ts +207 -0
- package/templates/opencode/scripts/agent-registration_test.ts +92 -0
- package/templates/opencode/scripts/check-edit.ts +147 -0
- package/templates/opencode/scripts/ensure-external-directory-permission.ts +110 -0
- package/templates/opencode/scripts/ensure-external-directory-permission_test.ts +142 -0
- package/templates/opencode/scripts/lib/guard.ts +45 -0
- package/templates/opencode/scripts/lib/model-health.ts +133 -0
- package/templates/opencode/scripts/lib/model-health_test.ts +181 -0
- package/templates/opencode/scripts/lib/project.ts +240 -0
- package/templates/opencode/scripts/lib/project_test.ts +45 -0
- package/templates/opencode/scripts/lib/status.ts +69 -0
- package/templates/opencode/scripts/lib/worktree.ts +41 -0
- package/templates/opencode/scripts/model-health.ts +50 -0
- package/templates/opencode/scripts/model-health_test.ts +80 -0
- package/templates/opencode/scripts/resolve-model.ts +112 -0
- package/templates/opencode/scripts/resolve-model_test.ts +185 -0
- package/templates/opencode/scripts/safety-check.ts +203 -0
- package/templates/opencode/scripts/vetor-checks.sh +217 -0
- package/templates/opencode/scripts/vetor-status.sh +99 -0
- package/templates/skills/architecture-review/SKILL.md +187 -0
- package/templates/skills/backlog-ideator/SKILL.md +277 -0
- package/templates/skills/design/SKILL.md +468 -0
- package/templates/skills/design/examples/design-contract-example.md +46 -0
- package/templates/skills/design/examples/prototype-handoff-example.md +142 -0
- package/templates/skills/fix-loop-agent/SKILL.md +255 -0
- package/templates/skills/guardian/SKILL.md +343 -0
- package/templates/skills/issue-coordinator/SKILL.md +596 -0
- package/templates/skills/retro/SKILL.md +156 -0
- package/templates/skills/shared/references/agent-status.template.md +68 -0
- package/templates/skills/shared/references/codebase-design-vocabulary.md +54 -0
- package/templates/skills/shared/references/conflict-resolution.md +94 -0
- package/templates/skills/shared/references/delegate-to-runtime.md +239 -0
- package/templates/skills/shared/references/design-vocabulary.md +508 -0
- package/templates/skills/shared/references/evidence-state.md +365 -0
- package/templates/skills/shared/references/frontend-design-enforcement.md +33 -0
- package/templates/skills/shared/references/grilling-conventions.md +64 -0
- package/templates/skills/shared/references/knowledge-provider-contract.md +150 -0
- package/templates/skills/shared/references/mcp-availability.md +104 -0
- package/templates/skills/shared/references/module-test-map.template.md +72 -0
- package/templates/skills/shared/references/planning-conventions.md +97 -0
- package/templates/skills/shared/references/project-conventions.md +63 -0
- package/templates/skills/shared/references/tdd-conventions.md +81 -0
- package/templates/skills/shared/references/touched-files-cache.md +30 -0
- package/templates/skills/spec/SKILL.md +524 -0
- package/templates/skills/spec-validate/SKILL.md +195 -0
- package/templates/skills/spec-validate/references/traceability.md +169 -0
- package/templates/skills/stack-practices/SKILL.md +151 -0
- package/templates/skills/vetor/SKILL.md +174 -0
- package/templates/skills/worktree-create/SKILL.md +142 -0
- 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
|
+
};
|