@ricsam/r5dctl 0.0.22 → 0.0.23

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 CHANGED
@@ -67,8 +67,12 @@ r5dctl -s <session-id> prompt --mode plan --model max "..."
67
67
  r5dctl -s <session-id> answer-questions -a1 "Recipe Collection" -a2 "Both Manual & AI"
68
68
  r5dctl -s <session-id> apply-required-envs -e backend/KEY=value -e frontend/KEY=value
69
69
 
70
+ r5dctl -p <project> -b <branch> shell
71
+ r5dctl -p <project> -b <branch> shell -c "bun test"
70
72
  ```
71
73
 
74
+ `shell` connects to the same live worker PTY as the web Shell tab. Without `-c` it attaches the local terminal interactively. With `-c`/`--command`, it runs the supplied shell expression, streams combined PTY output, and exits with the remote command's status. Because command output uses a PTY, stdout and stderr are combined and programs may emit colors or other terminal control sequences.
75
+
72
76
  `get envs` reports names and whether values are set, but never prints the values. Prefer `--from-env-file` for bulk imports so secret values do not appear in shell arguments or history. Dotenv imports target backend envs by default; use `--target frontend` when needed. Empty dotenv values are skipped unless `--include-empty` is passed, and `--only-missing` preserves values that are already set.
73
77
 
74
78
  Conversation output is human-readable by default and excludes both the system prompt and tool definitions. `--raw` keeps the transcript layout but renders structured message and tool parts as JSON, `--tools` includes the provider-shaped tool definitions, and `--system` includes system prompt messages. The global `--json` flag remains separate and prints the complete API response envelope.
package/dist/cjs/cli.cjs CHANGED
@@ -37,6 +37,7 @@ __export(cli_exports, {
37
37
  parseGlobalArgs: () => parseGlobalArgs,
38
38
  parsePromptArgs: () => parsePromptArgs,
39
39
  parseSetEnvArgs: () => parseSetEnvArgs,
40
+ parseShellArgs: () => parseShellArgs,
40
41
  readDotenvFile: () => readDotenvFile,
41
42
  renderConversationResponse: () => renderConversationResponse,
42
43
  resolveCommandExecution: () => resolveCommandExecution,
@@ -51,6 +52,7 @@ var import_node_child_process = require("node:child_process");
51
52
  var import_promises = require("node:timers/promises");
52
53
  var import_dotenv = require("dotenv");
53
54
  var import_r5d_api = require("@ricsam/r5d-api");
55
+ var import_shell = require("./shell.cjs");
54
56
  const CHAT_MODES = /* @__PURE__ */ new Set([
55
57
  "ask",
56
58
  "plan",
@@ -83,12 +85,21 @@ const CLI_ONLY_HELP_ENTRIES = [
83
85
  section: "auth",
84
86
  usage: "auth login [--no-open]",
85
87
  description: "Start the browser login flow."
88
+ },
89
+ {
90
+ section: "shell",
91
+ usage: "-p <project> -b <branch> shell [-c <command>]",
92
+ description: "Open an interactive worker shell or run one command through a PTY."
86
93
  }
87
94
  ];
88
95
  const CLI_ONLY_COMMAND_HELP = [
89
96
  {
90
97
  path: ["auth", "login"],
91
98
  usage: "auth login [--no-open]"
99
+ },
100
+ {
101
+ path: ["shell"],
102
+ usage: "-p <project> -b <branch> shell [-c <command>]"
92
103
  }
93
104
  ];
94
105
  const SHARED_HELP_ENTRIES = [
@@ -170,13 +181,14 @@ const SHARED_HELP_ENTRIES = [
170
181
  { section: "merges", usage: "-p <project> continue-merge <target-branch>", description: "Commit a resolved merge." },
171
182
  { section: "merges", usage: "-p <project> abort-merge <target-branch>", description: "Abort an in-progress merge." }
172
183
  ];
173
- const HELP_SECTION_ORDER = ["auth", "projects", "branches", "envs", "sessions", "agents", "merges"];
184
+ const HELP_SECTION_ORDER = ["auth", "projects", "branches", "envs", "sessions", "shell", "agents", "merges"];
174
185
  const HELP_SECTION_TITLES = {
175
186
  auth: "Auth",
176
187
  projects: "Projects",
177
188
  branches: "Branches",
178
189
  envs: "Environment variables",
179
190
  sessions: "Sessions",
191
+ shell: "Shell",
180
192
  agents: "Agents",
181
193
  merges: "Merges"
182
194
  };
@@ -725,6 +737,33 @@ function parsePromptArgs(args) {
725
737
  message
726
738
  };
727
739
  }
740
+ function parseShellArgs(args) {
741
+ let command;
742
+ for (let index = 0; index < args.length; index += 1) {
743
+ const arg = args[index];
744
+ if (!arg) {
745
+ continue;
746
+ }
747
+ if (arg === "-c" || arg === "--command") {
748
+ if (command !== void 0) {
749
+ throw new Error("Use only one -c/--command value");
750
+ }
751
+ command = requireValue(args[index + 1], `Missing value for ${arg}`);
752
+ index += 1;
753
+ continue;
754
+ }
755
+ const inlineCommand = parseLongOptionWithEquals(arg, "--command");
756
+ if (inlineCommand !== void 0) {
757
+ if (command !== void 0) {
758
+ throw new Error("Use only one -c/--command value");
759
+ }
760
+ command = requireValue(inlineCommand, "Missing value for --command");
761
+ continue;
762
+ }
763
+ throw new Error(arg.startsWith("-") ? `Unknown shell flag: ${arg}` : `Unexpected shell value: ${arg}`);
764
+ }
765
+ return command === void 0 ? {} : { command };
766
+ }
728
767
  async function openInBrowser(url) {
729
768
  if (process.platform === "darwin") {
730
769
  (0, import_node_child_process.spawn)("open", [url], { stdio: "ignore", detached: true }).unref();
@@ -1573,6 +1612,21 @@ function resolveCommandExecution(options, rest) {
1573
1612
  pluginArgs: ["conversation", sessionId, ...commandArgs]
1574
1613
  };
1575
1614
  }
1615
+ if (command === "shell") {
1616
+ if (!options.project) {
1617
+ throw new Error("--project/-p is required for `shell`");
1618
+ }
1619
+ if (!options.branch) {
1620
+ throw new Error("--branch/-b is required for `shell`");
1621
+ }
1622
+ if (options.json) {
1623
+ throw new Error("--json is not supported for `shell`");
1624
+ }
1625
+ return {
1626
+ kind: "shell",
1627
+ ...parseShellArgs(commandArgs)
1628
+ };
1629
+ }
1576
1630
  if (command === "prompt") {
1577
1631
  const sessionId = options.session;
1578
1632
  if (!sessionId) {
@@ -1696,31 +1750,43 @@ async function runCommand(argv) {
1696
1750
  const { options, rest } = parseGlobalArgs(argv);
1697
1751
  if (options.version) {
1698
1752
  printVersion();
1699
- return;
1753
+ return 0;
1700
1754
  }
1701
1755
  if (options.help || rest.length === 0) {
1702
1756
  printHelp();
1703
- return;
1757
+ return 0;
1704
1758
  }
1705
1759
  const config = parseConfig(options.configPath);
1706
- const client = new import_r5d_api.R5dctlClient(resolveClientOptions(options, config));
1760
+ const clientOptions = resolveClientOptions(options, config);
1761
+ const client = new import_r5d_api.R5dctlClient(clientOptions);
1707
1762
  const execution = resolveCommandExecution(options, rest);
1708
1763
  if (execution.kind === "auth-login") {
1709
1764
  await handleAuthLogin(client, options, execution.commandArgs, config);
1710
- return;
1765
+ return 0;
1711
1766
  }
1712
1767
  if (execution.kind === "auth-api-key-create") {
1713
1768
  await handleAuthApiKeyCreate(client, options, config, execution.commandArgs);
1714
- return;
1769
+ return 0;
1715
1770
  }
1716
1771
  if (execution.kind === "cli-help") {
1717
1772
  process.stdout.write(execution.text);
1718
- return;
1773
+ return 0;
1774
+ }
1775
+ if (execution.kind === "shell") {
1776
+ const project = await client.projects.describe(options.project);
1777
+ return await (0, import_shell.runR5dctlShell)({
1778
+ baseUrl: clientOptions.baseUrl ?? "https://r5d.dev",
1779
+ credential: clientOptions.token ?? clientOptions.apiKey ?? "",
1780
+ projectId: project.id,
1781
+ branchName: options.branch,
1782
+ command: execution.command
1783
+ });
1719
1784
  }
1720
1785
  await executeR5dctlCommand(client, options.json, execution.pluginArgs);
1721
1786
  if (execution.clearAuthOnSuccess) {
1722
1787
  writeConfig(options.configPath, clearAuthFromConfig(config, options));
1723
1788
  }
1789
+ return 0;
1724
1790
  }
1725
1791
  function extractApiErrorMessage(error) {
1726
1792
  if (typeof error.body === "object" && error.body !== null) {
@@ -1733,8 +1799,7 @@ function extractApiErrorMessage(error) {
1733
1799
  }
1734
1800
  async function runR5dctlCli(argv) {
1735
1801
  try {
1736
- await runCommand(argv);
1737
- return 0;
1802
+ return await runCommand(argv);
1738
1803
  } catch (error) {
1739
1804
  if (error instanceof import_r5d_api.R5dctlApiError) {
1740
1805
  process.stderr.write(`${extractApiErrorMessage(error)}
@@ -1749,7 +1814,7 @@ async function runR5dctlCli(argv) {
1749
1814
  async function main(argv = process.argv.slice(2)) {
1750
1815
  const exitCode = await runR5dctlCli(argv);
1751
1816
  if (exitCode !== 0) {
1752
- process.exit(exitCode);
1817
+ process.exitCode = exitCode;
1753
1818
  }
1754
1819
  }
1755
1820
  // Annotate the CommonJS export names for ESM import in node:
@@ -1763,6 +1828,7 @@ async function main(argv = process.argv.slice(2)) {
1763
1828
  parseGlobalArgs,
1764
1829
  parsePromptArgs,
1765
1830
  parseSetEnvArgs,
1831
+ parseShellArgs,
1766
1832
  readDotenvFile,
1767
1833
  renderConversationResponse,
1768
1834
  resolveCommandExecution,
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5dctl",
3
- "version": "0.0.22",
3
+ "version": "0.0.23",
4
4
  "type": "commonjs"
5
5
  }
@@ -0,0 +1,245 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+ var shell_exports = {};
30
+ __export(shell_exports, {
31
+ createShellWebSocketUrl: () => createShellWebSocketUrl,
32
+ runR5dctlShell: () => runR5dctlShell
33
+ });
34
+ module.exports = __toCommonJS(shell_exports);
35
+ var import_ws = __toESM(require("ws"), 1);
36
+ function clampTerminalSize(value, fallback) {
37
+ if (!Number.isFinite(value) || (value ?? 0) <= 0) {
38
+ return fallback;
39
+ }
40
+ return Math.max(1, Math.min(Math.floor(value), 500));
41
+ }
42
+ function createShellWebSocketUrl(baseUrl) {
43
+ const url = new URL("/ws", baseUrl);
44
+ if (url.protocol === "https:") {
45
+ url.protocol = "wss:";
46
+ } else if (url.protocol === "http:") {
47
+ url.protocol = "ws:";
48
+ } else {
49
+ throw new Error(`Unsupported r5d base URL protocol: ${url.protocol}`);
50
+ }
51
+ return url.toString();
52
+ }
53
+ function decodeMessage(data) {
54
+ if (typeof data === "string") {
55
+ return data;
56
+ }
57
+ if (Buffer.isBuffer(data)) {
58
+ return data.toString("utf8");
59
+ }
60
+ if (data instanceof ArrayBuffer) {
61
+ return Buffer.from(data).toString("utf8");
62
+ }
63
+ if (ArrayBuffer.isView(data)) {
64
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
65
+ }
66
+ if (Array.isArray(data)) {
67
+ return Buffer.concat(data).toString("utf8");
68
+ }
69
+ return String(data);
70
+ }
71
+ async function runR5dctlShell(options) {
72
+ const stdin = options.stdin ?? process.stdin;
73
+ const stdout = options.stdout ?? process.stdout;
74
+ const signalTarget = options.signalTarget ?? process;
75
+ const interactive = options.command === void 0;
76
+ if (interactive && !stdin.isTTY) {
77
+ throw new Error("Interactive shell requires a TTY. Use `shell -c <command>` for non-interactive use.");
78
+ }
79
+ if (!options.credential) {
80
+ throw new Error("Authentication required. Run `r5dctl auth login` or provide a token or API key.");
81
+ }
82
+ const createWebSocket = options.createWebSocket ?? ((url, clientOptions) => new import_ws.default(url, clientOptions));
83
+ const socket = createWebSocket(createShellWebSocketUrl(options.baseUrl), {
84
+ headers: {
85
+ Authorization: `Bearer ${options.credential}`
86
+ }
87
+ });
88
+ return await new Promise((resolve, reject) => {
89
+ let ptyId;
90
+ let openRequested = false;
91
+ let settled = false;
92
+ let inputAttached = false;
93
+ const priorRawMode = stdin.isRaw ?? false;
94
+ const send = (message) => {
95
+ if (socket.readyState !== import_ws.default.OPEN) {
96
+ return;
97
+ }
98
+ socket.send(JSON.stringify(message));
99
+ };
100
+ const sendResize = () => {
101
+ if (!ptyId) {
102
+ return;
103
+ }
104
+ send({
105
+ type: "shell_pty_resize",
106
+ ptyId,
107
+ cols: clampTerminalSize(stdout.columns, 80),
108
+ rows: clampTerminalSize(stdout.rows, 24)
109
+ });
110
+ };
111
+ const onInput = (chunk) => {
112
+ if (ptyId) {
113
+ send({ type: "shell_pty_input", ptyId, data: Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk });
114
+ }
115
+ };
116
+ const onInputEnd = () => {
117
+ if (!interactive && ptyId) {
118
+ send({ type: "shell_pty_input", ptyId, data: "" });
119
+ }
120
+ };
121
+ const onSigint = () => {
122
+ if (ptyId) {
123
+ send({ type: "shell_pty_input", ptyId, data: "" });
124
+ }
125
+ };
126
+ const onSigterm = () => {
127
+ if (ptyId) {
128
+ send({ type: "shell_pty_close", ptyId });
129
+ ptyId = void 0;
130
+ }
131
+ finish({ exitCode: 143 });
132
+ };
133
+ const cleanup = () => {
134
+ socket.off("message", onMessage);
135
+ socket.off("error", onSocketError);
136
+ socket.off("close", onSocketClose);
137
+ if (inputAttached) {
138
+ stdin.off("data", onInput);
139
+ stdin.off("end", onInputEnd);
140
+ }
141
+ signalTarget.off("SIGWINCH", sendResize);
142
+ signalTarget.off("SIGINT", onSigint);
143
+ signalTarget.off("SIGTERM", onSigterm);
144
+ if (stdin.isTTY && stdin.setRawMode) {
145
+ stdin.setRawMode(priorRawMode);
146
+ }
147
+ };
148
+ const finish = (result) => {
149
+ if (settled) {
150
+ return;
151
+ }
152
+ settled = true;
153
+ cleanup();
154
+ if (socket.readyState === import_ws.default.OPEN || socket.readyState === import_ws.default.CONNECTING) {
155
+ socket.close(1e3, "r5dctl shell finished");
156
+ }
157
+ if ("error" in result) {
158
+ reject(result.error);
159
+ } else {
160
+ resolve(result.exitCode);
161
+ }
162
+ };
163
+ function attachInput() {
164
+ if (inputAttached) {
165
+ return;
166
+ }
167
+ inputAttached = true;
168
+ if (stdin.isTTY && stdin.setRawMode) {
169
+ stdin.setRawMode(true);
170
+ }
171
+ stdin.on("data", onInput);
172
+ stdin.on("end", onInputEnd);
173
+ signalTarget.on("SIGWINCH", sendResize);
174
+ signalTarget.on("SIGTERM", onSigterm);
175
+ if (!stdin.isTTY) {
176
+ signalTarget.on("SIGINT", onSigint);
177
+ }
178
+ }
179
+ function onMessage(data) {
180
+ let message;
181
+ try {
182
+ const parsed = JSON.parse(decodeMessage(data));
183
+ if (!parsed || typeof parsed !== "object" || !("type" in parsed)) {
184
+ throw new Error("Invalid message");
185
+ }
186
+ message = parsed;
187
+ } catch {
188
+ finish({ error: new Error("Received an invalid response from r5d.dev") });
189
+ return;
190
+ }
191
+ if (message.type === "connected") {
192
+ if (!openRequested) {
193
+ openRequested = true;
194
+ send({
195
+ type: "shell_pty_open",
196
+ projectId: options.projectId,
197
+ branchName: options.branchName,
198
+ cols: clampTerminalSize(stdout.columns, 80),
199
+ rows: clampTerminalSize(stdout.rows, 24),
200
+ ...options.command === void 0 ? {} : { command: options.command }
201
+ });
202
+ }
203
+ return;
204
+ }
205
+ if (message.type === "shell_pty_opened") {
206
+ ptyId = message.ptyId;
207
+ attachInput();
208
+ return;
209
+ }
210
+ if (message.type === "shell_pty_output") {
211
+ if (message.ptyId === ptyId) {
212
+ stdout.write(message.data);
213
+ }
214
+ return;
215
+ }
216
+ if (message.type === "shell_pty_exit") {
217
+ if (message.ptyId === ptyId) {
218
+ ptyId = void 0;
219
+ finish({ exitCode: message.exitCode });
220
+ }
221
+ return;
222
+ }
223
+ if (message.type === "shell_pty_error" && (!message.ptyId || message.ptyId === ptyId)) {
224
+ finish({ error: new Error(message.message) });
225
+ }
226
+ }
227
+ function onSocketError(error) {
228
+ finish({ error: new Error(`r5d.dev shell connection failed: ${error.message}`) });
229
+ }
230
+ function onSocketClose(code, reason) {
231
+ if (!settled) {
232
+ const suffix = reason.length > 0 ? `: ${reason.toString("utf8")}` : "";
233
+ finish({ error: new Error(`r5d.dev shell connection closed (${code})${suffix}`) });
234
+ }
235
+ }
236
+ socket.on("message", onMessage);
237
+ socket.on("error", onSocketError);
238
+ socket.on("close", onSocketClose);
239
+ });
240
+ }
241
+ // Annotate the CommonJS export names for ESM import in node:
242
+ 0 && (module.exports = {
243
+ createShellWebSocketUrl,
244
+ runR5dctlShell
245
+ });
package/dist/mjs/cli.mjs CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  R5dctlApiError,
9
9
  R5dctlClient
10
10
  } from "@ricsam/r5d-api";
11
+ import { runR5dctlShell } from "./shell.mjs";
11
12
  const CHAT_MODES = /* @__PURE__ */ new Set([
12
13
  "ask",
13
14
  "plan",
@@ -40,12 +41,21 @@ const CLI_ONLY_HELP_ENTRIES = [
40
41
  section: "auth",
41
42
  usage: "auth login [--no-open]",
42
43
  description: "Start the browser login flow."
44
+ },
45
+ {
46
+ section: "shell",
47
+ usage: "-p <project> -b <branch> shell [-c <command>]",
48
+ description: "Open an interactive worker shell or run one command through a PTY."
43
49
  }
44
50
  ];
45
51
  const CLI_ONLY_COMMAND_HELP = [
46
52
  {
47
53
  path: ["auth", "login"],
48
54
  usage: "auth login [--no-open]"
55
+ },
56
+ {
57
+ path: ["shell"],
58
+ usage: "-p <project> -b <branch> shell [-c <command>]"
49
59
  }
50
60
  ];
51
61
  const SHARED_HELP_ENTRIES = [
@@ -127,13 +137,14 @@ const SHARED_HELP_ENTRIES = [
127
137
  { section: "merges", usage: "-p <project> continue-merge <target-branch>", description: "Commit a resolved merge." },
128
138
  { section: "merges", usage: "-p <project> abort-merge <target-branch>", description: "Abort an in-progress merge." }
129
139
  ];
130
- const HELP_SECTION_ORDER = ["auth", "projects", "branches", "envs", "sessions", "agents", "merges"];
140
+ const HELP_SECTION_ORDER = ["auth", "projects", "branches", "envs", "sessions", "shell", "agents", "merges"];
131
141
  const HELP_SECTION_TITLES = {
132
142
  auth: "Auth",
133
143
  projects: "Projects",
134
144
  branches: "Branches",
135
145
  envs: "Environment variables",
136
146
  sessions: "Sessions",
147
+ shell: "Shell",
137
148
  agents: "Agents",
138
149
  merges: "Merges"
139
150
  };
@@ -682,6 +693,33 @@ function parsePromptArgs(args) {
682
693
  message
683
694
  };
684
695
  }
696
+ function parseShellArgs(args) {
697
+ let command;
698
+ for (let index = 0; index < args.length; index += 1) {
699
+ const arg = args[index];
700
+ if (!arg) {
701
+ continue;
702
+ }
703
+ if (arg === "-c" || arg === "--command") {
704
+ if (command !== void 0) {
705
+ throw new Error("Use only one -c/--command value");
706
+ }
707
+ command = requireValue(args[index + 1], `Missing value for ${arg}`);
708
+ index += 1;
709
+ continue;
710
+ }
711
+ const inlineCommand = parseLongOptionWithEquals(arg, "--command");
712
+ if (inlineCommand !== void 0) {
713
+ if (command !== void 0) {
714
+ throw new Error("Use only one -c/--command value");
715
+ }
716
+ command = requireValue(inlineCommand, "Missing value for --command");
717
+ continue;
718
+ }
719
+ throw new Error(arg.startsWith("-") ? `Unknown shell flag: ${arg}` : `Unexpected shell value: ${arg}`);
720
+ }
721
+ return command === void 0 ? {} : { command };
722
+ }
685
723
  async function openInBrowser(url) {
686
724
  if (process.platform === "darwin") {
687
725
  spawn("open", [url], { stdio: "ignore", detached: true }).unref();
@@ -1530,6 +1568,21 @@ function resolveCommandExecution(options, rest) {
1530
1568
  pluginArgs: ["conversation", sessionId, ...commandArgs]
1531
1569
  };
1532
1570
  }
1571
+ if (command === "shell") {
1572
+ if (!options.project) {
1573
+ throw new Error("--project/-p is required for `shell`");
1574
+ }
1575
+ if (!options.branch) {
1576
+ throw new Error("--branch/-b is required for `shell`");
1577
+ }
1578
+ if (options.json) {
1579
+ throw new Error("--json is not supported for `shell`");
1580
+ }
1581
+ return {
1582
+ kind: "shell",
1583
+ ...parseShellArgs(commandArgs)
1584
+ };
1585
+ }
1533
1586
  if (command === "prompt") {
1534
1587
  const sessionId = options.session;
1535
1588
  if (!sessionId) {
@@ -1653,31 +1706,43 @@ async function runCommand(argv) {
1653
1706
  const { options, rest } = parseGlobalArgs(argv);
1654
1707
  if (options.version) {
1655
1708
  printVersion();
1656
- return;
1709
+ return 0;
1657
1710
  }
1658
1711
  if (options.help || rest.length === 0) {
1659
1712
  printHelp();
1660
- return;
1713
+ return 0;
1661
1714
  }
1662
1715
  const config = parseConfig(options.configPath);
1663
- const client = new R5dctlClient(resolveClientOptions(options, config));
1716
+ const clientOptions = resolveClientOptions(options, config);
1717
+ const client = new R5dctlClient(clientOptions);
1664
1718
  const execution = resolveCommandExecution(options, rest);
1665
1719
  if (execution.kind === "auth-login") {
1666
1720
  await handleAuthLogin(client, options, execution.commandArgs, config);
1667
- return;
1721
+ return 0;
1668
1722
  }
1669
1723
  if (execution.kind === "auth-api-key-create") {
1670
1724
  await handleAuthApiKeyCreate(client, options, config, execution.commandArgs);
1671
- return;
1725
+ return 0;
1672
1726
  }
1673
1727
  if (execution.kind === "cli-help") {
1674
1728
  process.stdout.write(execution.text);
1675
- return;
1729
+ return 0;
1730
+ }
1731
+ if (execution.kind === "shell") {
1732
+ const project = await client.projects.describe(options.project);
1733
+ return await runR5dctlShell({
1734
+ baseUrl: clientOptions.baseUrl ?? "https://r5d.dev",
1735
+ credential: clientOptions.token ?? clientOptions.apiKey ?? "",
1736
+ projectId: project.id,
1737
+ branchName: options.branch,
1738
+ command: execution.command
1739
+ });
1676
1740
  }
1677
1741
  await executeR5dctlCommand(client, options.json, execution.pluginArgs);
1678
1742
  if (execution.clearAuthOnSuccess) {
1679
1743
  writeConfig(options.configPath, clearAuthFromConfig(config, options));
1680
1744
  }
1745
+ return 0;
1681
1746
  }
1682
1747
  function extractApiErrorMessage(error) {
1683
1748
  if (typeof error.body === "object" && error.body !== null) {
@@ -1690,8 +1755,7 @@ function extractApiErrorMessage(error) {
1690
1755
  }
1691
1756
  async function runR5dctlCli(argv) {
1692
1757
  try {
1693
- await runCommand(argv);
1694
- return 0;
1758
+ return await runCommand(argv);
1695
1759
  } catch (error) {
1696
1760
  if (error instanceof R5dctlApiError) {
1697
1761
  process.stderr.write(`${extractApiErrorMessage(error)}
@@ -1706,7 +1770,7 @@ async function runR5dctlCli(argv) {
1706
1770
  async function main(argv = process.argv.slice(2)) {
1707
1771
  const exitCode = await runR5dctlCli(argv);
1708
1772
  if (exitCode !== 0) {
1709
- process.exit(exitCode);
1773
+ process.exitCode = exitCode;
1710
1774
  }
1711
1775
  }
1712
1776
  export {
@@ -1719,6 +1783,7 @@ export {
1719
1783
  parseGlobalArgs,
1720
1784
  parsePromptArgs,
1721
1785
  parseSetEnvArgs,
1786
+ parseShellArgs,
1722
1787
  readDotenvFile,
1723
1788
  renderConversationResponse,
1724
1789
  resolveCommandExecution,
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5dctl",
3
- "version": "0.0.22",
3
+ "version": "0.0.23",
4
4
  "type": "module"
5
5
  }
@@ -0,0 +1,210 @@
1
+ import WebSocket, {} from "ws";
2
+ function clampTerminalSize(value, fallback) {
3
+ if (!Number.isFinite(value) || (value ?? 0) <= 0) {
4
+ return fallback;
5
+ }
6
+ return Math.max(1, Math.min(Math.floor(value), 500));
7
+ }
8
+ function createShellWebSocketUrl(baseUrl) {
9
+ const url = new URL("/ws", baseUrl);
10
+ if (url.protocol === "https:") {
11
+ url.protocol = "wss:";
12
+ } else if (url.protocol === "http:") {
13
+ url.protocol = "ws:";
14
+ } else {
15
+ throw new Error(`Unsupported r5d base URL protocol: ${url.protocol}`);
16
+ }
17
+ return url.toString();
18
+ }
19
+ function decodeMessage(data) {
20
+ if (typeof data === "string") {
21
+ return data;
22
+ }
23
+ if (Buffer.isBuffer(data)) {
24
+ return data.toString("utf8");
25
+ }
26
+ if (data instanceof ArrayBuffer) {
27
+ return Buffer.from(data).toString("utf8");
28
+ }
29
+ if (ArrayBuffer.isView(data)) {
30
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
31
+ }
32
+ if (Array.isArray(data)) {
33
+ return Buffer.concat(data).toString("utf8");
34
+ }
35
+ return String(data);
36
+ }
37
+ async function runR5dctlShell(options) {
38
+ const stdin = options.stdin ?? process.stdin;
39
+ const stdout = options.stdout ?? process.stdout;
40
+ const signalTarget = options.signalTarget ?? process;
41
+ const interactive = options.command === void 0;
42
+ if (interactive && !stdin.isTTY) {
43
+ throw new Error("Interactive shell requires a TTY. Use `shell -c <command>` for non-interactive use.");
44
+ }
45
+ if (!options.credential) {
46
+ throw new Error("Authentication required. Run `r5dctl auth login` or provide a token or API key.");
47
+ }
48
+ const createWebSocket = options.createWebSocket ?? ((url, clientOptions) => new WebSocket(url, clientOptions));
49
+ const socket = createWebSocket(createShellWebSocketUrl(options.baseUrl), {
50
+ headers: {
51
+ Authorization: `Bearer ${options.credential}`
52
+ }
53
+ });
54
+ return await new Promise((resolve, reject) => {
55
+ let ptyId;
56
+ let openRequested = false;
57
+ let settled = false;
58
+ let inputAttached = false;
59
+ const priorRawMode = stdin.isRaw ?? false;
60
+ const send = (message) => {
61
+ if (socket.readyState !== WebSocket.OPEN) {
62
+ return;
63
+ }
64
+ socket.send(JSON.stringify(message));
65
+ };
66
+ const sendResize = () => {
67
+ if (!ptyId) {
68
+ return;
69
+ }
70
+ send({
71
+ type: "shell_pty_resize",
72
+ ptyId,
73
+ cols: clampTerminalSize(stdout.columns, 80),
74
+ rows: clampTerminalSize(stdout.rows, 24)
75
+ });
76
+ };
77
+ const onInput = (chunk) => {
78
+ if (ptyId) {
79
+ send({ type: "shell_pty_input", ptyId, data: Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk });
80
+ }
81
+ };
82
+ const onInputEnd = () => {
83
+ if (!interactive && ptyId) {
84
+ send({ type: "shell_pty_input", ptyId, data: "" });
85
+ }
86
+ };
87
+ const onSigint = () => {
88
+ if (ptyId) {
89
+ send({ type: "shell_pty_input", ptyId, data: "" });
90
+ }
91
+ };
92
+ const onSigterm = () => {
93
+ if (ptyId) {
94
+ send({ type: "shell_pty_close", ptyId });
95
+ ptyId = void 0;
96
+ }
97
+ finish({ exitCode: 143 });
98
+ };
99
+ const cleanup = () => {
100
+ socket.off("message", onMessage);
101
+ socket.off("error", onSocketError);
102
+ socket.off("close", onSocketClose);
103
+ if (inputAttached) {
104
+ stdin.off("data", onInput);
105
+ stdin.off("end", onInputEnd);
106
+ }
107
+ signalTarget.off("SIGWINCH", sendResize);
108
+ signalTarget.off("SIGINT", onSigint);
109
+ signalTarget.off("SIGTERM", onSigterm);
110
+ if (stdin.isTTY && stdin.setRawMode) {
111
+ stdin.setRawMode(priorRawMode);
112
+ }
113
+ };
114
+ const finish = (result) => {
115
+ if (settled) {
116
+ return;
117
+ }
118
+ settled = true;
119
+ cleanup();
120
+ if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
121
+ socket.close(1e3, "r5dctl shell finished");
122
+ }
123
+ if ("error" in result) {
124
+ reject(result.error);
125
+ } else {
126
+ resolve(result.exitCode);
127
+ }
128
+ };
129
+ function attachInput() {
130
+ if (inputAttached) {
131
+ return;
132
+ }
133
+ inputAttached = true;
134
+ if (stdin.isTTY && stdin.setRawMode) {
135
+ stdin.setRawMode(true);
136
+ }
137
+ stdin.on("data", onInput);
138
+ stdin.on("end", onInputEnd);
139
+ signalTarget.on("SIGWINCH", sendResize);
140
+ signalTarget.on("SIGTERM", onSigterm);
141
+ if (!stdin.isTTY) {
142
+ signalTarget.on("SIGINT", onSigint);
143
+ }
144
+ }
145
+ function onMessage(data) {
146
+ let message;
147
+ try {
148
+ const parsed = JSON.parse(decodeMessage(data));
149
+ if (!parsed || typeof parsed !== "object" || !("type" in parsed)) {
150
+ throw new Error("Invalid message");
151
+ }
152
+ message = parsed;
153
+ } catch {
154
+ finish({ error: new Error("Received an invalid response from r5d.dev") });
155
+ return;
156
+ }
157
+ if (message.type === "connected") {
158
+ if (!openRequested) {
159
+ openRequested = true;
160
+ send({
161
+ type: "shell_pty_open",
162
+ projectId: options.projectId,
163
+ branchName: options.branchName,
164
+ cols: clampTerminalSize(stdout.columns, 80),
165
+ rows: clampTerminalSize(stdout.rows, 24),
166
+ ...options.command === void 0 ? {} : { command: options.command }
167
+ });
168
+ }
169
+ return;
170
+ }
171
+ if (message.type === "shell_pty_opened") {
172
+ ptyId = message.ptyId;
173
+ attachInput();
174
+ return;
175
+ }
176
+ if (message.type === "shell_pty_output") {
177
+ if (message.ptyId === ptyId) {
178
+ stdout.write(message.data);
179
+ }
180
+ return;
181
+ }
182
+ if (message.type === "shell_pty_exit") {
183
+ if (message.ptyId === ptyId) {
184
+ ptyId = void 0;
185
+ finish({ exitCode: message.exitCode });
186
+ }
187
+ return;
188
+ }
189
+ if (message.type === "shell_pty_error" && (!message.ptyId || message.ptyId === ptyId)) {
190
+ finish({ error: new Error(message.message) });
191
+ }
192
+ }
193
+ function onSocketError(error) {
194
+ finish({ error: new Error(`r5d.dev shell connection failed: ${error.message}`) });
195
+ }
196
+ function onSocketClose(code, reason) {
197
+ if (!settled) {
198
+ const suffix = reason.length > 0 ? `: ${reason.toString("utf8")}` : "";
199
+ finish({ error: new Error(`r5d.dev shell connection closed (${code})${suffix}`) });
200
+ }
201
+ }
202
+ socket.on("message", onMessage);
203
+ socket.on("error", onSocketError);
204
+ socket.on("close", onSocketClose);
205
+ });
206
+ }
207
+ export {
208
+ createShellWebSocketUrl,
209
+ runR5dctlShell
210
+ };
@@ -26,6 +26,9 @@ type CommandExecutionPlan = {
26
26
  } | {
27
27
  kind: "auth-api-key-create";
28
28
  commandArgs: string[];
29
+ } | {
30
+ kind: "shell";
31
+ command?: string;
29
32
  } | {
30
33
  kind: "cli-help";
31
34
  text: string;
@@ -55,6 +58,9 @@ export declare function parsePromptArgs(args: string[]): {
55
58
  model: ModelTier;
56
59
  message: string;
57
60
  };
61
+ export declare function parseShellArgs(args: string[]): {
62
+ command?: string;
63
+ };
58
64
  export type EnvSummaryData = Partial<Record<R5dctlEnvTarget, Record<string, {
59
65
  optional: boolean;
60
66
  description: string;
@@ -0,0 +1,26 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ import WebSocket, { type ClientOptions } from "ws";
3
+ type ShellInput = Readable & {
4
+ isTTY?: boolean;
5
+ isRaw?: boolean;
6
+ setRawMode?: (mode: boolean) => unknown;
7
+ };
8
+ type ShellOutput = Writable & {
9
+ columns?: number;
10
+ rows?: number;
11
+ };
12
+ export type ShellWebSocket = Pick<WebSocket, "readyState" | "send" | "close" | "on" | "off">;
13
+ export type RunR5dctlShellOptions = {
14
+ baseUrl: string;
15
+ credential: string;
16
+ projectId: string;
17
+ branchName: string;
18
+ command?: string;
19
+ stdin?: ShellInput;
20
+ stdout?: ShellOutput;
21
+ signalTarget?: Pick<NodeJS.Process, "on" | "off">;
22
+ createWebSocket?: (url: string, options: ClientOptions) => ShellWebSocket;
23
+ };
24
+ export declare function createShellWebSocketUrl(baseUrl: string): string;
25
+ export declare function runR5dctlShell(options: RunR5dctlShellOptions): Promise<number>;
26
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5dctl",
3
- "version": "0.0.22",
3
+ "version": "0.0.23",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/cli.cjs",
6
6
  "module": "./dist/mjs/cli.mjs",
@@ -26,8 +26,9 @@
26
26
  "r5dctl": "dist/cjs/main.cjs"
27
27
  },
28
28
  "dependencies": {
29
- "@ricsam/r5d-api": "^0.0.22",
30
- "dotenv": "^17"
29
+ "@ricsam/r5d-api": "^0.0.23",
30
+ "dotenv": "^17",
31
+ "ws": "^8.18.3"
31
32
  },
32
33
  "files": [
33
34
  "dist",