arsenal-agent 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/COMANDOS.md +34 -0
- package/README.md +36 -0
- package/app.py +308 -0
- package/dist/agents.d.ts +8 -0
- package/dist/agents.js +37 -0
- package/dist/app.d.ts +1 -0
- package/dist/app.js +121 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +57 -0
- package/dist/profiles.d.ts +14 -0
- package/dist/profiles.js +171 -0
- package/dist/router.d.ts +4 -0
- package/dist/router.js +42 -0
- package/dist/types.d.ts +20 -0
- package/dist/types.js +2 -0
- package/package.json +40 -0
- package/run.sh +19 -0
- package/src/agents.ts +54 -0
- package/src/app.tsx +236 -0
- package/src/index.tsx +64 -0
- package/src/profiles.ts +217 -0
- package/src/router.ts +47 -0
- package/src/types.ts +23 -0
- package/tsconfig.json +18 -0
package/dist/profiles.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, copyFileSync, } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
const CONFIG_DIR = join(homedir(), ".arsenal-agent");
|
|
7
|
+
const CONFIG_FILE = join(CONFIG_DIR, "profiles.json");
|
|
8
|
+
const CREDS_DIR = join(CONFIG_DIR, "credentials"); // um .json por perfil
|
|
9
|
+
const CLAUDE_CREDS = join(homedir(), ".claude", ".credentials.json");
|
|
10
|
+
// ── Helpers de arquivo ───────────────────────────────────────────────────────
|
|
11
|
+
function ensureDir() {
|
|
12
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
13
|
+
mkdirSync(CREDS_DIR, { recursive: true, mode: 0o700 });
|
|
14
|
+
}
|
|
15
|
+
function loadConfig() {
|
|
16
|
+
ensureDir();
|
|
17
|
+
if (!existsSync(CONFIG_FILE))
|
|
18
|
+
return { current: "", profiles: {} };
|
|
19
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
20
|
+
}
|
|
21
|
+
function saveConfig(config) {
|
|
22
|
+
ensureDir();
|
|
23
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
24
|
+
chmodSync(CONFIG_FILE, 0o600);
|
|
25
|
+
}
|
|
26
|
+
function credsPath(name) {
|
|
27
|
+
return join(CREDS_DIR, `${name}.json`);
|
|
28
|
+
}
|
|
29
|
+
function prompt(question) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
32
|
+
rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
// ── Adicionar perfil ─────────────────────────────────────────────────────────
|
|
36
|
+
export async function addProfile(name) {
|
|
37
|
+
const config = loadConfig();
|
|
38
|
+
const profileName = name || await prompt("Nome do perfil (ex: pessoal, trabalho): ");
|
|
39
|
+
if (!profileName) {
|
|
40
|
+
console.error("Nome obrigatório.");
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
const label = await prompt(`Label (ex: DBC Tech, Pessoal) [Enter p/ pular]: `);
|
|
44
|
+
const email = await prompt("Email da conta: ");
|
|
45
|
+
const stored = {
|
|
46
|
+
label: label || profileName,
|
|
47
|
+
auth_method: "browser",
|
|
48
|
+
email,
|
|
49
|
+
};
|
|
50
|
+
await _captureOAuthToken(profileName, email);
|
|
51
|
+
config.profiles[profileName] = stored;
|
|
52
|
+
if (!config.current)
|
|
53
|
+
config.current = profileName;
|
|
54
|
+
saveConfig(config);
|
|
55
|
+
console.log(`\n✓ Perfil "${profileName}" salvo (chmod 600)`);
|
|
56
|
+
console.log(` Use: aa --profile ${profileName}`);
|
|
57
|
+
}
|
|
58
|
+
async function _captureOAuthToken(profileName, email) {
|
|
59
|
+
const backup = `${CLAUDE_CREDS}.bak`;
|
|
60
|
+
const hadCreds = existsSync(CLAUDE_CREDS);
|
|
61
|
+
if (hadCreds)
|
|
62
|
+
copyFileSync(CLAUDE_CREDS, backup);
|
|
63
|
+
console.log(`\n──────────────────────────────────────────`);
|
|
64
|
+
console.log(` Autenticando: ${email}`);
|
|
65
|
+
console.log(` O navegador vai abrir — faça login com essa conta.`);
|
|
66
|
+
console.log(`──────────────────────────────────────────\n`);
|
|
67
|
+
try {
|
|
68
|
+
spawnSync("claude", ["auth", "logout"], { stdio: "inherit", env: process.env });
|
|
69
|
+
// Inicia login — no WSL pode não abrir o browser automaticamente
|
|
70
|
+
const login = spawnSync("claude", ["auth", "login", "--email", email], {
|
|
71
|
+
stdio: "inherit",
|
|
72
|
+
env: process.env,
|
|
73
|
+
});
|
|
74
|
+
// Mesmo que retorne erro (browser não abriu), verifica se o token foi gravado
|
|
75
|
+
if (!existsSync(CLAUDE_CREDS)) {
|
|
76
|
+
// Fallback: pede para o usuário abrir o link manualmente e confirmar
|
|
77
|
+
console.log("\n⚠ Browser não abriu automaticamente.");
|
|
78
|
+
console.log(" Copie a URL acima, abra no navegador, faça login e volte aqui.");
|
|
79
|
+
await prompt("\nPressione Enter após completar o login no navegador...");
|
|
80
|
+
if (!existsSync(CLAUDE_CREDS)) {
|
|
81
|
+
throw new Error("Login não completado — .credentials.json não encontrado.");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
copyFileSync(CLAUDE_CREDS, credsPath(profileName));
|
|
85
|
+
chmodSync(credsPath(profileName), 0o600);
|
|
86
|
+
console.log(`\n✓ Token OAuth de "${profileName}" salvo`);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
if (hadCreds) {
|
|
90
|
+
copyFileSync(backup, CLAUDE_CREDS);
|
|
91
|
+
console.log("✓ Conta anterior restaurada");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// ── Listar perfis ────────────────────────────────────────────────────────────
|
|
96
|
+
export function listProfiles() {
|
|
97
|
+
const config = loadConfig();
|
|
98
|
+
const names = Object.keys(config.profiles);
|
|
99
|
+
if (names.length === 0) {
|
|
100
|
+
console.log("Nenhum perfil. Use: aa profile add");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
console.log("\nPerfis cadastrados:\n");
|
|
104
|
+
for (const name of names) {
|
|
105
|
+
const p = config.profiles[name];
|
|
106
|
+
const active = name === config.current ? " ← ativo" : "";
|
|
107
|
+
console.log(` ${name}${active} [${p.auth_method}]`);
|
|
108
|
+
console.log(` label: ${p.label ?? name}`);
|
|
109
|
+
if (p.auth_method === "api_key" && p.anthropic_api_key) {
|
|
110
|
+
console.log(` anthropic: ${p.anthropic_api_key.slice(0, 14)}...`);
|
|
111
|
+
if (p.openrouter_api_key)
|
|
112
|
+
console.log(` openrouter: ${p.openrouter_api_key.slice(0, 14)}...`);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const hasCreds = existsSync(credsPath(name));
|
|
116
|
+
console.log(` email: ${p.email ?? "?"}`);
|
|
117
|
+
console.log(` oauth token: ${hasCreds ? "✓ salvo" : "✗ não encontrado — rode: aa profile add " + name}`);
|
|
118
|
+
}
|
|
119
|
+
console.log();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// ── Trocar perfil padrão ─────────────────────────────────────────────────────
|
|
123
|
+
export function useProfile(name) {
|
|
124
|
+
const config = loadConfig();
|
|
125
|
+
if (!config.profiles[name]) {
|
|
126
|
+
console.error(`Perfil "${name}" não encontrado. Perfis: ${Object.keys(config.profiles).join(", ")}`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
config.current = name;
|
|
130
|
+
saveConfig(config);
|
|
131
|
+
// Se for browser, faz o swap do .credentials.json
|
|
132
|
+
const p = config.profiles[name];
|
|
133
|
+
if (p.auth_method === "browser") {
|
|
134
|
+
_swapCredentials(name, p.label ?? name);
|
|
135
|
+
}
|
|
136
|
+
console.log(`✓ Perfil ativo: ${name} (${p.label ?? name})`);
|
|
137
|
+
}
|
|
138
|
+
function _swapCredentials(profileName, label) {
|
|
139
|
+
const src = credsPath(profileName);
|
|
140
|
+
if (!existsSync(src)) {
|
|
141
|
+
console.error(`Token OAuth não encontrado para "${profileName}". Rode: aa profile add ${profileName}`);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
// Backup do atual
|
|
145
|
+
if (existsSync(CLAUDE_CREDS))
|
|
146
|
+
copyFileSync(CLAUDE_CREDS, `${CLAUDE_CREDS}.prev`);
|
|
147
|
+
copyFileSync(src, CLAUDE_CREDS);
|
|
148
|
+
chmodSync(CLAUDE_CREDS, 0o600);
|
|
149
|
+
console.log(`✓ Credenciais OAuth trocadas → ${label}`);
|
|
150
|
+
}
|
|
151
|
+
// ── Carregar e aplicar perfil ────────────────────────────────────────────────
|
|
152
|
+
export function loadProfile(name) {
|
|
153
|
+
const config = loadConfig();
|
|
154
|
+
const profileName = name || config.current;
|
|
155
|
+
if (!profileName || !config.profiles[profileName])
|
|
156
|
+
return null;
|
|
157
|
+
return { name: profileName, ...config.profiles[profileName] };
|
|
158
|
+
}
|
|
159
|
+
export function applyProfile(profile) {
|
|
160
|
+
if (profile.auth_method === "browser") {
|
|
161
|
+
// Garante que as credenciais certas estão no lugar para o claude CLI
|
|
162
|
+
_swapCredentials(profile.name, profile.label ?? profile.name);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
if (profile.anthropic_api_key)
|
|
166
|
+
process.env.ANTHROPIC_API_KEY = profile.anthropic_api_key;
|
|
167
|
+
if (profile.openrouter_api_key)
|
|
168
|
+
process.env.OPENROUTER_API_KEY = profile.openrouter_api_key;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=profiles.js.map
|
package/dist/router.d.ts
ADDED
package/dist/router.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const MODELS = {
|
|
2
|
+
"nemotron-120b": "openrouter/nvidia/nemotron-3-super-120b-a12b:free",
|
|
3
|
+
"nemotron-nano": "openrouter/nvidia/nemotron-nano-12b-v2-vl:free",
|
|
4
|
+
"sonnet": "anthropic/claude-sonnet-4-6",
|
|
5
|
+
};
|
|
6
|
+
// Custo por 1k tokens (output estimado ~300 tokens)
|
|
7
|
+
export const COST_PER_1K = {
|
|
8
|
+
"nemotron-120b": 0.0,
|
|
9
|
+
"nemotron-nano": 0.0,
|
|
10
|
+
"sonnet": 0.003,
|
|
11
|
+
};
|
|
12
|
+
export function getModelId(alias) {
|
|
13
|
+
return MODELS[alias];
|
|
14
|
+
}
|
|
15
|
+
const COMPLEX_PATTERNS = [
|
|
16
|
+
/arquitetura|architecture/i,
|
|
17
|
+
/seguran[cç]a|security|vulnerabilidade/i,
|
|
18
|
+
/debug|investiga|diagnos/i,
|
|
19
|
+
/refatora|refactor/i,
|
|
20
|
+
/por que|why|como funciona|how does/i,
|
|
21
|
+
/analisa|analyze|avalia/i,
|
|
22
|
+
/projeta|design system/i,
|
|
23
|
+
/pipeline|infraestrutura|infrastructure/i,
|
|
24
|
+
/revis[aã]o|review/i,
|
|
25
|
+
/estrat[eé]gia|strategy/i,
|
|
26
|
+
/crie um sistema|cria um sistema|build a system/i,
|
|
27
|
+
/explique a arquitetura/i,
|
|
28
|
+
/implemente.*sistema|implement.*system/i,
|
|
29
|
+
/agent[e]? mcp|mcp server/i,
|
|
30
|
+
];
|
|
31
|
+
export function classifyTask(task) {
|
|
32
|
+
for (const pattern of COMPLEX_PATTERNS) {
|
|
33
|
+
if (pattern.test(task)) {
|
|
34
|
+
return { model: "sonnet", reason: `match: ${pattern.source.split("|")[0]}` };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (task.length > 400) {
|
|
38
|
+
return { model: "sonnet", reason: "prompt longo (>400 chars)" };
|
|
39
|
+
}
|
|
40
|
+
return { model: "nemotron-120b", reason: "task simples → free" };
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=router.js.map
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type ModelAlias = "nemotron-120b" | "nemotron-nano" | "sonnet";
|
|
2
|
+
export interface Message {
|
|
3
|
+
id: string;
|
|
4
|
+
role: "user" | "assistant";
|
|
5
|
+
content: string;
|
|
6
|
+
model: ModelAlias;
|
|
7
|
+
elapsed?: number;
|
|
8
|
+
timestamp: Date;
|
|
9
|
+
}
|
|
10
|
+
export interface RouterDecision {
|
|
11
|
+
model: ModelAlias;
|
|
12
|
+
reason: string;
|
|
13
|
+
}
|
|
14
|
+
export interface Stats {
|
|
15
|
+
tasksTotal: number;
|
|
16
|
+
tasksFree: number;
|
|
17
|
+
tasksPaid: number;
|
|
18
|
+
costPaid: number;
|
|
19
|
+
costSaved: number;
|
|
20
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "arsenal-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Dual AI agent CLI — routes tasks between Claude Sonnet and Nemotron (free) automatically",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "DBC Tech",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"arsenal-agent": "./dist/index.js",
|
|
10
|
+
"aa": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"dev": "tsx src/index.tsx",
|
|
15
|
+
"start": "node dist/index.js",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@anthropic-ai/sdk": "^0.51.0",
|
|
20
|
+
"@opencode-ai/sdk": "^1.3.13",
|
|
21
|
+
"ink": "^5.2.1",
|
|
22
|
+
"react": "^18.3.1",
|
|
23
|
+
"chalk": "^5.4.1",
|
|
24
|
+
"commander": "^13.1.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/react": "^18.3.3",
|
|
28
|
+
"@types/node": "^22.0.0",
|
|
29
|
+
"typescript": "^5.8.3",
|
|
30
|
+
"tsx": "^4.19.2"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20"
|
|
34
|
+
},
|
|
35
|
+
"keywords": ["ai", "claude", "opencode", "nemotron", "cli", "agent", "dual-agent"],
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "https://github.com/dbc-tech/arsenal-agent.git"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/run.sh
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Dual Agent CLI launcher
|
|
3
|
+
# Verifica deps e lança o TUI
|
|
4
|
+
|
|
5
|
+
VENV="/home/dbeze/projetos/arsenal/venv"
|
|
6
|
+
SCRIPT="$(dirname "$0")/app.py"
|
|
7
|
+
|
|
8
|
+
# Verifica se claude CLI está disponível
|
|
9
|
+
if ! command -v claude &>/dev/null; then
|
|
10
|
+
echo "AVISO: 'claude' CLI não encontrado — tasks complexas usarão Nemotron como fallback"
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
# Verifica se opencode está disponível
|
|
14
|
+
if ! command -v opencode &>/dev/null; then
|
|
15
|
+
echo "ERRO: 'opencode' não encontrado. Instale com: npm install -g opencode-ai"
|
|
16
|
+
exit 1
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
exec "$VENV/bin/python3" "$SCRIPT"
|
package/src/agents.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { execFile } from "node:child_process"
|
|
2
|
+
import { promisify } from "node:util"
|
|
3
|
+
import Anthropic from "@anthropic-ai/sdk"
|
|
4
|
+
import type { ModelAlias } from "./types.js"
|
|
5
|
+
import { getModelId } from "./router.js"
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile)
|
|
8
|
+
|
|
9
|
+
const anthropic = new Anthropic({
|
|
10
|
+
apiKey: process.env.ANTHROPIC_API_KEY,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export interface AgentResult {
|
|
14
|
+
content: string
|
|
15
|
+
elapsed: number
|
|
16
|
+
model: ModelAlias
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function runClaude(task: string): Promise<AgentResult> {
|
|
20
|
+
const t0 = Date.now()
|
|
21
|
+
|
|
22
|
+
const message = await anthropic.messages.create({
|
|
23
|
+
model: "claude-sonnet-4-6-20251101",
|
|
24
|
+
max_tokens: 2048,
|
|
25
|
+
messages: [{ role: "user", content: task }],
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const content = message.content
|
|
29
|
+
.filter((b) => b.type === "text")
|
|
30
|
+
.map((b) => (b as { type: "text"; text: string }).text)
|
|
31
|
+
.join("\n")
|
|
32
|
+
|
|
33
|
+
return { content, elapsed: (Date.now() - t0) / 1000, model: "sonnet" }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function runOpencode(task: string, alias: ModelAlias): Promise<AgentResult> {
|
|
37
|
+
const t0 = Date.now()
|
|
38
|
+
const modelId = getModelId(alias)
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const { stdout, stderr } = await execFileAsync(
|
|
42
|
+
"opencode",
|
|
43
|
+
["run", task, "--model", modelId],
|
|
44
|
+
{ timeout: 120_000, env: process.env }
|
|
45
|
+
)
|
|
46
|
+
const raw = stdout || stderr || "Sem resposta."
|
|
47
|
+
// Remove ANSI escape codes
|
|
48
|
+
const content = raw.replace(/\x1b\[[0-9;]*m/g, "").replace(/\[[\d]+m/g, "").trim()
|
|
49
|
+
return { content, elapsed: (Date.now() - t0) / 1000, model: alias }
|
|
50
|
+
} catch (err: unknown) {
|
|
51
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
52
|
+
return { content: `Erro: ${msg}`, elapsed: (Date.now() - t0) / 1000, model: alias }
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/app.tsx
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import React, { useState, useCallback } from "react"
|
|
2
|
+
import { Box, Text, useInput, useApp } from "ink"
|
|
3
|
+
import type { Message, Stats, RouterDecision } from "./types.js"
|
|
4
|
+
import { classifyTask, COST_PER_1K } from "./router.js"
|
|
5
|
+
import { runClaude, runOpencode } from "./agents.js"
|
|
6
|
+
|
|
7
|
+
// ── Componentes ──────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
function MessageList({ messages, model }: { messages: Message[]; model: "sonnet" | "nemotron" }) {
|
|
10
|
+
const filtered = messages.filter((m) =>
|
|
11
|
+
model === "sonnet" ? m.model === "sonnet" : m.model !== "sonnet"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<Box flexDirection="column" gap={0}>
|
|
16
|
+
{filtered.map((msg) => (
|
|
17
|
+
<Box key={msg.id} flexDirection="column" marginBottom={1}>
|
|
18
|
+
<Text color={msg.role === "user" ? "cyan" : "green"} bold>
|
|
19
|
+
{msg.role === "user" ? "Você" : model === "sonnet" ? "Claude" : "Nemotron"}
|
|
20
|
+
{msg.elapsed ? ` (${msg.elapsed.toFixed(1)}s)` : ""}
|
|
21
|
+
</Text>
|
|
22
|
+
<Text wrap="wrap">{msg.content}</Text>
|
|
23
|
+
</Box>
|
|
24
|
+
))}
|
|
25
|
+
</Box>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function Panel({
|
|
30
|
+
title,
|
|
31
|
+
color,
|
|
32
|
+
messages,
|
|
33
|
+
model,
|
|
34
|
+
loading,
|
|
35
|
+
width,
|
|
36
|
+
}: {
|
|
37
|
+
title: string
|
|
38
|
+
color: string
|
|
39
|
+
messages: Message[]
|
|
40
|
+
model: "sonnet" | "nemotron"
|
|
41
|
+
loading: boolean
|
|
42
|
+
width: number
|
|
43
|
+
}) {
|
|
44
|
+
return (
|
|
45
|
+
<Box
|
|
46
|
+
flexDirection="column"
|
|
47
|
+
borderStyle="round"
|
|
48
|
+
borderColor={color as "cyan" | "yellow"}
|
|
49
|
+
width={width}
|
|
50
|
+
height={30}
|
|
51
|
+
paddingX={1}
|
|
52
|
+
>
|
|
53
|
+
<Text bold color={color as "cyan" | "yellow"}>
|
|
54
|
+
{title}
|
|
55
|
+
</Text>
|
|
56
|
+
<Box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
57
|
+
<MessageList messages={messages} model={model} />
|
|
58
|
+
{loading && (
|
|
59
|
+
<Text color="gray" dimColor>
|
|
60
|
+
aguardando...
|
|
61
|
+
</Text>
|
|
62
|
+
)}
|
|
63
|
+
</Box>
|
|
64
|
+
</Box>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function RouterLog({ entries }: { entries: string[] }) {
|
|
69
|
+
const last = entries.slice(-12)
|
|
70
|
+
return (
|
|
71
|
+
<Box
|
|
72
|
+
flexDirection="column"
|
|
73
|
+
borderStyle="round"
|
|
74
|
+
borderColor="gray"
|
|
75
|
+
width={26}
|
|
76
|
+
height={30}
|
|
77
|
+
paddingX={1}
|
|
78
|
+
>
|
|
79
|
+
<Text bold color="gray">
|
|
80
|
+
ROUTER
|
|
81
|
+
</Text>
|
|
82
|
+
{last.map((e, i) => (
|
|
83
|
+
<Text key={i} color="gray" dimColor wrap="wrap">
|
|
84
|
+
{e}
|
|
85
|
+
</Text>
|
|
86
|
+
))}
|
|
87
|
+
</Box>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function StatsBar({ stats }: { stats: Stats }) {
|
|
92
|
+
const pct =
|
|
93
|
+
stats.costSaved + stats.costPaid > 0
|
|
94
|
+
? Math.round((stats.costSaved / (stats.costSaved + stats.costPaid)) * 100)
|
|
95
|
+
: 0
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<Box borderStyle="single" borderColor="gray" paddingX={2} marginTop={0}>
|
|
99
|
+
<Text>
|
|
100
|
+
Tasks: <Text bold>{stats.tasksTotal}</Text>{" "}
|
|
101
|
+
Free: <Text color="green" bold>{stats.tasksFree}</Text>{" "}
|
|
102
|
+
Pago: <Text color="yellow" bold>{stats.tasksPaid}</Text>{" "}
|
|
103
|
+
Economizado: <Text color="green" bold>{pct}%</Text> (~${stats.costSaved.toFixed(4)}){" "}
|
|
104
|
+
Gasto: <Text color="yellow">${stats.costPaid.toFixed(4)}</Text>{" "}
|
|
105
|
+
<Text dimColor>[ctrl+c sair • ctrl+l limpar]</Text>
|
|
106
|
+
</Text>
|
|
107
|
+
</Box>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── App principal ─────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
export function App() {
|
|
114
|
+
const { exit } = useApp()
|
|
115
|
+
const [messages, setMessages] = useState<Message[]>([])
|
|
116
|
+
const [routerLog, setRouterLog] = useState<string[]>(["iniciado", "claude: sonnet-4-6", "free: nemotron-120b"])
|
|
117
|
+
const [input, setInput] = useState("")
|
|
118
|
+
const [loadingLeft, setLoadingLeft] = useState(false)
|
|
119
|
+
const [loadingRight, setLoadingRight] = useState(false)
|
|
120
|
+
const [stats, setStats] = useState<Stats>({
|
|
121
|
+
tasksTotal: 0, tasksFree: 0, tasksPaid: 0, costPaid: 0, costSaved: 0,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const addRouter = useCallback((line: string) => {
|
|
125
|
+
setRouterLog((prev) => [...prev, line])
|
|
126
|
+
}, [])
|
|
127
|
+
|
|
128
|
+
const dispatch = useCallback(
|
|
129
|
+
async (task: string) => {
|
|
130
|
+
const decision: RouterDecision = classifyTask(task)
|
|
131
|
+
const ts = new Date().toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
|
132
|
+
addRouter(`${ts} → ${task.slice(0, 22)}...`)
|
|
133
|
+
addRouter(` ${decision.model} (${decision.reason})`)
|
|
134
|
+
|
|
135
|
+
const userMsg: Message = {
|
|
136
|
+
id: `u-${Date.now()}`,
|
|
137
|
+
role: "user",
|
|
138
|
+
content: task,
|
|
139
|
+
model: decision.model,
|
|
140
|
+
timestamp: new Date(),
|
|
141
|
+
}
|
|
142
|
+
setMessages((prev) => [...prev, userMsg])
|
|
143
|
+
|
|
144
|
+
if (decision.model === "sonnet") {
|
|
145
|
+
setLoadingLeft(true)
|
|
146
|
+
try {
|
|
147
|
+
const result = await runClaude(task)
|
|
148
|
+
const assistantMsg: Message = {
|
|
149
|
+
id: `a-${Date.now()}`,
|
|
150
|
+
role: "assistant",
|
|
151
|
+
content: result.content,
|
|
152
|
+
model: "sonnet",
|
|
153
|
+
elapsed: result.elapsed,
|
|
154
|
+
timestamp: new Date(),
|
|
155
|
+
}
|
|
156
|
+
setMessages((prev) => [...prev, assistantMsg])
|
|
157
|
+
const cost = (300 / 1000) * COST_PER_1K["sonnet"]
|
|
158
|
+
const saved = (300 / 1000) * COST_PER_1K["nemotron-120b"]
|
|
159
|
+
setStats((s) => ({ ...s, tasksTotal: s.tasksTotal + 1, tasksPaid: s.tasksPaid + 1, costPaid: s.costPaid + cost, costSaved: s.costSaved + saved }))
|
|
160
|
+
} finally {
|
|
161
|
+
setLoadingLeft(false)
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
setLoadingRight(true)
|
|
165
|
+
try {
|
|
166
|
+
const result = await runOpencode(task, decision.model)
|
|
167
|
+
const assistantMsg: Message = {
|
|
168
|
+
id: `a-${Date.now()}`,
|
|
169
|
+
role: "assistant",
|
|
170
|
+
content: result.content,
|
|
171
|
+
model: decision.model,
|
|
172
|
+
elapsed: result.elapsed,
|
|
173
|
+
timestamp: new Date(),
|
|
174
|
+
}
|
|
175
|
+
setMessages((prev) => [...prev, assistantMsg])
|
|
176
|
+
const saved = (300 / 1000) * COST_PER_1K["sonnet"]
|
|
177
|
+
setStats((s) => ({ ...s, tasksTotal: s.tasksTotal + 1, tasksFree: s.tasksFree + 1, costSaved: s.costSaved + saved }))
|
|
178
|
+
} finally {
|
|
179
|
+
setLoadingRight(false)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
[addRouter]
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
useInput((char, key) => {
|
|
187
|
+
if (key.ctrl && char === "c") { exit(); return }
|
|
188
|
+
if (key.ctrl && char === "l") { setMessages([]); setRouterLog(["limpo"]); return }
|
|
189
|
+
if (key.return) {
|
|
190
|
+
const task = input.trim()
|
|
191
|
+
if (task) { setInput(""); dispatch(task) }
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
if (key.backspace || key.delete) {
|
|
195
|
+
setInput((prev) => prev.slice(0, -1))
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
if (char && !key.ctrl && !key.meta) {
|
|
199
|
+
setInput((prev) => prev + char)
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
return (
|
|
204
|
+
<Box flexDirection="column">
|
|
205
|
+
<Box>
|
|
206
|
+
<Text bold color="blue"> arsenal-agent </Text>
|
|
207
|
+
<Text dimColor>Claude Sonnet + Nemotron free — roteamento automatico</Text>
|
|
208
|
+
</Box>
|
|
209
|
+
<Box flexDirection="row" gap={0}>
|
|
210
|
+
<Panel
|
|
211
|
+
title="Claude Sonnet 4.6 (tasks complexas)"
|
|
212
|
+
color="cyan"
|
|
213
|
+
messages={messages}
|
|
214
|
+
model="sonnet"
|
|
215
|
+
loading={loadingLeft}
|
|
216
|
+
width={50}
|
|
217
|
+
/>
|
|
218
|
+
<RouterLog entries={routerLog} />
|
|
219
|
+
<Panel
|
|
220
|
+
title="Nemotron 120B (free · tasks simples)"
|
|
221
|
+
color="yellow"
|
|
222
|
+
messages={messages}
|
|
223
|
+
model="nemotron"
|
|
224
|
+
loading={loadingRight}
|
|
225
|
+
width={50}
|
|
226
|
+
/>
|
|
227
|
+
</Box>
|
|
228
|
+
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
|
229
|
+
<Text color="gray">{">"} </Text>
|
|
230
|
+
<Text>{input}</Text>
|
|
231
|
+
<Text color="gray">█</Text>
|
|
232
|
+
</Box>
|
|
233
|
+
<StatsBar stats={stats} />
|
|
234
|
+
</Box>
|
|
235
|
+
)
|
|
236
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import React from "react"
|
|
3
|
+
import { render } from "ink"
|
|
4
|
+
import { Command } from "commander"
|
|
5
|
+
import { App } from "./app.js"
|
|
6
|
+
import { addProfile, listProfiles, useProfile, loadProfile, applyProfile } from "./profiles.js"
|
|
7
|
+
|
|
8
|
+
const program = new Command()
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.name("arsenal-agent")
|
|
12
|
+
.description("Dual AI agent CLI — Claude Sonnet + Nemotron free, roteamento automatico")
|
|
13
|
+
.version("0.1.0")
|
|
14
|
+
.option("-p, --profile <name>", "perfil a usar (substitui env vars)")
|
|
15
|
+
|
|
16
|
+
// ── Subcomando: profile ────────────────────────────────────────────────────
|
|
17
|
+
const profileCmd = program
|
|
18
|
+
.command("profile")
|
|
19
|
+
.description("gerenciar perfis de API keys")
|
|
20
|
+
|
|
21
|
+
profileCmd
|
|
22
|
+
.command("add [name]")
|
|
23
|
+
.description("adicionar ou atualizar um perfil")
|
|
24
|
+
.action(async (name?: string) => {
|
|
25
|
+
await addProfile(name)
|
|
26
|
+
process.exit(0)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
profileCmd
|
|
30
|
+
.command("list")
|
|
31
|
+
.alias("ls")
|
|
32
|
+
.description("listar perfis cadastrados")
|
|
33
|
+
.action(() => {
|
|
34
|
+
listProfiles()
|
|
35
|
+
process.exit(0)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
profileCmd
|
|
39
|
+
.command("use <name>")
|
|
40
|
+
.description("definir perfil ativo padrão")
|
|
41
|
+
.action((name: string) => {
|
|
42
|
+
useProfile(name)
|
|
43
|
+
process.exit(0)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// ── Ação principal: lança TUI ──────────────────────────────────────────────
|
|
47
|
+
program.action((opts: { profile?: string }) => {
|
|
48
|
+
// Aplica perfil se informado ou se há um perfil ativo salvo
|
|
49
|
+
const profile = loadProfile(opts.profile)
|
|
50
|
+
if (profile) {
|
|
51
|
+
applyProfile(profile)
|
|
52
|
+
process.stderr.write(`[arsenal-agent] perfil: ${profile.label ?? profile.name}\n`)
|
|
53
|
+
} else if (!process.env.ANTHROPIC_API_KEY) {
|
|
54
|
+
console.error("Nenhuma ANTHROPIC_API_KEY encontrada.")
|
|
55
|
+
console.error("Adicione um perfil: aa profile add")
|
|
56
|
+
console.error("Ou exporte a variável: export ANTHROPIC_API_KEY=sk-ant-...")
|
|
57
|
+
process.exit(1)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { waitUntilExit } = render(<App />)
|
|
61
|
+
waitUntilExit().then(() => process.exit(0))
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
program.parse()
|