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.
@@ -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
@@ -0,0 +1,4 @@
1
+ import type { ModelAlias, RouterDecision } from "./types.js";
2
+ export declare const COST_PER_1K: Record<ModelAlias, number>;
3
+ export declare function getModelId(alias: ModelAlias): string;
4
+ export declare function classifyTask(task: string): RouterDecision;
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
@@ -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
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
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()