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