From 01dbb07b4771a2cb4f731d5b1274b3ce9edaab08 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 5 Oct 2026 23:55:40 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20CodeForge=20Web=20=E2=80=93=20brows?= =?UTF-8?q?er=20workspace=20behind=20Cloudflare=20Access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit web/codeforge-web-server.mjs serves the UI and runs terminals on the server. It listens on 127.0.0.1 only and accepts HTTP and WebSocket requests only with a valid Cloudflare Access JWT (signature, issuer, audience, expiry, e-mail allowlist) and, for WebSockets, the public origin. Without that configuration it refuses to start. Terminals are tmux sessions shared with the codeforge CLI: they survive disconnects and reloads, the browser re-attaches without retyping the start command, and they end only when closed. Antigravity accounts come from ~/.codeforge/agy-accounts, same as the CLI. Co-Authored-By: Claude Opus 5.5 --- src/AppContext.tsx | 19 ++ src/components/MainArea.tsx | 99 ++++--- src/electron.d.ts | 6 +- src/main.tsx | 34 ++- web/codeforge-web-server.mjs | 561 +++++++++++++++++++++++++++++++++++ web/codeforge-web.service | 27 ++ web/install.sh | 38 +++ web/package.json | 13 + 8 files changed, 749 insertions(+), 48 deletions(-) create mode 100644 web/codeforge-web-server.mjs create mode 100644 web/codeforge-web.service create mode 100755 web/install.sh create mode 100644 web/package.json diff --git a/src/AppContext.tsx b/src/AppContext.tsx index 23f7a1a..90b2486 100644 --- a/src/AppContext.tsx +++ b/src/AppContext.tsx @@ -890,6 +890,25 @@ export function AppProvider({ children }: { children: ReactNode }) { useEffect(() => writeStorage('terminalStartCommand', terminalStartCommand), [terminalStartCommand]); useEffect(() => writeStorage('cliHarnesses', cliHarnesses), [cliHarnesses]); useEffect(() => writeStorage('agyAccounts', agyAccounts), [agyAccounts]); + // CodeForge Web: the accounts live on the server (shared with the codeforge CLI), not in this browser. + const agyAccountsLoaded = useRef(false); + useEffect(() => { + const workspace = window.agentWorkspace; + if (!workspace?.isWeb || !workspace.listAgyAccounts) return; + workspace + .listAgyAccounts() + .then((accounts) => { + agyAccountsLoaded.current = true; + setAgyAccounts(accounts); + }) + .catch(() => {}); + }, []); + useEffect(() => { + const workspace = window.agentWorkspace; + if (!workspace?.isWeb || !workspace.saveAgyAccounts || !agyAccountsLoaded.current) return; + const timer = setTimeout(() => void workspace.saveAgyAccounts?.(agyAccounts).catch(() => {}), 500); + return () => clearTimeout(timer); + }, [agyAccounts]); useEffect(() => writeStorage('terminalPrefixSuffixEnabled', terminalPrefixSuffixEnabled), [terminalPrefixSuffixEnabled]); useEffect(() => writeStorage('terminalPrefix', terminalPrefix), [terminalPrefix]); useEffect(() => writeStorage('terminalSuffix', terminalSuffix), [terminalSuffix]); diff --git a/src/components/MainArea.tsx b/src/components/MainArea.tsx index 89165a3..32348c4 100644 --- a/src/components/MainArea.tsx +++ b/src/components/MainArea.tsx @@ -1095,7 +1095,7 @@ function TerminalInstance({ } // Start PTY session in backend - window.agentWorkspace.createShellSession({ + const shellCreated = window.agentWorkspace.createShellSession({ chatId: tabId, cwd: startPath, shellType, @@ -1104,50 +1104,63 @@ function TerminalInstance({ agyAccountId: tab?.agyAccountId, }); - // SSH login takes longer than a local shell before a command can be typed. - const commandDelay = sshServer ? 3500 : 1000; - if (tab?.command || tab?.blank) { - if (tab.command && !startedTerminalTabs.has(tabId)) { - startedTerminalTabs.add(tabId); - const tabCommand = tab.command; - setTimeout(() => { - window.agentWorkspace.writeToShellSession({ chatId: tabId, text: tabCommand + '\r' }); - }, commandDelay); - } - } else if (sshServer) { - // Plain SSH session: no local start command. - } else if (chat.mode === 'standard') { - const agentCmd = platformStartCommands[provider] || getAgentCommand(provider); - const waitTime = platformWaitTimes[provider] || 5000; - if (!startedTerminalTabs.has(tabId)) { - startedTerminalTabs.add(tabId); - setTimeout(() => { - window.agentWorkspace.writeToShellSession({ chatId: tabId, text: agentCmd + '\r' }); + const runStartCommands = () => { + // SSH login takes longer than a local shell before a command can be typed. + const commandDelay = sshServer ? 3500 : 1000; + if (tab?.command || tab?.blank) { + if (tab.command && !startedTerminalTabs.has(tabId)) { + startedTerminalTabs.add(tabId); + const tabCommand = tab.command; + setTimeout(() => { + window.agentWorkspace.writeToShellSession({ chatId: tabId, text: tabCommand + '\r' }); + }, commandDelay); + } + } else if (sshServer) { + // Plain SSH session: no local start command. + } else if (chat.mode === 'standard') { + const agentCmd = platformStartCommands[provider] || getAgentCommand(provider); + const waitTime = platformWaitTimes[provider] || 5000; + if (!startedTerminalTabs.has(tabId)) { + startedTerminalTabs.add(tabId); + setTimeout(() => { + window.agentWorkspace.writeToShellSession({ chatId: tabId, text: agentCmd + '\r' }); - const initialPrompt = chat.messages.find(m => m.sender === 'user')?.text; - if (initialPrompt) { - setTimeout(() => { - window.agentWorkspace.writeToShellSession({ chatId: tabId, text: initialPrompt + '\r' }); - }, waitTime); - } - }, 1000); - } - } else if (chat.harness) { - if (chat.harness.command && !startedTerminalTabs.has(tabId)) { - startedTerminalTabs.add(tabId); - const harnessCommand = chat.harness.command; - setTimeout(() => { - window.agentWorkspace.writeToShellSession({ chatId: tabId, text: harnessCommand + '\r' }); - }, 1000); - } - } else { - if (terminalStartCommandEnabled && terminalStartCommand && !startedTerminalTabs.has(tabId)) { - startedTerminalTabs.add(tabId); - setTimeout(() => { - window.agentWorkspace.writeToShellSession({ chatId: tabId, text: terminalStartCommand + '\r' }); - }, 1000); + const initialPrompt = chat.messages.find(m => m.sender === 'user')?.text; + if (initialPrompt) { + setTimeout(() => { + window.agentWorkspace.writeToShellSession({ chatId: tabId, text: initialPrompt + '\r' }); + }, waitTime); + } + }, 1000); + } + } else if (chat.harness) { + if (chat.harness.command && !startedTerminalTabs.has(tabId)) { + startedTerminalTabs.add(tabId); + const harnessCommand = chat.harness.command; + setTimeout(() => { + window.agentWorkspace.writeToShellSession({ chatId: tabId, text: harnessCommand + '\r' }); + }, 1000); + } + } else { + if (terminalStartCommandEnabled && terminalStartCommand && !startedTerminalTabs.has(tabId)) { + startedTerminalTabs.add(tabId); + setTimeout(() => { + window.agentWorkspace.writeToShellSession({ chatId: tabId, text: terminalStartCommand + '\r' }); + }, 1000); + } } - } + + }; + // CodeForge Web re-attaches running sessions; their program must not be started a second time. + Promise.resolve(shellCreated) + .then((result) => { + if (result && result.skipStartCommand) { + startedTerminalTabs.add(tabId); + return; + } + runStartCommands(); + }) + .catch(() => runStartCommands()); // Handle incoming output from backend PTY const unsubscribe = window.agentWorkspace.onShellOutput(tabId, (payload: any) => { diff --git a/src/electron.d.ts b/src/electron.d.ts index 86d1c60..97d3aac 100644 --- a/src/electron.d.ts +++ b/src/electron.d.ts @@ -154,7 +154,11 @@ declare global { runTerminalCommand(request: { id: string; command: string; cwd?: string }): Promise<{ exitCode?: number; error?: string }>; cancelTerminalCommand(id: string): Promise; onTerminalOutput(id: string, callback: (payload: { type: 'stdout' | 'stderr' | 'exit'; text?: string; code?: number }) => void): () => void; - createShellSession(request: { chatId: string; cwd?: string; shellType?: 'powershell' | 'cmd'; externalServer?: any; vserver?: VServerConnection; agyAccountId?: string }): Promise; + // CodeForge Web answers with skipStartCommand when the session already runs its program. + createShellSession(request: { chatId: string; cwd?: string; shellType?: 'powershell' | 'cmd'; externalServer?: any; vserver?: VServerConnection; agyAccountId?: string; cols?: number; rows?: number }): Promise; + listAgyAccounts?(): Promise<{ id: string; name: string }[]>; + saveAgyAccounts?(accounts: { id: string; name: string }[]): Promise<{ id: string; name: string }[]>; + isWeb?: boolean; getAgyAccountsStatus(accountIds: string[]): Promise<{ id: string; profileExists: boolean; email: string }[]>; removeAgyAccountProfile(accountId: string): Promise; writeToShellSession(request: { chatId: string; text: string }): Promise; diff --git a/src/main.tsx b/src/main.tsx index c71c85d..f07787a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -18,6 +18,8 @@ if (typeof (window as any).agentWorkspace === 'undefined' && canUseSyncSocket) { const eventListeners = new Map(); let ws: WebSocket | null = null; let connectPromise: Promise | null = null; + let hadConnection = false; + const openShells = new Map(); const connect = (): Promise => { if (connectPromise) return connectPromise; @@ -71,6 +73,9 @@ if (typeof (window as any).agentWorkspace === 'undefined' && canUseSyncSocket) { else pending.resolve(msg.result); } } else if (msg.type === 'ipc-event') { + if (typeof msg.channel === 'string' && msg.channel.startsWith('shell:output:') && msg.data?.type === 'exit') { + openShells.delete(msg.channel.slice('shell:output:'.length)); + } const list = eventListeners.get(msg.channel); if (list) { for (const cb of list) { @@ -86,6 +91,13 @@ if (typeof (window as any).agentWorkspace === 'undefined' && canUseSyncSocket) { ws.onopen = () => { console.log('WebSocket connected'); resolve(ws!); + // After a dropped connection, re-attach the terminals that were open (their sessions kept running). + if (hadConnection) { + for (const request of openShells.values()) { + invoke('shell:create', { ...request, reattachOnly: true }).catch(() => {}); + } + } + hadConnection = true; }; ws.onerror = (err) => { @@ -176,8 +188,14 @@ if (typeof (window as any).agentWorkspace === 'undefined' && canUseSyncSocket) { eventListeners.set(channel, list.filter((cb: any) => cb !== callback)); }; }, - openExternal: (url: string) => invoke('shell:open-external', url), - openInChrome: (url: string) => invoke('shell:open-in-chrome', url), + // The server cannot open a browser for us: web links open in a new tab of this browser. + openExternal: async (url: string) => { + if (/^https?:\/\//i.test(url)) window.open(url, '_blank', 'noopener,noreferrer'); + }, + openInChrome: async (url: string) => { + if (/^https?:\/\//i.test(url)) window.open(url, '_blank', 'noopener,noreferrer'); + return 'default'; + }, openPath: (targetPath: string) => invoke('shell:open-path', targetPath), getActions: (dirPath: string) => invoke('actions:list', dirPath), playAction: (dirPath: string, name: string) => invoke('actions:play', dirPath, name), @@ -199,12 +217,20 @@ if (typeof (window as any).agentWorkspace === 'undefined' && canUseSyncSocket) { eventListeners.set(channel, list.filter((cb: any) => cb !== callback)); }; }, - createShellSession: (request: any) => invoke('shell:create', request), + createShellSession: (request: any) => { + openShells.set(request.chatId, request); + return invoke('shell:create', request); + }, writeToShellSession: (request: any) => invoke('shell:write', request), - killShellSession: (chatId: string) => invoke('shell:kill', chatId), + killShellSession: (chatId: string) => { + openShells.delete(chatId); + return invoke('shell:kill', chatId); + }, resizeShellSession: (request: any) => invoke('shell:resize', request), getAgyAccountsStatus: (accountIds: string[]) => invoke('agy-accounts:status', accountIds), removeAgyAccountProfile: (accountId: string) => invoke('agy-accounts:remove-profile', accountId), + listAgyAccounts: () => invoke('agy-accounts:list'), + saveAgyAccounts: (accounts: any[]) => invoke('agy-accounts:save', accounts), onShellOutput: (chatId: string, callback: any) => { const channel = `shell:output:${chatId}`; if (!eventListeners.has(channel)) eventListeners.set(channel, []); diff --git a/web/codeforge-web-server.mjs b/web/codeforge-web-server.mjs new file mode 100644 index 0000000..05fc417 --- /dev/null +++ b/web/codeforge-web-server.mjs @@ -0,0 +1,561 @@ +#!/usr/bin/env node +// CodeForge Web: serves the CodeForge UI and runs its terminals on this server. +// +// Security model (fail closed): +// - Listens on 127.0.0.1 only; the public hostname reaches it through a Cloudflare Tunnel. +// - Cloudflare Access sits in front. Every HTTP request and WebSocket upgrade must carry a +// valid Access JWT (RS256, signed by the team's keys, matching audience, not expired) +// whose e-mail is on CODEFORGE_ALLOWED_EMAILS. Without that configuration nothing is served. +// - WebSocket upgrades must come from the public origin. +// +// Terminals are tmux sessions (shared with the `codeforge` CLI), so they keep running when the +// browser disconnects and end only when they are closed. +import { createServer } from 'node:http'; +import { createPublicKey, verify as verifySignature } from 'node:crypto'; +import { execFile, execFileSync } from 'node:child_process'; +import { createReadStream, existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { extname, join, resolve, sep } from 'node:path'; +import { promisify } from 'node:util'; +import { WebSocketServer } from 'ws'; +import pty from 'node-pty'; + +const execFileAsync = promisify(execFile); + +const PORT = Number(process.env.PORT || 8790); +const HOST = '127.0.0.1'; +const PUBLIC_ORIGIN = (process.env.CODEFORGE_PUBLIC_ORIGIN || '').replace(/\/+$/, ''); +const TEAM_DOMAIN = (process.env.CF_ACCESS_TEAM_DOMAIN || '').replace(/^https?:\/\//, '').replace(/\/+$/, ''); +const AUDIENCES = (process.env.CF_ACCESS_AUD || '').split(',').map((item) => item.trim()).filter(Boolean); +const ALLOWED_EMAILS = new Set( + (process.env.CODEFORGE_ALLOWED_EMAILS || '').split(',').map((item) => item.trim().toLowerCase()).filter(Boolean), +); +const DIST_DIR = resolve(process.env.CODEFORGE_DIST || join(import.meta.dirname, '..', 'dist')); +const WORK_DIR = resolve(process.env.CODEFORGE_WORKDIR || process.env.HOME || '/root'); +const REAL_HOME = process.env.HOME || '/root'; +const ACCOUNTS_ROOT = resolve(process.env.CODEFORGE_HOME || join(REAL_HOME, '.codeforge'), 'agy-accounts'); +const CLI = process.env.CODEFORGE_CLI || '/usr/local/bin/codeforge'; +const SESSION_PREFIX = 'cf-w-'; +const ACCOUNT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/i; +const CHAT_ID_PATTERN = /^[a-z0-9-]{1,64}$/i; + +const configError = [ + !PUBLIC_ORIGIN && 'CODEFORGE_PUBLIC_ORIGIN', + !TEAM_DOMAIN && 'CF_ACCESS_TEAM_DOMAIN', + AUDIENCES.length === 0 && 'CF_ACCESS_AUD', + ALLOWED_EMAILS.size === 0 && 'CODEFORGE_ALLOWED_EMAILS', +].filter(Boolean); +if (configError.length) { + console.error(`[CodeForge Web] Missing configuration: ${configError.join(', ')}. Refusing to start.`); + process.exit(1); +} + +// ---------- Cloudflare Access JWT ---------- + +const CERTS_URL = `https://${TEAM_DOMAIN}/cdn-cgi/access/certs`; +const ISSUER = `https://${TEAM_DOMAIN}`; +let signingKeys = new Map(); +let keysFetchedAt = 0; + +async function refreshKeys(force = false) { + const age = Date.now() - keysFetchedAt; + // Forced refreshes (unknown key id) are limited to one per minute. + if (signingKeys.size && (force ? age < 60 * 1000 : age < 10 * 60 * 1000)) return; + const response = await fetch(CERTS_URL, { signal: AbortSignal.timeout(5000) }); + if (!response.ok) throw new Error(`Access certs: HTTP ${response.status}`); + const { keys = [] } = await response.json(); + const next = new Map(); + for (const jwk of keys) { + if (jwk.kty === 'RSA' && jwk.kid) next.set(jwk.kid, createPublicKey({ key: jwk, format: 'jwk' })); + } + if (next.size === 0) throw new Error('Access certs: no keys'); + signingKeys = next; + keysFetchedAt = Date.now(); +} + +function decodePart(part) { + return JSON.parse(Buffer.from(part, 'base64url').toString('utf8')); +} + +// Returns the verified e-mail or throws. +async function verifyAccessJwt(token) { + if (typeof token !== 'string' || token.length > 8192) throw new Error('missing token'); + const parts = token.split('.'); + if (parts.length !== 3) throw new Error('malformed token'); + const header = decodePart(parts[0]); + if (header.alg !== 'RS256' || typeof header.kid !== 'string') throw new Error('unexpected algorithm'); + await refreshKeys(); + if (!signingKeys.has(header.kid)) await refreshKeys(true); + const key = signingKeys.get(header.kid); + if (!key) throw new Error('unknown signing key'); + const valid = verifySignature('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], 'base64url')); + if (!valid) throw new Error('bad signature'); + const claims = decodePart(parts[1]); + const now = Math.floor(Date.now() / 1000); + if (claims.iss !== ISSUER) throw new Error('wrong issuer'); + const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; + if (!aud.some((item) => AUDIENCES.includes(item))) throw new Error('wrong audience'); + if (typeof claims.exp !== 'number' || claims.exp < now - 30) throw new Error('expired'); + if (typeof claims.nbf === 'number' && claims.nbf > now + 30) throw new Error('not yet valid'); + const email = String(claims.email || '').toLowerCase(); + if (!email || !ALLOWED_EMAILS.has(email)) throw new Error('e-mail not allowed'); + return email; +} + +function readCookie(req, name) { + for (const part of String(req.headers.cookie || '').split(';')) { + const index = part.indexOf('='); + if (index > 0 && part.slice(0, index).trim() === name) return part.slice(index + 1).trim(); + } + return ''; +} + +async function authenticate(req) { + const token = req.headers['cf-access-jwt-assertion'] || readCookie(req, 'CF_Authorization'); + return verifyAccessJwt(Array.isArray(token) ? token[0] : token); +} + +// ---------- HTTP ---------- + +const CONTENT_TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.webp': 'image/webp', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', +}; + +const wsOrigin = PUBLIC_ORIGIN.replace(/^http/, 'ws'); +const SECURITY_HEADERS = { + 'content-security-policy': [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' data: https://fonts.gstatic.com", + "img-src 'self' data: blob: https:", + "media-src 'self' data: blob: https:", + `connect-src 'self' ${wsOrigin}`, + "frame-src 'none'", + "object-src 'none'", + "base-uri 'none'", + "form-action 'self'", + "frame-ancestors 'none'", + ].join('; '), + 'strict-transport-security': 'max-age=31536000; includeSubDomains', + 'x-content-type-options': 'nosniff', + 'x-frame-options': 'DENY', + 'referrer-policy': 'no-referrer', + 'permissions-policy': 'camera=(), microphone=(), geolocation=(), payment=()', + 'cross-origin-opener-policy': 'same-origin', + 'cross-origin-resource-policy': 'same-origin', +}; + +function send(res, status, body, headers = {}) { + res.writeHead(status, { ...SECURITY_HEADERS, 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store', ...headers }); + res.end(body); +} + +function serveStatic(req, res) { + const url = new URL(req.url || '/', 'http://localhost'); + let pathname; + try { + pathname = decodeURIComponent(url.pathname); + } catch { + return send(res, 400, 'Bad request'); + } + let filePath = resolve(DIST_DIR, `.${pathname}`); + if (filePath !== DIST_DIR && !filePath.startsWith(DIST_DIR + sep)) return send(res, 404, 'Not found'); + if (!existsSync(filePath) || !statSync(filePath).isFile()) { + // Missing files stay missing; only app routes fall back to the SPA entry. + if (extname(pathname)) return send(res, 404, 'Not found'); + filePath = join(DIST_DIR, 'index.html'); + } + const isIndex = filePath.endsWith('index.html'); + res.writeHead(200, { + ...SECURITY_HEADERS, + 'content-type': CONTENT_TYPES[extname(filePath).toLowerCase()] || 'application/octet-stream', + 'cache-control': isIndex ? 'no-store' : 'private, max-age=86400', + }); + if (req.method === 'HEAD') return res.end(); + createReadStream(filePath).pipe(res); +} + +const server = createServer(async (req, res) => { + if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'Method not allowed'); + try { + await authenticate(req); + } catch (error) { + console.warn(`[Auth] HTTP ${req.url} rejected: ${error.message}`); + return send(res, 403, 'Forbidden'); + } + serveStatic(req, res); +}); + +// ---------- terminals (tmux) ---------- + +function tmux(args) { + return execFileAsync('tmux', args, { timeout: 5000 }); +} + +async function hasSession(name) { + try { + await tmux(['has-session', '-t', `=${name}`]); + return true; + } catch { + return false; + } +} + +function sessionName(chatId) { + return `${SESSION_PREFIX}${chatId}`; +} + +function accountHome(accountId) { + if (!ACCOUNT_ID_PATTERN.test(String(accountId || ''))) throw new Error('Ungueltige Antigravity-Account-ID.'); + const home = resolve(ACCOUNTS_ROOT, accountId); + if (!home.startsWith(ACCOUNTS_ROOT + sep)) throw new Error('Ungueltige Antigravity-Account-ID.'); + return home; +} + +function accountName(accountId) { + try { + return readFileSync(join(accountHome(accountId), '.codeforge-name'), 'utf8').trim() || accountId; + } catch { + return accountId; + } +} + +function safeCwd(cwd) { + if (typeof cwd === 'string' && cwd.startsWith('/')) { + try { + if (statSync(cwd).isDirectory()) return cwd; + } catch {} + } + return WORK_DIR; +} + +// One tmux client (PTY) per browser connection and tab. +const clients = new Map(); // `${connId}:${chatId}` -> { pty, ws } + +async function createShell(conn, { chatId, cwd, agyAccountId, cols, rows, reattachOnly }) { + if (!CHAT_ID_PATTERN.test(String(chatId || ''))) throw new Error('Ungueltige Terminal-ID.'); + const key = `${conn.id}:${chatId}`; + if (clients.has(key)) return { reattached: true, skipStartCommand: true }; + const name = sessionName(chatId); + const existed = await hasSession(name); + // After a reconnect the browser only re-attaches; a session that was closed meanwhile stays closed. + if (!existed && reattachOnly) { + conn.emit(`shell:output:${chatId}`, { type: 'exit', code: 0 }); + return { reattached: false, skipStartCommand: true }; + } + if (!existed) { + const startDir = safeCwd(cwd); + let command; + let label = 'Web-Terminal'; + if (agyAccountId) { + accountHome(agyAccountId); + label = accountName(agyAccountId); + command = `exec ${CLI} __run ${agyAccountId}`; + } else { + command = 'exec bash -l'; + } + await tmux(['new-session', '-d', '-s', name, '-c', startDir, '-x', '200', '-y', '50', '-e', `CODEFORGE_REAL_HOME=${REAL_HOME}`, command]); + await tmux([ + 'set', '-t', name, '@cf_session', `${label} (Web)`, ';', + 'set', '-t', name, 'status', 'off', ';', + 'set', '-t', name, 'mouse', 'on', ';', + 'set', '-s', 'escape-time', '10', + ]).catch(() => {}); + } + const term = pty.spawn('tmux', ['attach-session', '-t', `=${name}`], { + name: 'xterm-256color', + cols: Math.max(20, Math.min(500, Number(cols) || 120)), + rows: Math.max(5, Math.min(200, Number(rows) || 30)), + cwd: WORK_DIR, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + clients.set(key, { pty: term, ws: conn.ws }); + const channel = `shell:output:${chatId}`; + term.onData((data) => conn.emit(channel, { type: 'stdout', text: data })); + term.onExit(async () => { + clients.delete(key); + // The client ends on detach as well; only report an exit when the session itself is gone. + if (!(await hasSession(name))) conn.emit(channel, { type: 'exit', code: 0 }); + }); + // Existing sessions already run their program: tell the UI not to type the start command again. + // Account sessions start agy themselves. + return { reattached: existed, skipStartCommand: existed || Boolean(agyAccountId) }; +} + +function writeShell(conn, { chatId, text }) { + const client = clients.get(`${conn.id}:${chatId}`); + if (!client || typeof text !== 'string') return false; + client.pty.write(text.slice(0, 64 * 1024)); + return true; +} + +function resizeShell(conn, { chatId, cols, rows }) { + const client = clients.get(`${conn.id}:${chatId}`); + if (!client) return false; + try { + client.pty.resize(Math.max(20, Math.min(500, Number(cols) || 120)), Math.max(5, Math.min(200, Number(rows) || 30))); + } catch {} + return true; +} + +// Closing a tab ends the session for good. +async function killShell(conn, chatId) { + if (!CHAT_ID_PATTERN.test(String(chatId || ''))) return false; + await tmux(['kill-session', '-t', `=${sessionName(chatId)}`]).catch(() => {}); + for (const [key, client] of clients) { + if (key.endsWith(`:${chatId}`)) { + try { + client.pty.kill(); + } catch {} + clients.delete(key); + } + } + return true; +} + +function detachConnection(conn) { + for (const [key, client] of clients) { + if (key.startsWith(`${conn.id}:`)) { + try { + client.pty.kill(); + } catch {} + clients.delete(key); + } + } +} + +// ---------- Antigravity accounts (same folders as the CLI) ---------- + +function listAccounts() { + if (!existsSync(ACCOUNTS_ROOT)) return []; + return readdirSync(ACCOUNTS_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && ACCOUNT_ID_PATTERN.test(entry.name)) + .map((entry) => ({ id: entry.name, created: statSync(join(ACCOUNTS_ROOT, entry.name)).mtimeMs, name: accountName(entry.name) })) + .sort((a, b) => a.created - b.created) + .map(({ id, name }) => ({ id, name })); +} + +// Creates folders for new accounts and stores names; never deletes (that is remove-profile). +function saveAccounts(_conn, accounts) { + if (!Array.isArray(accounts)) throw new Error('Ungueltige Accounts.'); + mkdirSync(ACCOUNTS_ROOT, { recursive: true, mode: 0o700 }); + for (const account of accounts.slice(0, 16)) { + const home = accountHome(account?.id); + const name = String(account?.name || '').replace(/[\r\n]/g, ' ').trim().slice(0, 60) || account.id; + if (!existsSync(home)) mkdirSync(home, { recursive: true, mode: 0o700 }); + writeFileSync(join(home, '.codeforge-name'), name, { mode: 0o600 }); + } + return listAccounts(); +} + +function readAccountEmail(home) { + const logDir = join(home, '.gemini', 'antigravity-cli', 'log'); + let files; + try { + files = readdirSync(logDir) + .filter((name) => name.endsWith('.log')) + .map((name) => join(logDir, name)) + .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs) + .slice(0, 5); + } catch { + return ''; + } + for (const file of files) { + let lines; + try { + const text = readFileSync(file, 'utf8'); + lines = text.slice(-1024 * 1024).split(/\r?\n/); + } catch { + continue; + } + for (let index = lines.length - 1; index >= 0; index -= 1) { + const login = lines[index].match(/authenticated successfully as (\S+@\S+)/); + if (login) return login[1]; + if (/Logging out current user/.test(lines[index])) return ''; + } + } + return ''; +} + +function accountsStatus(_conn, ids = []) { + return (Array.isArray(ids) ? ids.slice(0, 16) : []).map((id) => { + try { + const home = accountHome(id); + const exists = existsSync(home); + return { id, profileExists: exists, email: exists ? readAccountEmail(home) : '' }; + } catch { + return { id, profileExists: false, email: '' }; + } + }); +} + +async function removeAccountProfile(_conn, accountId) { + const home = accountHome(accountId); + // End this account's sessions first (web and CLI), they keep files open in the folder. + try { + const { stdout } = await tmux(['list-panes', '-a', '-F', '#{session_name}\t#{pane_start_command}']); + for (const line of stdout.split('\n')) { + const [session, command = ''] = line.split('\t'); + if (session && new RegExp(`__run[ '"]+${accountId}['"]?(\\s|$)`).test(command)) { + await tmux(['kill-session', '-t', `=${session}`]).catch(() => {}); + } + } + } catch {} + rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + return true; +} + +// ---------- other UI calls ---------- + +const PROVIDERS = { + antigravity: 'agy', + openai: 'codex', + anthropic: 'claude', + cursor: 'agent', + opencode: 'opencode', + freebuff: 'freebuff', +}; + +async function systemStatus() { + const entries = await Promise.all( + Object.entries(PROVIDERS).map(async ([id, command]) => { + try { + const { stdout } = await execFileAsync('bash', ['-lc', `command -v ${command}`], { timeout: 5000 }); + const executable = stdout.trim().split('\n')[0]; + let version = ''; + try { + const result = await execFileAsync(executable, ['--version'], { timeout: 8000 }); + version = (result.stdout || result.stderr).trim().split(/\r?\n/)[0] || ''; + } catch {} + return [id, { installed: Boolean(executable), executable, version }]; + } catch { + return [id, { installed: false, executable: '', version: '' }]; + } + }), + ); + return Object.fromEntries(entries); +} + +async function runGit(projectPath, args) { + if (typeof projectPath !== 'string' || !projectPath.startsWith('/')) return { ok: false, output: '' }; + try { + const { stdout } = await execFileAsync('git', ['-C', projectPath, ...args], { timeout: 8000 }); + return { ok: true, output: stdout.trim() }; + } catch { + return { ok: false, output: '' }; + } +} + +const handlers = new Map( + Object.entries({ + 'shell:create': createShell, + 'shell:write': writeShell, + 'shell:resize': resizeShell, + 'shell:kill': killShell, + 'agy-accounts:list': () => listAccounts(), + 'agy-accounts:save': saveAccounts, + 'agy-accounts:status': accountsStatus, + 'agy-accounts:remove-profile': removeAccountProfile, + 'system:status': systemStatus, + getScratchProjectFolder: () => WORK_DIR, + 'dialog:scratch-project': () => WORK_DIR, + selectProjectFolder: () => null, + 'git:info': async (_conn, projectPath) => { + const branch = await runGit(projectPath, ['branch', '--show-current']); + const root = await runGit(projectPath, ['rev-parse', '--show-toplevel']); + return { isRepository: branch.ok || root.ok, branch: branch.output, root: root.output }; + }, + 'git:branches': async (_conn, projectPath) => { + const result = await runGit(projectPath, ['branch', '--format=%(refname:short)']); + return result.ok ? result.output.split('\n').filter(Boolean) : []; + }, + // Desktop-only features answer with empty data instead of failing. + 'updates:status': () => ({ status: 'idle', web: true }), + 'system:mcp-servers': () => [], + 'codex-plugins:list': () => [], + 'usage:provider': () => null, + 'spotify:track': () => null, + 'discord:presence': () => false, + 'agent:notify-complete': () => false, + 'sync:get-server-status': () => null, + }), +); + +// ---------- WebSocket ---------- + +const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 }); +let nextConnId = 1; + +server.on('upgrade', async (req, socket, head) => { + const reject = (reason) => { + console.warn(`[Auth] WS rejected: ${reason}`); + socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); + socket.destroy(); + }; + if (req.headers.origin !== PUBLIC_ORIGIN) return reject(`origin ${req.headers.origin}`); + let email; + try { + email = await authenticate(req); + } catch (error) { + return reject(error.message); + } + wss.handleUpgrade(req, socket, head, (ws) => { + const conn = { + id: nextConnId++, + ws, + email, + emit: (channel, data) => { + if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'ipc-event', channel, data })); + }, + }; + console.log(`[WS] ${email} connected (#${conn.id})`); + ws.on('message', async (raw) => { + let msg; + try { + msg = JSON.parse(raw.toString()); + } catch { + return; + } + if (msg?.type !== 'ipc-call' || typeof msg.method !== 'string') return; + const reply = (payload) => ws.readyState === ws.OPEN && ws.send(JSON.stringify({ type: 'ipc-response', id: msg.id, ...payload })); + const handler = handlers.get(msg.method); + if (!handler) return reply({ error: `In CodeForge Web nicht verfuegbar: ${msg.method}` }); + try { + reply({ result: await handler(conn, ...(Array.isArray(msg.args) ? msg.args : [])) }); + } catch (error) { + reply({ error: error?.message || 'Fehler' }); + } + }); + // A closed tab or dropped connection only detaches; the tmux sessions keep running. + ws.on('close', () => { + detachConnection(conn); + console.log(`[WS] #${conn.id} disconnected`); + }); + }); +}); + +try { + execFileSync('tmux', ['-V'], { stdio: 'ignore' }); +} catch { + console.error('[CodeForge Web] tmux is required.'); + process.exit(1); +} + +server.listen(PORT, HOST, () => { + console.log(`[CodeForge Web] http://${HOST}:${PORT} -> ${PUBLIC_ORIGIN} (Access team ${TEAM_DOMAIN}, ${ALLOWED_EMAILS.size} e-mail(s))`); +}); diff --git a/web/codeforge-web.service b/web/codeforge-web.service new file mode 100644 index 0000000..fdb650c --- /dev/null +++ b/web/codeforge-web.service @@ -0,0 +1,27 @@ +[Unit] +Description=CodeForge Web (behind Cloudflare Access) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/codeforge-web +EnvironmentFile=/etc/codeforge-web.env +Environment=HOME=/root +Environment=PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +Environment=CODEFORGE_DIST=/opt/codeforge-web/dist +ExecStart=/usr/bin/node /opt/codeforge-web/codeforge-web-server.mjs +Restart=always +RestartSec=3 +# Terminals live in tmux, which this service may start: keep it alive on restarts. +KillMode=process +NoNewPrivileges=yes +ProtectSystem=no +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +LockPersonality=yes +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/web/install.sh b/web/install.sh new file mode 100755 index 0000000..eb8ae5e --- /dev/null +++ b/web/install.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Installs or updates CodeForge Web on this server. Run from the repository root: +# web/install.sh +# Configuration lives in /etc/codeforge-web.env (created once, mode 600). +set -euo pipefail +cd "$(dirname "$0")/.." +TARGET=/opt/codeforge-web + +command -v tmux >/dev/null || apt-get install -y tmux +npm ci --ignore-scripts --no-audit --no-fund +npx vite build + +install -d -m 755 "$TARGET" +install -m 644 web/codeforge-web-server.mjs web/package.json "$TARGET/" +rm -rf "$TARGET/dist.new" && cp -r dist "$TARGET/dist.new" && rm -rf "$TARGET/dist" && mv "$TARGET/dist.new" "$TARGET/dist" +(cd "$TARGET" && npm install --omit=dev --no-audit --no-fund) +# The build from npm install can crash on newer Node versions; a clean node-gyp rebuild works. +(cd "$TARGET/node_modules/node-pty" && npx --yes node-gyp rebuild >/dev/null) +node -e "require('$TARGET/node_modules/node-pty')" +install -m 755 cli/codeforge /usr/local/bin/codeforge + +if [ ! -f /etc/codeforge-web.env ]; then + install -m 600 /dev/null /etc/codeforge-web.env + cat > /etc/codeforge-web.env </dev/null +systemctl restart codeforge-web diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..732f9c2 --- /dev/null +++ b/web/package.json @@ -0,0 +1,13 @@ +{ + "name": "codeforge-web-server", + "private": true, + "version": "1.0.0", + "description": "Serves CodeForge in the browser behind Cloudflare Access", + "type": "module", + "main": "codeforge-web-server.mjs", + "engines": { "node": ">=20" }, + "dependencies": { + "node-pty": "^1.1.0", + "ws": "^8.21.0" + } +} From 983ea1214ae8a8b235591579891e3f185c1be913 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 00:14:01 +0200 Subject: [PATCH 2/4] Remove startup, setup and auto-update popups News and welcome popups no longer show on start, the setup modal is gone, and the update window only opens when the user starts a check. "Was ist neu" in the account menu now opens the news window directly. --- src/App.tsx | 48 ----- src/components/AccountMenu.tsx | 12 +- src/components/NewsPopup.tsx | 17 -- src/components/SetupModal.tsx | 341 -------------------------------- src/components/UpdateModal.tsx | 16 +- src/components/WelcomePopup.tsx | 117 ----------- 6 files changed, 8 insertions(+), 543 deletions(-) delete mode 100644 src/components/SetupModal.tsx delete mode 100644 src/components/WelcomePopup.tsx diff --git a/src/App.tsx b/src/App.tsx index 75b0dab..7b7aae9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,6 @@ import { Menu, X as XIcon, Wifi } from 'lucide-react'; import TopBar from './components/TopBar'; import Sidebar from './components/Sidebar'; import MainArea from './components/MainArea'; -import NewsPopup, { shouldShowNewsPopup, dismissNewsPopup } from './components/NewsPopup'; import { AppProvider, useAppContext } from './AppContext'; import { isVideoPath, toFileUrl } from './media'; import UpdateModal from './components/UpdateModal'; @@ -18,18 +17,13 @@ import { syncWakeMode } from './voice'; // Modals and widgets load on demand to keep startup fast. const SettingsModal = lazy(() => import('./components/SettingsModal')); -const WelcomePopup = lazy(() => import('./components/WelcomePopup')); const SpotifyWidget = lazy(() => import('./components/SpotifyWidget')); -const SetupModal = lazy(() => import('./components/SetupModal')); const MobileConnectModal = lazy(() => import('./components/MobileConnectModal')); -const WELCOME_POPUP_STORAGE_KEY = 'agentWorkspace.welcomePopupDismissed'; - function AppLayout() { const { theme, customThemes, - hasSetupCompleted, sidebarTransparency, surfaceTransparency, spotifyWidgetEnabled, @@ -45,31 +39,11 @@ function AppLayout() { const [showSettings, setShowSettings] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [showMobileConnect, setShowMobileConnect] = useState(false); - const [showWelcomePopup, setShowWelcomePopup] = useState(() => { - try { - return localStorage.getItem(WELCOME_POPUP_STORAGE_KEY) !== 'true'; - } catch { - return true; - } - }); - const [showNewsPopup, setShowNewsPopup] = useState(shouldShowNewsPopup); - - const closeNewsPopup = () => { - setShowNewsPopup(false); - dismissNewsPopup(); - }; - // Start the always-on wake word listener if it is enabled. useEffect(() => { void syncWakeMode(); }, []); - // Re-check news popup when setup completes later in the session - useEffect(() => { - if (hasSetupCompleted && shouldShowNewsPopup()) { - setShowNewsPopup(true); - } - }, [hasSetupCompleted]); const selectedCustomTheme = customThemes.find((item) => item.id === theme); // Media-only themes keep the look of their base theme and only swap the background. const baseThemeId = selectedCustomTheme?.mediaOnly ? selectedCustomTheme.baseTheme || 'modern-dark' : theme; @@ -79,15 +53,6 @@ function AppLayout() { const backdropTheme = selectedCustomTheme?.mediaOnly ? selectedCustomTheme : customTheme; const cssTheme = customTheme ? 'custom' : baseThemeId; - const closeWelcomePopup = () => { - setShowWelcomePopup(false); - try { - localStorage.setItem(WELCOME_POPUP_STORAGE_KEY, 'true'); - } catch { - // localStorage may be unavailable in restricted render contexts. - } - }; - const bgClass = { 'modern-dark': 'bg-gradient-to-br from-[#1c181a] via-[#111111] to-[#0a0a0a]', 'classic-light': 'bg-gradient-to-br from-[#f5f2ed] via-[#e5e1d8] to-[#d1ccc0]', @@ -225,16 +190,6 @@ function AppLayout() { - - {hasSetupCompleted && showNewsPopup && ( - - )} - - - {hasSetupCompleted && !showNewsPopup && showWelcomePopup && ( - - )} - {spotifyWidgetEnabled && } @@ -244,9 +199,6 @@ function AppLayout() { - - {!hasSetupCompleted && } - {/* Mobile Mode: Connect modal */} diff --git a/src/components/AccountMenu.tsx b/src/components/AccountMenu.tsx index 04ac540..71ed993 100644 --- a/src/components/AccountMenu.tsx +++ b/src/components/AccountMenu.tsx @@ -2,6 +2,7 @@ import { useState, type ReactNode } from 'react'; import { BookOpen, CircleUserRound, Loader2, LogOut, Megaphone, MessageSquare, Play, Settings } from 'lucide-react'; import { PROVIDER_MODELS, useAppContext } from '../AppContext'; import TutorialModal from './TutorialModal'; +import NewsPopup from './NewsPopup'; export default function AccountMenu({ onSettingsClick, @@ -26,6 +27,7 @@ export default function AccountMenu({ const [busy, setBusy] = useState<'limit' | null>(null); const [status, setStatus] = useState(''); const [showTutorial, setShowTutorial] = useState(false); + const [showNews, setShowNews] = useState(false); const providerInfo = PROVIDER_INFO[provider]; return ( @@ -139,14 +141,7 @@ export default function AccountMenu({ } label="Was ist neu" - onClick={() => { - if (window.confirm('Seite neu laden um das News-Popup erneut anzuzeigen?')) { - try { - localStorage.removeItem('agentWorkspace.newsPopupDismissed'); - } catch {} - window.location.reload(); - } - }} + onClick={() => setShowNews(true)} /> } @@ -159,6 +154,7 @@ export default function AccountMenu({ {showTutorial && setShowTutorial(false)} />} + {showNews && setShowNews(false)} />} ); } diff --git a/src/components/NewsPopup.tsx b/src/components/NewsPopup.tsx index b7628c8..99b2629 100644 --- a/src/components/NewsPopup.tsx +++ b/src/components/NewsPopup.tsx @@ -2,7 +2,6 @@ import { motion } from 'motion/react'; import { Megaphone, Sparkles, X, Zap, Smartphone, Terminal, Wifi } from 'lucide-react'; import AppLogo from './AppLogo'; -const NEWS_STORAGE_KEY = 'agentWorkspace.newsPopupDismissed'; const CURRENT_VERSION = '2.1.0'; const NEWS_ITEMS = [ @@ -158,19 +157,3 @@ export default function NewsPopup({ onClose }: { onClose: () => void }) { ); } -export function shouldShowNewsPopup(): boolean { - try { - const dismissed = localStorage.getItem(NEWS_STORAGE_KEY); - return dismissed !== CURRENT_VERSION; - } catch { - return true; - } -} - -export function dismissNewsPopup(): void { - try { - localStorage.setItem(NEWS_STORAGE_KEY, CURRENT_VERSION); - } catch { - // localStorage may be unavailable - } -} diff --git a/src/components/SetupModal.tsx b/src/components/SetupModal.tsx deleted file mode 100644 index 2c545bc..0000000 --- a/src/components/SetupModal.tsx +++ /dev/null @@ -1,341 +0,0 @@ -import { Check, CheckCircle2, Download, FolderOpen, KeyRound, Loader2, Sparkles, Terminal, XCircle } from 'lucide-react'; -import { useEffect, useState } from 'react'; -import { motion } from 'motion/react'; -import { RECOMMENDED_SYSTEM_PROMPT, useAppContext } from '../AppContext'; -import type { ProviderId } from '../types'; -import AppLogo from './AppLogo'; - -const PROVIDERS: { id: ProviderId; name: string; command: string; description: string }[] = [ - { - id: 'antigravity', - name: 'Google Antigravity', - command: 'agy', - description: 'Gemini, Claude und GPT-OSS ueber die Antigravity CLI.', - }, - { - id: 'openai', - name: 'OpenAI Codex', - command: 'codex', - description: 'Codex arbeitet nicht-interaktiv direkt im Projektordner.', - }, - { - id: 'anthropic', - name: 'Anthropic Claude', - command: 'claude', - description: 'Claude Code mit konfigurierbarem Modell und Zugriffsmodus.', - }, - { - id: 'cursor', - name: 'Cursor Agent', - command: 'agent / cursor-agent', - description: 'Cursor CLI direkt im Projektordner starten.', - }, - { - id: 'opencode', - name: 'OpenCode', - command: 'opencode', - description: 'OpenCode im nicht-interaktiven Run-Modus verwenden.', - }, - { - id: 'freebuff', - name: 'FreeBuff', - command: 'freebuff', - description: 'Kostenloser AI Coding Agent via npm.', - }, -]; - -const containerVariants = { - hidden: { opacity: 0 }, - show: { - opacity: 1, - transition: { - staggerChildren: 0.05, - delayChildren: 0.15, - }, - }, -} as const; - -const itemVariants = { - hidden: { opacity: 0, y: 12 }, - show: { opacity: 1, y: 0, transition: { type: 'spring', stiffness: 350, damping: 25 } }, -} as const; - -export default function SetupModal() { - const { - provider, - setProvider, - cliStatus, - refreshCliStatus, - installCli, - selectedProject, - addProject, - createNewProject, - setHasSetupCompleted, - apiKeys, - setApiKey, - systemPrompt, - setSystemPrompt, - useRecommendedSystemPrompt, - generateSystemPrompt, - } = useAppContext(); - const [checking, setChecking] = useState(false); - const [generatingPrompt, setGeneratingPrompt] = useState(false); - const [promptError, setPromptError] = useState(''); - const [installing, setInstalling] = useState(null); - const [installNotice, setInstallNotice] = useState(''); - - useEffect(() => { - setChecking(true); - refreshCliStatus().finally(() => setChecking(false)); - }, [refreshCliStatus]); - - return ( - - -
- - - - - CodeForge einrichten - - - Waehle eine lokale Agent-CLI. Ein Projekt kannst du erstellen, oeffnen oder spaeter nachholen. - -
- - - {PROVIDERS.map((item) => { - const status = cliStatus[item.id]; - return ( - setProvider(item.id)} - whileHover={{ scale: 1.02, y: -2 }} - whileTap={{ scale: 0.98 }} - className={`relative text-left p-4 rounded-xl border transition-colors ${ - provider === item.id ? 'border-white/30 bg-white/[0.06]' : 'border-white/5 hover:border-white/15' - }`} - > - {provider === item.id && ( - - - - )} -
{item.name}
-
{item.command}
-

{item.description}

-
- {checking ? ( - - ) : status.installed ? ( - - ) : ( - - )} - {checking ? 'Pruefe...' : status.installed ? status.version || 'Installiert' : 'Nicht gefunden'} -
- {!status.installed && ( - { - event.stopPropagation(); - setInstalling(item.id); - setInstallNotice(''); - try { - setInstallNotice(await installCli(item.id)); - } catch (error) { - setInstallNotice(error instanceof Error ? error.message : 'Installation konnte nicht gestartet werden.'); - } finally { - setInstalling(null); - } - }} - className="mt-3 inline-flex items-center gap-1 rounded-md border border-amber-400/20 bg-amber-400/10 px-2 py-1 text-[10px] text-amber-200 hover:bg-amber-400/15 transition-colors" - > - {installing === item.id ? : } - Installieren - - )} -
- ); - })} -
- - {installNotice && ( - - {installNotice} - - )} - - - - -
-
Neues Projekt erstellen
-
- Legt einen neuen Ordner an und nutzt ihn sofort. -
-
-
- - -
-
{selectedProject?.title || 'Projektordner oeffnen'}
-
- {selectedProject?.path || 'Vorhandenen Ordner als Arbeitsverzeichnis verwenden.'} -
-
-
-
- - {!cliStatus[provider].installed && ( - - Die gewaehlte CLI fehlt noch. Du kannst die App einrichten, aber Agent-Aufrufe funktionieren erst nach der Installation. - - )} - - -
-
- - API-Key -
- setApiKey(provider, event.target.value)} - className="input w-full mt-3" - placeholder={`${PROVIDERS.find((item) => item.id === provider)?.name} API-Key`} - autoComplete="off" - /> -

- Wird nur fuer den gewaehlten CLI-Prozess als Umgebungsvariable gesetzt. -

-
- -
-
-
System-Prompt
-
- - -
-
-