@hermit-org/cli 0.0.1-alpha.0

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,73 @@
1
+ # `@hermit-org/cli`
2
+
3
+ Bun CLI for Hermit. Manages local ACP agents, exposes them as an SSE gateway,
4
+ and handles mobile device pairing.
5
+
6
+ ## Commands
7
+
8
+ ```bash
9
+ # Show help
10
+ bun packages/cli/src/index.ts --help
11
+
12
+ # Generate a pairing code
13
+ bun packages/cli/src/index.ts pair
14
+
15
+ # Start the gateway
16
+ bun packages/cli/src/index.ts start
17
+ ```
18
+
19
+ ## Configuration
20
+
21
+ The CLI reads `hermit.config.json` from the current working directory.
22
+
23
+ ```json
24
+ {
25
+ "agent": {
26
+ "command": "npx",
27
+ "args": ["codex", "--acp"]
28
+ },
29
+ "gateway": {
30
+ "port": 8787,
31
+ "hostname": "0.0.0.0",
32
+ "endpoint": "/",
33
+ "sendEndpoint": "/send",
34
+ "heartbeatInterval": 30000,
35
+ "cors": true,
36
+ "timeout": 0
37
+ },
38
+ "authorizedTokens": []
39
+ }
40
+ ```
41
+
42
+ ## Gateway endpoints
43
+
44
+ When running `start`, the CLI exposes:
45
+
46
+ - `GET/POST /` — SSE stream of the agent stdout (requires bearer token)
47
+ - `POST /send` — write request body to agent stdin (requires bearer token)
48
+ - `POST /pair` — exchange a 6-digit pairing code for a bearer token
49
+
50
+ ## Pairing flow
51
+
52
+ 1. Run `bun packages/cli/src/index.ts pair`.
53
+ 2. Enter the displayed 6-digit code in the Hermit mobile app.
54
+ 3. The app calls `POST /pair` and receives a bearer token.
55
+ 4. The token is persisted in `~/.hermit/authorized-tokens.json`.
56
+
57
+ ## Programmatic usage
58
+
59
+ ```ts
60
+ import { AcpGatewayServer } from "@hermit-org/cli/src/lib/gateway";
61
+
62
+ const server = new AcpGatewayServer({
63
+ command: "npx",
64
+ args: ["codex", "--acp"],
65
+ port: 8787,
66
+ onRequest: async (req, res) => {
67
+ // Custom auth / routing logic
68
+ return false;
69
+ },
70
+ });
71
+
72
+ const { url, stop } = await server.start();
73
+ ```
@@ -0,0 +1,72 @@
1
+ # `@hermit-org/cli`
2
+
3
+ Hermit 的 Bun CLI。管理本地 ACP Agent,将其暴露为 SSE Gateway,并处理移动设备配对。
4
+
5
+ ## 命令
6
+
7
+ ```bash
8
+ # 显示帮助
9
+ bun packages/cli/src/index.ts --help
10
+
11
+ # 生成配对码
12
+ bun packages/cli/src/index.ts pair
13
+
14
+ # 启动 Gateway
15
+ bun packages/cli/src/index.ts start
16
+ ```
17
+
18
+ ## 配置
19
+
20
+ CLI 会从当前工作目录读取 `hermit.config.json`。
21
+
22
+ ```json
23
+ {
24
+ "agent": {
25
+ "command": "npx",
26
+ "args": ["codex", "--acp"]
27
+ },
28
+ "gateway": {
29
+ "port": 8787,
30
+ "hostname": "0.0.0.0",
31
+ "endpoint": "/",
32
+ "sendEndpoint": "/send",
33
+ "heartbeatInterval": 30000,
34
+ "cors": true,
35
+ "timeout": 0
36
+ },
37
+ "authorizedTokens": []
38
+ }
39
+ ```
40
+
41
+ ## Gateway 端点
42
+
43
+ 执行 `start` 后,CLI 暴露以下端点:
44
+
45
+ - `GET/POST /` —— Agent stdout 的 SSE 流(需要 Bearer token)
46
+ - `POST /send` —— 将请求体写入 Agent stdin(需要 Bearer token)
47
+ - `POST /pair` —— 用 6 位配对码换取 Bearer token
48
+
49
+ ## 配对流程
50
+
51
+ 1. 运行 `bun packages/cli/src/index.ts pair`。
52
+ 2. 在 Hermit 移动应用的 ServerList 页面输入显示的 6 位配对码。
53
+ 3. 应用调用 `POST /pair` 获取 Bearer token。
54
+ 4. Token 持久化到 `~/.hermit/authorized-tokens.json`。
55
+
56
+ ## 编程方式使用
57
+
58
+ ```ts
59
+ import { AcpGatewayServer } from "@hermit-org/cli/src/lib/gateway";
60
+
61
+ const server = new AcpGatewayServer({
62
+ command: "npx",
63
+ args: ["codex", "--acp"],
64
+ port: 8787,
65
+ onRequest: async (req, res) => {
66
+ // 自定义认证 / 路由逻辑
67
+ return false;
68
+ },
69
+ });
70
+
71
+ const { url, stop } = await server.start();
72
+ ```
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@hermit-org/cli",
3
+ "version": "0.0.1-alpha.0",
4
+ "type": "module",
5
+ "module": "src/index.ts",
6
+ "bin": {
7
+ "hermit": "./src/index.ts"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.ts"
11
+ },
12
+ "dependencies": {
13
+ "@hermit-org/types": "^0.0.1-alpha.0",
14
+ "@hermit-org/utils": "^0.0.1-alpha.0",
15
+ "@hermit-org/stdio-to-sse": "^0.0.1-alpha.0",
16
+ "commander": "^13.0.0",
17
+ "qrcode": "^1.5.4"
18
+ },
19
+ "devDependencies": {
20
+ "@types/qrcode": "^1.5.5"
21
+ }
22
+ }
@@ -0,0 +1,54 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { Command } from "commander";
4
+
5
+ async function scanCommandsDir(dir: string): Promise<Command[]> {
6
+ const commands: Command[] = [];
7
+ const entries = await readdir(dir, { withFileTypes: true });
8
+
9
+ for (const entry of entries) {
10
+ const fullPath = join(dir, entry.name);
11
+
12
+ if (entry.isDirectory()) {
13
+ let parent: Command | undefined;
14
+
15
+ try {
16
+ const mod = await import(join(fullPath, "index.ts"));
17
+ if (mod.command instanceof Command) {
18
+ parent = mod.command;
19
+ }
20
+ } catch {
21
+ // no index.ts
22
+ }
23
+
24
+ if (!parent) {
25
+ parent = new Command(entry.name);
26
+ }
27
+
28
+ const children = await scanCommandsDir(fullPath);
29
+ for (const child of children) {
30
+ parent.addCommand(child);
31
+ }
32
+
33
+ commands.push(parent);
34
+ } else if (
35
+ entry.isFile() &&
36
+ entry.name.endsWith(".ts") &&
37
+ entry.name !== "index.ts"
38
+ ) {
39
+ const mod = await import(fullPath);
40
+ if (mod.command instanceof Command) {
41
+ commands.push(mod.command);
42
+ }
43
+ }
44
+ }
45
+
46
+ return commands;
47
+ }
48
+
49
+ export async function loadCommands(program: Command): Promise<void> {
50
+ const commands = await scanCommandsDir(import.meta.dir);
51
+ for (const cmd of commands) {
52
+ program.addCommand(cmd);
53
+ }
54
+ }
@@ -0,0 +1,95 @@
1
+ import { Command } from "commander";
2
+ import { networkInterfaces } from "node:os";
3
+ import { createPendingPair } from "../lib/pairing";
4
+ import { loadConfig } from "../lib/config";
5
+ import { generateQrTerminal, encodeConnectionPayload } from "../lib/qr";
6
+ import type { ConnectionPayload } from "../lib/gateway";
7
+
8
+ const VIRTUAL_IFACE_PATTERNS = [
9
+ /^docker/i,
10
+ /^veth/i,
11
+ /^br-/i,
12
+ /^tun/i,
13
+ /^tap/i,
14
+ /^vmnet/i,
15
+ /^vboxnet/i,
16
+ /^hyper-v/i,
17
+ /^lo$/i,
18
+ ];
19
+
20
+ const PREFERRED_IFACE_PATTERNS = [
21
+ /^wlan/i,
22
+ /^en[0-9]/i,
23
+ /^eth[0-9]/i,
24
+ /^Wi-?Fi/i,
25
+ /^Ethernet/i,
26
+ ];
27
+
28
+ function isVirtualInterface(name: string): boolean {
29
+ return VIRTUAL_IFACE_PATTERNS.some((pattern) => pattern.test(name));
30
+ }
31
+
32
+ function getInterfacePriority(name: string): number {
33
+ for (let i = 0; i < PREFERRED_IFACE_PATTERNS.length; i++) {
34
+ if (PREFERRED_IFACE_PATTERNS[i].test(name)) {
35
+ return i;
36
+ }
37
+ }
38
+ return PREFERRED_IFACE_PATTERNS.length;
39
+ }
40
+
41
+ function getLanAddress(port: number): string {
42
+ const interfaces = networkInterfaces();
43
+ const candidates: { name: string; address: string }[] = [];
44
+
45
+ for (const [name, entries] of Object.entries(interfaces)) {
46
+ if (isVirtualInterface(name)) continue;
47
+ for (const iface of entries ?? []) {
48
+ if (iface.family === "IPv4" && !iface.internal) {
49
+ candidates.push({ name, address: iface.address });
50
+ }
51
+ }
52
+ }
53
+
54
+ if (candidates.length === 0) {
55
+ return `http://localhost:${port}`;
56
+ }
57
+
58
+ candidates.sort((a, b) => getInterfacePriority(a.name) - getInterfacePriority(b.name));
59
+
60
+ return `http://${candidates[0].address}:${port}`;
61
+ }
62
+
63
+ async function pairAction(): Promise<void> {
64
+ const { code, token } = await createPendingPair();
65
+ const config = await loadConfig();
66
+
67
+ const sseEndpoint = config.gateway?.endpoint || "/";
68
+ const sendEndpoint = sseEndpoint === "/" ? "/send" : `${sseEndpoint}/send`;
69
+ const port = config.gateway?.port ?? 8787;
70
+
71
+ const qrPayload: ConnectionPayload = {
72
+ url: getLanAddress(port) + sseEndpoint,
73
+ sendUrl: getLanAddress(port) + sendEndpoint,
74
+ token,
75
+ };
76
+
77
+ console.log("Hermit pairing initiated.");
78
+ console.log("");
79
+ console.log(`Pairing code : ${code}`);
80
+ console.log(`Bearer token : ${token}`);
81
+ console.log("");
82
+ console.log("Scan the QR code with Hermit mobile app to connect:");
83
+ console.log(await generateQrTerminal(qrPayload));
84
+ console.log("\nOr paste this connection string:");
85
+ console.log(encodeConnectionPayload(qrPayload));
86
+ console.log("");
87
+ console.log(
88
+ "Enter the pairing code in the Hermit mobile app to authorize this gateway.",
89
+ );
90
+ console.log("This code expires in 5 minutes.");
91
+ }
92
+
93
+ export const command = new Command("pair")
94
+ .description("Generate a pairing code for a mobile client")
95
+ .action(pairAction);
@@ -0,0 +1,247 @@
1
+ import { Command } from "commander";
2
+ import { AcpGatewayServer, type ConnectionPayload } from "../../lib/gateway";
3
+ import { loadConfig, type HermitConfig } from "../../lib/config";
4
+ import {
5
+ validatePairingCode,
6
+ isTokenAuthorized,
7
+ generateToken,
8
+ authorizeToken,
9
+ } from "../../lib/pairing";
10
+ import { generateQrTerminal, encodeConnectionPayload } from "../../lib/qr";
11
+ import type { IncomingMessage, ServerResponse } from "node:http";
12
+ import { networkInterfaces } from "node:os";
13
+
14
+ function extractBearer(req: IncomingMessage): string | undefined {
15
+ const auth = req.headers.authorization ?? "";
16
+ if (auth.startsWith("Bearer ")) {
17
+ return auth.slice(7);
18
+ }
19
+ return undefined;
20
+ }
21
+
22
+ async function readBody(req: IncomingMessage): Promise<string> {
23
+ const chunks: Buffer[] = [];
24
+ return new Promise((resolve, reject) => {
25
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
26
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
27
+ req.on("error", reject);
28
+ });
29
+ }
30
+
31
+ function sendJson(
32
+ res: ServerResponse,
33
+ status: number,
34
+ payload: unknown,
35
+ cors = true,
36
+ ): void {
37
+ const headers: Record<string, string> = {
38
+ "Content-Type": "application/json",
39
+ };
40
+ if (cors) {
41
+ headers["Access-Control-Allow-Origin"] = "*";
42
+ headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS";
43
+ headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization";
44
+ }
45
+ res.writeHead(status, headers);
46
+ res.end(JSON.stringify(payload));
47
+ }
48
+
49
+ // Virtual/Docker interfaces that should not be advertised to mobile clients.
50
+ const VIRTUAL_IFACE_PATTERNS = [
51
+ /^docker/i,
52
+ /^veth/i,
53
+ /^br-/i,
54
+ /^tun/i,
55
+ /^tap/i,
56
+ /^vmnet/i,
57
+ /^vboxnet/i,
58
+ /^hyper-v/i,
59
+ /^lo$/i,
60
+ ];
61
+
62
+ // Interfaces commonly used for Wi-Fi / Ethernet, ordered by preference.
63
+ const PREFERRED_IFACE_PATTERNS = [
64
+ /^wlan/i,
65
+ /^en[0-9]/i,
66
+ /^eth[0-9]/i,
67
+ /^Wi-?Fi/i,
68
+ /^Ethernet/i,
69
+ ];
70
+
71
+ function isVirtualInterface(name: string): boolean {
72
+ return VIRTUAL_IFACE_PATTERNS.some((pattern) => pattern.test(name));
73
+ }
74
+
75
+ function getInterfacePriority(name: string): number {
76
+ for (let i = 0; i < PREFERRED_IFACE_PATTERNS.length; i++) {
77
+ if (PREFERRED_IFACE_PATTERNS[i].test(name)) {
78
+ return i;
79
+ }
80
+ }
81
+ return PREFERRED_IFACE_PATTERNS.length;
82
+ }
83
+
84
+ function getLanAddress(port: number): string {
85
+ const interfaces = networkInterfaces();
86
+ const candidates: { name: string; address: string }[] = [];
87
+
88
+ for (const [name, entries] of Object.entries(interfaces)) {
89
+ if (isVirtualInterface(name)) continue;
90
+ for (const iface of entries ?? []) {
91
+ if (iface.family === "IPv4" && !iface.internal) {
92
+ candidates.push({ name, address: iface.address });
93
+ }
94
+ }
95
+ }
96
+
97
+ if (candidates.length === 0) {
98
+ return `http://localhost:${port}`;
99
+ }
100
+
101
+ // Sort by preferred interface names (lower priority number = better).
102
+ candidates.sort((a, b) => getInterfacePriority(a.name) - getInterfacePriority(b.name));
103
+
104
+ return `http://${candidates[0].address}:${port}`;
105
+ }
106
+
107
+ async function startServer(config: HermitConfig, webClientUrl?: string): Promise<void> {
108
+ const { agent, gateway } = config;
109
+
110
+ const sseEndpoint = gateway!.endpoint || "/";
111
+ const sendEndpoint = sseEndpoint === "/" ? "/send" : `${sseEndpoint}/send`;
112
+ const port = gateway!.port ?? 8787;
113
+
114
+ // Create a persistent bearer token for QR/auto-connect.
115
+ const token = generateToken();
116
+ await authorizeToken(token);
117
+
118
+ const server = new AcpGatewayServer({
119
+ command: agent!.command,
120
+ args: agent!.args,
121
+ port,
122
+ hostname: gateway!.hostname,
123
+ endpoint: sseEndpoint,
124
+ sendEndpoint,
125
+ cors: gateway!.cors,
126
+ heartbeatInterval: gateway!.heartbeatInterval,
127
+ getQrPayload: (): ConnectionPayload => {
128
+ const url = getLanAddress(port) + sseEndpoint;
129
+ const sendUrl = getLanAddress(port) + sendEndpoint;
130
+ return { url, sendUrl, token };
131
+ },
132
+ onRequest: async (req, res) => {
133
+ // Read-only connection info endpoint. The web client uses this to
134
+ // pre-fill its connection form from `hermit.config.json` when the page
135
+ // is opened without URL params. No token is exposed here.
136
+ if (gateway!.cors && req.method === "OPTIONS" && req.url === "/api/config") {
137
+ res.writeHead(204, {
138
+ "Access-Control-Allow-Origin": "*",
139
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
140
+ "Access-Control-Allow-Headers": "Content-Type",
141
+ });
142
+ res.end();
143
+ return true;
144
+ }
145
+
146
+ if (req.method === "GET" && req.url === "/api/config") {
147
+ sendJson(res, 200, {
148
+ agent: { command: agent!.command, args: agent!.args ?? [] },
149
+ gateway: {
150
+ url: getLanAddress(port) + sseEndpoint,
151
+ sendUrl: getLanAddress(port) + sendEndpoint,
152
+ endpoint: sseEndpoint,
153
+ port,
154
+ },
155
+ });
156
+ return true;
157
+ }
158
+
159
+ // CORS preflight for the pairing endpoint.
160
+ if (gateway!.cors && req.method === "OPTIONS" && req.url === "/pair") {
161
+ res.writeHead(204, {
162
+ "Access-Control-Allow-Origin": "*",
163
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
164
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
165
+ });
166
+ res.end();
167
+ return true;
168
+ }
169
+
170
+ if (req.method === "POST" && req.url === "/pair") {
171
+ const body = await readBody(req);
172
+ const { code } = JSON.parse(body || "{}") as { code?: string };
173
+ const token = code ? await validatePairingCode(code) : null;
174
+
175
+ if (!token) {
176
+ sendJson(res, 401, { ok: false, error: "Invalid or expired pairing code" });
177
+ return true;
178
+ }
179
+
180
+ sendJson(res, 200, { ok: true, token });
181
+ return true;
182
+ }
183
+
184
+ // Require a valid bearer token for the SSE and send endpoints.
185
+ const protectedPaths = [sseEndpoint, sendEndpoint];
186
+ if (protectedPaths.includes(req.url ?? "") && (req.method === "GET" || req.method === "POST")) {
187
+ const token = extractBearer(req);
188
+ if (!token || !(await isTokenAuthorized(token))) {
189
+ sendJson(res, 401, { ok: false, error: "Unauthorized" });
190
+ return true;
191
+ }
192
+ return false;
193
+ }
194
+
195
+ return false;
196
+ },
197
+ });
198
+
199
+ const { url, stop } = await server.start();
200
+ const qrPayload: ConnectionPayload = {
201
+ url: getLanAddress(port) + sseEndpoint,
202
+ sendUrl: getLanAddress(port) + sendEndpoint,
203
+ token,
204
+ };
205
+
206
+ console.log(`Hermit gateway listening at ${url}`);
207
+ console.log(`Send endpoint: ${sendEndpoint}`);
208
+ console.log(`Agent: ${agent!.command} ${(agent!.args ?? []).join(" ")}`);
209
+ console.log("\nScan the QR code with Hermit mobile app to connect:");
210
+ console.log(await generateQrTerminal(qrPayload));
211
+ console.log("\nOr paste this connection string:");
212
+ console.log(encodeConnectionPayload(qrPayload));
213
+
214
+ if (webClientUrl) {
215
+ const params = new URLSearchParams({
216
+ name: "Hermit Gateway",
217
+ url: qrPayload.url,
218
+ sendUrl: qrPayload.sendUrl,
219
+ token: qrPayload.token,
220
+ });
221
+ const base = webClientUrl.replace(/[?#].*$/, "");
222
+ console.log(`\nOpen the web client (connection pre-configured):`);
223
+ console.log(`${base}?${params.toString()}`);
224
+ }
225
+
226
+ console.log("\nPress Ctrl+C to stop");
227
+
228
+ process.once("SIGINT", async () => {
229
+ console.log("\nShutting down...");
230
+ await stop();
231
+ process.exit(0);
232
+ });
233
+ }
234
+
235
+ async function startAction(options: { web?: string }): Promise<void> {
236
+ const config = await loadConfig();
237
+ await startServer(config, options.web);
238
+ }
239
+
240
+ export const command = new Command("start")
241
+ .description("Start the Hermit gateway (ACP agent -> SSE)")
242
+ .option(
243
+ "--web <url>",
244
+ "web client base URL; prints a pre-configured link when set",
245
+ "http://localhost:5180",
246
+ )
247
+ .action(startAction);
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bun
2
+ import { Command } from "commander";
3
+ import pkg from "../package.json";
4
+ import { loadCommands } from "./commands";
5
+
6
+ const { version } = pkg;
7
+
8
+ const program = new Command();
9
+
10
+ program
11
+ .name("hermit")
12
+ .description("Hermit CLI")
13
+ .version(version);
14
+
15
+ await loadCommands(program);
16
+
17
+ program.parse();