Skip to content

Commit 250a41f

Browse files
author
NagyVikt
committed
Make Kitty remote commands dry-run testable
Kitty remote control needs argv-safe builders that tests can assert without a running Kitty daemon. This adds deterministic command construction for launch, focus, close, send-text, ls, and version probes while preserving the existing backend aliases. Constraint: Tests must not require a live Kitty instance Rejected: Build shell strings for send-text | stdin input keeps user text out of argv and avoids shell quoting Confidence: high Scope-risk: narrow Directive: Keep Kitty builders pure; runtime execution belongs in createBackend Tested: node --test test/terminal-kitty.test.js test/cockpit-terminal-backend.test.js Tested: openspec validate --specs Tested: git diff --check Not-tested: npm test has existing baseline failures in agents launch/lifecycle/session tests
1 parent 2737eb3 commit 250a41f

4 files changed

Lines changed: 292 additions & 50 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-04-30
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# agent-codex-dry-run-testable-kitty-command-builders-2026-04-30-14-10 (minimal / T1)
2+
3+
- Add pure Kitty remote-control command builders in `src/terminal/kitty.js` for launch, focus, close, send-text, ls, and version probes.
4+
- Keep commands as `{ cmd, args }` argv arrays with optional stdin `input`; do not require a live Kitty instance to test construction.
5+
- Support cwd/title values with spaces, deterministic env ordering, command argv, id/title matches, control windows, and agent pane launches.
6+
- Verification:
7+
- `node --test test/terminal-kitty.test.js test/cockpit-terminal-backend.test.js`
8+
- `openspec validate --specs`
9+
- `git diff --check`
10+
- `npm test` ran 446 tests: 438 pass, 7 baseline failures, 1 skip. Failures are in `test/agents-launch.test.js`, `test/agents-lifecycle.test.js`, and `test/agents-sessions.test.js`.

src/terminal/kitty.js

Lines changed: 171 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ function firstText(...values) {
2929
return '';
3030
}
3131

32-
function kittyBin(config = {}) {
33-
return text(config.kittyBin || process.env.GUARDEX_KITTY_BIN, DEFAULT_KITTY_BIN);
32+
function kittyBin(config = {}, options = {}) {
33+
const envValue = options.allowEnv ? process.env.GUARDEX_KITTY_BIN : '';
34+
return text(config.kittyBin || envValue, DEFAULT_KITTY_BIN);
3435
}
3536

3637
function commandShape(args, config = {}) {
@@ -40,31 +41,128 @@ function commandShape(args, config = {}) {
4041
};
4142
}
4243

43-
function appendShellCommand(args, command) {
44-
const normalized = text(command);
45-
if (normalized) {
46-
args.push('--', 'sh', '-lc', normalized);
44+
function commandShapeWithEnv(args, config = {}) {
45+
return {
46+
cmd: kittyBin(config, { allowEnv: true }),
47+
args,
48+
};
49+
}
50+
51+
function appendOption(args, flag, value) {
52+
const normalized = text(value);
53+
if (normalized) args.push(flag, normalized);
54+
return args;
55+
}
56+
57+
function normalizeEnvEntries(env = {}) {
58+
if (!env) return [];
59+
if (Array.isArray(env)) {
60+
return env.map((entry) => {
61+
if (Array.isArray(entry)) {
62+
const key = requireText(entry[0], 'kitty env key');
63+
const value = entry.length > 1 && entry[1] !== undefined && entry[1] !== null ? String(entry[1]) : '';
64+
return `${key}=${value}`;
65+
}
66+
return requireText(entry, 'kitty env');
67+
});
68+
}
69+
if (typeof env !== 'object') {
70+
throw new TypeError('kitty env must be an object, array, or undefined');
71+
}
72+
return Object.keys(env)
73+
.sort()
74+
.map((key) => `${requireText(key, 'kitty env key')}=${env[key] === undefined || env[key] === null ? '' : String(env[key])}`);
75+
}
76+
77+
function appendEnv(args, env) {
78+
for (const entry of normalizeEnvEntries(env)) {
79+
args.push('--env', entry);
4780
}
4881
return args;
4982
}
5083

84+
function normalizeCommandArgv(options = {}) {
85+
const commandArgv = options.argv || options.commandArgv || (Array.isArray(options.command) ? options.command : undefined);
86+
if (commandArgv === undefined || commandArgv === null) return [];
87+
if (!Array.isArray(commandArgv)) {
88+
throw new TypeError('kitty command argv must be an array');
89+
}
90+
return commandArgv.map((arg) => {
91+
if (arg === undefined || arg === null) {
92+
throw new TypeError('kitty command argv values must be strings');
93+
}
94+
return String(arg);
95+
});
96+
}
97+
98+
function appendCommandArgv(args, options = {}) {
99+
const commandArgv = normalizeCommandArgv(options);
100+
if (commandArgv.length > 0) args.push('--', ...commandArgv);
101+
return args;
102+
}
103+
104+
function shellCommandArgv(command) {
105+
const normalized = text(command);
106+
return normalized ? ['sh', '-lc', normalized] : [];
107+
}
108+
109+
function launchTitle(options = {}) {
110+
const session = options.session && typeof options.session === 'object' ? options.session : {};
111+
return firstText(
112+
options.title,
113+
options.control || options.role === 'control' ? DEFAULT_COCKPIT_TITLE : '',
114+
options.agent || options.role === 'agent' || options.session ? agentTitle(session) : '',
115+
options.terminal || options.role === 'terminal' ? DEFAULT_TERMINAL_TITLE : '',
116+
);
117+
}
118+
119+
function launchCwd(options = {}) {
120+
const session = options.session && typeof options.session === 'object' ? options.session : {};
121+
return firstText(options.cwd, options.repoRoot, options.worktree, session.worktreePath, session.path);
122+
}
123+
124+
function buildKittyLaunchCommand(options = {}) {
125+
const args = ['@', 'launch'];
126+
const type = text(options.type, 'window');
127+
const location = firstText(options.location, options.pane ? 'vsplit' : '');
128+
const cwd = launchCwd(options);
129+
const title = launchTitle(options);
130+
131+
if (type) args.push(`--type=${type}`);
132+
if (location) args.push(`--location=${location}`);
133+
appendOption(args, '--cwd', cwd);
134+
appendOption(args, '--title', title);
135+
appendEnv(args, options.env);
136+
appendCommandArgv(args, options);
137+
138+
const shape = commandShape(args, options);
139+
if (Object.prototype.hasOwnProperty.call(options, 'input')) {
140+
shape.input = options.input === undefined || options.input === null ? '' : String(options.input);
141+
}
142+
return shape;
143+
}
144+
145+
function buildKittyLsCommand(options = {}) {
146+
return commandShape(['@', 'ls'], options);
147+
}
148+
149+
function buildKittyVersionCommand(options = {}) {
150+
return commandShape(['--version'], options);
151+
}
152+
51153
function buildAvailabilityCommand(config = {}) {
52-
return commandShape(['@', 'ls'], config);
154+
return commandShapeWithEnv(buildKittyLsCommand().args, config);
53155
}
54156

55157
function buildOpenCockpitLayoutCommand(options = {}, config = {}) {
56158
const repoRoot = requireText(options.repoRoot, 'kitty cockpit repoRoot');
57-
const args = [
58-
'@',
59-
'launch',
60-
'--type=window',
61-
'--cwd',
62-
repoRoot,
63-
'--title',
64-
text(options.title, DEFAULT_COCKPIT_TITLE),
65-
];
66-
appendShellCommand(args, options.command);
67-
return commandShape(args, config);
159+
return buildKittyLaunchCommand({
160+
role: 'control',
161+
cwd: repoRoot,
162+
title: text(options.title, DEFAULT_COCKPIT_TITLE),
163+
argv: shellCommandArgv(options.command),
164+
kittyBin: kittyBin(config, { allowEnv: true }),
165+
});
68166
}
69167

70168
function agentTitle(session = {}, title) {
@@ -82,57 +180,74 @@ function agentTitle(session = {}, title) {
82180
function buildLaunchAgentPaneCommand(options = {}, config = {}) {
83181
const session = options.session && typeof options.session === 'object' ? options.session : {};
84182
const cwd = requireText(firstText(options.worktree, session.worktreePath, session.path), 'kitty agent worktree');
85-
const args = [
86-
'@',
87-
'launch',
88-
'--type=window',
89-
'--location=vsplit',
90-
'--cwd',
183+
return buildKittyLaunchCommand({
184+
role: 'agent',
185+
pane: true,
91186
cwd,
92-
'--title',
93-
agentTitle(session, options.title),
94-
];
95-
appendShellCommand(args, options.command);
96-
return commandShape(args, config);
187+
title: agentTitle(session, options.title),
188+
argv: shellCommandArgv(options.command),
189+
kittyBin: kittyBin(config, { allowEnv: true }),
190+
});
97191
}
98192

99193
function buildLaunchTerminalPaneCommand(options = {}, config = {}) {
100194
const cwd = requireText(options.cwd, 'kitty terminal cwd');
101-
const args = [
102-
'@',
103-
'launch',
104-
'--type=window',
105-
'--location=vsplit',
106-
'--cwd',
195+
return buildKittyLaunchCommand({
196+
role: 'terminal',
197+
pane: true,
107198
cwd,
108-
'--title',
109-
text(options.title, DEFAULT_TERMINAL_TITLE),
110-
];
111-
appendShellCommand(args, options.command);
112-
return commandShape(args, config);
199+
title: text(options.title, DEFAULT_TERMINAL_TITLE),
200+
argv: shellCommandArgv(options.command),
201+
kittyBin: kittyBin(config, { allowEnv: true }),
202+
});
113203
}
114204

115-
function targetId(target) {
116-
const raw = target && typeof target === 'object'
117-
? firstText(target.id, target.windowId, target.paneId, target.target)
118-
: text(target);
119-
return requireText(raw, 'kitty target id');
205+
function targetMatch(target) {
206+
if (target && typeof target === 'object') {
207+
const explicitMatch = firstText(target.match, target.kittyMatch);
208+
if (explicitMatch) return explicitMatch;
209+
210+
const id = firstText(target.id, target.windowId, target.kittyWindowId, target.paneId, target.target);
211+
if (id) return `id:${id}`;
212+
213+
const title = firstText(target.title, target.windowTitle, target.kittyTitle);
214+
if (title) return `title:${title}`;
215+
} else {
216+
const id = text(target);
217+
if (id) return `id:${id}`;
218+
}
219+
throw new TypeError('kitty target must include id, title, or match');
120220
}
121221

122-
function targetMatch(target) {
123-
return `id:${targetId(target)}`;
222+
function buildKittyFocusCommand(target, options = {}) {
223+
return commandShape(['@', 'focus-window', '--match', targetMatch(target)], options);
224+
}
225+
226+
function buildKittyCloseCommand(target, options = {}) {
227+
return commandShape(['@', 'close-window', '--match', targetMatch(target)], options);
228+
}
229+
230+
function buildKittySendTextCommand(target, options = {}) {
231+
const shape = commandShape(['@', 'send-text', '--match', targetMatch(target), '--stdin'], options);
232+
if (Object.prototype.hasOwnProperty.call(options, 'input') || Object.prototype.hasOwnProperty.call(options, 'text') || options.submit) {
233+
shape.input = sendTextInput(
234+
Object.prototype.hasOwnProperty.call(options, 'input') ? options.input : options.text,
235+
{ submit: options.submit },
236+
);
237+
}
238+
return shape;
124239
}
125240

126241
function buildFocusPaneCommand(target, config = {}) {
127-
return commandShape(['@', 'focus-window', '--match', targetMatch(target)], config);
242+
return buildKittyFocusCommand(target, { kittyBin: kittyBin(config, { allowEnv: true }) });
128243
}
129244

130245
function buildClosePaneCommand(target, config = {}) {
131-
return commandShape(['@', 'close-window', '--match', targetMatch(target)], config);
246+
return buildKittyCloseCommand(target, { kittyBin: kittyBin(config, { allowEnv: true }) });
132247
}
133248

134249
function buildSendTextCommand(target, config = {}) {
135-
return commandShape(['@', 'send-text', '--match', targetMatch(target), '--stdin'], config);
250+
return buildKittySendTextCommand(target, { kittyBin: kittyBin(config, { allowEnv: true }) });
136251
}
137252

138253
function sendTextInput(value, options = {}) {
@@ -200,6 +315,12 @@ function createBackend(config = {}) {
200315

201316
module.exports = {
202317
DEFAULT_KITTY_BIN,
318+
buildKittyLaunchCommand,
319+
buildKittyFocusCommand,
320+
buildKittyCloseCommand,
321+
buildKittySendTextCommand,
322+
buildKittyLsCommand,
323+
buildKittyVersionCommand,
203324
buildAvailabilityCommand,
204325
buildOpenCockpitLayoutCommand,
205326
buildLaunchAgentPaneCommand,

0 commit comments

Comments
 (0)