Skip to content

Commit 2f66705

Browse files
NagyViktNagyViktOmX
authored
Isolate Kitty runtime control behind argv helpers (#509)
The cockpit needs a lower-level Kitty surface before UI code can share dmux-style control paths. This adds command and runtime helpers that build argv-shaped Kitty remote-control commands, support dry-run planning, and keep tests independent from a local Kitty install. Constraint: User requested no cockpit behavior changes and only four files edited Rejected: Wire cockpit to the new adapter now | requested low-level layer only Confidence: high Scope-risk: narrow Directive: Keep cockpit UI wiring separate from this low-level adapter until the behavior layer is ready to migrate Tested: node --test test/kitty-command.test.js test/kitty-runtime.test.js test/terminal-kitty.test.js test/cockpit-terminal-backend.test.js test/cockpit-kitty-layout.test.js Tested: git diff --check Co-authored-by: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: OmX <omx@oh-my-codex.dev>
1 parent f6a1bd5 commit 2f66705

4 files changed

Lines changed: 591 additions & 0 deletions

File tree

src/kitty/command.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
'use strict';
2+
3+
const cp = require('node:child_process');
4+
5+
const DEFAULT_KITTY_BIN = 'kitty';
6+
7+
function text(value, fallback = '') {
8+
if (typeof value === 'string') return value.trim() || fallback;
9+
if (value === null || value === undefined) return fallback;
10+
return String(value).trim() || fallback;
11+
}
12+
13+
function resolveKittyBin(options = {}) {
14+
return text(options.kittyBin || options.bin || process.env.GUARDEX_KITTY_BIN, DEFAULT_KITTY_BIN);
15+
}
16+
17+
function assertArgs(args) {
18+
if (!Array.isArray(args)) {
19+
throw new TypeError('kitty args must be an array');
20+
}
21+
for (const arg of args) {
22+
if (typeof arg !== 'string') {
23+
throw new TypeError('kitty args must contain only strings');
24+
}
25+
}
26+
}
27+
28+
function commandShape(cmd, args, options = {}) {
29+
const command = {
30+
cmd,
31+
args: [...args],
32+
};
33+
if (Object.prototype.hasOwnProperty.call(options, 'input')) {
34+
command.input = options.input === undefined || options.input === null ? '' : String(options.input);
35+
}
36+
return command;
37+
}
38+
39+
function runnerOptions(options = {}) {
40+
return {
41+
cwd: options.cwd,
42+
env: options.env ? { ...process.env, ...options.env } : process.env,
43+
encoding: 'utf8',
44+
input: options.input,
45+
stdio: options.stdio || 'pipe',
46+
timeout: options.timeout,
47+
};
48+
}
49+
50+
function defaultRunner(cmd, args, options = {}) {
51+
return cp.spawnSync(cmd, args, runnerOptions(options));
52+
}
53+
54+
function runnerFor(options = {}) {
55+
if (typeof options.runner === 'function') return options.runner;
56+
if (options.runtime && typeof options.runtime.run === 'function') return options.runtime.run;
57+
return defaultRunner;
58+
}
59+
60+
function dryRunOptions(options = {}) {
61+
const result = {};
62+
for (const key of ['cwd', 'env', 'input', 'stdio', 'timeout']) {
63+
if (Object.prototype.hasOwnProperty.call(options, key) && options[key] !== undefined) {
64+
result[key] = options[key];
65+
}
66+
}
67+
return Object.keys(result).length > 0 ? result : undefined;
68+
}
69+
70+
function runKitty(args, options = {}) {
71+
assertArgs(args);
72+
const cmd = resolveKittyBin(options);
73+
const command = commandShape(cmd, args, options);
74+
75+
if (options.dryRun) {
76+
const result = {
77+
dryRun: true,
78+
commands: [command],
79+
};
80+
const optionsForReport = dryRunOptions(options);
81+
if (optionsForReport) result.options = optionsForReport;
82+
return result;
83+
}
84+
85+
return runnerFor(options)(cmd, [...args], runnerOptions(options));
86+
}
87+
88+
function isKittyAvailable(options = {}) {
89+
const result = runKitty(['@', 'ls'], {
90+
...options,
91+
stdio: 'pipe',
92+
});
93+
if (result && result.dryRun) return result;
94+
return Boolean(result && result.status === 0 && !result.error);
95+
}
96+
97+
module.exports = {
98+
isKittyAvailable,
99+
runKitty,
100+
resolveKittyBin,
101+
};

src/kitty/runtime.js

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
'use strict';
2+
3+
const {
4+
isKittyAvailable,
5+
resolveKittyBin,
6+
runKitty,
7+
} = require('./command');
8+
9+
function text(value, fallback = '') {
10+
if (typeof value === 'string') return value.trim() || fallback;
11+
if (value === null || value === undefined) return fallback;
12+
return String(value).trim() || fallback;
13+
}
14+
15+
function requireText(value, name) {
16+
const normalized = text(value);
17+
if (!normalized) {
18+
throw new TypeError(`${name} must be a non-empty string`);
19+
}
20+
return normalized;
21+
}
22+
23+
function appendOption(args, flag, value) {
24+
const normalized = text(value);
25+
if (normalized) args.push(flag, normalized);
26+
return args;
27+
}
28+
29+
function normalizeEnvEntries(env = {}) {
30+
if (!env) return [];
31+
if (Array.isArray(env)) {
32+
return env.map((entry) => {
33+
if (Array.isArray(entry)) {
34+
const key = requireText(entry[0], 'kitty env key');
35+
const value = entry.length > 1 && entry[1] !== undefined && entry[1] !== null ? String(entry[1]) : '';
36+
return `${key}=${value}`;
37+
}
38+
return requireText(entry, 'kitty env');
39+
});
40+
}
41+
if (typeof env !== 'object') {
42+
throw new TypeError('kitty env must be an object, array, or undefined');
43+
}
44+
return Object.keys(env)
45+
.sort()
46+
.map((key) => `${requireText(key, 'kitty env key')}=${env[key] === undefined || env[key] === null ? '' : String(env[key])}`);
47+
}
48+
49+
function appendEnv(args, env) {
50+
for (const entry of normalizeEnvEntries(env)) {
51+
args.push('--env', entry);
52+
}
53+
return args;
54+
}
55+
56+
function normalizeCommandArgv(options = {}) {
57+
if (options.argv === undefined && options.commandArgv === undefined && Object.prototype.hasOwnProperty.call(options, 'command') && !Array.isArray(options.command)) {
58+
throw new TypeError('kitty command argv must be an array');
59+
}
60+
const commandArgv = options.argv || options.commandArgv || (Array.isArray(options.command) ? options.command : undefined);
61+
if (commandArgv === undefined || commandArgv === null) return [];
62+
if (!Array.isArray(commandArgv)) {
63+
throw new TypeError('kitty command argv must be an array');
64+
}
65+
return commandArgv.map((arg) => {
66+
if (arg === undefined || arg === null) {
67+
throw new TypeError('kitty command argv values must be strings');
68+
}
69+
return String(arg);
70+
});
71+
}
72+
73+
function appendCommandArgv(args, options = {}) {
74+
const commandArgv = normalizeCommandArgv(options);
75+
if (commandArgv.length > 0) args.push('--', ...commandArgv);
76+
return args;
77+
}
78+
79+
function commandShape(args, options = {}) {
80+
const command = {
81+
cmd: resolveKittyBin(options),
82+
args,
83+
};
84+
if (Object.prototype.hasOwnProperty.call(options, 'input')) {
85+
command.input = options.input === undefined || options.input === null ? '' : String(options.input);
86+
}
87+
return command;
88+
}
89+
90+
function buildKittyLaunchCommand(options = {}) {
91+
const args = ['@', 'launch'];
92+
appendOption(args, '--type', text(options.type, 'window'));
93+
appendOption(args, '--location', options.location);
94+
appendOption(args, '--cwd', options.cwd);
95+
appendOption(args, '--title', options.title);
96+
appendEnv(args, options.env);
97+
appendCommandArgv(args, options);
98+
return commandShape(args, options);
99+
}
100+
101+
function runCommand(command, action, options = {}) {
102+
const runOptions = {
103+
...options,
104+
kittyBin: command.cmd,
105+
action,
106+
};
107+
if (Object.prototype.hasOwnProperty.call(command, 'input')) {
108+
runOptions.input = command.input;
109+
} else if (Object.prototype.hasOwnProperty.call(options, 'input')) {
110+
runOptions.input = options.input;
111+
}
112+
return runKitty(command.args, runOptions);
113+
}
114+
115+
function launchKittyWindow(options = {}) {
116+
return runCommand(
117+
buildKittyLaunchCommand({
118+
...options,
119+
type: text(options.type, 'window'),
120+
}),
121+
'launch-window',
122+
options,
123+
);
124+
}
125+
126+
function launchKittyTab(options = {}) {
127+
return runCommand(
128+
buildKittyLaunchCommand({
129+
...options,
130+
type: 'tab',
131+
}),
132+
'launch-tab',
133+
options,
134+
);
135+
}
136+
137+
function launchKittyPane(options = {}) {
138+
return runCommand(
139+
buildKittyLaunchCommand({
140+
...options,
141+
type: 'window',
142+
location: text(options.location, 'vsplit'),
143+
}),
144+
'launch-pane',
145+
options,
146+
);
147+
}
148+
149+
function targetMatch(target) {
150+
if (target && typeof target === 'object') {
151+
const explicit = text(target.match || target.kittyMatch);
152+
if (explicit) return explicit;
153+
154+
const id = text(target.id || target.windowId || target.kittyWindowId || target.paneId || target.target);
155+
if (id) return `id:${id}`;
156+
157+
const title = text(target.title || target.windowTitle || target.kittyTitle);
158+
if (title) return `title:${title}`;
159+
} else {
160+
const id = text(target);
161+
if (id) return `id:${id}`;
162+
}
163+
throw new TypeError('kitty target must include id, title, or match');
164+
}
165+
166+
function sendTextToKitty(target, value, options = {}) {
167+
return runKitty(['@', 'send-text', '--match', targetMatch(target), '--stdin'], {
168+
...options,
169+
input: value === undefined || value === null ? '' : String(value),
170+
stdio: options.stdio || 'pipe',
171+
});
172+
}
173+
174+
function setKittyWindowTitle(target, title, options = {}) {
175+
return runKitty(['@', 'set-window-title', '--match', targetMatch(target), requireText(title, 'kitty window title')], options);
176+
}
177+
178+
module.exports = {
179+
isKittyAvailable,
180+
runKitty,
181+
buildKittyLaunchCommand,
182+
launchKittyWindow,
183+
launchKittyTab,
184+
launchKittyPane,
185+
sendTextToKitty,
186+
setKittyWindowTitle,
187+
};

0 commit comments

Comments
 (0)