@rigkit/provider-cmux 0.0.0-canary-20260518T014918-c5bc0c2

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 ADDED
@@ -0,0 +1,67 @@
1
+ # @rigkit/provider-cmux
2
+
3
+ Small SDK and Rigkit provider facade for opening workspaces in local `cmux`.
4
+
5
+ ```ts
6
+ import { createCmuxClient } from "@rigkit/provider-cmux";
7
+
8
+ const cmux = createCmuxClient();
9
+
10
+ await cmux.newWorkspace({
11
+ name: "playground",
12
+ command: "echo hello world",
13
+ focus: true,
14
+ });
15
+ ```
16
+
17
+ Commands are printed to stderr before execution:
18
+
19
+ ```text
20
+ $ cmux new-workspace --name playground --command 'echo hello world' --focus true
21
+ ```
22
+
23
+ `cmux new-workspace` and `cmux ssh` are socket commands. With cmux's default socket control mode (`cmuxOnly`), run rigkit from a terminal inside cmux so cmux sets `CMUX_SOCKET_PATH` and accepts the process.
24
+
25
+ If you intentionally enable external socket control in cmux, opt in explicitly:
26
+
27
+ ```ts
28
+ const cmux = createCmuxClient({ allowExternalAutomation: true });
29
+ ```
30
+
31
+ With `allowExternalAutomation`, the SDK can run `open -a cmux` and retry a workspace command while cmux starts.
32
+
33
+ Config-defined operations can request the typed `cmux.open` host capability through the provider facade:
34
+
35
+ ```ts
36
+ import { workflow } from "@rigkit/sdk";
37
+ import { cmux } from "@rigkit/provider-cmux";
38
+
39
+ export default workflow("site", {
40
+ providers: {
41
+ cmux: cmux.provider(),
42
+ },
43
+ })
44
+ .sequence("site")
45
+ .operation("open", {
46
+ run: async ({ providers }) => {
47
+ await providers.cmux.open({
48
+ name: "site",
49
+ ssh: {
50
+ host: "devbox.example.com",
51
+ username: "root",
52
+ sshOptions: ["ServerAliveInterval=15"],
53
+ },
54
+ cwd: "/workspace/site",
55
+ surfaceLayout: "tabs",
56
+ terminals: [{ command: "pnpm dev" }],
57
+ url: "http://localhost:3000",
58
+ });
59
+ },
60
+ });
61
+ ```
62
+
63
+ Set `surfaceLayout: "tabs"` to open terminals and the optional browser URL as
64
+ tabs in the same cmux pane. Omit it, or set `"splits"`, to keep the default
65
+ split-pane behavior.
66
+
67
+ Local hosts can import `@rigkit/provider-cmux/host` to register the trusted `cmux.open` handler. The Rigkit CLI registers this handler automatically.
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@rigkit/provider-cmux",
3
+ "version": "0.0.0-canary-20260518T014918-c5bc0c2",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/freestyle-sh/rigkit.git",
8
+ "directory": "packages/provider-cmux"
9
+ },
10
+ "exports": {
11
+ ".": "./src/index.ts",
12
+ "./host": "./src/host.ts",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md"
18
+ ],
19
+ "dependencies": {
20
+ "@rigkit/engine": "0.0.0-canary-20260518T014918-c5bc0c2",
21
+ "@rigkit/sdk": "0.0.0-canary-20260518T014918-c5bc0c2"
22
+ },
23
+ "devDependencies": {
24
+ "@types/bun": "latest",
25
+ "typescript": "latest"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "build": "tsc --noEmit",
32
+ "typecheck": "tsc --noEmit",
33
+ "test": "bun test"
34
+ }
35
+ }
@@ -0,0 +1,70 @@
1
+ export const CMUX_OPEN_CAPABILITY_ID = "cmux.open";
2
+
3
+ export const CMUX_OPEN_SCHEMA_HASH =
4
+ "sha256:3a2975cfe53089c6a607da751b55575dc8806bd90132242d4b7e9065a26ae3af";
5
+
6
+ export const CMUX_OPEN_CAPABILITY = {
7
+ id: CMUX_OPEN_CAPABILITY_ID,
8
+ schemaHash: CMUX_OPEN_SCHEMA_HASH,
9
+ } as const;
10
+
11
+ export type CmuxOpenSshInput = string | {
12
+ kind?: "ssh";
13
+ destination?: string;
14
+ host?: string;
15
+ port?: number;
16
+ username?: string;
17
+ identity?: string;
18
+ sshOptions?: readonly string[];
19
+ remoteCommandArgs?: readonly string[];
20
+ initialCommand?: string;
21
+ terminalStartupCommand?: string;
22
+ autoConnect?: boolean;
23
+ skipDaemonBootstrap?: boolean;
24
+ };
25
+
26
+ export type CmuxRemoteReadyOptions = {
27
+ timeoutMs?: number;
28
+ intervalMs?: number;
29
+ requireProxy?: boolean;
30
+ };
31
+
32
+ export type CmuxOpenTerminalDirection = "left" | "right" | "up" | "down";
33
+ export type CmuxOpenSurfaceLayout = "splits" | "tabs";
34
+
35
+ export type CmuxOpenTerminalInput = {
36
+ command: string;
37
+ cwd?: string;
38
+ direction?: CmuxOpenTerminalDirection;
39
+ focus?: boolean;
40
+ };
41
+
42
+ export type CmuxOpenInput = {
43
+ name: string;
44
+ ssh?: CmuxOpenSshInput;
45
+ cwd?: string;
46
+ surfaceLayout?: CmuxOpenSurfaceLayout;
47
+ terminals?: readonly CmuxOpenTerminalInput[];
48
+ url?: string;
49
+ focus?: boolean;
50
+ waitForRemoteReady?: boolean | CmuxRemoteReadyOptions;
51
+ };
52
+
53
+ export type CmuxOpenPaneResult = {
54
+ paneId?: string;
55
+ paneRef?: string;
56
+ surfaceId?: string;
57
+ surfaceRef?: string;
58
+ };
59
+
60
+ export type CmuxOpenResult = {
61
+ sessionId: string;
62
+ workspaceId: string;
63
+ workspaceRef?: string;
64
+ terminalPanes: CmuxOpenPaneResult[];
65
+ browserPane?: CmuxOpenPaneResult;
66
+ };
67
+
68
+ export type CmuxOpenSession = CmuxOpenResult & {
69
+ closed: Promise<void>;
70
+ };
package/src/host.ts ADDED
@@ -0,0 +1,370 @@
1
+ import {
2
+ createCmuxClient,
3
+ formatShellCommand,
4
+ type CmuxClient,
5
+ type CmuxClientOptions,
6
+ type CmuxNewWorkspaceOptions,
7
+ type CmuxPane,
8
+ type CmuxPortsKickOptions,
9
+ type CmuxSendOptions,
10
+ type CmuxSshOptions,
11
+ type CmuxWaitForRemoteOptions,
12
+ type CmuxWorkspace,
13
+ } from "./index.ts";
14
+ import {
15
+ defineHostCapability,
16
+ type HostCapabilityContext,
17
+ type HostCapabilityHandler,
18
+ } from "@rigkit/sdk/host";
19
+ import {
20
+ CMUX_OPEN_CAPABILITY,
21
+ type CmuxOpenInput,
22
+ type CmuxOpenPaneResult,
23
+ type CmuxOpenResult,
24
+ type CmuxOpenSshInput,
25
+ type CmuxOpenTerminalDirection,
26
+ type CmuxOpenTerminalInput,
27
+ type CmuxRemoteReadyOptions,
28
+ } from "./capabilities.ts";
29
+
30
+ export type CmuxHostCapabilityHandler = HostCapabilityHandler;
31
+
32
+ export type CmuxOpenClient = Pick<
33
+ CmuxClient,
34
+ | "newWorkspace"
35
+ | "ssh"
36
+ | "newPane"
37
+ | "newSurface"
38
+ | "send"
39
+ | "portsKick"
40
+ | "browserOpen"
41
+ | "selectWorkspace"
42
+ | "waitForRemoteReady"
43
+ >;
44
+
45
+ export type CmuxOpenHostOptions = {
46
+ client?: CmuxOpenClient;
47
+ clientOptions?: CmuxClientOptions;
48
+ logger?: (message: string) => void;
49
+ };
50
+
51
+ export function createCmuxOpenHostCapability(
52
+ options: CmuxOpenHostOptions = {},
53
+ ): CmuxHostCapabilityHandler {
54
+ return defineHostCapability(CMUX_OPEN_CAPABILITY.id, {
55
+ schemaHash: CMUX_OPEN_CAPABILITY.schemaHash,
56
+ handle: async (params, context) =>
57
+ await openCmux(params, {
58
+ ...options,
59
+ logger: options.logger ?? hostCapabilityLogger(context) ?? options.clientOptions?.logger,
60
+ }),
61
+ });
62
+ }
63
+
64
+ export const cmuxHostCapabilities = [createCmuxOpenHostCapability()] as const;
65
+
66
+ export async function openCmux(
67
+ params: unknown,
68
+ options: CmuxOpenHostOptions = {},
69
+ ): Promise<CmuxOpenResult> {
70
+ const input = parseCmuxOpenInput(params);
71
+ const logger = cmuxOpenLogger(options);
72
+ const cmux = options.client ?? createCmuxClient({
73
+ ...options.clientOptions,
74
+ ...(options.logger ? { logger: options.logger } : {}),
75
+ printCommands: options.clientOptions?.printCommands ?? false,
76
+ });
77
+ let workspace: CmuxWorkspace;
78
+ const terminalPanes: CmuxPane[] = [];
79
+
80
+ logger?.(`cmux: opening ${input.name}`);
81
+ if (input.ssh) {
82
+ logger?.("cmux: connecting remote workspace");
83
+ workspace = await cmux.ssh({
84
+ ...cmuxSshOptionsForInput(input.ssh),
85
+ name: input.name,
86
+ noFocus: input.focus === false,
87
+ });
88
+ } else {
89
+ logger?.("cmux: creating workspace");
90
+ const workspaceOptions: CmuxNewWorkspaceOptions = {
91
+ name: input.name,
92
+ cwd: input.cwd,
93
+ focus: input.focus,
94
+ };
95
+ workspace = await cmux.newWorkspace(workspaceOptions);
96
+ }
97
+
98
+ const workspaceId = workspace.id ?? workspace.handle;
99
+ const useTabLayout = input.surfaceLayout === "tabs";
100
+
101
+ for (const terminal of input.terminals ?? []) {
102
+ const cwd = terminal.cwd ?? input.cwd;
103
+ logger?.(cwd ? `cmux: starting terminal in ${cwd}` : "cmux: starting terminal");
104
+ const terminalPane = useTabLayout
105
+ ? await cmux.newSurface({
106
+ workspace: workspaceId,
107
+ type: "terminal",
108
+ focus: terminal.focus ?? true,
109
+ })
110
+ : await cmux.newPane({
111
+ workspace: workspaceId,
112
+ type: "terminal",
113
+ direction: terminal.direction ?? "down",
114
+ focus: terminal.focus ?? true,
115
+ });
116
+ terminalPanes.push(terminalPane);
117
+ const sendOptions: CmuxSendOptions = {
118
+ workspace: workspaceId,
119
+ surface: terminalPane.surface,
120
+ text: commandForTerminal(terminal, input),
121
+ };
122
+ await cmux.send(sendOptions);
123
+ }
124
+
125
+ const waitOptions = remoteReadyOptionsForInput(input);
126
+ if (input.ssh && waitOptions) {
127
+ logger?.("cmux: waiting for remote ports");
128
+ await cmux.waitForRemoteReady(workspaceId, waitOptions);
129
+ }
130
+
131
+ if (input.ssh && terminalPanes.some((pane) => pane.surface)) {
132
+ logger?.("cmux: refreshing remote ports");
133
+ for (const pane of terminalPanes) {
134
+ if (!pane.surface) continue;
135
+ const kickOptions: CmuxPortsKickOptions = {
136
+ workspace: workspaceId,
137
+ surface: pane.surface,
138
+ reason: "command",
139
+ };
140
+ await cmux.portsKick(kickOptions);
141
+ }
142
+ }
143
+
144
+ let browserPane: CmuxPane | undefined;
145
+ if (input.url) {
146
+ logger?.(`cmux: opening ${input.url}`);
147
+ browserPane = useTabLayout
148
+ ? await cmux.newSurface({
149
+ workspace: workspaceId,
150
+ type: "browser",
151
+ url: input.url,
152
+ focus: input.focus !== false,
153
+ })
154
+ : await cmux.browserOpen({
155
+ workspace: workspaceId,
156
+ url: input.url,
157
+ focus: input.focus !== false,
158
+ });
159
+ }
160
+
161
+ if (input.focus !== false) {
162
+ logger?.("cmux: focusing workspace");
163
+ await cmux.selectWorkspace(workspaceId);
164
+ }
165
+
166
+ logger?.(`cmux: ready ${input.name}`);
167
+ return {
168
+ sessionId: workspaceId,
169
+ workspaceId,
170
+ ...(workspace.ref ? { workspaceRef: workspace.ref } : {}),
171
+ terminalPanes: terminalPanes.map(paneResultForCmuxPane),
172
+ ...(browserPane ? { browserPane: paneResultForCmuxPane(browserPane) } : {}),
173
+ };
174
+ }
175
+
176
+ function cmuxOpenLogger(options: CmuxOpenHostOptions): ((message: string) => void) | undefined {
177
+ return options.logger ?? options.clientOptions?.logger;
178
+ }
179
+
180
+ function hostCapabilityLogger(context: HostCapabilityContext | undefined): ((message: string) => void) | undefined {
181
+ if (!context) return undefined;
182
+ return (message) => context.log(message, { label: "cmux" });
183
+ }
184
+
185
+ export function parseCmuxOpenInput(value: unknown): CmuxOpenInput {
186
+ if (!isRecord(value)) throw new Error(`cmux.open requires an object input`);
187
+ const name = requiredString(value, "name");
188
+ return {
189
+ name,
190
+ ...(value.ssh !== undefined ? { ssh: parseSshInput(value.ssh) } : {}),
191
+ ...optionalStringField(value, "cwd"),
192
+ ...optionalSurfaceLayoutField(value, "surfaceLayout"),
193
+ ...(value.terminals !== undefined ? { terminals: parseTerminalInputs(value.terminals) } : {}),
194
+ ...optionalStringField(value, "url"),
195
+ ...optionalBooleanField(value, "focus"),
196
+ ...(value.waitForRemoteReady !== undefined
197
+ ? { waitForRemoteReady: parseRemoteReadyOptions(value.waitForRemoteReady) }
198
+ : {}),
199
+ };
200
+ }
201
+
202
+ function parseSshInput(value: unknown): CmuxOpenSshInput {
203
+ if (typeof value === "string" && value.trim()) return value.trim();
204
+ if (!isRecord(value)) throw new Error(`cmux.open ssh must be a string or object`);
205
+ if (value.kind !== undefined && value.kind !== "ssh") {
206
+ throw new Error(`cmux.open ssh.kind must be "ssh"`);
207
+ }
208
+ return {
209
+ ...optionalStringField(value, "destination"),
210
+ ...optionalStringField(value, "host"),
211
+ ...optionalNumberField(value, "port"),
212
+ ...optionalStringField(value, "username"),
213
+ ...optionalStringField(value, "identity"),
214
+ ...optionalStringArrayField(value, "sshOptions"),
215
+ ...optionalStringArrayField(value, "remoteCommandArgs"),
216
+ ...optionalStringField(value, "initialCommand"),
217
+ ...optionalStringField(value, "terminalStartupCommand"),
218
+ ...optionalBooleanField(value, "autoConnect"),
219
+ ...optionalBooleanField(value, "skipDaemonBootstrap"),
220
+ };
221
+ }
222
+
223
+ function parseRemoteReadyOptions(value: unknown): boolean | CmuxRemoteReadyOptions {
224
+ if (typeof value === "boolean") return value;
225
+ if (!isRecord(value)) throw new Error(`cmux.open waitForRemoteReady must be a boolean or object`);
226
+ return {
227
+ ...optionalNumberField(value, "timeoutMs"),
228
+ ...optionalNumberField(value, "intervalMs"),
229
+ ...optionalBooleanField(value, "requireProxy"),
230
+ };
231
+ }
232
+
233
+ function cmuxSshOptionsForInput(ssh: CmuxOpenSshInput): CmuxSshOptions {
234
+ if (typeof ssh === "string") return { destination: ssh };
235
+
236
+ const destination = ssh.destination ?? sshDestination(ssh);
237
+ return {
238
+ destination,
239
+ ...(ssh.port !== undefined ? { port: ssh.port } : {}),
240
+ ...(ssh.identity !== undefined ? { identity: ssh.identity } : {}),
241
+ ...(ssh.sshOptions?.length ? { sshOptions: ssh.sshOptions } : {}),
242
+ ...(ssh.remoteCommandArgs !== undefined ? { remoteCommandArgs: ssh.remoteCommandArgs } : {}),
243
+ ...(ssh.initialCommand !== undefined ? { initialCommand: ssh.initialCommand } : {}),
244
+ ...(ssh.terminalStartupCommand !== undefined ? { terminalStartupCommand: ssh.terminalStartupCommand } : {}),
245
+ ...(ssh.autoConnect !== undefined ? { autoConnect: ssh.autoConnect } : {}),
246
+ ...(ssh.skipDaemonBootstrap !== undefined ? { skipDaemonBootstrap: ssh.skipDaemonBootstrap } : {}),
247
+ };
248
+ }
249
+
250
+ function sshDestination(ssh: Extract<CmuxOpenSshInput, object>): string {
251
+ if (!ssh.host) throw new Error(`cmux.open ssh.host is required when ssh.destination is omitted`);
252
+ if (!ssh.username) throw new Error(`cmux.open ssh.username is required when ssh.destination is omitted`);
253
+ return `${ssh.username}@${ssh.host}`;
254
+ }
255
+
256
+ function parseTerminalInputs(value: unknown): CmuxOpenTerminalInput[] {
257
+ if (!Array.isArray(value)) throw new Error(`cmux.open terminals must be an array`);
258
+ return value.map((item, index) => parseTerminalInput(item, index));
259
+ }
260
+
261
+ function parseTerminalInput(value: unknown, index: number): CmuxOpenTerminalInput {
262
+ if (!isRecord(value)) throw new Error(`cmux.open terminals[${index}] must be an object`);
263
+ return {
264
+ command: requiredString(value, "command"),
265
+ ...optionalStringField(value, "cwd"),
266
+ ...optionalTerminalDirectionField(value, "direction"),
267
+ ...optionalBooleanField(value, "focus"),
268
+ };
269
+ }
270
+
271
+ function commandForTerminal(terminal: CmuxOpenTerminalInput, input: CmuxOpenInput): string {
272
+ const cwd = terminal.cwd ?? input.cwd;
273
+ const prefix = cwd ? `${formatShellCommand(["cd", cwd])} && ` : "";
274
+ return `${prefix}${terminal.command}\n`;
275
+ }
276
+
277
+ function paneResultForCmuxPane(pane: CmuxPane): CmuxOpenPaneResult {
278
+ return {
279
+ ...(pane.pane ? { paneId: pane.pane } : {}),
280
+ ...(pane.paneRef ? { paneRef: pane.paneRef } : {}),
281
+ ...(pane.surface ? { surfaceId: pane.surface } : {}),
282
+ ...(pane.surfaceRef ? { surfaceRef: pane.surfaceRef } : {}),
283
+ };
284
+ }
285
+
286
+ function remoteReadyOptionsForInput(input: CmuxOpenInput): CmuxWaitForRemoteOptions | false {
287
+ if (input.waitForRemoteReady === false) return false;
288
+ if (input.waitForRemoteReady === true || input.waitForRemoteReady === undefined) {
289
+ return input.url ? {} : false;
290
+ }
291
+ return input.waitForRemoteReady;
292
+ }
293
+
294
+ function requiredString(record: Record<string, unknown>, key: string): string {
295
+ const value = record[key];
296
+ if (typeof value !== "string" || value.trim() === "") {
297
+ throw new Error(`cmux.open ${key} must be a non-empty string`);
298
+ }
299
+ return value.trim();
300
+ }
301
+
302
+ function optionalStringField(
303
+ record: Record<string, unknown>,
304
+ key: string,
305
+ expected?: string,
306
+ ): Record<string, string> {
307
+ const value = record[key];
308
+ if (value === undefined) return {};
309
+ if (typeof value !== "string" || value.trim() === "") {
310
+ throw new Error(`cmux.open ${key} must be a non-empty string`);
311
+ }
312
+ const normalized = value.trim();
313
+ if (expected !== undefined && normalized !== expected) {
314
+ throw new Error(`cmux.open ${key} must be "${expected}"`);
315
+ }
316
+ return { [key]: normalized };
317
+ }
318
+
319
+ function optionalStringArrayField(record: Record<string, unknown>, key: string): Record<string, string[]> {
320
+ const value = record[key];
321
+ if (value === undefined) return {};
322
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
323
+ throw new Error(`cmux.open ${key} must be an array of strings`);
324
+ }
325
+ return { [key]: value };
326
+ }
327
+
328
+ function optionalNumberField(record: Record<string, unknown>, key: string): Record<string, number> {
329
+ const value = record[key];
330
+ if (value === undefined) return {};
331
+ if (typeof value !== "number" || !Number.isFinite(value)) {
332
+ throw new Error(`cmux.open ${key} must be a finite number`);
333
+ }
334
+ return { [key]: value };
335
+ }
336
+
337
+ function optionalBooleanField(record: Record<string, unknown>, key: string): Record<string, boolean> {
338
+ const value = record[key];
339
+ if (value === undefined) return {};
340
+ if (typeof value !== "boolean") throw new Error(`cmux.open ${key} must be a boolean`);
341
+ return { [key]: value };
342
+ }
343
+
344
+ function optionalTerminalDirectionField(
345
+ record: Record<string, unknown>,
346
+ key: string,
347
+ ): Record<string, CmuxOpenTerminalDirection> {
348
+ const value = record[key];
349
+ if (value === undefined) return {};
350
+ if (value !== "left" && value !== "right" && value !== "up" && value !== "down") {
351
+ throw new Error(`cmux.open ${key} must be "left", "right", "up", or "down"`);
352
+ }
353
+ return { [key]: value };
354
+ }
355
+
356
+ function optionalSurfaceLayoutField(
357
+ record: Record<string, unknown>,
358
+ key: string,
359
+ ): Record<string, "splits" | "tabs"> {
360
+ const value = record[key];
361
+ if (value === undefined) return {};
362
+ if (value !== "splits" && value !== "tabs") {
363
+ throw new Error(`cmux.open ${key} must be "splits" or "tabs"`);
364
+ }
365
+ return { [key]: value };
366
+ }
367
+
368
+ function isRecord(value: unknown): value is Record<string, unknown> {
369
+ return typeof value === "object" && value !== null && !Array.isArray(value);
370
+ }