import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs'; import { execSync, execFile } from 'node:child_process'; export const PORT = parseInt(process.env.PORT || '7860', 10); export const DATA_DIR = process.env.DATA_DIR || path.resolve(process.cwd(), 'data'); export const WORKSPACES_DIR = path.join(DATA_DIR, 'workspaces'); export const STATE_DIR = path.join(DATA_DIR, 'state'); // Shared skills live in the workspace so agents can reach them (../skills). export const SKILLS_DIR = path.join(WORKSPACES_DIR, 'skills'); export const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json'); export const PUBLIC_DIR = process.env.PUBLIC_DIR || path.resolve(process.cwd(), '..', 'web', 'dist'); function commandExists(cmd) { try { execSync(`command -v ${cmd}`, { stdio: 'ignore', shell: '/bin/sh' }); return true; } catch { return false; } } // Installed CLI versions, resolved asynchronously (a few `--version` calls) and // cached so /api/clis stays fast. Slow starters (hermes = python venv, gemini) // can miss the boot-time pass — cliCatalog re-runs it for the gaps, throttled. const versionCache = new Map(); let versionsAttemptedAt = 0; export function refreshVersions() { versionsAttemptedAt = Date.now(); for (const c of CLIS) { if (!c.bin || versionCache.has(c.id) || !commandExists(c.bin)) continue; execFile(c.bin, ['--version'], { timeout: 30000 }, (err, stdout = '') => { if (err) return; const s = String(stdout); const m = s.match(/\d+\.\d+\.\d+[\w.-]*/); versionCache.set(c.id, m ? m[0] : s.trim().split('\n')[0].slice(0, 40)); }); } } export const TMUX_AVAILABLE = commandExists('tmux'); export const USE_TMUX = process.env.USE_TMUX ? process.env.USE_TMUX === '1' : TMUX_AVAILABLE; /** * CLI catalog. * - `bin` : binary checked on PATH to report availability * - `run` : command run inside the session shell on first launch * - `cont` : command used to resume a prior conversation (null = no resume) */ // One setup story for every agent; only the accepted secret names differ. const setupHint = (...keys) => `Launch it once — the first run walks you through sign-in. Or add ${keys.join(' / ')} as a Space secret.`; export const CLIS = [ { id: 'shell', label: 'Shell', bin: 'bash', color: '#8aa0ad', run: 'exec bash -il', cont: null }, { id: 'files', label: 'Files', bin: null, color: '#d99a2b', run: null, cont: null }, { id: 'claude', label: 'Claude Code', bin: 'claude', color: '#d97757', run: 'claude', cont: 'claude --continue', setup: setupHint('ANTHROPIC_API_KEY') }, { id: 'codex', label: 'Codex', bin: 'codex', color: '#5eb6a6', run: 'codex', cont: 'codex resume --last', setup: setupHint('OPENAI_API_KEY') }, { id: 'gemini', label: 'Gemini CLI', bin: 'gemini', color: '#4796e3', run: 'gemini', cont: null, setup: setupHint('GEMINI_API_KEY') }, { id: 'opencode', label: 'opencode', bin: 'opencode', color: '#8a93a0', run: 'opencode', cont: 'opencode --continue', setup: setupHint('ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY') }, { id: 'hermes', label: 'Hermes', bin: 'hermes', color: '#a78bfa', run: 'hermes', cont: 'hermes -c', setup: setupHint('HF_TOKEN', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY', 'NOUS_API_KEY') }, // `chat` = TUI in --local mode: embedded agent, no gateway/daemon needed. { id: 'openclaw', label: 'OpenClaw', bin: 'openclaw', color: '#c83636', run: 'openclaw chat', cont: null, setup: setupHint('ANTHROPIC_API_KEY') }, ]; export function cliById(id) { return CLIS.find((c) => c.id === id) || null; } // Is an agent configured (has a credential)? Checks env keys OR an on-disk // credential file. Detects "configured", not "token still valid". function isConfigured(id) { const env = process.env; const home = env.HOME || os.homedir(); const hasEnv = (...keys) => keys.some((k) => env[k] && env[k].trim()); const fileOk = (p) => { try { return fs.existsSync(p) && fs.statSync(p).size > 0; } catch { return false; } }; switch (id) { case 'claude': return hasEnv('ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN') || fileOk(path.join(env.CLAUDE_CONFIG_DIR || path.join(home, '.claude'), '.credentials.json')); case 'codex': return hasEnv('OPENAI_API_KEY', 'CODEX_API_KEY') || fileOk(path.join(env.CODEX_HOME || path.join(home, '.codex'), 'auth.json')); case 'gemini': return hasEnv('GEMINI_API_KEY', 'GOOGLE_API_KEY') || fileOk(path.join(home, '.gemini', 'oauth_creds.json')); case 'opencode': { const xdgConfig = env.XDG_CONFIG_HOME || path.join(home, '.config'); return hasEnv('ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY') || fileOk(path.join(home, '.local', 'share', 'opencode', 'auth.json')) // pre-1.x || fileOk(path.join(xdgConfig, 'opencode', 'opencode.jsonc')) // 1.x user config || fileOk(path.join(xdgConfig, 'opencode', 'opencode.json')); } case 'hermes': // HF_TOKEN: hermes has a huggingface provider (HF router models). return hasEnv('ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY', 'NOUS_API_KEY', 'HF_TOKEN') || fileOk(path.join(home, '.hermes', '.env')) || fileOk(path.join(home, '.hermes', 'auth.json')); case 'openclaw': return hasEnv('ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY') || fileOk(path.join(env.OPENCLAW_STATE_DIR || path.join(home, '.openclaw'), 'openclaw.json')); default: return true; // shell / files need no auth } } /** CLI catalog enriched with runtime availability + configured (credential present). */ export function cliCatalog() { // Fill version gaps left by the boot-time pass (results land for the next poll). if (Date.now() - versionsAttemptedAt > 60_000 && CLIS.some((c) => c.bin && !versionCache.has(c.id) && commandExists(c.bin))) { refreshVersions(); } return CLIS.map((c) => ({ id: c.id, label: c.label, color: c.color, available: c.bin ? commandExists(c.bin) : true, // no binary (shell/files) = always available ready: (c.bin ? commandExists(c.bin) : true) && isConfigured(c.id), version: versionCache.get(c.id) || null, setup: c.setup || null, })); } export function ensureDirs() { for (const dir of [DATA_DIR, WORKSPACES_DIR, STATE_DIR, SKILLS_DIR]) { fs.mkdirSync(dir, { recursive: true }); } } export function slugify(s) { return (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40); } // A fresh workspace folder named after `base` (suffixed if taken). Used as the // default location when an agent is created without an explicit path. export function allocFolder(base) { const b = slugify(base) || 'workspace'; let name = b; let n = 1; while (fs.existsSync(path.join(WORKSPACES_DIR, name))) { n++; name = `${b}-${n}`; } fs.mkdirSync(path.join(WORKSPACES_DIR, name), { recursive: true }); return name; } export function workspacePath(folder) { return path.join(WORKSPACES_DIR, folder); }