diff --git a/README.md b/README.md index af37e5c..c3a94a6 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,19 @@ The interface is currently in German. Labels in parentheses show the exact text 2. Go to **Home** and choose **Start workspace** (*Workspace starten*) or **Start chat** (*Chat starten*). 3. Select a project folder, or start without one, and get to work. +## CLI for servers (Linux) + +`cli/codeforge` brings the Antigravity account switcher to a terminal, e.g. on a V-Server over SSH. + +```bash +sudo install -m 755 cli/codeforge /usr/local/bin/codeforge +codeforge +``` + +The menu offers `1` start one account (or the main login), `2` settings to add, rename, log out or delete accounts, `3` all accounts side by side in a grid and `4` the running sessions. agy always starts with `--dangerously-skip-permissions`. Every account has its own home folder under `~/.codeforge/agy-accounts/`, so each keeps its own login; settings, skills and MCP servers are shared with the main login. + +Sessions run in tmux (`apt install tmux`): they keep running when the SSH connection drops and end only when agy is closed or the session is ended under `4`. `Ctrl-b d` puts a session in the background. Shortcuts: `codeforge start 2`, `codeforge grid`, `codeforge sessions`, `codeforge add`. + ## Development ```bash diff --git a/cli/codeforge b/cli/codeforge new file mode 100755 index 0000000..e4075de --- /dev/null +++ b/cli/codeforge @@ -0,0 +1,359 @@ +#!/usr/bin/env bash +# CodeForge CLI: start Antigravity (agy) with one of several saved Google accounts. +# Each account gets its own HOME folder, so agy keeps a separate login per account +# (agy stores its OAuth token as a file below HOME on SSH/Linux sessions). +# Sessions run in tmux: they survive a dropped SSH connection and end only when closed. +set -euo pipefail + +REAL_HOME="${CODEFORGE_REAL_HOME:-$HOME}" +ROOT="${CODEFORGE_HOME:-$REAL_HOME/.codeforge}/agy-accounts" +SELF="$(readlink -f "$0")" +AGY_BIN="$(command -v agy || true)" +AGY_ARGS=(--dangerously-skip-permissions) +PREFIX="cf-" + +c_bold=$'\e[1m'; c_dim=$'\e[2m'; c_or=$'\e[38;5;208m'; c_gr=$'\e[32m'; c_red=$'\e[31m'; c_off=$'\e[0m' + +die() { echo "${c_red}$*${c_off}" >&2; exit 1; } +warn() { echo "${c_red}$*${c_off}" >&2; } +[ -n "$AGY_BIN" ] || die "agy wurde nicht gefunden (PATH)." + +mkdir -p "$ROOT" +chmod 700 "$ROOT" "$(dirname "$ROOT")" + +# ---------- accounts ---------- + +# Account folders sorted by creation, one id per line. +list_ids() { + find "$ROOT" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %f\n' 2>/dev/null | sort -n | cut -d' ' -f2 +} + +account_name() { cat "$ROOT/$1/.codeforge-name" 2>/dev/null || echo "$1"; } + +# Last successful login in the account's agy logs, empty after a logout. +account_email() { + local logdir="$ROOT/$1/.gemini/antigravity-cli/log" line + [ -d "$logdir" ] || return 0 + line="$(ls -t "$logdir"/*.log 2>/dev/null | head -5 | xargs -r cat 2>/dev/null \ + | grep -oE 'authenticated successfully as [^ ]+@[^ ]+|Logging out current user' | tail -1 || true)" + case "$line" in + authenticated*) echo "${line#authenticated successfully as }" ;; + esac +} + +# Share preferences, skills and MCP servers with the main login; never login data. +seed_account() { + local home="$1" real="$REAL_HOME/.gemini" + mkdir -p "$home/.gemini/antigravity-cli" "$home/.gemini/config" + [ -f "$real/antigravity-cli/settings.json" ] && [ ! -e "$home/.gemini/antigravity-cli/settings.json" ] \ + && cp "$real/antigravity-cli/settings.json" "$home/.gemini/antigravity-cli/settings.json" + local link + for link in GEMINI.md config/skills config/plugins config/mcp_config.json antigravity-cli/bin; do + [ -e "$real/$link" ] && [ ! -e "$home/.gemini/$link" ] && ln -s "$real/$link" "$home/.gemini/$link" + done + return 0 +} + +print_accounts() { + local i=1 id email + while read -r id; do + [ -n "$id" ] || continue + email="$(account_email "$id")" + printf ' %s%d)%s %-18s %s\n' "$c_or" "$i" "$c_off" "$(account_name "$id")" \ + "$([ -n "$email" ] && echo "${c_gr}$email${c_off}" || echo "${c_dim}nicht angemeldet${c_off}")" + i=$((i + 1)) + done < <(list_ids) + [ "$i" -gt 1 ] || echo " ${c_dim}Noch keine Accounts hinterlegt (Einstellungen).${c_off}" +} + +# Prints the chosen id; "-" stands for the main login when allow_main is set. +pick_account() { + local allow_main="${1:-}" choice + mapfile -t ids < <(list_ids) + if [ "${#ids[@]}" -eq 0 ] && [ -z "$allow_main" ]; then + warn "Noch keine Accounts hinterlegt. In den Einstellungen (2) einen anlegen."; return 1 + fi + [ -n "$allow_main" ] && echo " ${c_or}0)${c_off} Hauptlogin ${c_dim}ohne eigenen Account-Ordner${c_off}" >&2 + print_accounts >&2 + read -rp "Account-Nummer (Enter = zurueck): " choice + [ -n "$choice" ] || return 1 + if [ "$choice" = "0" ] && [ -n "$allow_main" ]; then echo "-"; return 0; fi + [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#ids[@]}" ] \ + || { warn "Ungueltige Auswahl."; return 1; } + echo "${ids[$((choice - 1))]}" +} + +resolve_account() { # number or id/name -> id + local arg="$1" id lower + mapfile -t ids < <(list_ids) + if [[ "$arg" =~ ^[0-9]+$ ]] && [ "$arg" -ge 1 ] && [ "$arg" -le "${#ids[@]}" ]; then + echo "${ids[$((arg - 1))]}"; return 0 + fi + lower="$(echo "$arg" | tr '[:upper:]' '[:lower:]')" + for id in "${ids[@]}"; do + if [ "$id" = "$arg" ] || [ "$(account_name "$id" | tr '[:upper:]' '[:lower:]')" = "$lower" ]; then + echo "$id"; return 0 + fi + done + die "Account \"$arg\" nicht gefunden." +} + +add_account() { + local name id yn + read -rp "Name fuer den Account (z.B. Main, Alt 2): " name + name="$(echo "$name" | tr -d '\n' | sed 's/^ *//;s/ *$//')" + [ -n "$name" ] || name="Account $(( $(list_ids | wc -l) + 1 ))" + id="$(echo "$name" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//;s/-*$//' | cut -c1-40)" + [ -n "$id" ] || id="account" + local base="$id" n=2 + while [ -e "$ROOT/$id" ]; do id="$base-$n"; n=$((n + 1)); done + mkdir -p "$ROOT/$id" + chmod 700 "$ROOT/$id" + printf '%s' "$name" > "$ROOT/$id/.codeforge-name" + seed_account "$ROOT/$id" + echo "${c_gr}Account \"$name\" angelegt.${c_off}" + read -rp "Jetzt mit Google anmelden (agy starten)? [J/n] " yn + case "$yn" in n|N|nein) return 0 ;; esac + start_session "$id" +} + +rename_account() { + local id name + id="$(pick_account)" || return 0 + read -rp "Neuer Name fuer \"$(account_name "$id")\": " name + [ -n "$name" ] || return 0 + printf '%s' "$name" > "$ROOT/$id/.codeforge-name" + echo "${c_gr}Umbenannt.${c_off}" +} + +# Deletes only the stored token, the account entry stays. +logout_account() { + local id yn + id="$(pick_account)" || return 0 + read -rp "Login von \"$(account_name "$id")\" loeschen? [j/N] " yn + case "$yn" in + j|J|ja|y|Y) rm -f -- "$ROOT/$id/.gemini/antigravity-cli/antigravity-oauth-token"; echo "${c_gr}Abgemeldet.${c_off}" ;; + *) echo "Abgebrochen." ;; + esac +} + +remove_account() { + local id yn + id="$(pick_account)" || return 0 + read -rp "Account \"$(account_name "$id")\" samt Login loeschen? [j/N] " yn + case "$yn" in j|J|ja|y|Y) rm -rf -- "${ROOT:?}/$id"; echo "${c_gr}Geloescht.${c_off}" ;; *) echo "Abgebrochen." ;; esac +} + +# ---------- agy process (runs inside tmux) ---------- + +run_agy() { # $1 = account id, "-" = main login + local id="$1"; shift + if [ "$id" = "-" ]; then + exec "$AGY_BIN" "${AGY_ARGS[@]}" "$@" + fi + local home="$ROOT/$id" + [ -d "$home" ] || die "Account \"$id\" existiert nicht mehr." + seed_account "$home" + HOME="$home" \ + GIT_CONFIG_GLOBAL="${GIT_CONFIG_GLOBAL:-$REAL_HOME/.gitconfig}" \ + NPM_CONFIG_USERCONFIG="${NPM_CONFIG_USERCONFIG:-$REAL_HOME/.npmrc}" \ + SSH_CONNECTION="${SSH_CONNECTION:-127.0.0.1 0 127.0.0.1 0}" \ + SSH_CLIENT="${SSH_CLIENT:-127.0.0.1 0 0}" \ + SSH_TTY="${SSH_TTY:-codeforge-agy}" \ + exec "$AGY_BIN" "${AGY_ARGS[@]}" "$@" +} + +# ---------- tmux sessions ---------- + +require_tmux() { command -v tmux >/dev/null || die "tmux fehlt (apt install tmux)."; } + +label_for() { [ "$1" = "-" ] && echo "Hauptlogin" || account_name "$1"; } + +unique_session() { + local base="$PREFIX$1" name n=2 + name="$base" + while tmux has-session -t "=$name" 2>/dev/null; do name="$base-$n"; n=$((n + 1)); done + echo "$name" +} + +pane_cmd() { printf "%q __run %q" "$SELF" "$1"; } + +style_session() { # $1 = session, $2 = label + local s="$1" + tmux set -s escape-time 10 \; \ + set -t "$s" @cf_session "$2" \; \ + set -t "$s" mouse on \; \ + set -t "$s" status-style "bg=colour235,fg=colour250" \; \ + set -t "$s" status-left "#[fg=colour208,bold] CodeForge #[default]· #{@cf_session} " \; \ + set -t "$s" status-left-length 60 \; \ + set -t "$s" status-right "#[fg=colour244]Ctrl-b d = Hintergrund · Ctrl-b Pfeile = Pane wechseln " \; \ + set -t "$s" status-right-length 80 \; \ + set -t "$s" pane-border-status top \; \ + set -t "$s" pane-border-format " #[bold]#{@cf_label} " \; \ + set -t "$s" pane-active-border-style "fg=colour208" >/dev/null +} + +attach_session() { + if [ -n "${TMUX:-}" ]; then tmux switch-client -t "=$1"; else tmux attach-session -t "=$1"; fi +} + +term_size() { echo "-x $(tput cols 2>/dev/null || echo 200) -y $(tput lines 2>/dev/null || echo 50)"; } + +start_session() { # $1 = account id or "-", rest goes to agy + require_tmux + local id="$1"; shift + local label name + label="$(label_for "$id")" + name="$(unique_session "$( [ "$id" = "-" ] && echo main || echo "$id")")" + # shellcheck disable=SC2046 + tmux new-session -d -s "$name" -c "$PWD" $(term_size) -e "CODEFORGE_REAL_HOME=$REAL_HOME" \ + "$(pane_cmd "$id")$([ $# -eq 0 ] || printf ' %q' "$@")" + style_session "$name" "$label" + tmux set -p -t "$name" @cf_label "$label" >/dev/null + attach_session "$name" +} + +start_grid() { + require_tmux + mapfile -t ids < <(list_ids) + [ "${#ids[@]}" -gt 0 ] || { warn "Noch keine Accounts hinterlegt. In den Einstellungen (2) einen anlegen."; return 1; } + local name pane id + name="$(unique_session grid)" + # shellcheck disable=SC2046 + pane="$(tmux new-session -d -P -F '#{pane_id}' -s "$name" -c "$PWD" $(term_size) \ + -e "CODEFORGE_REAL_HOME=$REAL_HOME" "$(pane_cmd "${ids[0]}")")" + style_session "$name" "Grid (${#ids[@]} Accounts)" + tmux set -p -t "$pane" @cf_label "$(account_name "${ids[0]}")" >/dev/null + for id in "${ids[@]:1}"; do + pane="$(tmux split-window -P -F '#{pane_id}' -t "$name" -c "$PWD" \ + -e "CODEFORGE_REAL_HOME=$REAL_HOME" "$(pane_cmd "$id")")" + tmux set -p -t "$pane" @cf_label "$(account_name "$id")" \; select-layout -t "$name" tiled >/dev/null + done + tmux select-layout -t "$name" tiled \; select-pane -t "$name.0" >/dev/null + attach_session "$name" +} + +list_sessions() { # prints "name|label|created|attached|panes" + command -v tmux >/dev/null || return 0 + tmux list-sessions -F '#{session_name}|#{@cf_session}|#{session_created}|#{session_attached}|#{session_windows}|#{window_panes}' 2>/dev/null \ + | grep "^$PREFIX" || true +} + +sessions_menu() { + local choice + while true; do + mapfile -t rows < <(list_sessions) + echo + echo " ${c_or}${c_bold}Laufende Sessions${c_off} ${c_dim}(laufen weiter, auch wenn die Verbindung abbricht)${c_off}" + echo + if [ "${#rows[@]}" -eq 0 ]; then + echo " ${c_dim}Keine laufenden Sessions.${c_off}"; echo; return 0 + fi + local i=1 row name label created attached _w panes + for row in "${rows[@]}"; do + IFS='|' read -r name label created attached _w panes <<<"$row" + printf ' %s%d)%s %-26s %s%s · seit %s%s %s\n' "$c_or" "$i" "$c_off" "${label:-$name}" "$c_dim" \ + "$panes Pane(s)" "$(date -d "@$created" '+%d.%m. %H:%M')" "$c_off" \ + "$([ "$attached" -gt 0 ] && echo "${c_gr}verbunden${c_off}" || echo "${c_dim}im Hintergrund${c_off}")" + i=$((i + 1)) + done + echo + read -rp "Nummer = oeffnen, x = beenden, Enter = zurueck: " choice + case "$choice" in + "") return 0 ;; + x[0-9]*|X[0-9]*) + local n="${choice:1}" + if [ "$n" -ge 1 ] && [ "$n" -le "${#rows[@]}" ]; then + tmux kill-session -t "=${rows[$((n - 1))]%%|*}" && echo "${c_gr}Beendet.${c_off}" + else warn "Ungueltige Auswahl."; fi ;; + *) + if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#rows[@]}" ]; then + attach_session "${rows[$((choice - 1))]%%|*}" + else warn "Ungueltige Auswahl."; fi ;; + esac + done +} + +settings_menu() { + local key + while true; do + echo + echo " ${c_or}${c_bold}Einstellungen${c_off} ${c_dim}· Accounts in $ROOT${c_off}" + echo + print_accounts + echo + echo " ${c_or}1)${c_off} Account hinzufuegen" + echo " ${c_or}2)${c_off} Account umbenennen" + echo " ${c_or}3)${c_off} Account abmelden (Login loeschen)" + echo " ${c_or}4)${c_off} Account loeschen" + echo " ${c_or}b)${c_off} Zurueck" + echo + read -rsn1 -p "Auswahl: " key; echo + case "$key" in + 1) add_account ;; + 2) rename_account ;; + 3) logout_account ;; + 4) remove_account ;; + b|B|q|Q|""|$'\e') return 0 ;; + *) warn "Unbekannte Auswahl." ;; + esac + done +} + +menu() { + local key id count + while true; do + count="$(list_sessions | wc -l)" + echo + echo " ${c_or}${c_bold}CodeForge${c_off} ${c_dim}· Antigravity · $(pwd)${c_off}" + echo + echo " ${c_or}1)${c_off} Account auswaehlen und starten" + echo " ${c_or}2)${c_off} Einstellungen (Accounts verwalten)" + echo " ${c_or}3)${c_off} Alle Accounts im Grid starten ${c_dim}($(list_ids | wc -l))${c_off}" + echo " ${c_or}4)${c_off} Laufende Sessions ${c_dim}($count)${c_off}" + echo " ${c_or}q)${c_off} Beenden" + echo + read -rsn1 -p "Auswahl: " key; echo + case "$key" in + 1) id="$(pick_account main)" && start_session "$id" || true ;; + 2) settings_menu ;; + 3) start_grid || true ;; + 4) sessions_menu ;; + q|Q|0|$'\e') exit 0 ;; + *) warn "Unbekannte Auswahl." ;; + esac + done +} + +usage() { + cat < { + if (/^https?:\/\//i.test(details.url)) { + openUrlInChrome(details.url).catch((error) => console.error(`[Links] ${error.message}`)); + } + return { action: 'deny' }; + }); + mainWindow.webContents.on('will-attach-webview', (_event, webPreferences) => { webPreferences.nodeIntegration = false; webPreferences.contextIsolation = true; @@ -672,6 +680,37 @@ function createWindow() { }); } +function findChromeExecutable() { + if (process.platform !== 'win32') return null; + const roots = [process.env.ProgramFiles, process.env['ProgramFiles(x86)'], process.env.LOCALAPPDATA].filter(Boolean); + for (const root of roots) { + const candidate = path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'); + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +// Terminal links (e.g. the Google login of agy) belong in the user's real browser, where their accounts are. +async function openUrlInChrome(url) { + const parsed = new URL(String(url)); + if (!['https:', 'http:'].includes(parsed.protocol)) throw new Error('URL-Protokoll nicht erlaubt.'); + const chrome = findChromeExecutable(); + if (chrome) { + // A failed spawn emits 'error' asynchronously; without a listener it would crash the main process. + const started = await new Promise((resolve) => { + const child = spawn(chrome, [parsed.toString()], { detached: true, stdio: 'ignore', windowsHide: false }); + child.once('error', () => resolve(false)); + child.once('spawn', () => { + child.unref(); + resolve(true); + }); + }); + if (started) return 'chrome'; + } + await shell.openExternal(parsed.toString()); + return 'default'; +} + function findExecutable(command) { return new Promise((resolve) => { const locator = process.platform === 'win32' ? 'where.exe' : 'which'; @@ -3080,7 +3119,155 @@ function removeSshKey(_event, keyPath) { return true; } -async function createShellSession(event, { chatId, cwd, shellType, externalServer, vserver }) { +// Each Antigravity account gets its own home folder so several agy logins can run side by side. +const AGY_ACCOUNT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/i; +const agyShellAccounts = new Map(); + +function getAgyAccountsRoot() { + return path.join(app.getPath('userData'), 'agy-accounts'); +} + +function resolveAgyAccountHome(accountId) { + const id = String(accountId || ''); + if (!AGY_ACCOUNT_ID_PATTERN.test(id)) throw new Error('Ungueltige Antigravity-Account-ID.'); + const root = getAgyAccountsRoot(); + const home = path.resolve(root, id); + if (!home.startsWith(root + path.sep)) throw new Error('Ungueltige Antigravity-Account-ID.'); + return home; +} + +function ensureAgyAccountHome(accountId) { + const root = getAgyAccountsRoot(); + if (!fs.existsSync(root)) { + fs.mkdirSync(root, { recursive: true }); + // The folders hold OAuth tokens: keep them readable for the current Windows user only. + if (process.platform === 'win32') { + const user = process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}` : process.env.USERNAME; + try { + require('node:child_process').execFileSync('icacls', [root, '/inheritance:r', '/grant:r', `${user}:(OI)(CI)F`], { windowsHide: true, stdio: 'ignore' }); + } catch (_) {} + } + } + const home = resolveAgyAccountHome(accountId); + if (!fs.existsSync(home)) { + fs.mkdirSync(home, { recursive: true }); + // Carry over agy preferences (model, permissions, global instructions), never login data. + const realGemini = path.join(app.getPath('home'), '.gemini'); + const seeds = [ + [path.join(realGemini, 'antigravity-cli', 'settings.json'), path.join(home, '.gemini', 'antigravity-cli', 'settings.json')], + [path.join(realGemini, 'GEMINI.md'), path.join(home, '.gemini', 'GEMINI.md')], + ]; + for (const [from, to] of seeds) { + try { + if (fs.existsSync(from)) { + fs.mkdirSync(path.dirname(to), { recursive: true }); + fs.copyFileSync(from, to); + } + } catch (_) {} + } + } + return home; +} + +function setEnvVar(env, key, value) { + // Windows env names are case-insensitive; drop variants so the child sees exactly one value. + for (const existing of Object.keys(env)) { + if (existing.toUpperCase() === key) delete env[existing]; + } + env[key] = value; +} + +function applyAgyAccountEnv(env, accountId) { + const realHome = app.getPath('home'); + const home = ensureAgyAccountHome(accountId); + setEnvVar(env, 'USERPROFILE', home); + setEnvVar(env, 'HOME', home); + // agy stores its login in the shared Windows credential store ("gemini:antigravity") unless it + // detects an SSH session; then it keeps the token in a file below HOME, one per account. + setEnvVar(env, 'SSH_CONNECTION', '127.0.0.1 0 127.0.0.1 0'); + setEnvVar(env, 'SSH_CLIENT', '127.0.0.1 0 0'); + setEnvVar(env, 'SSH_TTY', 'codeforge-agy'); + // Keep the user's git and npm config although HOME points to the account folder. + const gitconfig = path.join(realHome, '.gitconfig'); + if (!env.GIT_CONFIG_GLOBAL && fs.existsSync(gitconfig)) setEnvVar(env, 'GIT_CONFIG_GLOBAL', gitconfig); + const npmrc = path.join(realHome, '.npmrc'); + if (!env.NPM_CONFIG_USERCONFIG && fs.existsSync(npmrc)) setEnvVar(env, 'NPM_CONFIG_USERCONFIG', npmrc); +} + +function readAgyAccountEmail(home) { + const logDir = path.join(home, '.gemini', 'antigravity-cli', 'log'); + let files; + try { + files = fs.readdirSync(logDir) + .filter((name) => name.endsWith('.log')) + .map((name) => path.join(logDir, name)) + .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs) + .slice(0, 5); + } catch (_) { + return ''; + } + for (const file of files) { + let text = ''; + try { + const size = fs.statSync(file).size; + const length = Math.min(size, 1024 * 1024); + const buffer = Buffer.alloc(length); + const fd = fs.openSync(file, 'r'); + try { + fs.readSync(fd, buffer, 0, length, size - length); + } finally { + fs.closeSync(fd); + } + text = buffer.toString('utf8'); + } catch (_) { + continue; + } + const lines = text.split(/\r?\n/); + 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 getAgyAccountsStatus(_event, accountIds = []) { + const ids = Array.isArray(accountIds) ? accountIds.slice(0, 16) : []; + return ids.map((id) => { + try { + const home = resolveAgyAccountHome(id); + const exists = fs.existsSync(home); + return { id, profileExists: exists, email: exists ? readAgyAccountEmail(home) : '' }; + } catch (_) { + return { id, profileExists: false, email: '' }; + } + }); +} + +async function removeAgyAccountProfile(_event, accountId) { + const home = resolveAgyAccountHome(accountId); + // Running agy sessions keep files open in the folder; close them and wait until they are gone. + const exits = []; + for (const [chatId, shellAccountId] of agyShellAccounts) { + if (shellAccountId !== accountId) continue; + const ptyProcess = activeShells.get(chatId); + if (ptyProcess) { + exits.push(new Promise((resolve) => { + ptyProcess.onExit(() => resolve()); + setTimeout(resolve, 5000); + })); + try { + ptyProcess.kill(); + } catch (_) {} + } + } + await Promise.all(exits); + await fs.promises.rm(home, { recursive: true, force: true, maxRetries: 10, retryDelay: 300 }); + return true; +} + +async function createShellSession(event, { chatId, cwd, shellType, externalServer, vserver, agyAccountId }) { console.log(`[Shell] Creating PTY session for chat: ${chatId}, requested cwd: ${cwd}, shellType: ${shellType}`); if (activeShells.has(chatId)) { console.log(`[Shell] PTY Session already active for chat: ${chatId}`); @@ -3104,7 +3291,8 @@ async function createShellSession(event, { chatId, cwd, shellType, externalServe return; } } else { - server = externalServer?.enabled ? normalizeExternalServerConfig(externalServer) : null; + // Account tabs must run locally: their login lives in a folder on this PC. + server = externalServer?.enabled && !agyAccountId ? normalizeExternalServerConfig(externalServer) : null; } if (vserver) { // args already prepared above @@ -3141,6 +3329,15 @@ async function createShellSession(event, { chatId, cwd, shellType, externalServe } env[pathKey] = paths.join(path.delimiter); } + + if (agyAccountId) { + try { + applyAgyAccountEnv(env, agyAccountId); + } catch (error) { + event.sender.send(`shell:output:${chatId}`, { type: 'stderr', text: `\x1b[31m${error.message}\x1b[0m\r\n` }); + return; + } + } } let spawnCwd = app.getPath('home'); @@ -3170,15 +3367,17 @@ async function createShellSession(event, { chatId, cwd, shellType, externalServe }); activeShells.set(chatId, ptyProcess); + if (agyAccountId) agyShellAccounts.set(chatId, agyAccountId); console.log(`[Shell] Spawned PTY process PID: ${ptyProcess.pid}`); - + ptyProcess.onData((data) => { event.sender.send(`shell:output:${chatId}`, { type: 'stdout', text: data }); }); - + ptyProcess.onExit(({ exitCode, signal }) => { console.log(`[Shell CLOSE] pid: ${ptyProcess.pid}, code: ${exitCode}, signal: ${signal}`); activeShells.delete(chatId); + agyShellAccounts.delete(chatId); event.sender.send(`shell:output:${chatId}`, { type: 'exit', code: exitCode }); }); } catch (err) { @@ -3535,6 +3734,7 @@ ipcMain.handle('device:status', () => { if (!['https:', 'http:'].includes(parsed.protocol)) throw new Error('URL-Protokoll nicht erlaubt.'); return shell.openExternal(parsed.toString()); }); + ipcMain.handle('shell:open-in-chrome', (_event, url) => openUrlInChrome(url)); ipcMain.handle('shell:open-path', (_event, targetPath) => { if (typeof targetPath !== 'string' || !targetPath.trim()) throw new Error('Ungueltiger Pfad.'); if (/^[a-z]+:\/\//i.test(targetPath)) throw new Error('Nur lokale Dateipfade sind erlaubt.'); @@ -3666,6 +3866,8 @@ ipcMain.handle('device:status', () => { ipcMain.handle('shell:write', writeToShellSession); ipcMain.handle('shell:kill', (_event, chatId) => killShellSession(_event, chatId)); ipcMain.handle('shell:resize', resizeShellSession); + ipcMain.handle('agy-accounts:status', getAgyAccountsStatus); + ipcMain.handle('agy-accounts:remove-profile', removeAgyAccountProfile); ipcMain.handle('sync:start-server', async () => { return startSyncServer(); diff --git a/electron/preload.cjs b/electron/preload.cjs index c7b12fa..6c9b718 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -61,6 +61,7 @@ contextBridge.exposeInMainWorld('agentWorkspace', { return () => ipcRenderer.removeListener('device:connected', listener); }, openExternal: (url) => ipcRenderer.invoke('shell:open-external', url), + openInChrome: (url) => ipcRenderer.invoke('shell:open-in-chrome', url), openPath: (targetPath) => ipcRenderer.invoke('shell:open-path', targetPath), getActions: (dirPath) => ipcRenderer.invoke('actions:list', dirPath), playAction: (dirPath, name) => ipcRenderer.invoke('actions:play', dirPath, name), @@ -85,6 +86,8 @@ contextBridge.exposeInMainWorld('agentWorkspace', { writeToShellSession: (request) => ipcRenderer.invoke('shell:write', request), killShellSession: (chatId) => ipcRenderer.invoke('shell:kill', chatId), resizeShellSession: (request) => ipcRenderer.invoke('shell:resize', request), + getAgyAccountsStatus: (accountIds) => ipcRenderer.invoke('agy-accounts:status', accountIds), + removeAgyAccountProfile: (accountId) => ipcRenderer.invoke('agy-accounts:remove-profile', accountId), onShellOutput: (chatId, callback) => { const listener = (_event, payload) => callback(payload); ipcRenderer.on(`shell:output:${chatId}`, listener); diff --git a/electron/sftp.cjs b/electron/sftp.cjs index 83ac387..30864c8 100644 --- a/electron/sftp.cjs +++ b/electron/sftp.cjs @@ -5,7 +5,12 @@ const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); -const { Client } = require('ssh2'); +let Client = null; +try { + ({ Client } = require('ssh2')); +} catch (err) { + console.warn('[SFTP] Failed to load ssh2 module:', err?.message || err); +} const IDLE_MS = 5 * 60 * 1000; const connections = new Map(); @@ -93,6 +98,9 @@ function disconnect(id) { function openConnection(server, secret) { const config = validateServer(server); return new Promise((resolve, reject) => { + if (!Client) { + return reject(new Error('Das SFTP-Modul (ssh2) ist nicht verfuegbar.')); + } const client = new Client(); let hostKeyChanged = false; const options = { diff --git a/package-lock.json b/package-lock.json index 00a6cc1..864d296 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codeforge-desktop", - "version": "2.8.0", + "version": "2.8.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codeforge-desktop", - "version": "2.8.0", + "version": "2.8.3", "dependencies": { "@capacitor/android": "^8.4.1", "@capacitor/cli": "^8.4.1", diff --git a/package.json b/package.json index e839ad5..35bad91 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codeforge-desktop", "private": true, - "version": "2.8.1", + "version": "2.8.3", "description": "Desktop UI for local coding agents across Antigravity CLI, Codex CLI and Claude Code", "author": "CodeForge ", "type": "module", diff --git a/src/AppContext.tsx b/src/AppContext.tsx index bcaac6f..23f7a1a 100644 --- a/src/AppContext.tsx +++ b/src/AppContext.tsx @@ -17,6 +17,7 @@ import type { ApiKeys, Automation, Chat, + AgyAccount, CliHarness, CliStatus, CodexPluginInfo, @@ -366,6 +367,9 @@ interface AppContextType { startLyzDevChat(): Promise; startTerminalChat(): Promise; startWorkspace(input: { harnessId: string; grid: boolean; count: number }): Promise; + agyAccounts: AgyAccount[]; + setAgyAccounts: React.Dispatch>; + startAgyAccounts(accountIds?: string[]): Promise; startVServerSession(server: VServerConnection): Promise; startVServerSessions(servers: VServerConnection[]): Promise; apiProviders: ApiProviderConfig[]; @@ -429,6 +433,8 @@ function hasHomeAppRunFile(app: HomeApp) { ); } +export const MAX_AGY_ACCOUNTS = 6; + function newId() { return crypto.randomUUID(); } @@ -766,6 +772,7 @@ export function AppProvider({ children }: { children: ReactNode }) { { id: 'shell', name: 'Leeres Terminal', command: '', icon: 'Terminal' }, ]), ); + const [agyAccounts, setAgyAccounts] = useState(() => readStorage('agyAccounts', [])); const [terminalPrefixSuffixEnabled, setTerminalPrefixSuffixEnabled] = useState(() => readStorage('terminalPrefixSuffixEnabled', false), ); @@ -882,6 +889,7 @@ export function AppProvider({ children }: { children: ReactNode }) { useEffect(() => writeStorage('terminalStartCommandEnabled', terminalStartCommandEnabled), [terminalStartCommandEnabled]); useEffect(() => writeStorage('terminalStartCommand', terminalStartCommand), [terminalStartCommand]); useEffect(() => writeStorage('cliHarnesses', cliHarnesses), [cliHarnesses]); + useEffect(() => writeStorage('agyAccounts', agyAccounts), [agyAccounts]); useEffect(() => writeStorage('terminalPrefixSuffixEnabled', terminalPrefixSuffixEnabled), [terminalPrefixSuffixEnabled]); useEffect(() => writeStorage('terminalPrefix', terminalPrefix), [terminalPrefix]); useEffect(() => writeStorage('terminalSuffix', terminalSuffix), [terminalSuffix]); @@ -2218,6 +2226,34 @@ export function AppProvider({ children }: { children: ReactNode }) { selectChat(chat.id); }; + // One terminal per saved Antigravity account, side by side in a single workspace. + const startAgyAccounts = async (accountIds?: string[]) => { + const accounts = (accountIds ? agyAccounts.filter((account) => accountIds.includes(account.id)) : agyAccounts).slice(0, MAX_AGY_ACCOUNTS); + if (accounts.length === 0) throw new Error('Keine Antigravity-Accounts angelegt. Fuege sie in den Einstellungen hinzu.'); + const projectForRun = await ensureRunnableProject(); + const chatId = newId(); + const chat: Chat = { + id: chatId, + title: accounts.length === 1 ? `Antigravity · ${accounts[0].name}` : 'Antigravity Accounts', + updatedAt: Date.now(), + folderId: projectForRun.id, + mode: 'terminal', + terminalTabs: accounts.map((account, index) => ({ + id: index === 0 ? chatId : newId(), + title: account.name, + shellType: 'cmd' as const, + command: 'agy', + agyAccountId: account.id, + })), + activeTerminalTabId: chatId, + terminalLayout: accounts.length > 1 ? 'grid' : 'single', + terminalGridSize: accounts.length, + messages: [], + }; + setChats((current) => [chat, ...current]); + selectChat(chat.id); + }; + const startApiChat = async (projectId: string | null) => { const providerConfig = apiProviders.find((item) => item.id === apiChatConfig.providerId); if (!providerConfig) throw new Error('Bitte einen AI-Anbieter auswaehlen oder in den Einstellungen anlegen.'); @@ -3101,6 +3137,9 @@ export function AppProvider({ children }: { children: ReactNode }) { startLyzDevChat, startTerminalChat, startWorkspace, + agyAccounts, + setAgyAccounts, + startAgyAccounts, startVServerSession, startVServerSessions, apiProviders, @@ -3205,6 +3244,9 @@ export function AppProvider({ children }: { children: ReactNode }) { startLyzDevChat, startTerminalChat, startWorkspace, + agyAccounts, + setAgyAccounts, + startAgyAccounts, startVServerSession, startVServerSessions, cliHarnesses, diff --git a/src/components/AgyAccounts.tsx b/src/components/AgyAccounts.tsx new file mode 100644 index 0000000..e4cfe27 --- /dev/null +++ b/src/components/AgyAccounts.tsx @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useState } from 'react'; +import { CheckCircle2, LogOut, Play, RefreshCw, Rocket, Trash2 } from 'lucide-react'; +import { MAX_AGY_ACCOUNTS, useAppContext } from '../AppContext'; + +type AccountStatus = { profileExists: boolean; email: string }; + +// Saved Antigravity accounts: each one keeps its own agy login in a separate profile folder. +export function AgyAccountsSettings({ onClose }: { onClose(): void }) { + const { agyAccounts, setAgyAccounts, startAgyAccounts } = useAppContext(); + const [status, setStatus] = useState>({}); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(''); + + const refreshStatus = useCallback(async () => { + if (!window.agentWorkspace?.getAgyAccountsStatus || agyAccounts.length === 0) return; + const result = await window.agentWorkspace.getAgyAccountsStatus(agyAccounts.map((account) => account.id)); + setStatus(Object.fromEntries(result.map((item) => [item.id, { profileExists: item.profileExists, email: item.email }]))); + }, [agyAccounts]); + + useEffect(() => { + void refreshStatus(); + }, [refreshStatus]); + + const removeProfile = async (accountId: string) => { + setBusyId(accountId); + setError(''); + try { + await window.agentWorkspace?.removeAgyAccountProfile(accountId); + await refreshStatus(); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : 'Profil konnte nicht entfernt werden.'); + return false; + } finally { + setBusyId(null); + } + }; + + const startSingle = async (accountId: string) => { + setError(''); + try { + await startAgyAccounts([accountId]); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Antigravity konnte nicht gestartet werden.'); + } + }; + + return ( +
+
+ + + Antigravity Accounts + + +
+
+ {agyAccounts.map((account) => { + const info = status[account.id]; + return ( +
+ + setAgyAccounts((current) => current.map((item) => (item.id === account.id ? { ...item, name: event.target.value } : item))) + } + className="input !w-[140px] shrink-0" + placeholder="Name" + autoComplete="off" + /> + + {info?.email ? ( + + + {info.email} + + ) : ( + Nicht angemeldet + )} + + + + +
+ ); + })} + {agyAccounts.length < MAX_AGY_ACCOUNTS && ( + + )} +
+ {error &&
{error}
} +

+ "Antigravity starten" auf Home oeffnet fuer jeden Account ein eigenes agy-Terminal (bis zu {MAX_AGY_ACCOUNTS}). Jeder Account hat einen + eigenen Login-Ordner auf diesem PC; beim ersten Start fragt agy einmal nach dem Google-Login, danach bleibt er gespeichert. +

+
+ ); +} diff --git a/src/components/MainArea.tsx b/src/components/MainArea.tsx index 92aa751..89165a3 100644 --- a/src/components/MainArea.tsx +++ b/src/components/MainArea.tsx @@ -17,6 +17,7 @@ import { FolderOpen, Gamepad2, Home, + Rocket, Server, MessageSquare, LayoutGrid, @@ -1072,6 +1073,13 @@ function TerminalInstance({ cursor: cursor, }, convertEol: true, + // Links in the terminal (e.g. the agy Google login) open in Chrome, not in an app window. + linkHandler: { + activate: (_event, uri) => { + if (/^https?:\/\//i.test(uri)) void window.agentWorkspace?.openInChrome(uri); + }, + allowNonHttpProtocols: false, + }, }); const fitAddon = new FitAddon(); @@ -1091,8 +1099,9 @@ function TerminalInstance({ chatId: tabId, cwd: startPath, shellType, - externalServer: !chat.vserver && !sshServer && externalServer.enabled ? externalServer : undefined, + externalServer: !chat.vserver && !sshServer && !tab?.agyAccountId && externalServer.enabled ? externalServer : undefined, vserver: sshServer, + agyAccountId: tab?.agyAccountId, }); // SSH login takes longer than a local shell before a command can be typed. @@ -1322,7 +1331,7 @@ function TerminalChatView({ chat }: { chat: Chat }) { const tabs = currentChat.terminalTabs || [{ id: chat.id, title: 'Terminal 1' }]; const activeTabId = currentChat.activeTerminalTabId || chat.id; const terminalLayout = currentChat.terminalLayout || 'single'; - const gridSize = Math.max(1, Math.min(4, currentChat.terminalGridSize || 4)); + const gridSize = Math.max(1, Math.min(6, currentChat.terminalGridSize || 4)); const startPath = terminalStartPath || selectedProject?.path || undefined; const activeServer = tabs.find((tab) => tab.id === activeTabId)?.vserver ?? currentChat.vserver; @@ -1763,7 +1772,7 @@ function TerminalChatView({ chat }: { chat: Chat }) {
{terminalLayout === 'grid' ? ( -
+
{tabs.slice(0, gridSize).map((tab) => (
@@ -1824,13 +1833,30 @@ function TerminalChatView({ chat }: { chat: Chat }) { } function HomeView() { - const { cliHarnesses, startWorkspace, selectedProject } = useAppContext(); + const { cliHarnesses, startWorkspace, selectedProject, agyAccounts, startAgyAccounts } = useAppContext(); const [picking, setPicking] = useState(false); const [harnessId, setHarnessId] = useState(() => cliHarnesses[0]?.id || ''); const [grid, setGrid] = useState(true); const [count, setCount] = useState(1); const [starting, setStarting] = useState(false); const [error, setError] = useState(''); + const [agyStarting, setAgyStarting] = useState(false); + const [agyError, setAgyError] = useState(''); + + const handleStartAgy = async () => { + if (agyAccounts.length === 0) { + setAgyError('Noch keine Antigravity-Accounts angelegt. Fuege sie in den Einstellungen unter "Antigravity Accounts" hinzu.'); + return; + } + setAgyStarting(true); + setAgyError(''); + try { + await startAgyAccounts(); + } catch (err) { + setAgyError(err instanceof Error ? err.message : 'Antigravity konnte nicht gestartet werden.'); + setAgyStarting(false); + } + }; const handleStart = async () => { setStarting(true); @@ -1877,6 +1903,25 @@ function HomeView() { V-Server + + {agyError && ( +
+ {agyError}{' '} + {agyAccounts.length === 0 && ( + + )} +
+ )}
) : picking === 'chat' ? ( setPicking(false)} /> diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 7b4b8c8..cde1792 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -9,6 +9,7 @@ import TutorialModal from './TutorialModal'; import { HARNESS_ICONS, harnessIcon } from '../harnessIcons'; import { isVideoPath, toFileUrl } from '../media'; import { ApiProvidersSettings } from './ApiChat'; +import { AgyAccountsSettings } from './AgyAccounts'; import type { CliHarness, ProviderId } from '../types'; const PROVIDERS: { id: ProviderId; label: string; command: string }[] = [ @@ -407,6 +408,8 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) { )} + {!mobileMode && } + {!mobileMode && (
diff --git a/src/electron.d.ts b/src/electron.d.ts index 6f4bbbb..86d1c60 100644 --- a/src/electron.d.ts +++ b/src/electron.d.ts @@ -129,6 +129,7 @@ declare global { getSpotifyTrack(): Promise; onDeviceConnected(callback: (payload: { id: string; name: string; kind: string }) => void): () => void; openExternal(url: string): Promise; + openInChrome(url: string): Promise<'chrome' | 'default'>; openPath(path: string): Promise; getActions(dirPath?: string): Promise<{ name: string; events: number; path: string }[]>; playAction(dirPath: string, name: string): Promise<{ success: boolean }>; @@ -153,7 +154,9 @@ 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 }): Promise; + createShellSession(request: { chatId: string; cwd?: string; shellType?: 'powershell' | 'cmd'; externalServer?: any; vserver?: VServerConnection; agyAccountId?: string }): Promise; + getAgyAccountsStatus(accountIds: string[]): Promise<{ id: string; profileExists: boolean; email: string }[]>; + removeAgyAccountProfile(accountId: string): Promise; writeToShellSession(request: { chatId: string; text: string }): Promise; killShellSession(chatId: string): Promise; resizeShellSession(request: { chatId: string; cols: number; rows: number }): Promise; diff --git a/src/main.tsx b/src/main.tsx index 24eb702..c71c85d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -177,6 +177,7 @@ if (typeof (window as any).agentWorkspace === 'undefined' && canUseSyncSocket) { }; }, openExternal: (url: string) => invoke('shell:open-external', url), + openInChrome: (url: string) => invoke('shell:open-in-chrome', url), 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), @@ -202,6 +203,8 @@ if (typeof (window as any).agentWorkspace === 'undefined' && canUseSyncSocket) { writeToShellSession: (request: any) => invoke('shell:write', request), killShellSession: (chatId: string) => 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), onShellOutput: (chatId: string, callback: any) => { const channel = `shell:output:${chatId}`; if (!eventListeners.has(channel)) eventListeners.set(channel, []); diff --git a/src/types.ts b/src/types.ts index f7919b1..4ef8150 100644 --- a/src/types.ts +++ b/src/types.ts @@ -116,6 +116,8 @@ export type TerminalTab = { location?: 'local'; // SSH login to a saved V-Server; lets one workspace hold tabs for several servers. vserver?: VServerConnection; + // Runs this tab with the login of a saved Antigravity account (own profile folder). + agyAccountId?: string; }; export type SftpEntry = { @@ -140,6 +142,11 @@ export type VServer = { export type VServerConnection = Pick; +export type AgyAccount = { + id: string; + name: string; +}; + export type CliHarness = { id: string; name: string;