Skip to content

Commit 7277056

Browse files
NagyViktNagyVikt
andauthored
Auto-bootstrap Kitty when bare gx runs from a non-Kitty TTY (#529)
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. Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent 5c91647 commit 7277056

4 files changed

Lines changed: 114 additions & 6 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Bare `gx` auto-bootstraps Kitty on TTY
2+
3+
## Why
4+
5+
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`.
6+
7+
## What changed
8+
9+
- `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.
10+
- `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`.
11+
- 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.
12+
- `gx --help` / `gx help` / `gx -h` and the non-TTY (CI/pipe) path are unchanged.
13+
14+
## Verification
15+
16+
```text
17+
node --test test/default-gx-cockpit.test.js
18+
# 7/7 pass (5 existing + 2 new)
19+
```
20+
21+
## Files
22+
23+
- `src/cockpit/index.js`
24+
- `src/cli/main.js`
25+
- `test/default-gx-cockpit.test.js`
26+
- `openspec/changes/agent-claude-bare-gx-auto-bootstrap-kitty-2026-05-05-09-43/notes.md`

src/cli/main.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,13 @@ function legacyDefaultStatusEnabled() {
890890
return envFlagIsTruthy(process.env.GUARDEX_LEGACY_STATUS);
891891
}
892892

893+
function defaultCockpitDisabled() {
894+
const raw = process.env.GUARDEX_DEFAULT_COCKPIT;
895+
if (raw == null) return false;
896+
const normalized = String(raw).trim().toLowerCase();
897+
return ['0', 'false', 'no', 'off'].includes(normalized);
898+
}
899+
893900
function parseAutoApproval(name) {
894901
const raw = process.env[name];
895902
if (raw == null) return null;
@@ -3776,7 +3783,7 @@ async function main() {
37763783
const args = process.argv.slice(2);
37773784

37783785
if (args.length === 0) {
3779-
if (isInteractiveTerminal() && !legacyDefaultStatusEnabled()) {
3786+
if (isInteractiveTerminal() && !legacyDefaultStatusEnabled() && !defaultCockpitDisabled()) {
37803787
cockpitModule.openDefaultCockpit({
37813788
resolveRepoRoot,
37823789
toolName: TOOL_NAME,

src/cockpit/index.js

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -261,21 +261,25 @@ function backendAvailable(backend) {
261261
}
262262
}
263263

264-
function defaultCockpitBackends(preferredBackend, terminalBackendOptions = {}) {
264+
function defaultCockpitBackends(preferredBackend, terminalBackendOptions = {}, options = {}) {
265265
const preferred = normalizeBackendName(preferredBackend || DEFAULT_INTERACTIVE_BACKEND, DEFAULT_INTERACTIVE_BACKEND);
266266
const seen = new Set();
267267
const candidates = [];
268-
const add = (name, options = {}) => {
268+
const add = (name, addOptions = {}) => {
269269
if (seen.has(name)) return;
270270
const backend = selectTerminalBackend(name, terminalBackendOptions);
271271
if (!backend) return;
272-
if (options.onlyIfAvailable && !backendAvailable(backend)) return;
272+
if (addOptions.onlyIfAvailable && !backendAvailable(backend)) return;
273273
seen.add(name);
274274
candidates.push(backend);
275275
};
276276

277277
if (preferred === 'auto') {
278-
add('kitty', { onlyIfAvailable: true });
278+
if (options.autoHostPermitted) {
279+
add('kitty');
280+
} else {
281+
add('kitty', { onlyIfAvailable: true });
282+
}
279283
add('tmux');
280284
return candidates;
281285
}
@@ -314,6 +318,7 @@ function openDefaultCockpit(deps = {}) {
314318
}
315319

316320
const target = deps.target || process.cwd();
321+
const stdout = deps.stdout || process.stdout;
317322
const options = {
318323
sessionName: DEFAULT_SESSION_NAME,
319324
backend: env.GUARDEX_COCKPIT_BACKEND || DEFAULT_INTERACTIVE_BACKEND,
@@ -324,8 +329,9 @@ function openDefaultCockpit(deps = {}) {
324329
const controlCommand = cockpitControlCommand(repoRoot);
325330
const terminalBackendOptions = terminalBackendOptionsFromDeps(deps);
326331
const failures = [];
332+
const autoHostPermitted = shouldAutoHost({}, { env, stdout });
327333

328-
for (const backend of defaultCockpitBackends(options.backend, terminalBackendOptions)) {
334+
for (const backend of defaultCockpitBackends(options.backend, terminalBackendOptions, { autoHostPermitted })) {
329335
try {
330336
return openWithBackend(backend, options, repoRoot, controlCommand, deps);
331337
} catch (error) {

test/default-gx-cockpit.test.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,72 @@ test('gx status still prints status output', () => {
172172
assert.match(result.stdout, /\[gitguardex\] CLI:/);
173173
assert.match(result.stdout, /\[gitguardex\] Repo safety service:/);
174174
});
175+
176+
test('GUARDEX_DEFAULT_COCKPIT=0 keeps plain gx on status output', async () => {
177+
const repoDir = initRepo();
178+
const originalOpenDefaultCockpit = cockpit.openDefaultCockpit;
179+
cockpit.openDefaultCockpit = () => {
180+
throw new Error('interactive cockpit should not open when GUARDEX_DEFAULT_COCKPIT=0');
181+
};
182+
183+
let output = '';
184+
try {
185+
output = await withCliContext({
186+
args: [],
187+
cwd: repoDir,
188+
stdinTTY: true,
189+
stdoutTTY: true,
190+
env: {
191+
...STATUS_ENV,
192+
GUARDEX_LEGACY_STATUS: undefined,
193+
GUARDEX_DEFAULT_COCKPIT: '0',
194+
},
195+
}, async () => captureStdout(async () => {
196+
await cliMain.main();
197+
assert.equal(process.exitCode, 0);
198+
}));
199+
} finally {
200+
cockpit.openDefaultCockpit = originalOpenDefaultCockpit;
201+
}
202+
203+
assert.match(output, /\[gitguardex\] CLI:/);
204+
assert.match(output, /\[gitguardex\] Repo safety service:/);
205+
});
206+
207+
test('defaultCockpitBackends in auto mode skips kitty when remote control is unavailable and auto-host is forbidden', () => {
208+
const kittyBackend = {
209+
name: 'kitty',
210+
isAvailable: () => false,
211+
};
212+
const tmuxBackend = {
213+
name: 'tmux',
214+
isAvailable: () => true,
215+
};
216+
217+
const candidates = cockpit.defaultCockpitBackends(
218+
'auto',
219+
{ kittyBackend, tmuxBackend },
220+
{ autoHostPermitted: false },
221+
);
222+
223+
assert.deepEqual(candidates.map((b) => b.name), ['tmux']);
224+
});
225+
226+
test('defaultCockpitBackends in auto mode keeps kitty when auto-host is permitted so the bootstrap path can run', () => {
227+
const kittyBackend = {
228+
name: 'kitty',
229+
isAvailable: () => false,
230+
};
231+
const tmuxBackend = {
232+
name: 'tmux',
233+
isAvailable: () => true,
234+
};
235+
236+
const candidates = cockpit.defaultCockpitBackends(
237+
'auto',
238+
{ kittyBackend, tmuxBackend },
239+
{ autoHostPermitted: true },
240+
);
241+
242+
assert.deepEqual(candidates.map((b) => b.name), ['kitty', 'tmux']);
243+
});

0 commit comments

Comments
 (0)