Spaces:
Sleeping
Sleeping
File size: 7,427 Bytes
5b5da0e ad23973 5b5da0e ece21ed 5b5da0e ece21ed 5b5da0e ece21ed 5b5da0e ad23973 37775da ad23973 37775da ad23973 37775da ad23973 37775da ad23973 5b5da0e 87c8b12 5b5da0e ea9d6ed b38b5c1 87c8b12 ea9d6ed b38b5c1 87c8b12 ea9d6ed b38b5c1 87c8b12 ea9d6ed b38b5c1 87c8b12 ea9d6ed df0df18 a69a644 ea9d6ed 87c8b12 5b5da0e 578c443 5b5da0e 578c443 5b5da0e df0df18 a69a644 13aa212 5b5da0e 37775da 5b5da0e ad23973 ea9d6ed 5b5da0e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | 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');
// A CLI's presence on PATH doesn't change while the process runs, but
// cliCatalog() (called on every /api/clis) probed every CLI each time — ~16
// synchronous `command -v` subprocess spawns per request, blocking the event
// loop that also pumps the terminals. Memoize per command.
const cmdExistsCache = new Map();
function commandExists(cmd) {
if (cmdExistsCache.has(cmd)) return cmdExistsCache.get(cmd);
let ok = false;
try {
execSync(`command -v ${cmd}`, { stdio: 'ignore', shell: '/bin/sh' });
ok = true;
} catch { ok = false; }
cmdExistsCache.set(cmd, ok);
return ok;
}
// 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',
withPrompt: (q) => `claude ${q}`,
setup: setupHint('ANTHROPIC_API_KEY') },
{ id: 'codex', label: 'Codex', bin: 'codex', color: '#5eb6a6', run: 'codex', cont: 'codex resume --last',
withPrompt: (q) => `codex ${q}`,
setup: setupHint('OPENAI_API_KEY') },
{ id: 'gemini', label: 'Gemini CLI', bin: 'gemini', color: '#4796e3', run: 'gemini', cont: null,
withPrompt: (q) => `gemini -i ${q}`, // -i = interactive session seeded with the prompt
setup: setupHint('GEMINI_API_KEY') },
{ id: 'opencode', label: 'opencode', bin: 'opencode', color: '#8a93a0', run: 'opencode', cont: 'opencode --continue',
withPrompt: (q) => `opencode --prompt ${q}`,
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);
}
export function workspacePath(folder) {
return path.join(WORKSPACES_DIR, folder);
}
|