sabia 0.0.0-stage → 1.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 (3) hide show
  1. package/package.json +31 -4
  2. package/sabia.mjs +582 -0
  3. package/README.md +0 -3
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "sabia",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "Instala, atualiza e desinstala o Sabiá e os toolkits dele no Kiro, no Claude Code e no opencode, com um inventário em ~/.sabia/.",
5
+ "type": "module",
6
+ "bin": {
7
+ "sabia": "sabia.mjs"
8
+ },
9
+ "files": [
10
+ "sabia.mjs"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "scripts": {
16
+ "test": "node --test"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/dxaraujo/sabia.git",
21
+ "directory": "cli"
22
+ },
23
+ "author": "Daniel Xavier Araújo <danielxaraujo@gmail.com>",
24
+ "license": "MIT",
25
+ "keywords": [
26
+ "sabia",
27
+ "kiro",
28
+ "claude-code",
29
+ "opencode",
30
+ "skills",
31
+ "agentes"
32
+ ]
33
+ }
package/sabia.mjs ADDED
@@ -0,0 +1,582 @@
1
+ #!/usr/bin/env node
2
+ // Sabiá CLI — instala, atualiza e desinstala o Sabiá e os toolkits no Kiro, no Claude Code e no opencode.
3
+ // ~/.sabia/ é a fonte (framework/ e toolkits/), cada ferramenta recebe uma cópia no padrão dela, e o
4
+ // ~/.sabia/inventario.json registra cada cópia (com o hash de cada arquivo) e cada MCP: atualizar e desinstalar
5
+ // mexem só no que está lá. Só a biblioteca padrão do Node 18+.
6
+ import { spawnSync } from "node:child_process";
7
+ import { createHash } from "node:crypto";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import readline from "node:readline/promises";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const VERSAO = JSON.parse(fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")).version;
15
+ const REPO = "https://github.com/dxaraujo/sabia.git";
16
+ const INDICE = "https://github.com/dxaraujo/sabia-toolkits.git";
17
+ const ALVOS = ["kiro", "claude", "opencode"];
18
+ const BANNER = `
19
+ ▄▄▀
20
+ ███████╗ █████╗ ██████╗ ██╗ █████╗
21
+ ██╔════╝██╔══██╗██╔══██╗██║██╔══██╗
22
+ ███████╗███████║██████╔╝██║███████║
23
+ ╚════██║██╔══██║██╔══██╗██║██╔══██║
24
+ ███████║██║ ██║██████╔╝██║██║ ██║
25
+ ╚══════╝╚═╝ ╚═╝╚═════╝ ╚═╝╚═╝ ╚═╝`;
26
+
27
+ // trocáveis nos testes
28
+ export const ctx = {
29
+ exec: (cmd, args, opcoes = {}) => spawnSync(cmd, args, { encoding: "utf8", ...opcoes }),
30
+ perguntar: async (texto, padrao) => {
31
+ if (!process.stdin.isTTY) return padrao;
32
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
33
+ const r = (await rl.question(`${texto} `)).trim();
34
+ rl.close();
35
+ return r === "" ? padrao : r;
36
+ },
37
+ log: (...xs) => console.log(...xs),
38
+ };
39
+
40
+ // ---------- caminhos e inventário ----------
41
+
42
+ const home = () => os.homedir();
43
+ const sabia = (...p) => path.join(home(), ".sabia", ...p);
44
+ /** Caminho no inventário: relativo ao home, com `~/`, para ser o mesmo em qualquer máquina. */
45
+ export const curto = (p) => (p.startsWith(home() + path.sep) ? "~/" + path.relative(home(), p).split(path.sep).join("/") : p);
46
+ export const longo = (p) => (p.startsWith("~/") ? path.join(home(), ...p.slice(2).split("/")) : p);
47
+
48
+ export const PASTA_SKILLS = {
49
+ claude: () => path.join(home(), ".claude", "skills"),
50
+ opencode: () => path.join(home(), ".config", "opencode", "skills"),
51
+ };
52
+
53
+ export function lerInventario() {
54
+ const f = sabia("inventario.json");
55
+ return fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, "utf8")) : { versao: 1, framework: null, alvos: {}, toolkits: {} };
56
+ }
57
+
58
+ export function gravarInventario(inv) {
59
+ fs.mkdirSync(sabia(), { recursive: true });
60
+ fs.writeFileSync(sabia("inventario.json"), JSON.stringify(inv, null, 2) + "\n");
61
+ }
62
+
63
+ // ---------- cópias com hash ----------
64
+
65
+ function arquivos(dir, base = dir) {
66
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
67
+ const p = path.join(dir, e.name);
68
+ return e.isDirectory() ? arquivos(p, base) : [path.relative(base, p).split(path.sep).join("/")];
69
+ });
70
+ }
71
+
72
+ export function hashes(dir) {
73
+ return Object.fromEntries(arquivos(dir).sort().map((f) => [f, createHash("sha256").update(fs.readFileSync(path.join(dir, f))).digest("hex")]));
74
+ }
75
+
76
+ /** O que mudou na cópia desde que foi registrada: arquivos alterados, novos e apagados à mão. */
77
+ export function alteradosAMao(dir, registrado) {
78
+ if (!fs.existsSync(dir)) return [];
79
+ const atual = hashes(dir);
80
+ return [...new Set([...Object.keys(atual), ...Object.keys(registrado)])].filter((f) => atual[f] !== registrado[f]);
81
+ }
82
+
83
+ /**
84
+ * Copia `origem` para `destino` e registra em `copias` (destino curto → hashes). Destino que existe e não está no
85
+ * inventário é de outra pessoa ou ferramenta: recusa, salvo `forcar`. Cópia alterada à mão: mostra e pergunta.
86
+ */
87
+ export async function copiar(origem, destino, copias, { forcar = false } = {}) {
88
+ const chave = curto(destino);
89
+ if (fs.existsSync(destino)) {
90
+ if (!copias[chave] && !forcar) throw new Error(`${chave} já existe e não foi instalado pelo Sabiá (use --forcar para sobrescrever)`);
91
+ const mudou = copias[chave] ? alteradosAMao(destino, copias[chave]) : [];
92
+ if (mudou.length && !forcar) {
93
+ const r = await ctx.perguntar(`${chave} foi alterado à mão (${mudou.join(", ")}). Sobrescrever? [s/N]`, "n");
94
+ if (!/^s/i.test(r)) return ctx.log(`mantido ${chave}`);
95
+ }
96
+ fs.rmSync(destino, { recursive: true, force: true });
97
+ }
98
+ fs.mkdirSync(path.dirname(destino), { recursive: true });
99
+ fs.cpSync(origem, destino, { recursive: true });
100
+ copias[chave] = hashes(destino);
101
+ ctx.log(`copiado ${chave}`);
102
+ }
103
+
104
+ /** Apaga as cópias registradas; a alterada à mão só com "sim". */
105
+ export async function apagar(copias, { forcar = false } = {}) {
106
+ for (const [chave, registrado] of Object.entries(copias)) {
107
+ const destino = longo(chave);
108
+ const mudou = alteradosAMao(destino, registrado);
109
+ if (mudou.length && !forcar) {
110
+ const r = await ctx.perguntar(`${chave} foi alterado à mão (${mudou.join(", ")}). Apagar mesmo assim? [s/N]`, "n");
111
+ if (!/^s/i.test(r)) { ctx.log(`mantido ${chave}`); continue; }
112
+ }
113
+ fs.rmSync(destino, { recursive: true, force: true });
114
+ delete copias[chave];
115
+ ctx.log(`apagado ${chave}`);
116
+ }
117
+ }
118
+
119
+ // ---------- git e fontes ----------
120
+
121
+ function git(args, cwd) {
122
+ const r = ctx.exec("git", args, { cwd });
123
+ if (r.status !== 0) throw new Error(`git ${args.join(" ")}: ${(r.stderr || r.error?.message || "").trim()}`);
124
+ return (r.stdout || "").trim();
125
+ }
126
+
127
+ /** `<url ou pasta>[@ref]` — o `@` da ref vem depois do último `/` e do último `:` (não confunde com `git@host:`). */
128
+ export function separarRef(fonte) {
129
+ const i = fonte.lastIndexOf("@");
130
+ return i > fonte.lastIndexOf("/") && i > fonte.lastIndexOf(":") && i > 0 ? [fonte.slice(0, i), fonte.slice(i + 1)] : [fonte, null];
131
+ }
132
+
133
+ /**
134
+ * Baixa `url` (ou copia a pasta local) na `ref`, confere o `sha` e devolve { pasta, sha }. Clona com o git da
135
+ * pessoa (credencial, SSH, CA), sem submódulos; nunca guarda token nem executa nada do que baixou.
136
+ */
137
+ export function baixar(url, { ref = null, sha = null, subpasta = null } = {}) {
138
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "sabia-"));
139
+ const repo = path.join(tmp, "repo");
140
+ let atual = null;
141
+ if (fs.existsSync(url) && fs.statSync(url).isDirectory()) {
142
+ fs.cpSync(url, repo, { recursive: true, filter: (f) => path.basename(f) !== ".git" });
143
+ if (fs.existsSync(path.join(url, ".git"))) atual = git(["rev-parse", ref || "HEAD"], url);
144
+ } else {
145
+ git(["clone", "--quiet", "--depth", "1", "--no-recurse-submodules", ...(ref ? ["--branch", ref] : []), url, repo]);
146
+ atual = git(["rev-parse", "HEAD"], repo);
147
+ fs.rmSync(path.join(repo, ".git"), { recursive: true, force: true });
148
+ }
149
+ if (sha && !(atual || "").startsWith(sha)) throw new Error(`${url}@${ref}: sha ${atual} ≠ ${sha} esperado no índice`);
150
+ const pasta = subpasta ? path.join(repo, dentro(subpasta, url)) : repo;
151
+ if (!fs.existsSync(pasta)) throw new Error(`${url}: não há a subpasta ${subpasta}`);
152
+ return { pasta, sha: atual };
153
+ }
154
+
155
+ /** Caminho do manifesto preso à pasta: relativo, com `/`, sem `..`. */
156
+ export function dentro(caminho, de) {
157
+ if (typeof caminho !== "string" || /^([/\\~]|[A-Za-z]:)/.test(caminho) || caminho.includes("\\") || caminho.split("/").includes(".."))
158
+ throw new Error(`${de}: caminho fora da pasta: ${caminho}`);
159
+ return caminho;
160
+ }
161
+
162
+ // ---------- framework ----------
163
+
164
+ const skillsDoFluxo = (fw) => fs.readdirSync(path.join(fw, "skills")).filter((d) => d === "sabia" || d.startsWith("sa-"));
165
+
166
+ function temComando(cmd) {
167
+ const r = ctx.exec(cmd, ["--version"]);
168
+ return !r.error && r.status === 0;
169
+ }
170
+
171
+ export function detectarAlvos() {
172
+ return ALVOS.filter((a) => temComando({ kiro: "kiro-cli", claude: "claude", opencode: "opencode" }[a]));
173
+ }
174
+
175
+ async function escolherAlvos(opcoes) {
176
+ if (opcoes.alvo) {
177
+ const alvos = opcoes.alvo.split(",");
178
+ const fora = alvos.filter((a) => !ALVOS.includes(a));
179
+ if (fora.length) throw new Error(`ferramenta desconhecida: ${fora.join(", ")} (use ${ALVOS.join(", ")})`);
180
+ return alvos;
181
+ }
182
+ const achadas = detectarAlvos();
183
+ const r = await ctx.perguntar(`Ferramentas do time (${ALVOS.join(", ")}) [${achadas.join(",") || "nenhuma achada"}]:`, achadas.join(","));
184
+ return r.split(",").map((a) => a.trim()).filter(Boolean);
185
+ }
186
+
187
+ function servidoresMcp(fw) {
188
+ return JSON.parse(fs.readFileSync(path.join(fw, "mcp.json"), "utf8")).mcpServers;
189
+ }
190
+
191
+ function configOpencode() {
192
+ return ["opencode.jsonc", "opencode.json"].map((f) => path.join(home(), ".config", "opencode", f)).find((f) => fs.existsSync(f));
193
+ }
194
+
195
+ /** Quais MCPs já existem no opencode (lê o JSONC sem os comentários). */
196
+ export function mcpsDoOpencode() {
197
+ const arq = configOpencode();
198
+ if (!arq) return [];
199
+ const sem = fs.readFileSync(arq, "utf8").replace(/("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m, s) => s || "")
200
+ .replace(/,(\s*[}\]])/g, "$1");
201
+ const c = JSON.parse(sem);
202
+ return Object.keys(c.mcp?.servers || c.mcp || {});
203
+ }
204
+
205
+ /** O opencode 2.x não tem `mcp remove`: tira do arquivo se ele é JSON puro; com comentários, não o reescreve e avisa. */
206
+ function tirarMcpDoOpencode(nome) {
207
+ const arq = configOpencode();
208
+ if (!arq) return;
209
+ try {
210
+ const c = JSON.parse(fs.readFileSync(arq, "utf8"));
211
+ const servidores = c.mcp?.servers || c.mcp || {};
212
+ delete servidores[nome];
213
+ fs.writeFileSync(arq, JSON.stringify(c, null, 2) + "\n");
214
+ ctx.log(`MCP ${nome} tirado do opencode`);
215
+ } catch {
216
+ ctx.log(`opencode: tire o MCP ${nome} de mcp.servers no ${curto(arq)} (o arquivo tem comentários; não o reescrevo)`);
217
+ }
218
+ }
219
+
220
+ async function registrarMcps(alvo, fw, reg) {
221
+ reg.mcp ||= [];
222
+ const ja = alvo === "opencode" ? mcpsDoOpencode() : [];
223
+ for (const [nome, s] of Object.entries(servidoresMcp(fw))) {
224
+ const env = Object.entries(s.env || {}).map(([k, v]) => `${k}=${v}`);
225
+ let r;
226
+ if (alvo === "claude") {
227
+ if (ctx.exec("claude", ["mcp", "get", nome]).status === 0) { ctx.log(`MCP ${nome} já existe no Claude Code — mantido`); continue; }
228
+ // o nome vem antes do -e, que aceita vários valores e engoliria o nome
229
+ r = ctx.exec("claude", ["mcp", "add", "--scope", "user", nome, ...env.flatMap((e) => ["-e", e]), "--", s.command, ...s.args]);
230
+ } else {
231
+ if (ja.includes(nome)) { ctx.log(`MCP ${nome} já existe no opencode — mantido`); continue; }
232
+ r = ctx.exec("opencode", ["mcp", "add", "--global", ...env.flatMap((e) => ["--env", e]), nome, "--", s.command, ...s.args]);
233
+ }
234
+ if (r.status !== 0) throw new Error(`MCP ${nome} no ${alvo}: ${(r.stderr || r.error?.message || "").trim()}`);
235
+ if (!reg.mcp.includes(nome)) reg.mcp.push(nome);
236
+ ctx.log(`MCP ${nome} registrado no ${alvo}`);
237
+ }
238
+ }
239
+
240
+ async function instalarAlvo(alvo, inv, opcoes) {
241
+ const fw = sabia("framework");
242
+ if (alvo === "kiro") {
243
+ const nosso = inv.alvos.kiro?.power;
244
+ // o painel Powers instala em ~/.kiro/powers/installed/sabia: fora do inventário, é de outra instalação
245
+ if (!nosso && fs.existsSync(path.join(home(), ".kiro", "powers", "installed", "sabia")) && !opcoes.forcar)
246
+ throw new Error("o power sabia já está no Kiro e não foi instalado pelo CLI (desinstale pelo painel Powers ou use --forcar)");
247
+ if (nosso || opcoes.forcar) ctx.exec("kiro-cli", ["powers", "uninstall", "sabia"]);
248
+ const r = ctx.exec("kiro-cli", ["powers", "install", fw]);
249
+ if (r.status !== 0) throw new Error(`kiro-cli powers install: ${(r.stderr || r.error?.message || "").trim()}`);
250
+ inv.alvos.kiro = { power: "sabia" };
251
+ ctx.log("power sabia instalado no Kiro");
252
+ if (fs.existsSync(path.join(home(), ".kiro", "powers", "installed", "sdd")))
253
+ ctx.log("aviso: o power antigo sdd ainda está no Kiro e concorre com o sabia (desinstale pelo painel Powers se não usa mais)");
254
+ return;
255
+ }
256
+ const reg = (inv.alvos[alvo] ||= { copias: {}, mcp: [] });
257
+ for (const s of skillsDoFluxo(fw)) await copiar(path.join(fw, "skills", s), path.join(PASTA_SKILLS[alvo](), s), reg.copias, opcoes);
258
+ if (!opcoes.semMcp) await registrarMcps(alvo, fw, reg);
259
+ if (alvo === "opencode") {
260
+ const velhas = fs.existsSync(PASTA_SKILLS.opencode()) ? fs.readdirSync(PASTA_SKILLS.opencode()).filter((d) => /^sdd/.test(d)) : [];
261
+ if (velhas.length) ctx.log(`aviso: skills antigas no opencode concorrem com as sa-*: ${velhas.join(", ")} (remova se não usa mais)`);
262
+ }
263
+ }
264
+
265
+ /** Baixa o framework para uma pasta temporária e diz o que muda em relação a ~/.sabia/framework/. */
266
+ function baixarFramework(opcoes) {
267
+ const versao = opcoes.versao || VERSAO;
268
+ const [url, ref] = opcoes.fonte ? separarRef(opcoes.fonte) : [REPO, `sabia-${versao}`];
269
+ const { pasta, sha } = baixar(url, { ref });
270
+ if (!fs.existsSync(path.join(pasta, "skills", "sa-setup", "SKILL.md"))) throw new Error(`${url}: não parece o Sabiá (falta skills/sa-setup)`);
271
+ const plugin = JSON.parse(fs.readFileSync(path.join(pasta, "plugin.json"), "utf8"));
272
+ return { pasta, sha, versao: plugin.version, fonte: opcoes.fonte || REPO };
273
+ }
274
+
275
+ export function diferencas(antes, depois) {
276
+ const a = fs.existsSync(antes) ? hashes(antes) : {};
277
+ const d = hashes(depois);
278
+ return {
279
+ novos: Object.keys(d).filter((f) => !(f in a)),
280
+ alterados: Object.keys(d).filter((f) => f in a && a[f] !== d[f]),
281
+ removidos: Object.keys(a).filter((f) => !(f in d)),
282
+ };
283
+ }
284
+
285
+ function mostrarDiferencas(dif) {
286
+ for (const [rotulo, xs] of Object.entries(dif)) if (xs.length) ctx.log(` ${rotulo} (${xs.length}): ${xs.slice(0, 20).join(", ")}${xs.length > 20 ? ", …" : ""}`);
287
+ return Object.values(dif).some((xs) => xs.length);
288
+ }
289
+
290
+ export async function instalar(opcoes = {}) {
291
+ ctx.log(BANNER + "\n");
292
+ const inv = lerInventario();
293
+ const novo = baixarFramework(opcoes);
294
+ fs.rmSync(sabia("framework"), { recursive: true, force: true });
295
+ fs.mkdirSync(sabia(), { recursive: true });
296
+ fs.cpSync(novo.pasta, sabia("framework"), { recursive: true });
297
+ inv.framework = { versao: novo.versao, sha: novo.sha, fonte: novo.fonte, instalado_em: new Date().toISOString() };
298
+ ctx.log(`Sabiá ${novo.versao} em ~/.sabia/framework`);
299
+ try {
300
+ for (const alvo of await escolherAlvos(opcoes)) await instalarAlvo(alvo, inv, opcoes);
301
+ } finally {
302
+ gravarInventario(inv);
303
+ }
304
+ ctx.log('Pronto. No projeto, peça "configurar o Sabiá" (ou "atualizar os agentes do Sabiá").');
305
+ }
306
+
307
+ export async function atualizar(opcoes = {}) {
308
+ const inv = lerInventario();
309
+ if (!inv.framework) throw new Error("o Sabiá não está instalado: rode `sabia instalar`");
310
+ const novo = baixarFramework({ fonte: inv.framework.fonte === REPO ? undefined : inv.framework.fonte, ...opcoes });
311
+ ctx.log(`Sabiá ${inv.framework.versao} → ${novo.versao}`);
312
+ if (!mostrarDiferencas(diferencas(sabia("framework"), novo.pasta))) return ctx.log("nada mudou");
313
+ if (!/^s/i.test(await ctx.perguntar("Atualizar? [s/N]", "n"))) return ctx.log("nada foi alterado");
314
+ fs.rmSync(sabia("framework"), { recursive: true, force: true });
315
+ fs.cpSync(novo.pasta, sabia("framework"), { recursive: true });
316
+ inv.framework = { ...inv.framework, versao: novo.versao, sha: novo.sha, instalado_em: new Date().toISOString() };
317
+ try {
318
+ for (const alvo of Object.keys(inv.alvos)) {
319
+ if (alvo !== "kiro") { // skill que saiu do framework sai da ferramenta
320
+ const copias = inv.alvos[alvo].copias;
321
+ const fica = new Set(skillsDoFluxo(sabia("framework")).map((s) => curto(path.join(PASTA_SKILLS[alvo](), s))));
322
+ const sai = Object.fromEntries(Object.entries(copias).filter(([k]) => !fica.has(k)));
323
+ await apagar(sai, opcoes);
324
+ for (const k of Object.keys(copias)) if (!fica.has(k) && !(k in sai)) delete copias[k];
325
+ }
326
+ await instalarAlvo(alvo, inv, { ...opcoes, semMcp: true });
327
+ }
328
+ } finally {
329
+ gravarInventario(inv);
330
+ }
331
+ ctx.log('Pronto. Em cada projeto, peça "atualizar os agentes do Sabiá".');
332
+ }
333
+
334
+ export async function desinstalar(opcoes = {}) {
335
+ const inv = lerInventario();
336
+ const alvos = opcoes.alvo ? opcoes.alvo.split(",") : Object.keys(inv.alvos);
337
+ try {
338
+ for (const alvo of alvos) {
339
+ const reg = inv.alvos[alvo];
340
+ if (!reg) { ctx.log(`${alvo}: nada instalado pelo Sabiá`); continue; }
341
+ for (const tk of Object.values(inv.toolkits)) if (tk.alvos?.[alvo]) { await apagar(tk.alvos[alvo].copias, opcoes); delete tk.alvos[alvo]; }
342
+ if (alvo === "kiro") ctx.exec("kiro-cli", ["powers", "uninstall", reg.power]);
343
+ else await apagar(reg.copias, opcoes);
344
+ for (const nome of reg.mcp || []) {
345
+ if (alvo === "claude") ctx.exec("claude", ["mcp", "remove", "--scope", "user", nome]);
346
+ else tirarMcpDoOpencode(nome);
347
+ }
348
+ if (alvo !== "kiro" && Object.keys(reg.copias).length) continue; // ficou cópia alterada à mão
349
+ delete inv.alvos[alvo];
350
+ ctx.log(`${alvo}: Sabiá desinstalado`);
351
+ }
352
+ if (!Object.keys(inv.alvos).length) {
353
+ fs.rmSync(sabia("framework"), { recursive: true, force: true });
354
+ inv.framework = null;
355
+ }
356
+ } finally {
357
+ gravarInventario(inv);
358
+ }
359
+ }
360
+
361
+ // ---------- toolkits ----------
362
+
363
+ /** Erros do toolkit.json: campos, nome, caminhos presos à pasta e arquivos que existem (o schema completo é o da setup). */
364
+ export function validarToolkit(pasta, nomeEsperado = null) {
365
+ const f = path.join(pasta, "toolkit.json");
366
+ if (!fs.existsSync(f)) return ["falta o toolkit.json"];
367
+ let m;
368
+ try { m = JSON.parse(fs.readFileSync(f, "utf8")); } catch (e) { return [`toolkit.json inválido: ${e.message}`]; }
369
+ const erros = [];
370
+ for (const c of ["nome", "versao", "descricao", "tipos-de-projeto", "skills"]) if (!(c in m)) erros.push(`falta o campo ${c}`);
371
+ const nomeOk = (n) => typeof n === "string" && /^[a-z0-9][a-z0-9-]*$/.test(n) && !n.startsWith("sa-");
372
+ if (!nomeOk(m.nome)) erros.push(`nome inválido (kebab-case, sem sa-): ${m.nome}`);
373
+ if (nomeEsperado && m.nome !== nomeEsperado) erros.push(`nome ${m.nome} ≠ ${nomeEsperado} do índice`);
374
+ const existe = (c, de) => {
375
+ try { if (!fs.existsSync(path.join(pasta, dentro(c, de)))) erros.push(`${de}: não existe ${c}`); } catch (e) { erros.push(e.message); }
376
+ };
377
+ const agentes = new Set((m.agentes || []).map((a) => a.nome));
378
+ for (const s of m.skills || []) {
379
+ existe(s.caminho, "skills");
380
+ if (s.passo?.agente && !agentes.has(s.passo.agente)) erros.push(`passo de ${s.caminho}: agente ${s.passo.agente} não está em agentes`);
381
+ }
382
+ for (const s of m.steering || []) existe(s.caminho, "steering");
383
+ for (const a of m.agentes || []) {
384
+ if (!nomeOk(a.nome)) erros.push(`agente com nome inválido (kebab-case, sem sa-): ${a.nome}`);
385
+ existe(a.prompt, `agente ${a.nome}`);
386
+ for (const s of a.skills || []) existe(s, `agente ${a.nome}`);
387
+ for (const s of a.subagentes || []) if (!agentes.has(s)) erros.push(`agente ${a.nome}: subagente ${s} não está em agentes`);
388
+ }
389
+ return erros;
390
+ }
391
+
392
+ function indices() {
393
+ const f = sabia("indices.json");
394
+ return [INDICE, ...(fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, "utf8")) : [])];
395
+ }
396
+
397
+ /** Entradas do índice (`.claude-plugin/marketplace.json`), cada uma resolvida para { nome, versao, url, ref, sha, subpasta }. */
398
+ export function lerIndice(fonte) {
399
+ const local = fs.existsSync(fonte);
400
+ const raiz = local ? fonte : baixar(fonte).pasta;
401
+ const m = JSON.parse(fs.readFileSync(path.join(raiz, ".claude-plugin", "marketplace.json"), "utf8"));
402
+ return (m.plugins || []).map((p) => {
403
+ const s = p.source;
404
+ const base = { nome: p.name, versao: p.version, descricao: p.description, indice: fonte };
405
+ if (typeof s === "string") return { ...base, url: local ? path.resolve(raiz, dentro(s.replace(/^\.\//, ""), fonte)) : fonte, subpasta: local ? null : s.replace(/^\.\//, "") };
406
+ if (s?.source === "github") return { ...base, url: `https://github.com/${s.repo}.git`, ref: s.ref, sha: s.sha };
407
+ if (s?.source === "url") return { ...base, url: s.url, ref: s.ref, sha: s.sha };
408
+ if (s?.source === "git-subdir") return { ...base, url: s.url, ref: s.ref, sha: s.sha, subpasta: s.path };
409
+ throw new Error(`${fonte}: fonte de ${p.name} não suportada (${JSON.stringify(s)}); use caminho, github, url ou git-subdir`);
410
+ });
411
+ }
412
+
413
+ function acharNoIndice(nome, versao, opcoes) {
414
+ for (const i of opcoes.indice ? [opcoes.indice] : indices()) {
415
+ const e = lerIndice(i).find((x) => x.nome === nome && (!versao || x.versao === versao));
416
+ if (e) return e;
417
+ }
418
+ throw new Error(`toolkit ${nome}${versao ? "@" + versao : ""} não está nos índices`);
419
+ }
420
+
421
+ export async function baixarToolkit(alvo, opcoes = {}) {
422
+ const ehUrl = /[:/]/.test(alvo) && !/^[a-z0-9-]+(@[\w.-]+)?$/.test(alvo);
423
+ const [nomeOuUrl, versaoOuRef] = separarRef(alvo);
424
+ const e = ehUrl ? { nome: null, url: nomeOuUrl, ref: versaoOuRef } : acharNoIndice(nomeOuUrl, versaoOuRef, opcoes);
425
+ const { pasta, sha } = baixar(e.url, e);
426
+ const erros = validarToolkit(pasta, e.nome);
427
+ if (erros.length) throw new Error(`toolkit recusado:\n- ${erros.join("\n- ")}`);
428
+ const m = JSON.parse(fs.readFileSync(path.join(pasta, "toolkit.json"), "utf8"));
429
+ const destino = sabia("toolkits", m.nome);
430
+ const dif = diferencas(destino, pasta);
431
+ if (fs.existsSync(destino)) {
432
+ ctx.log(`toolkit ${m.nome}: ${JSON.parse(fs.readFileSync(path.join(destino, "toolkit.json"), "utf8")).versao} → ${m.versao}`);
433
+ if (!mostrarDiferencas(dif)) { ctx.log("nada mudou"); return m.nome; }
434
+ if (!/^s/i.test(await ctx.perguntar("Substituir? [s/N]", "n"))) { ctx.log("nada foi alterado"); return null; }
435
+ fs.rmSync(destino, { recursive: true, force: true });
436
+ }
437
+ fs.mkdirSync(path.dirname(destino), { recursive: true });
438
+ fs.cpSync(pasta, destino, { recursive: true });
439
+ const origem = { indice: e.indice || null, url: e.url, ref: e.ref || null, sha, versao: m.versao };
440
+ fs.writeFileSync(path.join(destino, ".origem.json"), JSON.stringify(origem, null, 2) + "\n");
441
+ const inv = lerInventario();
442
+ inv.toolkits[m.nome] = { ...(inv.toolkits[m.nome] || { alvos: {} }), versao: m.versao, sha, fonte: e.url };
443
+ gravarInventario(inv);
444
+ ctx.log(`toolkit ${m.nome} ${m.versao} em ~/.sabia/toolkits/${m.nome}`);
445
+ return m.nome;
446
+ }
447
+
448
+ /** Copia as skills do toolkit para as ferramentas (o Kiro lê de ~/.sabia/toolkits pelo `skill://` de cada agente). */
449
+ export async function instalarToolkit(alvo, opcoes = {}) {
450
+ const [nome] = separarRef(alvo);
451
+ const baixado = fs.existsSync(sabia("toolkits", nome, "toolkit.json")) && !opcoes.atualizar ? nome : await baixarToolkit(alvo, opcoes);
452
+ if (!baixado) return;
453
+ const pasta = sabia("toolkits", baixado);
454
+ const m = JSON.parse(fs.readFileSync(path.join(pasta, "toolkit.json"), "utf8"));
455
+ const inv = lerInventario();
456
+ const tk = (inv.toolkits[baixado] ||= { versao: m.versao, alvos: {} });
457
+ const alvos = opcoes.alvo ? opcoes.alvo.split(",") : Object.keys(inv.alvos).length ? Object.keys(inv.alvos) : await escolherAlvos(opcoes);
458
+ try {
459
+ for (const a of alvos.filter((x) => x !== "kiro")) {
460
+ const reg = (tk.alvos[a] ||= { copias: {} });
461
+ for (const s of m.skills) {
462
+ const dir = path.dirname(path.join(pasta, s.caminho));
463
+ await copiar(dir, path.join(PASTA_SKILLS[a](), path.basename(dir)), reg.copias, opcoes);
464
+ }
465
+ }
466
+ } finally {
467
+ gravarInventario(inv);
468
+ }
469
+ if (alvos.includes("kiro")) ctx.log(`kiro: o gerador liga as skills de ~/.sabia/toolkits/${baixado} a cada agente (nada copiado para ~/.kiro)`);
470
+ ctx.log('No projeto, peça "atualizar os agentes do Sabiá" para ligar o toolkit.');
471
+ }
472
+
473
+ /** Sem argumento, dentro de um projeto: as versões que o `.sabia/config/agentes.json` dele usa. */
474
+ export async function instalarDoProjeto(opcoes = {}) {
475
+ const f = path.join(process.cwd(), ".sabia", "config", "agentes.json");
476
+ if (!fs.existsSync(f)) throw new Error("diga o toolkit, ou rode dentro de um projeto com .sabia/config/agentes.json");
477
+ const inv = lerInventario();
478
+ for (const [nome, t] of Object.entries(JSON.parse(fs.readFileSync(f, "utf8")).toolkits || {})) {
479
+ if (inv.toolkits[nome]?.versao === t.versao) { ctx.log(`toolkit ${nome} ${t.versao} já instalado`); continue; }
480
+ await instalarToolkit(`${nome}@${t.versao}`, { ...opcoes, atualizar: true });
481
+ }
482
+ }
483
+
484
+ export async function removerToolkit(nome, opcoes = {}) {
485
+ const inv = lerInventario();
486
+ const tk = inv.toolkits[nome];
487
+ try {
488
+ for (const reg of Object.values(tk?.alvos || {})) await apagar(reg.copias, opcoes);
489
+ fs.rmSync(sabia("toolkits", nome), { recursive: true, force: true });
490
+ delete inv.toolkits[nome];
491
+ } finally {
492
+ gravarInventario(inv);
493
+ }
494
+ ctx.log(`toolkit ${nome} removido`);
495
+ }
496
+
497
+ export async function atualizarToolkits(nome, opcoes = {}) {
498
+ const inv = lerInventario();
499
+ for (const n of nome ? [nome] : Object.keys(inv.toolkits)) {
500
+ const origem = JSON.parse(fs.readFileSync(sabia("toolkits", n, ".origem.json"), "utf8"));
501
+ const alvo = origem.indice ? n : `${origem.url}${origem.ref ? "@" + origem.ref : ""}`;
502
+ await instalarToolkit(alvo, { ...opcoes, atualizar: true, indice: origem.indice || opcoes.indice,
503
+ alvo: Object.keys(inv.toolkits[n]?.alvos || {}).join(",") || "kiro" });
504
+ }
505
+ }
506
+
507
+ export function listarToolkits(opcoes = {}) {
508
+ const inv = lerInventario();
509
+ const projeto = path.join(process.cwd(), ".sabia", "config", "agentes.json");
510
+ const doProjeto = fs.existsSync(projeto) ? JSON.parse(fs.readFileSync(projeto, "utf8")).toolkits || {} : {};
511
+ const linhas = [];
512
+ for (const i of opcoes.indice ? [opcoes.indice] : indices()) {
513
+ try {
514
+ for (const e of lerIndice(i)) linhas.push([e.nome, e.versao || "—", inv.toolkits[e.nome]?.versao || "—", doProjeto[e.nome]?.versao || "—", e.descricao || ""]);
515
+ } catch (err) {
516
+ ctx.log(`índice ${i}: ${err.message}`);
517
+ }
518
+ }
519
+ for (const [n, t] of Object.entries(inv.toolkits)) if (!linhas.some((l) => l[0] === n)) linhas.push([n, "—", t.versao, doProjeto[n]?.versao || "—", "(fora dos índices)"]);
520
+ ctx.log(["| Toolkit | No índice | Instalado | Neste projeto | Descrição |", "|---|---|---|---|---|",
521
+ ...linhas.map((l) => `| ${l.join(" | ")} |`)].join("\n"));
522
+ return linhas;
523
+ }
524
+
525
+ // ---------- linha de comando ----------
526
+
527
+ const AJUDA = `${BANNER}
528
+
529
+ sabia ${VERSAO} — instala o Sabiá e os toolkits no Kiro, no Claude Code e no opencode
530
+
531
+ sabia instalar [--alvo kiro,claude,opencode] [--versao X] [--fonte <pasta|url-git>[@ref]]
532
+ sabia atualizar [--versao X]
533
+ sabia desinstalar [--alvo …]
534
+ sabia toolkits listar [--indice <url-git|pasta>]
535
+ sabia toolkits baixar <nome>[@versao] | <url-git>[@ref]
536
+ sabia toolkits instalar <nome>[@versao] | <url-git>[@ref] [--alvo …] (sem argumento, no projeto: as versões dele)
537
+ sabia toolkits atualizar [<nome>]
538
+ sabia toolkits remover <nome>
539
+
540
+ --sim responde "sim" às confirmações (cópia alterada à mão continua perguntando, salvo --forcar)
541
+ --forcar sobrescreve o que não foi instalado pelo Sabiá ou foi alterado à mão
542
+ --sem-mcp não registra os MCPs
543
+
544
+ Tudo fica em ~/.sabia/ (framework/, toolkits/, inventario.json).`;
545
+
546
+ export function lerArgs(argv) {
547
+ const posicionais = [];
548
+ const opcoes = {};
549
+ for (let i = 0; i < argv.length; i++) {
550
+ const a = argv[i];
551
+ if (!a.startsWith("--")) { posicionais.push(a); continue; }
552
+ const chave = a.slice(2).replace(/-(\w)/g, (_, c) => c.toUpperCase());
553
+ if (["sim", "forcar", "semMcp", "ajuda", "help"].includes(chave)) opcoes[chave] = true;
554
+ else opcoes[chave] = argv[++i];
555
+ }
556
+ return { posicionais, opcoes };
557
+ }
558
+
559
+ export async function main(argv = process.argv.slice(2)) {
560
+ const { posicionais: [cmd, sub, arg], opcoes } = lerArgs(argv);
561
+ if (opcoes.sim) { // cópia alterada à mão continua perguntando: só o --forcar passa por cima
562
+ const antes = ctx.perguntar;
563
+ ctx.perguntar = async (texto, padrao) => (/alterado à mão/.test(texto) ? antes(texto, padrao) : /\[s\/N\]/.test(texto) ? "s" : padrao);
564
+ }
565
+ try {
566
+ if (cmd === "instalar") await instalar(opcoes);
567
+ else if (cmd === "atualizar") await atualizar(opcoes);
568
+ else if (cmd === "desinstalar") await desinstalar(opcoes);
569
+ else if (cmd === "toolkits" && sub === "listar") listarToolkits(opcoes);
570
+ else if (cmd === "toolkits" && sub === "baixar" && arg) await baixarToolkit(arg, opcoes);
571
+ else if (cmd === "toolkits" && sub === "instalar") await (arg ? instalarToolkit(arg, opcoes) : instalarDoProjeto(opcoes));
572
+ else if (cmd === "toolkits" && sub === "atualizar") await atualizarToolkits(arg, opcoes);
573
+ else if (cmd === "toolkits" && sub === "remover" && arg) await removerToolkit(arg, opcoes);
574
+ else { ctx.log(AJUDA); return cmd && !opcoes.ajuda && !opcoes.help ? 1 : 0; }
575
+ return 0;
576
+ } catch (e) {
577
+ console.error(`sabia: ${e.message}`);
578
+ return 1;
579
+ }
580
+ }
581
+
582
+ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = await main();
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.