@rigkit/provider-cmux 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -0
- package/package.json +35 -0
- package/src/capabilities.ts +56 -0
- package/src/host.ts +301 -0
- package/src/index.test.ts +692 -0
- package/src/index.ts +752 -0
- package/src/provider.ts +103 -0
- package/src/version.ts +1 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createConnection } from "node:net";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
export type CmuxCommandResult = {
|
|
6
|
+
exitCode: number;
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type CmuxCommandRunner = (
|
|
12
|
+
args: readonly string[],
|
|
13
|
+
) => CmuxCommandResult;
|
|
14
|
+
|
|
15
|
+
export type CmuxRpcParams = Record<string, CmuxRpcValue>;
|
|
16
|
+
export type CmuxRpcResult = Record<string, unknown>;
|
|
17
|
+
export type CmuxRpcValue =
|
|
18
|
+
| string
|
|
19
|
+
| number
|
|
20
|
+
| boolean
|
|
21
|
+
| null
|
|
22
|
+
| readonly CmuxRpcValue[]
|
|
23
|
+
| { readonly [key: string]: CmuxRpcValue };
|
|
24
|
+
|
|
25
|
+
export type CmuxRpcRunner = (
|
|
26
|
+
method: string,
|
|
27
|
+
params: CmuxRpcParams,
|
|
28
|
+
) => Promise<CmuxRpcResult> | CmuxRpcResult;
|
|
29
|
+
|
|
30
|
+
type SendSocketRpcOptions = {
|
|
31
|
+
socketPath: string;
|
|
32
|
+
socketPassword?: string;
|
|
33
|
+
method: string;
|
|
34
|
+
params: CmuxRpcParams;
|
|
35
|
+
responseTimeoutMs?: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type CmuxClientOptions = {
|
|
39
|
+
bin?: string;
|
|
40
|
+
socketPath?: string;
|
|
41
|
+
socketPassword?: string;
|
|
42
|
+
autoLaunch?: boolean;
|
|
43
|
+
allowExternalAutomation?: boolean;
|
|
44
|
+
launchCommand?: readonly string[];
|
|
45
|
+
printCommands?: boolean;
|
|
46
|
+
logger?: (message: string) => void;
|
|
47
|
+
readyAttempts?: number;
|
|
48
|
+
readyDelayMs?: number;
|
|
49
|
+
runner?: CmuxCommandRunner;
|
|
50
|
+
rpcRunner?: CmuxRpcRunner;
|
|
51
|
+
sleep?: (ms: number) => Promise<void>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type CmuxNewWorkspaceOptions = {
|
|
55
|
+
name?: string;
|
|
56
|
+
description?: string;
|
|
57
|
+
cwd?: string;
|
|
58
|
+
command?: string;
|
|
59
|
+
focus?: boolean;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type CmuxWorkspace = {
|
|
63
|
+
handle: string;
|
|
64
|
+
id?: string;
|
|
65
|
+
ref?: string;
|
|
66
|
+
result?: CmuxRpcResult;
|
|
67
|
+
stdout?: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type CmuxSshOptions = {
|
|
71
|
+
destination: string;
|
|
72
|
+
name?: string;
|
|
73
|
+
port?: number;
|
|
74
|
+
identity?: string;
|
|
75
|
+
sshOptions?: readonly string[];
|
|
76
|
+
noFocus?: boolean;
|
|
77
|
+
remoteCommandArgs?: readonly string[];
|
|
78
|
+
initialCommand?: string;
|
|
79
|
+
terminalStartupCommand?: string;
|
|
80
|
+
autoConnect?: boolean;
|
|
81
|
+
skipDaemonBootstrap?: boolean;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type CmuxWorkspaceStatus = {
|
|
85
|
+
handle: string;
|
|
86
|
+
id?: string;
|
|
87
|
+
ref?: string;
|
|
88
|
+
remote?: CmuxRpcResult;
|
|
89
|
+
result: CmuxRpcResult;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type CmuxWaitForRemoteOptions = {
|
|
93
|
+
timeoutMs?: number;
|
|
94
|
+
intervalMs?: number;
|
|
95
|
+
requireProxy?: boolean;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type CmuxNewPaneOptions = {
|
|
99
|
+
workspace?: string;
|
|
100
|
+
type?: "terminal" | "browser";
|
|
101
|
+
direction?: "left" | "right" | "up" | "down";
|
|
102
|
+
url?: string;
|
|
103
|
+
focus?: boolean;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export type CmuxPane = {
|
|
107
|
+
workspace?: string;
|
|
108
|
+
workspaceRef?: string;
|
|
109
|
+
pane?: string;
|
|
110
|
+
paneRef?: string;
|
|
111
|
+
surface?: string;
|
|
112
|
+
surfaceRef?: string;
|
|
113
|
+
result?: CmuxRpcResult;
|
|
114
|
+
stdout?: string;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export type CmuxBrowserOpenOptions = {
|
|
118
|
+
workspace?: string;
|
|
119
|
+
window?: string;
|
|
120
|
+
url?: string;
|
|
121
|
+
focus?: boolean;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export type CmuxSendOptions = {
|
|
125
|
+
workspace?: string;
|
|
126
|
+
surface?: string;
|
|
127
|
+
text: string;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export type CmuxPortsKickOptions = {
|
|
131
|
+
workspace: string;
|
|
132
|
+
surface?: string;
|
|
133
|
+
reason?: "command" | "refresh";
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export class CmuxCommandError extends Error {
|
|
137
|
+
readonly args: readonly string[];
|
|
138
|
+
readonly exitCode: number;
|
|
139
|
+
readonly stdout: string;
|
|
140
|
+
readonly stderr: string;
|
|
141
|
+
|
|
142
|
+
constructor(args: readonly string[], result: CmuxCommandResult) {
|
|
143
|
+
const output = [
|
|
144
|
+
result.stderr.trim() ? `stderr:\n${result.stderr.trimEnd()}` : "",
|
|
145
|
+
result.stdout.trim() ? `stdout:\n${result.stdout.trimEnd()}` : "",
|
|
146
|
+
].filter(Boolean).join("\n");
|
|
147
|
+
super(
|
|
148
|
+
`cmux command failed with exit code ${result.exitCode}: ${args.join(" ")}${
|
|
149
|
+
output ? `\n${output}` : ""
|
|
150
|
+
}`,
|
|
151
|
+
);
|
|
152
|
+
this.name = "CmuxCommandError";
|
|
153
|
+
this.args = args;
|
|
154
|
+
this.exitCode = result.exitCode;
|
|
155
|
+
this.stdout = result.stdout;
|
|
156
|
+
this.stderr = result.stderr;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export class CmuxClient {
|
|
161
|
+
private readonly bin: string;
|
|
162
|
+
private readonly socketPath?: string;
|
|
163
|
+
private readonly socketPassword?: string;
|
|
164
|
+
private readonly autoLaunch: boolean;
|
|
165
|
+
private readonly allowExternalAutomation: boolean;
|
|
166
|
+
private readonly launchCommand: readonly string[];
|
|
167
|
+
private readonly printCommands: boolean;
|
|
168
|
+
private readonly logger: (message: string) => void;
|
|
169
|
+
private readonly commandAttempts: number;
|
|
170
|
+
private readonly commandRetryDelayMs: number;
|
|
171
|
+
private readonly runner: CmuxCommandRunner;
|
|
172
|
+
private readonly rpcRunner?: CmuxRpcRunner;
|
|
173
|
+
private readonly sleep: (ms: number) => Promise<void>;
|
|
174
|
+
|
|
175
|
+
constructor(options: CmuxClientOptions = {}) {
|
|
176
|
+
this.bin = options.bin ?? "cmux";
|
|
177
|
+
this.socketPath = options.socketPath;
|
|
178
|
+
this.socketPassword = options.socketPassword;
|
|
179
|
+
this.autoLaunch = options.autoLaunch ?? true;
|
|
180
|
+
this.allowExternalAutomation = options.allowExternalAutomation ?? false;
|
|
181
|
+
this.launchCommand = options.launchCommand ?? ["open", "-a", "cmux"];
|
|
182
|
+
this.printCommands = options.printCommands ?? true;
|
|
183
|
+
this.logger = options.logger ?? ((message) => console.error(message));
|
|
184
|
+
this.commandAttempts = options.readyAttempts ?? 40;
|
|
185
|
+
this.commandRetryDelayMs = options.readyDelayMs ?? 250;
|
|
186
|
+
this.runner = options.runner ?? runSpawnSync;
|
|
187
|
+
this.rpcRunner = options.rpcRunner;
|
|
188
|
+
this.sleep = options.sleep ?? ((ms) => Bun.sleep(ms));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async ensureRunning(): Promise<void> {
|
|
192
|
+
if (this.tryRunRaw([this.bin, "ping"]).ok) return;
|
|
193
|
+
if (!this.canControlCmuxFromHere()) {
|
|
194
|
+
throw new Error(cmuxTerminalRequiredMessage([this.bin, "ping"]));
|
|
195
|
+
}
|
|
196
|
+
if (!this.autoLaunch || process.platform !== "darwin") {
|
|
197
|
+
throw new Error("cmux is not running");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
this.runRaw(this.launchCommand);
|
|
201
|
+
for (let attempt = 0; attempt < this.commandAttempts; attempt += 1) {
|
|
202
|
+
await this.sleep(this.commandRetryDelayMs);
|
|
203
|
+
if (this.tryRunRaw([this.bin, "ping"]).ok) return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
throw new Error("cmux did not become ready after launching it");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async newWorkspace(
|
|
210
|
+
options: CmuxNewWorkspaceOptions = {},
|
|
211
|
+
): Promise<CmuxWorkspace> {
|
|
212
|
+
const params: CmuxRpcParams = {};
|
|
213
|
+
if (options.name) params.title = options.name;
|
|
214
|
+
if (options.description) params.description = options.description;
|
|
215
|
+
if (options.cwd) params.cwd = options.cwd;
|
|
216
|
+
if (options.focus !== undefined) params.focus = options.focus;
|
|
217
|
+
|
|
218
|
+
const result = await this.rpc("workspace.create", params);
|
|
219
|
+
const workspace = workspaceFromResult(result);
|
|
220
|
+
if (options.command) {
|
|
221
|
+
await this.rpc("surface.send_text", {
|
|
222
|
+
workspace_id: workspace.id ?? workspace.handle,
|
|
223
|
+
text: `${options.command}\n`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return workspace;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async ssh(options: CmuxSshOptions): Promise<CmuxWorkspace> {
|
|
230
|
+
const startupCommand = options.terminalStartupCommand ??
|
|
231
|
+
options.initialCommand ??
|
|
232
|
+
buildSshStartupCommand(options);
|
|
233
|
+
const createResult = await this.rpc("workspace.create", {
|
|
234
|
+
initial_command: startupCommand,
|
|
235
|
+
});
|
|
236
|
+
const workspace = workspaceFromResult(createResult);
|
|
237
|
+
const workspaceId = workspace.id ?? workspace.handle;
|
|
238
|
+
|
|
239
|
+
if (options.name) {
|
|
240
|
+
await this.rpc("workspace.rename", {
|
|
241
|
+
workspace_id: workspaceId,
|
|
242
|
+
title: options.name,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const configureParams: CmuxRpcParams = {
|
|
247
|
+
workspace_id: workspaceId,
|
|
248
|
+
destination: options.destination,
|
|
249
|
+
auto_connect: options.autoConnect ?? true,
|
|
250
|
+
terminal_startup_command: startupCommand,
|
|
251
|
+
};
|
|
252
|
+
if (options.port !== undefined) configureParams.port = options.port;
|
|
253
|
+
if (options.identity) configureParams.identity_file = options.identity;
|
|
254
|
+
if (options.sshOptions?.length) {
|
|
255
|
+
configureParams.ssh_options = [...options.sshOptions];
|
|
256
|
+
}
|
|
257
|
+
if (options.skipDaemonBootstrap !== undefined) {
|
|
258
|
+
configureParams.skip_daemon_bootstrap = options.skipDaemonBootstrap;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
workspace.result = await this.rpc("workspace.remote.configure", configureParams);
|
|
262
|
+
if (!options.noFocus) {
|
|
263
|
+
await this.selectWorkspace(workspaceId);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return workspace;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async newPane(options: CmuxNewPaneOptions = {}): Promise<CmuxPane> {
|
|
270
|
+
const params: CmuxRpcParams = {};
|
|
271
|
+
if (options.type) params.type = options.type;
|
|
272
|
+
if (options.direction) params.direction = options.direction;
|
|
273
|
+
if (options.workspace) params.workspace_id = options.workspace;
|
|
274
|
+
if (options.url) params.url = options.url;
|
|
275
|
+
if (options.focus !== undefined) params.focus = options.focus;
|
|
276
|
+
|
|
277
|
+
return paneFromResult(await this.rpc("pane.create", params));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async listWorkspaces(): Promise<CmuxWorkspaceStatus[]> {
|
|
281
|
+
const result = await this.rpc("workspace.list");
|
|
282
|
+
const workspaces = Array.isArray(result.workspaces)
|
|
283
|
+
? result.workspaces.filter(isRecord)
|
|
284
|
+
: [];
|
|
285
|
+
return workspaces.map(workspaceStatusFromResult);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async workspaceStatus(workspace: string): Promise<CmuxWorkspaceStatus> {
|
|
289
|
+
const workspaces = await this.listWorkspaces();
|
|
290
|
+
const status = workspaces.find((candidate) =>
|
|
291
|
+
candidate.id === workspace ||
|
|
292
|
+
candidate.ref === workspace ||
|
|
293
|
+
candidate.handle === workspace
|
|
294
|
+
);
|
|
295
|
+
if (!status) {
|
|
296
|
+
throw new Error(`cmux workspace not found: ${workspace}`);
|
|
297
|
+
}
|
|
298
|
+
return status;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async waitForRemoteReady(
|
|
302
|
+
workspace: string,
|
|
303
|
+
options: CmuxWaitForRemoteOptions = {},
|
|
304
|
+
): Promise<CmuxWorkspaceStatus> {
|
|
305
|
+
const timeoutMs = options.timeoutMs ?? 90_000;
|
|
306
|
+
const intervalMs = options.intervalMs ?? 500;
|
|
307
|
+
const requireProxy = options.requireProxy ?? true;
|
|
308
|
+
const startedAt = Date.now();
|
|
309
|
+
let lastStatus: CmuxWorkspaceStatus | undefined;
|
|
310
|
+
|
|
311
|
+
while (Date.now() - startedAt <= timeoutMs) {
|
|
312
|
+
lastStatus = await this.workspaceStatus(workspace);
|
|
313
|
+
if (isRemoteReady(lastStatus, requireProxy)) {
|
|
314
|
+
return lastStatus;
|
|
315
|
+
}
|
|
316
|
+
await this.sleep(intervalMs);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
throw new Error(
|
|
320
|
+
`cmux remote workspace did not become ready within ${timeoutMs}ms: ${remoteStatusSummary(lastStatus)}`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async browserOpen(options: CmuxBrowserOpenOptions = {}): Promise<CmuxPane> {
|
|
325
|
+
const params: CmuxRpcParams = {};
|
|
326
|
+
if (options.url) params.url = options.url;
|
|
327
|
+
if (options.workspace) params.workspace_id = options.workspace;
|
|
328
|
+
if (options.window) params.window_id = options.window;
|
|
329
|
+
if (options.focus !== undefined) params.focus = options.focus;
|
|
330
|
+
|
|
331
|
+
return paneFromResult(await this.rpc("browser.open_split", params));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async send(options: CmuxSendOptions): Promise<string> {
|
|
335
|
+
const params: CmuxRpcParams = {
|
|
336
|
+
text: options.text,
|
|
337
|
+
};
|
|
338
|
+
if (options.workspace) params.workspace_id = options.workspace;
|
|
339
|
+
if (options.surface) params.surface_id = options.surface;
|
|
340
|
+
await this.rpc("surface.send_text", params);
|
|
341
|
+
return "OK";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async portsKick(options: CmuxPortsKickOptions): Promise<string> {
|
|
345
|
+
const params: CmuxRpcParams = {
|
|
346
|
+
workspace_id: options.workspace,
|
|
347
|
+
reason: options.reason ?? "command",
|
|
348
|
+
};
|
|
349
|
+
if (options.surface) params.surface_id = options.surface;
|
|
350
|
+
await this.rpc("surface.ports_kick", params);
|
|
351
|
+
return "OK";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async selectWorkspace(workspace: string): Promise<string> {
|
|
355
|
+
await this.rpc("workspace.select", { workspace_id: workspace });
|
|
356
|
+
return "OK";
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async rpc(method: string, params: CmuxRpcParams = {}): Promise<CmuxRpcResult> {
|
|
360
|
+
this.printRpc(method, params);
|
|
361
|
+
if (this.rpcRunner) {
|
|
362
|
+
return await this.rpcRunner(method, params);
|
|
363
|
+
}
|
|
364
|
+
if (!this.canControlCmuxFromHere()) {
|
|
365
|
+
throw new Error(cmuxSocketRequiredMessage(method, params));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return await sendSocketRpc({
|
|
369
|
+
socketPath: this.resolvedSocketPath(),
|
|
370
|
+
socketPassword: this.socketPassword ?? process.env.CMUX_SOCKET_PASSWORD,
|
|
371
|
+
method,
|
|
372
|
+
params,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
run(args: readonly string[]): string {
|
|
377
|
+
return this.runRaw([this.bin, ...args]);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
ok(args: readonly string[]): boolean {
|
|
381
|
+
const command = [this.bin, ...args];
|
|
382
|
+
try {
|
|
383
|
+
this.printCommand(command);
|
|
384
|
+
const result = this.runner(command);
|
|
385
|
+
return result.exitCode === 0;
|
|
386
|
+
} catch {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private runRaw(args: readonly string[]): string {
|
|
392
|
+
const result = this.tryRunRaw(args);
|
|
393
|
+
if (!result.ok) {
|
|
394
|
+
throw new CmuxCommandError(args, result.result);
|
|
395
|
+
}
|
|
396
|
+
return result.result.stdout;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private tryRunRaw(args: readonly string[]):
|
|
400
|
+
| { ok: true; result: CmuxCommandResult }
|
|
401
|
+
| { ok: false; result: CmuxCommandResult } {
|
|
402
|
+
this.printCommand(args);
|
|
403
|
+
const result = this.runner(args);
|
|
404
|
+
return result.exitCode === 0
|
|
405
|
+
? { ok: true, result }
|
|
406
|
+
: { ok: false, result };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private printCommand(args: readonly string[]): void {
|
|
410
|
+
if (!this.printCommands) return;
|
|
411
|
+
this.logger(`$ ${formatShellCommand(args)}`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private printRpc(method: string, params: CmuxRpcParams): void {
|
|
415
|
+
if (!this.printCommands) return;
|
|
416
|
+
this.logger(`$ ${formatShellCommand([
|
|
417
|
+
this.bin,
|
|
418
|
+
"rpc",
|
|
419
|
+
method,
|
|
420
|
+
JSON.stringify(params),
|
|
421
|
+
])}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private canControlCmuxFromHere(): boolean {
|
|
425
|
+
return this.allowExternalAutomation || isInsideCmuxTerminal();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private resolvedSocketPath(): string {
|
|
429
|
+
if (this.socketPath) return this.socketPath;
|
|
430
|
+
const envSocketPath = process.env.CMUX_SOCKET_PATH?.trim();
|
|
431
|
+
const legacyEnvSocketPath = process.env.CMUX_SOCKET?.trim();
|
|
432
|
+
if (envSocketPath && legacyEnvSocketPath && envSocketPath !== legacyEnvSocketPath) {
|
|
433
|
+
throw new Error("Refusing to choose cmux socket: CMUX_SOCKET_PATH and CMUX_SOCKET differ.");
|
|
434
|
+
}
|
|
435
|
+
return envSocketPath || legacyEnvSocketPath || `${homedir()}/Library/Application Support/cmux/cmux.sock`;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function createCmuxClient(options?: CmuxClientOptions): CmuxClient {
|
|
440
|
+
return new CmuxClient(options);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function parseCmuxHandle(output: string, kind: string): string {
|
|
444
|
+
const handle = parseOptionalCmuxHandle(output, kind);
|
|
445
|
+
if (handle) return handle;
|
|
446
|
+
|
|
447
|
+
const uuid = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i.exec(output)?.[0];
|
|
448
|
+
if (uuid) return uuid;
|
|
449
|
+
|
|
450
|
+
throw new Error(`cmux output did not include a ${kind} handle: ${output.trim()}`);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export function parseOptionalCmuxHandle(output: string, kind: string): string | undefined {
|
|
454
|
+
const ref = new RegExp(`\\b${kind}:[^\\s]+`).exec(output)?.[0];
|
|
455
|
+
if (ref) return ref;
|
|
456
|
+
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function isInsideCmuxTerminal(
|
|
461
|
+
environment: Record<string, string | undefined> = process.env,
|
|
462
|
+
): boolean {
|
|
463
|
+
return Boolean(
|
|
464
|
+
nonEmpty(environment.CMUX_SOCKET_PATH) ||
|
|
465
|
+
nonEmpty(environment.CMUX_WORKSPACE_ID) ||
|
|
466
|
+
nonEmpty(environment.CMUX_SURFACE_ID),
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function formatShellCommand(args: readonly string[]): string {
|
|
471
|
+
return args.map(shellQuote).join(" ");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export { RIGKIT_PROVIDER_CMUX_VERSION } from "./version.ts";
|
|
475
|
+
export {
|
|
476
|
+
CMUX_OPEN_CAPABILITY,
|
|
477
|
+
CMUX_OPEN_CAPABILITY_ID,
|
|
478
|
+
CMUX_OPEN_SCHEMA_HASH,
|
|
479
|
+
type CmuxOpenInput,
|
|
480
|
+
type CmuxOpenResult,
|
|
481
|
+
type CmuxOpenSession,
|
|
482
|
+
type CmuxOpenSshInput,
|
|
483
|
+
type CmuxRemoteReadyOptions,
|
|
484
|
+
} from "./capabilities.ts";
|
|
485
|
+
export {
|
|
486
|
+
CMUX_PROVIDER_ID,
|
|
487
|
+
cmux,
|
|
488
|
+
cmuxProviderPlugin,
|
|
489
|
+
provider as defineCmuxProvider,
|
|
490
|
+
type CmuxProviderDefinition,
|
|
491
|
+
type CmuxRuntime,
|
|
492
|
+
} from "./provider.ts";
|
|
493
|
+
|
|
494
|
+
function runSpawnSync(args: readonly string[]): CmuxCommandResult {
|
|
495
|
+
const result = Bun.spawnSync([...args], {
|
|
496
|
+
stdout: "pipe",
|
|
497
|
+
stderr: "pipe",
|
|
498
|
+
});
|
|
499
|
+
return {
|
|
500
|
+
exitCode: result.exitCode,
|
|
501
|
+
stdout: new TextDecoder().decode(result.stdout),
|
|
502
|
+
stderr: new TextDecoder().decode(result.stderr),
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function shellQuote(value: string): string {
|
|
507
|
+
if (/^[A-Za-z0-9_./:=@%+-]+$/.test(value)) return value;
|
|
508
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function nonEmpty(value: string | undefined): boolean {
|
|
512
|
+
return value !== undefined && value.trim() !== "";
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function workspaceFromResult(result: CmuxRpcResult): CmuxWorkspace {
|
|
516
|
+
const id = stringValue(result.workspace_id);
|
|
517
|
+
const ref = stringValue(result.workspace_ref);
|
|
518
|
+
const handle = id ?? ref;
|
|
519
|
+
if (!handle) {
|
|
520
|
+
throw new Error(`cmux workspace response did not include workspace_id: ${JSON.stringify(result)}`);
|
|
521
|
+
}
|
|
522
|
+
return { handle, id, ref, result };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function workspaceStatusFromResult(result: CmuxRpcResult): CmuxWorkspaceStatus {
|
|
526
|
+
const id = stringValue(result.id) ?? stringValue(result.workspace_id);
|
|
527
|
+
const ref = stringValue(result.ref) ?? stringValue(result.workspace_ref);
|
|
528
|
+
const handle = id ?? ref;
|
|
529
|
+
if (!handle) {
|
|
530
|
+
throw new Error(`cmux workspace status did not include id: ${JSON.stringify(result)}`);
|
|
531
|
+
}
|
|
532
|
+
const remote = isRecord(result.remote) ? result.remote : undefined;
|
|
533
|
+
return { handle, id, ref, remote, result };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function paneFromResult(result: CmuxRpcResult): CmuxPane {
|
|
537
|
+
return {
|
|
538
|
+
workspace: stringValue(result.workspace_id),
|
|
539
|
+
workspaceRef: stringValue(result.workspace_ref),
|
|
540
|
+
pane: stringValue(result.pane_id),
|
|
541
|
+
paneRef: stringValue(result.pane_ref),
|
|
542
|
+
surface: stringValue(result.surface_id),
|
|
543
|
+
surfaceRef: stringValue(result.surface_ref),
|
|
544
|
+
result,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function stringValue(value: unknown): string | undefined {
|
|
549
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function buildSshStartupCommand(options: CmuxSshOptions): string {
|
|
553
|
+
const args = ["ssh"];
|
|
554
|
+
if (options.port !== undefined) {
|
|
555
|
+
args.push("-p", String(options.port));
|
|
556
|
+
}
|
|
557
|
+
if (options.identity) {
|
|
558
|
+
args.push("-i", options.identity);
|
|
559
|
+
}
|
|
560
|
+
for (const option of options.sshOptions ?? []) {
|
|
561
|
+
args.push("-o", option);
|
|
562
|
+
}
|
|
563
|
+
args.push(options.destination);
|
|
564
|
+
args.push(...(options.remoteCommandArgs ?? []));
|
|
565
|
+
return formatShellCommand(args);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function cmuxSocketRequiredMessage(method: string, params: CmuxRpcParams): string {
|
|
569
|
+
return cmuxTerminalRequiredMessage([
|
|
570
|
+
"cmux",
|
|
571
|
+
"rpc",
|
|
572
|
+
method,
|
|
573
|
+
JSON.stringify(params),
|
|
574
|
+
]);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function isRemoteReady(status: CmuxWorkspaceStatus, requireProxy: boolean): boolean {
|
|
578
|
+
const remote = status.remote;
|
|
579
|
+
if (!remote) return false;
|
|
580
|
+
const connected = remote.connected === true || remote.state === "connected";
|
|
581
|
+
if (!connected) return false;
|
|
582
|
+
if (!requireProxy) return true;
|
|
583
|
+
const proxy = isRecord(remote.proxy) ? remote.proxy : undefined;
|
|
584
|
+
return proxy?.state === "ready";
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function remoteStatusSummary(status: CmuxWorkspaceStatus | undefined): string {
|
|
588
|
+
if (!status?.remote) return "no remote status";
|
|
589
|
+
const proxy = isRecord(status.remote.proxy) ? status.remote.proxy : undefined;
|
|
590
|
+
const daemon = isRecord(status.remote.daemon) ? status.remote.daemon : undefined;
|
|
591
|
+
const parts = [
|
|
592
|
+
`state=${String(status.remote.state ?? "unknown")}`,
|
|
593
|
+
`connected=${String(status.remote.connected ?? false)}`,
|
|
594
|
+
`proxy=${String(proxy?.state ?? "unknown")}`,
|
|
595
|
+
`daemon=${String(daemon?.state ?? "unknown")}`,
|
|
596
|
+
`daemon_detail=${String(daemon?.detail ?? "")}`,
|
|
597
|
+
`detail=${String(status.remote.detail ?? "")}`,
|
|
598
|
+
];
|
|
599
|
+
return parts.join(" ");
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function sendSocketRpc(options: SendSocketRpcOptions): Promise<CmuxRpcResult> {
|
|
603
|
+
const timeoutMs = options.responseTimeoutMs ?? 15_000;
|
|
604
|
+
const socket = createConnection({ path: options.socketPath });
|
|
605
|
+
const cleanupFns: Array<() => void> = [];
|
|
606
|
+
let buffer = "";
|
|
607
|
+
|
|
608
|
+
const cleanup = () => {
|
|
609
|
+
for (const cleanupFn of cleanupFns.splice(0)) {
|
|
610
|
+
cleanupFn();
|
|
611
|
+
}
|
|
612
|
+
socket.destroy();
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const nextLine = () =>
|
|
616
|
+
new Promise<string>((resolve, reject) => {
|
|
617
|
+
const fail = (error: Error) => {
|
|
618
|
+
cleanup();
|
|
619
|
+
reject(error);
|
|
620
|
+
};
|
|
621
|
+
const timer = setTimeout(() => {
|
|
622
|
+
fail(new Error(`Timed out waiting for cmux socket response from ${options.socketPath}`));
|
|
623
|
+
}, timeoutMs);
|
|
624
|
+
|
|
625
|
+
const finish = (line: string) => {
|
|
626
|
+
clearTimeout(timer);
|
|
627
|
+
socket.off("data", onData);
|
|
628
|
+
socket.off("error", onError);
|
|
629
|
+
socket.off("close", onClose);
|
|
630
|
+
resolve(line);
|
|
631
|
+
};
|
|
632
|
+
const tryResolveBufferedLine = () => {
|
|
633
|
+
const newlineIndex = buffer.indexOf("\n");
|
|
634
|
+
if (newlineIndex < 0) return false;
|
|
635
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
636
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
637
|
+
finish(line);
|
|
638
|
+
return true;
|
|
639
|
+
};
|
|
640
|
+
const onData = (chunk: Buffer) => {
|
|
641
|
+
buffer += chunk.toString("utf8");
|
|
642
|
+
tryResolveBufferedLine();
|
|
643
|
+
};
|
|
644
|
+
const onError = (error: Error) => fail(error);
|
|
645
|
+
const onClose = () => fail(new Error("cmux socket closed before reply"));
|
|
646
|
+
|
|
647
|
+
socket.on("data", onData);
|
|
648
|
+
socket.once("error", onError);
|
|
649
|
+
socket.once("close", onClose);
|
|
650
|
+
cleanupFns.push(() => {
|
|
651
|
+
clearTimeout(timer);
|
|
652
|
+
socket.off("data", onData);
|
|
653
|
+
socket.off("error", onError);
|
|
654
|
+
socket.off("close", onClose);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
tryResolveBufferedLine();
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
try {
|
|
661
|
+
await new Promise<void>((resolve, reject) => {
|
|
662
|
+
const timer = setTimeout(() => {
|
|
663
|
+
cleanup();
|
|
664
|
+
reject(new Error(`Timed out connecting to cmux socket at ${options.socketPath}`));
|
|
665
|
+
}, timeoutMs);
|
|
666
|
+
const onConnect = () => {
|
|
667
|
+
clearTimeout(timer);
|
|
668
|
+
socket.off("error", onError);
|
|
669
|
+
resolve();
|
|
670
|
+
};
|
|
671
|
+
const onError = (error: Error) => {
|
|
672
|
+
clearTimeout(timer);
|
|
673
|
+
reject(error);
|
|
674
|
+
};
|
|
675
|
+
socket.once("connect", onConnect);
|
|
676
|
+
socket.once("error", onError);
|
|
677
|
+
cleanupFns.push(() => {
|
|
678
|
+
clearTimeout(timer);
|
|
679
|
+
socket.off("connect", onConnect);
|
|
680
|
+
socket.off("error", onError);
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
if (options.socketPassword) {
|
|
685
|
+
socket.write(`auth ${options.socketPassword}\n`);
|
|
686
|
+
const authLine = await nextLine();
|
|
687
|
+
if (authLine.startsWith("ERROR:") && !authLine.includes("Unknown command 'auth'")) {
|
|
688
|
+
throw new Error(authLine);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const request = {
|
|
693
|
+
id: randomUUID(),
|
|
694
|
+
method: options.method,
|
|
695
|
+
params: options.params,
|
|
696
|
+
};
|
|
697
|
+
socket.write(`${JSON.stringify(request)}\n`);
|
|
698
|
+
const raw = await nextLine();
|
|
699
|
+
if (raw.startsWith("ERROR:")) {
|
|
700
|
+
throw new Error(raw);
|
|
701
|
+
}
|
|
702
|
+
const response = JSON.parse(raw) as unknown;
|
|
703
|
+
if (!isRecord(response)) {
|
|
704
|
+
throw new Error(`Invalid cmux socket response: ${raw}`);
|
|
705
|
+
}
|
|
706
|
+
if (response.ok === true) {
|
|
707
|
+
return isRecord(response.result) ? response.result : {};
|
|
708
|
+
}
|
|
709
|
+
if (isRecord(response.error)) {
|
|
710
|
+
const code = typeof response.error.code === "string" ? response.error.code : "error";
|
|
711
|
+
const message = typeof response.error.message === "string"
|
|
712
|
+
? response.error.message
|
|
713
|
+
: "Unknown cmux socket error";
|
|
714
|
+
throw new Error(`${code}: ${message}`);
|
|
715
|
+
}
|
|
716
|
+
throw new Error(`cmux socket request failed: ${raw}`);
|
|
717
|
+
} catch (error) {
|
|
718
|
+
if (error instanceof SyntaxError) {
|
|
719
|
+
throw new Error(`Invalid cmux socket JSON response: ${error.message}`);
|
|
720
|
+
}
|
|
721
|
+
throw error;
|
|
722
|
+
} finally {
|
|
723
|
+
cleanup();
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function isRecord(value: unknown): value is CmuxRpcResult {
|
|
728
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function cmuxTerminalRequiredMessage(
|
|
732
|
+
args: readonly string[],
|
|
733
|
+
result?: CmuxCommandResult,
|
|
734
|
+
): string {
|
|
735
|
+
const output = result
|
|
736
|
+
? [
|
|
737
|
+
result.stderr.trim() ? `stderr:\n${result.stderr.trimEnd()}` : "",
|
|
738
|
+
result.stdout.trim() ? `stdout:\n${result.stdout.trimEnd()}` : "",
|
|
739
|
+
].filter(Boolean).join("\n")
|
|
740
|
+
: "";
|
|
741
|
+
|
|
742
|
+
return [
|
|
743
|
+
"cmux socket commands need a cmux-controlled terminal by default.",
|
|
744
|
+
"",
|
|
745
|
+
`command: ${formatShellCommand(args)}`,
|
|
746
|
+
"",
|
|
747
|
+
"`cmux new-workspace` and `cmux ssh` are socket commands. With cmux's default socket control mode (`cmuxOnly`), they work from terminals started inside cmux because cmux sets CMUX_SOCKET_PATH/CMUX_WORKSPACE_ID and accepts descendant processes.",
|
|
748
|
+
"",
|
|
749
|
+
"Run this Rigkit workflow from a cmux terminal, or enable cmux Automation/Password socket control and create the client with `allowExternalAutomation: true`.",
|
|
750
|
+
output,
|
|
751
|
+
].filter(Boolean).join("\n");
|
|
752
|
+
}
|