terminal-commands 0.1.1 → 0.1.2

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/src/agent-core.ts DELETED
@@ -1,173 +0,0 @@
1
- import os from "node:os";
2
-
3
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5
- import WebSocket, { type RawData } from "ws";
6
- import { z } from "zod";
7
-
8
- import type { AgentConfig } from "./agent-config.js";
9
-
10
- const destructiveTools = new Set(["run_program", "write_text_file"]);
11
- const requestSchema = z.object({
12
- type: z.literal("request"),
13
- requestId: z.string().uuid(),
14
- tool: z.enum([
15
- "get_system_info",
16
- "list_directory",
17
- "read_text_file",
18
- "run_program",
19
- "write_text_file",
20
- ]),
21
- arguments: z.record(z.string(), z.unknown()),
22
- });
23
-
24
- export interface RunAgentOptions {
25
- config: AgentConfig;
26
- getToken?: () => Promise<string>;
27
- approve?: (tool: string, args: Record<string, unknown>) => Promise<boolean>;
28
- signal?: AbortSignal;
29
- log?: (message: string) => void;
30
- }
31
-
32
- export async function authorizeToolCall(
33
- tool: string,
34
- args: Record<string, unknown>,
35
- allowDestructive: boolean,
36
- approve?: (tool: string, args: Record<string, unknown>) => Promise<boolean>,
37
- ): Promise<void> {
38
- if (!destructiveTools.has(tool)) return;
39
- if (!allowDestructive) {
40
- throw new Error(
41
- "This action requires local approval. Commands and writes are disabled unless explicitly enabled.",
42
- );
43
- }
44
- if (!approve || !(await approve(tool, args))) {
45
- throw new Error("The action was not approved on the connected machine.");
46
- }
47
- }
48
-
49
- async function connectOnce(options: RunAgentOptions): Promise<void> {
50
- const { config, signal } = options;
51
- const client = new Client({
52
- name: "terminal-commands-cli",
53
- version: "0.1.0",
54
- });
55
- await client.connect(
56
- new StreamableHTTPClientTransport(
57
- new URL(config.MACHINE_TERMINAL_LOCAL_MCP_URL),
58
- ),
59
- );
60
- const token = options.getToken
61
- ? await options.getToken()
62
- : config.MACHINE_TERMINAL_DEVICE_TOKEN;
63
- const socket = new WebSocket(config.MACHINE_TERMINAL_GATEWAY_URL, {
64
- headers: { Authorization: `Bearer ${token}` },
65
- });
66
- const stop = () => socket.close(1000, "Client stopping.");
67
- signal?.addEventListener("abort", stop, { once: true });
68
-
69
- socket.on("open", () => {
70
- socket.send(
71
- JSON.stringify({
72
- type: "hello",
73
- deviceId: config.MACHINE_TERMINAL_DEVICE_ID,
74
- name: config.MACHINE_TERMINAL_DEVICE_NAME,
75
- platform: `${os.platform()}-${os.arch()}`,
76
- }),
77
- );
78
- });
79
- socket.on("message", (raw: RawData) => {
80
- void handleMessage(options, client, socket, raw);
81
- });
82
-
83
- await new Promise<void>((resolve, reject) => {
84
- socket.once("close", resolve);
85
- socket.once("error", reject);
86
- }).finally(() => signal?.removeEventListener("abort", stop));
87
- await client.close();
88
- }
89
-
90
- async function handleMessage(
91
- options: RunAgentOptions,
92
- client: Client,
93
- socket: WebSocket,
94
- raw: RawData,
95
- ): Promise<void> {
96
- let requestId: string | undefined;
97
- try {
98
- const parsed = requestSchema.parse(JSON.parse(raw.toString()) as unknown);
99
- requestId = parsed.requestId;
100
- await authorizeToolCall(
101
- parsed.tool,
102
- parsed.arguments,
103
- options.config.MACHINE_TERMINAL_AGENT_ALLOW_DESTRUCTIVE === "1",
104
- options.approve,
105
- );
106
- const result = await client.callTool({
107
- name: parsed.tool,
108
- arguments: parsed.arguments,
109
- });
110
- if (result.isError) {
111
- const content = Array.isArray(result.content) ? result.content : [];
112
- const message = content
113
- .flatMap((item) =>
114
- typeof item === "object" &&
115
- item !== null &&
116
- "type" in item &&
117
- item.type === "text" &&
118
- "text" in item &&
119
- typeof item.text === "string"
120
- ? [item.text]
121
- : [],
122
- )
123
- .join("\n");
124
- throw new Error(message || "The local tool failed.");
125
- }
126
- if (socket.readyState === WebSocket.OPEN) {
127
- socket.send(
128
- JSON.stringify({
129
- type: "response",
130
- requestId,
131
- result: result.structuredContent ?? { content: result.content },
132
- }),
133
- );
134
- }
135
- } catch (error) {
136
- if (!requestId || socket.readyState !== WebSocket.OPEN) return;
137
- socket.send(
138
- JSON.stringify({
139
- type: "response",
140
- requestId,
141
- error: error instanceof Error ? error.message : "Local request failed.",
142
- }),
143
- );
144
- }
145
- }
146
-
147
- export async function runAgent(options: RunAgentOptions): Promise<void> {
148
- let reconnectDelayMs = 1_000;
149
- while (!options.signal?.aborted) {
150
- try {
151
- await connectOnce(options);
152
- reconnectDelayMs = 1_000;
153
- } catch (error) {
154
- if (options.signal?.aborted) break;
155
- options.log?.(
156
- `Connection failed: ${error instanceof Error ? error.message : "unknown error"}`,
157
- );
158
- }
159
- if (options.signal?.aborted) break;
160
- await new Promise<void>((resolve) => {
161
- const timer = setTimeout(resolve, reconnectDelayMs);
162
- options.signal?.addEventListener(
163
- "abort",
164
- () => {
165
- clearTimeout(timer);
166
- resolve();
167
- },
168
- { once: true },
169
- );
170
- });
171
- reconnectDelayMs = Math.min(reconnectDelayMs * 2, 30_000);
172
- }
173
- }
package/src/agent.ts DELETED
@@ -1,16 +0,0 @@
1
- import { runAgent } from "./agent-core.js";
2
- import { loadAgentConfig } from "./agent-config.js";
3
-
4
- const controller = new AbortController();
5
- const stop = () => controller.abort();
6
- process.on("SIGINT", stop);
7
- process.on("SIGTERM", stop);
8
-
9
- void runAgent({
10
- config: loadAgentConfig(),
11
- signal: controller.signal,
12
- log: (message) => console.error(`Terminal Commands: ${message}`),
13
- }).catch((error) => {
14
- console.error(error instanceof Error ? error.message : error);
15
- process.exitCode = 1;
16
- });
package/src/cli-format.ts DELETED
@@ -1,57 +0,0 @@
1
- const isColorEnabled =
2
- Boolean(process.stdout.isTTY) &&
3
- process.env.NO_COLOR === undefined &&
4
- process.env.TERM !== "dumb";
5
-
6
- const CODES = {
7
- reset: "\u001b[0m",
8
- bold: "\u001b[1m",
9
- dim: "\u001b[2m",
10
- red: "\u001b[31m",
11
- green: "\u001b[32m",
12
- yellow: "\u001b[33m",
13
- blue: "\u001b[34m",
14
- cyan: "\u001b[36m",
15
- gray: "\u001b[90m",
16
- } as const;
17
-
18
- type ColorName = keyof typeof CODES;
19
-
20
- function paint(code: ColorName, text: string): string {
21
- if (!isColorEnabled) return text;
22
- return `${CODES[code]}${text}${CODES.reset}`;
23
- }
24
-
25
- export const bold = (text: string) => paint("bold", text);
26
- export const dim = (text: string) => paint("dim", text);
27
- export const ok = (text: string) => paint("green", text);
28
- export const warn = (text: string) => paint("yellow", text);
29
- export const danger = (text: string) => paint("red", text);
30
- export const info = (text: string) => paint("cyan", text);
31
- export const muted = (text: string) => paint("gray", text);
32
-
33
- export function heading(text: string): string {
34
- const rule = "─".repeat(Math.max(text.length, 3));
35
- return `${bold(info(text))}\n${muted(rule)}`;
36
- }
37
-
38
- export function subheading(text: string): string {
39
- return bold(text);
40
- }
41
-
42
- export function bullet(text: string): string {
43
- return ` ${muted("•")} ${text}`;
44
- }
45
-
46
- /** Renders "label value" pairs with labels aligned to the widest label. */
47
- export function keyValueBlock(
48
- rows: ReadonlyArray<readonly [string, string]>,
49
- ): string {
50
- const width = rows.reduce(
51
- (widest, [label]) => Math.max(widest, label.length),
52
- 0,
53
- );
54
- return rows
55
- .map(([label, value]) => ` ${dim(label.padEnd(width))} ${value}`)
56
- .join("\n");
57
- }
@@ -1,459 +0,0 @@
1
- import { promises as fs } from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
-
5
- export const COMMANDS = [
6
- "login",
7
- "connect",
8
- "status",
9
- "logout",
10
- "help",
11
- ] as const;
12
-
13
- export type Command = (typeof COMMANDS)[number];
14
-
15
- export type OptionKind = "boolean" | "string" | "list";
16
-
17
- export interface OptionSpec {
18
- readonly name: string;
19
- readonly alias?: string;
20
- readonly kind: OptionKind;
21
- readonly env?: string;
22
- readonly placeholder?: string;
23
- readonly separator?: string;
24
- readonly description: string;
25
- readonly commands: readonly Command[];
26
- }
27
-
28
- export const COMMAND_SUMMARIES: Record<Exclude<Command, "help">, string> = {
29
- login: "Sign in using a URL and one-time code",
30
- connect: "Connect this machine until stopped",
31
- status: "Show sign-in state and the settings connect would use",
32
- logout: "Remove locally saved credentials",
33
- };
34
-
35
- const AUTH_COMMANDS = ["login", "connect", "status", "logout"] as const;
36
-
37
- export const OPTIONS: readonly OptionSpec[] = [
38
- {
39
- name: "root",
40
- alias: "r",
41
- kind: "list",
42
- env: "MACHINE_TERMINAL_ROOTS",
43
- placeholder: "<path>",
44
- separator: path.delimiter,
45
- commands: ["connect", "status"],
46
- description:
47
- "Approved filesystem root. Repeat the flag, or pass several paths separated by the platform path delimiter. Defaults to the current directory.",
48
- },
49
- {
50
- name: "name",
51
- kind: "string",
52
- env: "MACHINE_TERMINAL_DEVICE_NAME",
53
- placeholder: "<name>",
54
- commands: ["connect", "status"],
55
- description:
56
- "Device name shown in the client. Defaults to this machine's hostname.",
57
- },
58
- {
59
- name: "device-id",
60
- kind: "string",
61
- env: "MACHINE_TERMINAL_DEVICE_ID",
62
- placeholder: "<id>",
63
- commands: ["connect", "status"],
64
- description:
65
- "Device identifier to report. Defaults to the saved local device ID.",
66
- },
67
- {
68
- name: "port",
69
- kind: "string",
70
- env: "MACHINE_TERMINAL_PORT",
71
- placeholder: "<port>",
72
- commands: ["connect", "status"],
73
- description: "Port for the local MCP service. Defaults to 3333.",
74
- },
75
- {
76
- name: "host",
77
- kind: "string",
78
- env: "MACHINE_TERMINAL_HOST",
79
- placeholder: "<host>",
80
- commands: ["connect", "status"],
81
- description:
82
- "Bind address for the local MCP service. Defaults to 127.0.0.1.",
83
- },
84
- {
85
- name: "gateway-url",
86
- kind: "string",
87
- env: "MACHINE_TERMINAL_GATEWAY_URL",
88
- placeholder: "<url>",
89
- commands: ["connect", "status"],
90
- description:
91
- "Gateway WebSocket URL. Defaults to wss://mcp.terminalcommands.fr/device/connect.",
92
- },
93
- {
94
- name: "allow-destructive",
95
- kind: "boolean",
96
- env: "MACHINE_TERMINAL_AGENT_ALLOW_DESTRUCTIVE",
97
- commands: ["connect"],
98
- description:
99
- "Ask in this terminal before each command and file write. Without it both are refused.",
100
- },
101
- {
102
- name: "allow-shell",
103
- kind: "boolean",
104
- env: "MACHINE_TERMINAL_ALLOW_SHELL",
105
- commands: ["connect"],
106
- description:
107
- "Permit shells and privilege tools such as sudo. Leave off unless you need them.",
108
- },
109
- {
110
- name: "auto-approve",
111
- kind: "boolean",
112
- env: "MACHINE_TERMINAL_AUTO_APPROVE",
113
- commands: ["connect"],
114
- description:
115
- "Approve every command and file write automatically, without a terminal prompt. Implies --allow-destructive. Use with caution.",
116
- },
117
- {
118
- name: "allowed-program",
119
- kind: "list",
120
- env: "MACHINE_TERMINAL_ALLOWED_PROGRAMS",
121
- placeholder: "<name>",
122
- separator: ",",
123
- commands: ["connect"],
124
- description:
125
- "Executable allowlist. Repeat the flag or separate names with commas. Defaults to any program that is not blocked.",
126
- },
127
- {
128
- name: "allow-public-bind",
129
- kind: "boolean",
130
- env: "MACHINE_TERMINAL_ALLOW_PUBLIC_BIND",
131
- commands: ["connect"],
132
- description: "Permit a non-loopback bind. Only behind authenticated HTTPS.",
133
- },
134
- {
135
- name: "auth-issuer",
136
- kind: "string",
137
- env: "MACHINE_TERMINAL_AUTH_ISSUER",
138
- placeholder: "<url>",
139
- commands: AUTH_COMMANDS,
140
- description: "OAuth issuer. Change this only for a self-hosted gateway.",
141
- },
142
- {
143
- name: "auth-client-id",
144
- kind: "string",
145
- env: "MACHINE_TERMINAL_AUTH_CLIENT_ID",
146
- placeholder: "<id>",
147
- commands: AUTH_COMMANDS,
148
- description: "OAuth client ID used by the device authorization flow.",
149
- },
150
- {
151
- name: "auth-audience",
152
- kind: "string",
153
- env: "MACHINE_TERMINAL_AUTH_AUDIENCE",
154
- placeholder: "<url>",
155
- commands: AUTH_COMMANDS,
156
- description: "OAuth audience the access token is issued for.",
157
- },
158
- {
159
- name: "dry-run",
160
- kind: "boolean",
161
- commands: ["connect"],
162
- description:
163
- "Print the resolved settings and exit without starting anything.",
164
- },
165
- {
166
- name: "json",
167
- kind: "boolean",
168
- commands: ["connect", "status"],
169
- description: "Print machine-readable JSON instead of a summary.",
170
- },
171
- ];
172
-
173
- export interface ParsedCommandLine {
174
- readonly command: Command;
175
- readonly help: boolean;
176
- readonly options: ReadonlyMap<string, string[] | boolean>;
177
- }
178
-
179
- function findByName(name: string): OptionSpec | undefined {
180
- return OPTIONS.find((option) => option.name === name);
181
- }
182
-
183
- function findByAlias(alias: string): OptionSpec | undefined {
184
- return OPTIONS.find((option) => option.alias === alias);
185
- }
186
-
187
- function asCommand(token: string): Command {
188
- const match = COMMANDS.find((name) => name === token);
189
- if (!match) {
190
- throw new Error(
191
- `Unknown command: ${token}. Expected one of: ${COMMANDS.join(", ")}.`,
192
- );
193
- }
194
- return match;
195
- }
196
-
197
- function splitValues(value: string, separator?: string): string[] {
198
- const parts = separator ? value.split(separator) : [value];
199
- return parts.map((part) => part.trim()).filter(Boolean);
200
- }
201
-
202
- function readBoolean(value: string, name: string): boolean {
203
- const normalized = value.trim().toLowerCase();
204
- if (new Set(["1", "true", "yes", "on"]).has(normalized)) return true;
205
- if (new Set(["0", "false", "no", "off"]).has(normalized)) return false;
206
- throw new Error(`--${name} accepts true or false, not "${value}".`);
207
- }
208
-
209
- function missingValue(spec: OptionSpec): Error {
210
- return new Error(
211
- `Option --${spec.name} needs a value, for example --${spec.name} ${spec.placeholder ?? "<value>"}.`,
212
- );
213
- }
214
-
215
- export function parseCommandLine(argv: readonly string[]): ParsedCommandLine {
216
- const options = new Map<string, string[] | boolean>();
217
- const queue = [...argv];
218
- let command: Command | undefined;
219
- let help = false;
220
-
221
- const record = (spec: OptionSpec, value: string): void => {
222
- if (spec.kind !== "list") {
223
- options.set(spec.name, [value]);
224
- return;
225
- }
226
- const current = options.get(spec.name);
227
- const existing = Array.isArray(current) ? current : [];
228
- options.set(spec.name, [
229
- ...existing,
230
- ...splitValues(value, spec.separator),
231
- ]);
232
- };
233
-
234
- while (queue.length > 0) {
235
- const token = queue.shift() as string;
236
-
237
- if (token === "--help" || token === "-h") {
238
- help = true;
239
- continue;
240
- }
241
-
242
- if (token.startsWith("--")) {
243
- const body = token.slice(2);
244
- if (body.length === 0) {
245
- throw new Error("A bare `--` is not supported. Pass options directly.");
246
- }
247
- const equals = body.indexOf("=");
248
- const rawName = equals === -1 ? body : body.slice(0, equals);
249
- const inline = equals === -1 ? undefined : body.slice(equals + 1);
250
- const negated =
251
- findByName(rawName) === undefined && rawName.startsWith("no-");
252
- const name = negated ? rawName.slice(3) : rawName;
253
- const spec = findByName(name);
254
- if (!spec) {
255
- throw new Error(
256
- `Unknown option: --${rawName}. Run \`terminal-commands --help\` to see every option.`,
257
- );
258
- }
259
- if (spec.kind === "boolean") {
260
- if (inline === undefined) {
261
- options.set(spec.name, !negated);
262
- continue;
263
- }
264
- if (negated)
265
- throw new Error(`--no-${spec.name} does not take a value.`);
266
- options.set(spec.name, readBoolean(inline, spec.name));
267
- continue;
268
- }
269
- if (negated)
270
- throw new Error(`--no-${name} is only available for on/off options.`);
271
- const value = inline ?? queue.shift();
272
- if (
273
- value === undefined ||
274
- (inline === undefined && value.startsWith("--"))
275
- ) {
276
- throw missingValue(spec);
277
- }
278
- record(spec, value);
279
- continue;
280
- }
281
-
282
- if (token.startsWith("-") && token.length > 1) {
283
- const body = token.slice(1);
284
- const alias = body.slice(0, 1);
285
- const spec = findByAlias(alias);
286
- if (!spec) {
287
- throw new Error(
288
- `Unknown option: -${alias}. Run \`terminal-commands --help\` to see every option.`,
289
- );
290
- }
291
- const attached = body.slice(1).replace(/^=/, "");
292
- if (spec.kind === "boolean") {
293
- if (attached.length > 0)
294
- throw new Error(`-${alias} does not take a value.`);
295
- options.set(spec.name, true);
296
- continue;
297
- }
298
- const value = attached.length > 0 ? attached : queue.shift();
299
- if (
300
- value === undefined ||
301
- (attached.length === 0 && value.startsWith("--"))
302
- ) {
303
- throw missingValue(spec);
304
- }
305
- record(spec, value);
306
- continue;
307
- }
308
-
309
- if (command !== undefined) throw new Error(`Unexpected argument: ${token}`);
310
- command = asCommand(token);
311
- }
312
-
313
- const resolved = command ?? "help";
314
- if (resolved !== "help") {
315
- for (const name of options.keys()) {
316
- const spec = findByName(name);
317
- if (!spec || spec.commands.includes(resolved)) continue;
318
- throw new Error(
319
- `--${name} is not available for \`${resolved}\`. It applies to: ${spec.commands.join(", ")}.`,
320
- );
321
- }
322
- }
323
-
324
- return { command: resolved, help: help || resolved === "help", options };
325
- }
326
-
327
- /**
328
- * Turn parsed options into environment overrides. A flag replaces the matching
329
- * environment variable outright, so --root never appends to MACHINE_TERMINAL_ROOTS.
330
- */
331
- export function optionEnvironment(
332
- parsed: ParsedCommandLine,
333
- ): Record<string, string> {
334
- const environment: Record<string, string> = {};
335
- for (const spec of OPTIONS) {
336
- if (!spec.env) continue;
337
- const value = parsed.options.get(spec.name);
338
- if (value === undefined) continue;
339
- if (typeof value === "boolean") {
340
- environment[spec.env] = value ? "1" : "0";
341
- continue;
342
- }
343
- if (value.length > 0)
344
- environment[spec.env] = value.join(spec.separator ?? ",");
345
- }
346
- return environment;
347
- }
348
-
349
- export function expandHome(value: string, home = os.homedir()): string {
350
- if (value === "~") return home;
351
- if (value.startsWith(`~${path.sep}`) || value.startsWith("~/")) {
352
- return path.join(home, value.slice(2));
353
- }
354
- return value;
355
- }
356
-
357
- export function splitRootList(raw: string | undefined): string[] {
358
- if (!raw) return [];
359
- return raw
360
- .split(path.delimiter)
361
- .map((part) => part.trim())
362
- .filter(Boolean);
363
- }
364
-
365
- export function normalizeRoots(
366
- values: readonly string[],
367
- cwd = process.cwd(),
368
- ): string[] {
369
- const resolved = values.map((value) => path.resolve(cwd, expandHome(value)));
370
- return [...new Set(resolved)];
371
- }
372
-
373
- export async function assertRootsExist(
374
- roots: readonly string[],
375
- ): Promise<void> {
376
- const problems: string[] = [];
377
- for (const root of roots) {
378
- try {
379
- const stats = await fs.stat(root);
380
- if (!stats.isDirectory()) problems.push(`${root} is not a directory`);
381
- } catch {
382
- problems.push(`${root} does not exist`);
383
- }
384
- }
385
- if (problems.length > 0) {
386
- throw new Error(
387
- `Cannot approve these roots:\n - ${problems.join("\n - ")}`,
388
- );
389
- }
390
- }
391
-
392
- function flagLabel(spec: OptionSpec): string {
393
- const alias = spec.alias ? `-${spec.alias}, ` : "";
394
- const value =
395
- spec.kind === "boolean" ? "" : ` ${spec.placeholder ?? "<value>"}`;
396
- return `${alias}--${spec.name}${value}`;
397
- }
398
-
399
- function renderOptionLines(specs: readonly OptionSpec[]): string[] {
400
- const labels = specs.map((spec) => flagLabel(spec));
401
- const width = labels.reduce(
402
- (widest, label) => Math.max(widest, label.length),
403
- 0,
404
- );
405
- return specs.map((spec, index) => {
406
- const label = (labels[index] ?? "").padEnd(width);
407
- const environment = spec.env ? ` [${spec.env}]` : "";
408
- return ` ${label} ${spec.description}${environment}`;
409
- });
410
- }
411
-
412
- export function renderHelp(command: Command = "help"): string {
413
- const lines: string[] = [];
414
-
415
- if (command === "help") {
416
- lines.push(
417
- "Terminal Commands CLI",
418
- "",
419
- "Usage:",
420
- " terminal-commands <command> [options]",
421
- " npm run cli -- <command> [options]",
422
- "",
423
- "Commands:",
424
- );
425
- for (const [name, summary] of Object.entries(COMMAND_SUMMARIES)) {
426
- lines.push(` ${name.padEnd(9)}${summary}`);
427
- }
428
- lines.push("", "Options:", ...renderOptionLines(OPTIONS));
429
- } else {
430
- const relevant = OPTIONS.filter((option) =>
431
- option.commands.includes(command),
432
- );
433
- lines.push(
434
- `terminal-commands ${command} - ${COMMAND_SUMMARIES[command]}`,
435
- "",
436
- "Usage:",
437
- ` terminal-commands ${command} [options]`,
438
- "",
439
- );
440
- if (relevant.length > 0)
441
- lines.push("Options:", ...renderOptionLines(relevant));
442
- else lines.push("This command takes no options.");
443
- }
444
-
445
- lines.push(
446
- "",
447
- "Each option also reads the environment variable in brackets. A flag beats the",
448
- "environment, and the environment beats the default. Use --no-<option> to turn an",
449
- "on/off option back off when the environment sets it.",
450
- "",
451
- "Examples:",
452
- " terminal-commands connect --root ~/Documents/GitHub --root ~/work",
453
- " terminal-commands connect --root . --dry-run",
454
- " terminal-commands connect --root ~/code --name laptop --allow-destructive",
455
- " npm run cli -- connect --root ~/Documents/GitHub --allowed-program git,node",
456
- );
457
-
458
- return lines.join("\n");
459
- }