Skip to content

Commit 5815c8f

Browse files
author
NagyVikt
committed
Gate Kitty selection on remote control
Kitty availability now distinguishes a missing binary from disabled remote control. The backend exposes createKittyBackend({ runtime, env }), checks kitty --version before kitty @ ls, returns describe() status, and can dry-run command plans without executing. Constraint: Preserve current Kitty command builders from main and leave tmux behavior unchanged Rejected: Rework terminal backend resolver here | recent main commits already own resolver and layout changes Confidence: high Scope-risk: narrow Directive: Keep the remote-control failure message stable for operator guidance Tested: node --test test/cockpit-terminal-backend.test.js test/cockpit-command.test.js test/tmux-command.test.js Tested: openspec validate agent-codex-kitty-availability-wrapper-final-2026-04-30-14-23 --strict Not-tested: real Kitty desktop remote-control integration
1 parent d87ea99 commit 5815c8f

5 files changed

Lines changed: 421 additions & 21 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Kitty availability wrapper
2+
3+
## Why
4+
5+
Kitty cockpit selection needs to distinguish a missing Kitty binary from a Kitty install whose remote control is disabled. Without both checks, `auto` backend selection can treat a partially configured Kitty install as usable and operator errors are harder to act on.
6+
7+
## What Changes
8+
9+
- Add `createKittyBackend({ runtime, env })` while preserving `createBackend`.
10+
- Probe `kitty --version` before `kitty @ ls`.
11+
- Add `describe()` status output and dry-run command plans.
12+
- Cover missing binary, disabled remote control, successful availability, dry-run plans, and readable errors.
13+
14+
## Impact
15+
16+
The change is limited to `src/terminal/kitty.js` and focused backend tests. Tmux behavior is unchanged.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Kitty backend availability probes
4+
5+
The Kitty backend SHALL report available only after both the Kitty binary probe and Kitty remote-control probe succeed.
6+
7+
#### Scenario: Kitty binary missing
8+
9+
- **WHEN** the backend checks availability
10+
- **AND** `kitty --version` fails
11+
- **THEN** the backend SHALL report unavailable.
12+
13+
#### Scenario: Kitty remote control unavailable
14+
15+
- **WHEN** `kitty --version` succeeds
16+
- **AND** `kitty @ ls` fails
17+
- **THEN** the backend SHALL report unavailable with `Kitty is installed, but remote control is not available. Enable allow_remote_control in kitty.conf or run gx cockpit --backend tmux.`
18+
19+
#### Scenario: Kitty available
20+
21+
- **WHEN** `kitty --version` succeeds
22+
- **AND** `kitty @ ls` succeeds
23+
- **THEN** the backend SHALL report available.
24+
25+
### Requirement: Kitty backend status and dry-run
26+
27+
The Kitty backend SHALL expose `createKittyBackend({ runtime, env })`, `describe()`, and dry-run command plans without executing commands.
28+
29+
#### Scenario: Status description
30+
31+
- **WHEN** availability is described
32+
- **THEN** the backend SHALL return readable installed, remote-control, availability, message, and check details.
33+
34+
#### Scenario: Dry-run plans
35+
36+
- **WHEN** the backend is created with `dryRun: true`
37+
- **THEN** availability and action methods SHALL return planned Kitty commands without calling the runtime.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
## 1. Spec
2+
3+
- [x] Capture Kitty binary, remote-control, status, and dry-run requirements.
4+
5+
## 2. Tests
6+
7+
- [x] Cover missing Kitty binary.
8+
- [x] Cover failing `kitty @ ls`.
9+
- [x] Cover successful availability.
10+
- [x] Cover dry-run planned commands.
11+
- [x] Cover readable remote-control errors.
12+
13+
## 3. Implementation
14+
15+
- [x] Add `createKittyBackend({ runtime, env })` while keeping `createBackend` compatible.
16+
- [x] Check `kitty --version` before `kitty @ ls`.
17+
- [x] Add `describe()` status and dry-run command plans.
18+
- [x] Leave tmux behavior untouched.
19+
20+
## 4. Verification
21+
22+
- [x] `node --test test/cockpit-terminal-backend.test.js test/cockpit-command.test.js test/tmux-command.test.js` passed 23/23.
23+
- [x] `openspec validate agent-codex-kitty-availability-wrapper-final-2026-04-30-14-23 --strict` passed.
24+
25+
## 5. Cleanup
26+
27+
- [ ] Commit changes.
28+
- [ ] Finish via PR, wait for merge, cleanup, and record `MERGED` evidence.

src/terminal/kitty.js

Lines changed: 191 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ const DEFAULT_KITTY_BIN = 'kitty';
66
const DEFAULT_COCKPIT_TITLE = 'gx cockpit';
77
const DEFAULT_AGENT_TITLE = 'agent';
88
const DEFAULT_TERMINAL_TITLE = 'terminal';
9+
const KITTY_MISSING_MESSAGE = 'Kitty is not installed or not on PATH. Install Kitty or run gx cockpit --backend tmux.';
10+
const KITTY_REMOTE_CONTROL_MESSAGE = 'Kitty is installed, but remote control is not available. Enable allow_remote_control in kitty.conf or run gx cockpit --backend tmux.';
911

1012
function text(value, fallback = '') {
1113
if (typeof value === 'string') return value.trim() || fallback;
@@ -29,8 +31,14 @@ function firstText(...values) {
2931
return '';
3032
}
3133

34+
function configEnv(config = {}) {
35+
return config.env && typeof config.env === 'object' ? config.env : {};
36+
}
37+
3238
function kittyBin(config = {}, options = {}) {
33-
const envValue = options.allowEnv ? process.env.GUARDEX_KITTY_BIN : '';
39+
const envValue = options.allowEnv
40+
? firstText(configEnv(config).GUARDEX_KITTY_BIN, process.env.GUARDEX_KITTY_BIN)
41+
: '';
3442
return text(config.kittyBin || envValue, DEFAULT_KITTY_BIN);
3543
}
3644

@@ -150,10 +158,21 @@ function buildKittyVersionCommand(options = {}) {
150158
return commandShape(['--version'], options);
151159
}
152160

161+
function buildVersionCommand(config = {}) {
162+
return commandShapeWithEnv(buildKittyVersionCommand().args, config);
163+
}
164+
153165
function buildAvailabilityCommand(config = {}) {
154166
return commandShapeWithEnv(buildKittyLsCommand().args, config);
155167
}
156168

169+
function buildAvailabilityCommands(config = {}) {
170+
return [
171+
buildVersionCommand(config),
172+
buildAvailabilityCommand(config),
173+
];
174+
}
175+
157176
function buildOpenCockpitLayoutCommand(options = {}, config = {}) {
158177
const repoRoot = requireText(options.repoRoot, 'kitty cockpit repoRoot');
159178
return buildKittyLaunchCommand({
@@ -255,6 +274,15 @@ function sendTextInput(value, options = {}) {
255274
return options.submit ? `${body}\n` : body;
256275
}
257276

277+
function mergeEnv(config = {}, options = {}) {
278+
const env = configEnv(config);
279+
if (Object.keys(env).length === 0) return options.env;
280+
return {
281+
...(options.env || {}),
282+
...env,
283+
};
284+
}
285+
258286
function defaultRunner(cmd, args, options = {}) {
259287
return cp.spawnSync(cmd, args, {
260288
cwd: options.cwd,
@@ -266,69 +294,211 @@ function defaultRunner(cmd, args, options = {}) {
266294
});
267295
}
268296

297+
function runnerFor(config = {}) {
298+
if (typeof config.runner === 'function') {
299+
return {
300+
run: config.runner,
301+
};
302+
}
303+
if (config.runtime && typeof config.runtime.run === 'function') {
304+
return config.runtime;
305+
}
306+
return {
307+
run: defaultRunner,
308+
};
309+
}
310+
311+
function cloneCommand(shape) {
312+
return {
313+
cmd: shape.cmd,
314+
args: [...shape.args],
315+
};
316+
}
317+
318+
function makeDryRunPlan(action, commands, extra = {}) {
319+
const list = Array.isArray(commands) ? commands : [commands];
320+
const plan = {
321+
dryRun: true,
322+
action,
323+
commands: list.map(cloneCommand),
324+
};
325+
for (const [key, value] of Object.entries(extra)) {
326+
if (value !== undefined) plan[key] = value;
327+
}
328+
return plan;
329+
}
330+
331+
function resultText(result) {
332+
if (!result) return '';
333+
if (result.error && result.error.message) return result.error.message;
334+
return String(result.stderr || result.stdout || '').trim();
335+
}
336+
337+
function resultOutput(result) {
338+
return String((result && (result.stdout || result.stderr)) || '').trim();
339+
}
340+
341+
function checkResult(name, command, result) {
342+
return {
343+
name,
344+
command: cloneCommand(command),
345+
ok: Boolean(result && result.status === 0 && !result.error),
346+
status: result && typeof result.status === 'number' ? result.status : null,
347+
output: resultOutput(result),
348+
error: resultText(result),
349+
};
350+
}
351+
269352
function assertResult(result, message) {
270353
if (result && result.error) throw result.error;
271354
if (!result || result.status === 0) return result;
272355
const detail = String(result.stderr || result.stdout || '').trim();
273356
throw new Error(`${message}${detail ? `: ${detail}` : '.'}`);
274357
}
275358

276-
function createBackend(config = {}) {
277-
const runner = typeof config.runner === 'function' ? config.runner : defaultRunner;
278-
const run = (shape, options = {}) => runner(shape.cmd, shape.args, options);
359+
function createKittyBackend(config = {}) {
360+
const runtime = runnerFor(config);
361+
const run = (shape, options = {}) => {
362+
const input = Object.prototype.hasOwnProperty.call(shape, 'input') && options.input === undefined
363+
? shape.input
364+
: options.input;
365+
return runtime.run(shape.cmd, shape.args, {
366+
...options,
367+
input,
368+
env: mergeEnv(config, options),
369+
});
370+
};
371+
const dryRun = Boolean(config.dryRun);
372+
373+
function describe() {
374+
const commands = buildAvailabilityCommands(config);
375+
if (dryRun) return makeDryRunPlan('check-availability', commands);
376+
377+
const versionResult = run(commands[0], { stdio: 'pipe' });
378+
const versionCheck = checkResult('kitty --version', commands[0], versionResult);
379+
if (!versionCheck.ok) {
380+
return {
381+
name: 'kitty',
382+
available: false,
383+
installed: false,
384+
remoteControl: false,
385+
binary: commands[0].cmd,
386+
message: KITTY_MISSING_MESSAGE,
387+
error: versionCheck.error,
388+
checks: [versionCheck],
389+
};
390+
}
391+
392+
const remoteResult = run(commands[1], { stdio: 'pipe' });
393+
const remoteCheck = checkResult('kitty @ ls', commands[1], remoteResult);
394+
const remoteControl = remoteCheck.ok;
395+
return {
396+
name: 'kitty',
397+
available: remoteControl,
398+
installed: true,
399+
remoteControl,
400+
binary: commands[0].cmd,
401+
version: versionCheck.output,
402+
message: remoteControl ? 'Kitty remote control is available.' : KITTY_REMOTE_CONTROL_MESSAGE,
403+
error: remoteControl ? '' : remoteCheck.error,
404+
checks: [versionCheck, remoteCheck],
405+
};
406+
}
407+
408+
function execute(action, shape, options = {}, message) {
409+
const input = Object.prototype.hasOwnProperty.call(shape, 'input') && options.input === undefined
410+
? shape.input
411+
: options.input;
412+
if (dryRun) {
413+
return makeDryRunPlan(action, shape, {
414+
cwd: options.cwd,
415+
input,
416+
});
417+
}
418+
return assertResult(run(shape, { ...options, input }), message);
419+
}
279420

280421
return {
281422
name: 'kitty',
282423
isAvailable() {
283-
const result = run(buildAvailabilityCommand(config), { stdio: 'pipe' });
284-
return Boolean(result && result.status === 0 && !result.error);
424+
if (dryRun) return makeDryRunPlan('check-availability', buildAvailabilityCommands(config));
425+
return describe().available;
426+
},
427+
describe,
428+
remoteControlUnavailableMessage: KITTY_REMOTE_CONTROL_MESSAGE,
429+
missingMessage: KITTY_MISSING_MESSAGE,
430+
dryRunPlan(action, commands, extra = {}) {
431+
return makeDryRunPlan(action, commands, extra);
285432
},
286433
openCockpitLayout(options = {}) {
287-
const result = run(buildOpenCockpitLayoutCommand(options, config), { cwd: options.repoRoot });
288-
return assertResult(result, 'kitty could not open cockpit layout');
434+
return execute(
435+
'open-cockpit-layout',
436+
buildOpenCockpitLayoutCommand(options, config),
437+
{ cwd: options.repoRoot },
438+
'kitty could not open cockpit layout',
439+
);
289440
},
290441
launchAgentPane(options = {}) {
291-
const result = run(buildLaunchAgentPaneCommand(options, config), { cwd: options.worktree });
292-
return assertResult(result, 'kitty could not launch agent pane');
442+
return execute(
443+
'launch-agent-pane',
444+
buildLaunchAgentPaneCommand(options, config),
445+
{ cwd: options.worktree },
446+
'kitty could not launch agent pane',
447+
);
293448
},
294449
launchTerminalPane(options = {}) {
295-
const result = run(buildLaunchTerminalPaneCommand(options, config), { cwd: options.cwd });
296-
return assertResult(result, 'kitty could not launch terminal pane');
450+
return execute(
451+
'launch-terminal-pane',
452+
buildLaunchTerminalPaneCommand(options, config),
453+
{ cwd: options.cwd },
454+
'kitty could not launch terminal pane',
455+
);
297456
},
298457
focusPane(target) {
299-
const result = run(buildFocusPaneCommand(target, config));
300-
return assertResult(result, 'kitty could not focus pane');
458+
return execute('focus-pane', buildFocusPaneCommand(target, config), {}, 'kitty could not focus pane');
301459
},
302460
closePane(target) {
303-
const result = run(buildClosePaneCommand(target, config));
304-
return assertResult(result, 'kitty could not close pane');
461+
return execute('close-pane', buildClosePaneCommand(target, config), {}, 'kitty could not close pane');
305462
},
306463
sendText(target, value, options = {}) {
307-
const result = run(buildSendTextCommand(target, config), {
308-
input: sendTextInput(value, options),
309-
stdio: 'pipe',
310-
});
311-
return assertResult(result, 'kitty could not send text');
464+
return execute(
465+
'send-text',
466+
buildSendTextCommand(target, config),
467+
{
468+
input: sendTextInput(value, options),
469+
stdio: 'pipe',
470+
},
471+
'kitty could not send text',
472+
);
312473
},
313474
};
314475
}
315476

477+
function createBackend(config = {}) {
478+
return createKittyBackend(config);
479+
}
480+
316481
module.exports = {
317482
DEFAULT_KITTY_BIN,
483+
KITTY_MISSING_MESSAGE,
484+
KITTY_REMOTE_CONTROL_MESSAGE,
318485
buildKittyLaunchCommand,
319486
buildKittyFocusCommand,
320487
buildKittyCloseCommand,
321488
buildKittySendTextCommand,
322489
buildKittyLsCommand,
323490
buildKittyVersionCommand,
491+
buildVersionCommand,
324492
buildAvailabilityCommand,
493+
buildAvailabilityCommands,
325494
buildOpenCockpitLayoutCommand,
326495
buildLaunchAgentPaneCommand,
327496
buildLaunchTerminalPaneCommand,
328497
buildFocusPaneCommand,
329498
buildClosePaneCommand,
330499
buildSendTextCommand,
331500
createBackend,
501+
createKittyBackend,
332502
sendTextInput,
333503
targetMatch,
334504
};

0 commit comments

Comments
 (0)