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']); +});