From f66eb2e4624f8dc3ecf08d2b24dc28f2b80e5c38 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 5 May 2026 09:47:52 +0200 Subject: [PATCH] Auto-bootstrap Kitty when bare gx runs from a non-Kitty TTY Bare `gx` (no subcommand) on a TTY routed to openDefaultCockpit, but the auto-mode backend list dropped Kitty whenever `kitty @ ls` failed (i.e. remote control wasn't already running). On a regular non-Kitty TTY that meant the cockpit silently fell through to tmux, so bare `gx` couldn't spawn a fresh Kitty window the way `gx cockpit` already could. defaultCockpitBackends now accepts an autoHostPermitted flag; when auto mode is paired with a permitted auto-host context, the kitty backend is kept in the candidate list without the strict isAvailable() gate so the existing openKittyCockpit bootstrap path can run. openDefaultCockpit computes the flag via the existing shouldAutoHost helper and threads it through. GUARDEX_DEFAULT_COCKPIT=0|false|no|off opts plain `gx` back into status output, mirroring the existing GUARDEX_LEGACY_STATUS escape hatch. --- .../notes.md | 26 +++++++ src/cli/main.js | 9 ++- src/cockpit/index.js | 16 +++-- test/default-gx-cockpit.test.js | 69 +++++++++++++++++++ 4 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 openspec/changes/agent-claude-bare-gx-auto-bootstrap-kitty-2026-05-05-09-43/notes.md diff --git a/openspec/changes/agent-claude-bare-gx-auto-bootstrap-kitty-2026-05-05-09-43/notes.md b/openspec/changes/agent-claude-bare-gx-auto-bootstrap-kitty-2026-05-05-09-43/notes.md new file mode 100644 index 0000000..1a3ee57 --- /dev/null +++ b/openspec/changes/agent-claude-bare-gx-auto-bootstrap-kitty-2026-05-05-09-43/notes.md @@ -0,0 +1,26 @@ +# Bare `gx` auto-bootstraps Kitty on TTY + +## Why + +PR #523 made `gx cockpit` auto-bootstrap a Kitty host window when launched from a non-Kitty TTY. Bare `gx` (no subcommand) on a TTY already routes to `cockpitModule.openDefaultCockpit`, but that path goes through `defaultCockpitBackends('auto', ...)` which gates kitty with `onlyIfAvailable`. The kitty backend's `isAvailable()` requires `kitty @ ls` to already succeed (i.e. remote control already running), so on a regular non-Kitty TTY the kitty candidate is dropped and the cockpit falls back to tmux. Net effect: bare `gx` couldn't deliver the same one-command "spawn fresh Kitty + cockpit" UX as `gx cockpit`. + +## What changed + +- `defaultCockpitBackends(preferred, terminalBackendOptions, options = {})` now accepts an `autoHostPermitted` flag. When `preferred === 'auto'` and `autoHostPermitted` is true, the kitty backend is added without the strict `onlyIfAvailable` gate so `openWithBackend` → `openKittyCockpit` can run its existing bootstrap path. tmux remains the fallback in the candidate list. +- `openDefaultCockpit` computes `autoHostPermitted` via the existing `shouldAutoHost({}, { env, stdout })` helper (TTY + `KITTY_LISTEN_ON` unset + `GUARDEX_AUTO_HOST` not opted out) and threads it into `defaultCockpitBackends`. +- New `defaultCockpitDisabled()` helper in `src/cli/main.js` returns true when `GUARDEX_DEFAULT_COCKPIT` is `0|false|no|off`. The bare-`gx` no-arg branch now skips the cockpit and prints status when this opt-out is set, matching the existing `GUARDEX_LEGACY_STATUS=1` escape hatch. +- `gx --help` / `gx help` / `gx -h` and the non-TTY (CI/pipe) path are unchanged. + +## Verification + +```text +node --test test/default-gx-cockpit.test.js +# 7/7 pass (5 existing + 2 new) +``` + +## Files + +- `src/cockpit/index.js` +- `src/cli/main.js` +- `test/default-gx-cockpit.test.js` +- `openspec/changes/agent-claude-bare-gx-auto-bootstrap-kitty-2026-05-05-09-43/notes.md` diff --git a/src/cli/main.js b/src/cli/main.js index 75cc39d..840df4d 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -890,6 +890,13 @@ function legacyDefaultStatusEnabled() { return envFlagIsTruthy(process.env.GUARDEX_LEGACY_STATUS); } +function defaultCockpitDisabled() { + const raw = process.env.GUARDEX_DEFAULT_COCKPIT; + if (raw == null) return false; + const normalized = String(raw).trim().toLowerCase(); + return ['0', 'false', 'no', 'off'].includes(normalized); +} + function parseAutoApproval(name) { const raw = process.env[name]; if (raw == null) return null; @@ -3776,7 +3783,7 @@ async function main() { const args = process.argv.slice(2); if (args.length === 0) { - if (isInteractiveTerminal() && !legacyDefaultStatusEnabled()) { + if (isInteractiveTerminal() && !legacyDefaultStatusEnabled() && !defaultCockpitDisabled()) { cockpitModule.openDefaultCockpit({ resolveRepoRoot, toolName: TOOL_NAME, diff --git a/src/cockpit/index.js b/src/cockpit/index.js index 6c1e776..ced0291 100644 --- a/src/cockpit/index.js +++ b/src/cockpit/index.js @@ -261,21 +261,25 @@ function backendAvailable(backend) { } } -function defaultCockpitBackends(preferredBackend, terminalBackendOptions = {}) { +function defaultCockpitBackends(preferredBackend, terminalBackendOptions = {}, options = {}) { const preferred = normalizeBackendName(preferredBackend || DEFAULT_INTERACTIVE_BACKEND, DEFAULT_INTERACTIVE_BACKEND); const seen = new Set(); const candidates = []; - const add = (name, options = {}) => { + const add = (name, addOptions = {}) => { if (seen.has(name)) return; const backend = selectTerminalBackend(name, terminalBackendOptions); if (!backend) return; - if (options.onlyIfAvailable && !backendAvailable(backend)) return; + if (addOptions.onlyIfAvailable && !backendAvailable(backend)) return; seen.add(name); candidates.push(backend); }; if (preferred === 'auto') { - add('kitty', { onlyIfAvailable: true }); + if (options.autoHostPermitted) { + add('kitty'); + } else { + add('kitty', { onlyIfAvailable: true }); + } add('tmux'); return candidates; } @@ -314,6 +318,7 @@ function openDefaultCockpit(deps = {}) { } const target = deps.target || process.cwd(); + const stdout = deps.stdout || process.stdout; const options = { sessionName: DEFAULT_SESSION_NAME, backend: env.GUARDEX_COCKPIT_BACKEND || DEFAULT_INTERACTIVE_BACKEND, @@ -324,8 +329,9 @@ function openDefaultCockpit(deps = {}) { const controlCommand = cockpitControlCommand(repoRoot); const terminalBackendOptions = terminalBackendOptionsFromDeps(deps); const failures = []; + const autoHostPermitted = shouldAutoHost({}, { env, stdout }); - for (const backend of defaultCockpitBackends(options.backend, terminalBackendOptions)) { + for (const backend of defaultCockpitBackends(options.backend, terminalBackendOptions, { autoHostPermitted })) { try { return openWithBackend(backend, options, repoRoot, controlCommand, deps); } catch (error) { diff --git a/test/default-gx-cockpit.test.js b/test/default-gx-cockpit.test.js index 11cf599..681e157 100644 --- a/test/default-gx-cockpit.test.js +++ b/test/default-gx-cockpit.test.js @@ -172,3 +172,72 @@ test('gx status still prints status output', () => { assert.match(result.stdout, /\[gitguardex\] CLI:/); assert.match(result.stdout, /\[gitguardex\] Repo safety service:/); }); + +test('GUARDEX_DEFAULT_COCKPIT=0 keeps plain gx on status output', async () => { + const repoDir = initRepo(); + const originalOpenDefaultCockpit = cockpit.openDefaultCockpit; + cockpit.openDefaultCockpit = () => { + throw new Error('interactive cockpit should not open when GUARDEX_DEFAULT_COCKPIT=0'); + }; + + let output = ''; + try { + output = await withCliContext({ + args: [], + cwd: repoDir, + stdinTTY: true, + stdoutTTY: true, + env: { + ...STATUS_ENV, + GUARDEX_LEGACY_STATUS: undefined, + GUARDEX_DEFAULT_COCKPIT: '0', + }, + }, async () => captureStdout(async () => { + await cliMain.main(); + assert.equal(process.exitCode, 0); + })); + } finally { + cockpit.openDefaultCockpit = originalOpenDefaultCockpit; + } + + assert.match(output, /\[gitguardex\] CLI:/); + assert.match(output, /\[gitguardex\] Repo safety service:/); +}); + +test('defaultCockpitBackends in auto mode skips kitty when remote control is unavailable and auto-host is forbidden', () => { + const kittyBackend = { + name: 'kitty', + isAvailable: () => false, + }; + const tmuxBackend = { + name: 'tmux', + isAvailable: () => true, + }; + + const candidates = cockpit.defaultCockpitBackends( + 'auto', + { kittyBackend, tmuxBackend }, + { autoHostPermitted: false }, + ); + + assert.deepEqual(candidates.map((b) => b.name), ['tmux']); +}); + +test('defaultCockpitBackends in auto mode keeps kitty when auto-host is permitted so the bootstrap path can run', () => { + const kittyBackend = { + name: 'kitty', + isAvailable: () => false, + }; + const tmuxBackend = { + name: 'tmux', + isAvailable: () => true, + }; + + const candidates = cockpit.defaultCockpitBackends( + 'auto', + { kittyBackend, tmuxBackend }, + { autoHostPermitted: true }, + ); + + assert.deepEqual(candidates.map((b) => b.name), ['kitty', 'tmux']); +});