terminal-commands 0.1.1

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/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "terminal-commands",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "A cross-platform, approval-gated terminal companion for ChatGPT, Claude, Codex, and any MCP-compatible AI client.",
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
10
+ "bin": {
11
+ "terminal-commands": "dist/cli.js"
12
+ },
13
+ "scripts": {
14
+ "build": "esbuild src/index.ts src/stdio.ts src/agent.ts src/cli.ts --bundle --platform=node --format=esm --target=node20 --outdir=dist --banner:js=\"import { createRequire as __createRequire } from 'module'; const require = __createRequire(import.meta.url);\"",
15
+ "check": "tsc --noEmit",
16
+ "dev": "tsx src/index.ts",
17
+ "start": "node dist/index.js",
18
+ "start:agent": "node dist/agent.js",
19
+ "cli": "node dist/cli.js",
20
+ "cli:dev": "tsx src/cli.ts",
21
+ "test": "tsx --test test/**/*.test.ts",
22
+ "test:smoke": "node test/smoke.mjs"
23
+ },
24
+ "dependencies": {
25
+ "@modelcontextprotocol/sdk": "1.30.0",
26
+ "express": "5.2.1",
27
+ "ws": "8.21.3",
28
+ "zod": "4.6.4"
29
+ },
30
+ "devDependencies": {
31
+ "@types/express": "5.0.6",
32
+ "@types/node": "26.5.1",
33
+ "@types/ws": "8.18.1",
34
+ "esbuild": "0.28.2",
35
+ "tsx": "4.23.13",
36
+ "typescript": "7.0.2"
37
+ }
38
+ }
@@ -0,0 +1,40 @@
1
+ import os from "node:os";
2
+
3
+ import { z } from "zod";
4
+
5
+ const schema = z.object({
6
+ MACHINE_TERMINAL_GATEWAY_URL: z.string().url(),
7
+ MACHINE_TERMINAL_DEVICE_TOKEN: z.string().min(1),
8
+ MACHINE_TERMINAL_DEVICE_ID: z.string().min(1).max(128),
9
+ MACHINE_TERMINAL_DEVICE_NAME: z
10
+ .string()
11
+ .min(1)
12
+ .max(128)
13
+ .default(os.hostname()),
14
+ MACHINE_TERMINAL_LOCAL_MCP_URL: z
15
+ .string()
16
+ .url()
17
+ .default("http://127.0.0.1:3333/mcp"),
18
+ MACHINE_TERMINAL_AGENT_ALLOW_DESTRUCTIVE: z.enum(["0", "1"]).default("0"),
19
+ });
20
+
21
+ export type AgentConfig = z.infer<typeof schema>;
22
+
23
+ export function loadAgentConfig(
24
+ environment: NodeJS.ProcessEnv = process.env,
25
+ ): AgentConfig {
26
+ const config = schema.parse(environment);
27
+ const gateway = new URL(config.MACHINE_TERMINAL_GATEWAY_URL);
28
+ if (!new Set(["wss:", "ws:"]).has(gateway.protocol)) {
29
+ throw new Error("MACHINE_TERMINAL_GATEWAY_URL must use wss:// or ws://.");
30
+ }
31
+ if (
32
+ gateway.protocol === "ws:" &&
33
+ !new Set(["127.0.0.1", "localhost", "::1"]).has(gateway.hostname)
34
+ ) {
35
+ throw new Error(
36
+ "Unencrypted ws:// is allowed only for loopback development.",
37
+ );
38
+ }
39
+ return config;
40
+ }
@@ -0,0 +1,173 @@
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 ADDED
@@ -0,0 +1,16 @@
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
+ });
@@ -0,0 +1,57 @@
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
+ }