linkshell-cli 0.1.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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -0
  3. package/dist/cli/src/commands/doctor.d.ts +1 -0
  4. package/dist/cli/src/commands/doctor.js +112 -0
  5. package/dist/cli/src/commands/doctor.js.map +1 -0
  6. package/dist/cli/src/commands/setup.d.ts +1 -0
  7. package/dist/cli/src/commands/setup.js +48 -0
  8. package/dist/cli/src/commands/setup.js.map +1 -0
  9. package/dist/cli/src/config.d.ts +12 -0
  10. package/dist/cli/src/config.js +23 -0
  11. package/dist/cli/src/config.js.map +1 -0
  12. package/dist/cli/src/index.d.ts +2 -0
  13. package/dist/cli/src/index.js +108 -0
  14. package/dist/cli/src/index.js.map +1 -0
  15. package/dist/cli/src/providers.d.ts +12 -0
  16. package/dist/cli/src/providers.js +61 -0
  17. package/dist/cli/src/providers.js.map +1 -0
  18. package/dist/cli/src/runtime/bridge-session.d.ts +40 -0
  19. package/dist/cli/src/runtime/bridge-session.js +317 -0
  20. package/dist/cli/src/runtime/bridge-session.js.map +1 -0
  21. package/dist/cli/src/runtime/scrollback.d.ts +11 -0
  22. package/dist/cli/src/runtime/scrollback.js +33 -0
  23. package/dist/cli/src/runtime/scrollback.js.map +1 -0
  24. package/dist/cli/src/utils/lan-ip.d.ts +5 -0
  25. package/dist/cli/src/utils/lan-ip.js +20 -0
  26. package/dist/cli/src/utils/lan-ip.js.map +1 -0
  27. package/dist/cli/tsconfig.tsbuildinfo +1 -0
  28. package/dist/shared-protocol/src/index.d.ts +380 -0
  29. package/dist/shared-protocol/src/index.js +158 -0
  30. package/dist/shared-protocol/src/index.js.map +1 -0
  31. package/package.json +49 -0
  32. package/src/commands/doctor.ts +119 -0
  33. package/src/commands/setup.ts +65 -0
  34. package/src/config.ts +34 -0
  35. package/src/index.ts +139 -0
  36. package/src/providers.ts +91 -0
  37. package/src/runtime/bridge-session.ts +407 -0
  38. package/src/runtime/scrollback.ts +43 -0
  39. package/src/types/qrcode-terminal.d.ts +7 -0
  40. package/src/utils/lan-ip.ts +19 -0
@@ -0,0 +1,119 @@
1
+ import { execSync } from "node:child_process";
2
+ import { loadConfig, getConfigPath } from "../config.js";
3
+
4
+ interface CheckResult {
5
+ name: string;
6
+ ok: boolean;
7
+ detail: string;
8
+ }
9
+
10
+ function check(name: string, fn: () => string): CheckResult {
11
+ try {
12
+ return { name, ok: true, detail: fn() };
13
+ } catch (e) {
14
+ return { name, ok: false, detail: e instanceof Error ? e.message : String(e) };
15
+ }
16
+ }
17
+
18
+ function which(bin: string): string | undefined {
19
+ try {
20
+ return execSync(`which ${bin}`, { encoding: "utf8", timeout: 5000 }).trim() || undefined;
21
+ } catch {
22
+ return undefined;
23
+ }
24
+ }
25
+
26
+ async function checkGateway(url: string): Promise<CheckResult> {
27
+ try {
28
+ const httpUrl = url.replace(/\/ws\/?$/, "").replace(/^wss:/, "https:").replace(/^ws:/, "http:");
29
+ const start = Date.now();
30
+ const res = await fetch(`${httpUrl}/healthz`, { signal: AbortSignal.timeout(5000) });
31
+ const latency = Date.now() - start;
32
+ if (!res.ok) return { name: "Gateway", ok: false, detail: `HTTP ${res.status}` };
33
+ return { name: "Gateway", ok: true, detail: `reachable (${latency}ms)` };
34
+ } catch (e) {
35
+ return { name: "Gateway", ok: false, detail: e instanceof Error ? e.message : "unreachable" };
36
+ }
37
+ }
38
+
39
+ export async function runDoctor(gatewayUrl?: string): Promise<void> {
40
+ const config = loadConfig();
41
+ const gateway = gatewayUrl ?? config.gateway;
42
+
43
+ process.stdout.write("\n LinkShell Doctor\n\n");
44
+
45
+ const results: CheckResult[] = [];
46
+
47
+ // Node.js version
48
+ results.push(check("Node.js", () => {
49
+ const ver = process.versions.node;
50
+ const major = Number(ver.split(".")[0]);
51
+ if (major < 18) throw new Error(`v${ver} (need >= 18)`);
52
+ return `v${ver}`;
53
+ }));
54
+
55
+ // node-pty
56
+ results.push(check("node-pty", () => {
57
+ try {
58
+ require("node-pty");
59
+ return "loaded";
60
+ } catch {
61
+ // Try dynamic import path for ESM
62
+ try {
63
+ execSync("node -e \"require('node-pty')\"", { timeout: 5000, stdio: "pipe" });
64
+ return "loaded";
65
+ } catch {
66
+ throw new Error("native module not built — run: pnpm approve-builds && pnpm install --force");
67
+ }
68
+ }
69
+ }));
70
+
71
+ // Claude CLI
72
+ const claudePath = which("claude");
73
+ if (claudePath) {
74
+ results.push(check("Claude CLI", () => {
75
+ const ver = execSync("claude --version 2>&1", { encoding: "utf8", timeout: 5000 }).trim();
76
+ return `${ver} (${claudePath})`;
77
+ }));
78
+ } else {
79
+ results.push({ name: "Claude CLI", ok: false, detail: "not found — npm i -g @anthropic-ai/claude-code" });
80
+ }
81
+
82
+ // Codex CLI
83
+ const codexPath = which("codex");
84
+ if (codexPath) {
85
+ results.push(check("Codex CLI", () => `found (${codexPath})`));
86
+ } else {
87
+ results.push({ name: "Codex CLI", ok: false, detail: "not found — npm i -g @openai/codex" });
88
+ }
89
+
90
+ // Config
91
+ results.push(check("Config", () => {
92
+ const path = getConfigPath();
93
+ const cfg = loadConfig();
94
+ const keys = Object.keys(cfg).filter((k) => cfg[k as keyof typeof cfg] !== undefined);
95
+ if (keys.length === 0) return `${path} (empty — run: linkshell setup)`;
96
+ return `${path} (${keys.join(", ")})`;
97
+ }));
98
+
99
+ // Gateway
100
+ if (gateway) {
101
+ results.push(await checkGateway(gateway));
102
+ } else {
103
+ results.push({ name: "Gateway", ok: false, detail: "no gateway configured — run: linkshell setup" });
104
+ }
105
+
106
+ // Print results
107
+ for (const r of results) {
108
+ const icon = r.ok ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m";
109
+ process.stdout.write(` ${icon} ${r.name}: ${r.detail}\n`);
110
+ }
111
+
112
+ const failed = results.filter((r) => !r.ok);
113
+ process.stdout.write("\n");
114
+ if (failed.length === 0) {
115
+ process.stdout.write(" \x1b[32mAll checks passed.\x1b[0m\n\n");
116
+ } else {
117
+ process.stdout.write(` \x1b[33m${failed.length} issue(s) found.\x1b[0m\n\n`);
118
+ }
119
+ }
@@ -0,0 +1,65 @@
1
+ import * as readline from "node:readline";
2
+ import { loadConfig, saveConfig, getConfigPath } from "../config.js";
3
+ import type { LinkShellConfig } from "../config.js";
4
+
5
+ function ask(rl: readline.Interface, question: string, defaultValue?: string): Promise<string> {
6
+ const suffix = defaultValue ? ` (${defaultValue})` : "";
7
+ return new Promise((resolve) => {
8
+ rl.question(` ${question}${suffix}: `, (answer) => {
9
+ resolve(answer.trim() || defaultValue || "");
10
+ });
11
+ });
12
+ }
13
+
14
+ function choose(rl: readline.Interface, question: string, options: string[], defaultIdx = 0): Promise<string> {
15
+ return new Promise((resolve) => {
16
+ process.stdout.write(` ${question}\n`);
17
+ for (let i = 0; i < options.length; i++) {
18
+ const marker = i === defaultIdx ? "\x1b[36m>\x1b[0m" : " ";
19
+ process.stdout.write(` ${marker} ${i + 1}. ${options[i]}\n`);
20
+ }
21
+ rl.question(` Choice (${defaultIdx + 1}): `, (answer) => {
22
+ const idx = Number(answer.trim()) - 1;
23
+ resolve(options[idx >= 0 && idx < options.length ? idx : defaultIdx]!);
24
+ });
25
+ });
26
+ }
27
+
28
+ export async function runSetup(): Promise<void> {
29
+ const existing = loadConfig();
30
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
31
+
32
+ process.stdout.write("\n LinkShell Setup\n\n");
33
+
34
+ const gateway = await ask(rl, "Gateway URL", existing.gateway ?? "wss://localhost:8787/ws");
35
+
36
+ const provider = await choose(
37
+ rl,
38
+ "Default provider:",
39
+ ["claude", "codex", "custom"],
40
+ ["claude", "codex", "custom"].indexOf(existing.provider ?? "claude"),
41
+ ) as LinkShellConfig["provider"];
42
+
43
+ let command: string | undefined;
44
+ if (provider === "custom") {
45
+ command = await ask(rl, "Custom command", existing.command ?? "bash");
46
+ }
47
+
48
+ const clientName = await ask(rl, "Client name", existing.clientName ?? require("os").hostname());
49
+
50
+ rl.close();
51
+
52
+ const config: LinkShellConfig = {
53
+ gateway,
54
+ provider,
55
+ command,
56
+ clientName,
57
+ };
58
+
59
+ saveConfig(config);
60
+
61
+ process.stdout.write(`\n \x1b[32mConfig saved to ${getConfigPath()}\x1b[0m\n\n`);
62
+ process.stdout.write(" Next steps:\n");
63
+ process.stdout.write(" linkshell doctor — verify your setup\n");
64
+ process.stdout.write(" linkshell start — start a bridge session\n\n");
65
+ }
package/src/config.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ export interface LinkShellConfig {
6
+ gateway?: string;
7
+ pairingGateway?: string;
8
+ provider?: "claude" | "codex" | "custom";
9
+ command?: string;
10
+ clientName?: string;
11
+ cols?: number;
12
+ rows?: number;
13
+ }
14
+
15
+ const CONFIG_DIR = join(homedir(), ".linkshell");
16
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
17
+
18
+ export function loadConfig(): LinkShellConfig {
19
+ try {
20
+ if (!existsSync(CONFIG_FILE)) return {};
21
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf8")) as LinkShellConfig;
22
+ } catch {
23
+ return {};
24
+ }
25
+ }
26
+
27
+ export function saveConfig(config: LinkShellConfig): void {
28
+ mkdirSync(CONFIG_DIR, { recursive: true });
29
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf8");
30
+ }
31
+
32
+ export function getConfigPath(): string {
33
+ return CONFIG_FILE;
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { BridgeSession } from "./runtime/bridge-session.js";
4
+ import { resolveProviderConfig } from "./providers.js";
5
+ import { loadConfig } from "./config.js";
6
+ import { runDoctor } from "./commands/doctor.js";
7
+ import { runSetup } from "./commands/setup.js";
8
+ import { getLanIp } from "./utils/lan-ip.js";
9
+
10
+ const config = loadConfig();
11
+ const program = new Command();
12
+
13
+ program
14
+ .name("linkshell")
15
+ .description(
16
+ "Bridge a local Claude/Codex terminal session to a remote gateway",
17
+ )
18
+ .version("0.1.0");
19
+
20
+ program
21
+ .command("start")
22
+ .description("Start a bridge session")
23
+ .option("--gateway <url>", "Gateway websocket URL (omit to start built-in gateway)", config.gateway)
24
+ .option(
25
+ "--pairing-gateway <url-or-host>",
26
+ "Public HTTP gateway used in QR/deep link output",
27
+ config.pairingGateway,
28
+ )
29
+ .option("--port <port>", "Port for built-in gateway (default: 8787)", "8787")
30
+ .option("--session-id <id>", "Session identifier (auto-created if omitted)")
31
+ .option(
32
+ "--provider <provider>",
33
+ "claude | codex | custom",
34
+ config.provider ?? "claude",
35
+ )
36
+ .option("--command <command>", "Override provider executable", config.command)
37
+ .option(
38
+ "--client-name <name>",
39
+ "Display name for this CLI",
40
+ config.clientName ?? "local-cli",
41
+ )
42
+ .option(
43
+ "--cols <cols>",
44
+ "Initial terminal columns",
45
+ String(config.cols ?? 120),
46
+ )
47
+ .option("--rows <rows>", "Initial terminal rows", String(config.rows ?? 36))
48
+ .option("--verbose", "Enable verbose logging")
49
+ .allowUnknownOption(true)
50
+ .action(async (options, command) => {
51
+ const passthroughArgs = command.args.filter(
52
+ (value: string) => value !== "--",
53
+ );
54
+ const providerConfig = resolveProviderConfig({
55
+ provider: options.provider,
56
+ command: options.command,
57
+ args: passthroughArgs,
58
+ });
59
+
60
+ let gatewayUrl = options.gateway as string | undefined;
61
+ let gatewayHttpUrl: string;
62
+ let pairingGateway = options.pairingGateway as string | undefined;
63
+ let embeddedGatewayHandle: { close: () => Promise<void> } | undefined;
64
+
65
+ if (!gatewayUrl) {
66
+ // Start built-in gateway
67
+ const { startEmbeddedGateway } = await import("@linkshell/gateway/embedded");
68
+ const port = Number(options.port);
69
+ const gw = await startEmbeddedGateway({
70
+ port,
71
+ logLevel: options.verbose ? "debug" : "warn",
72
+ silent: false,
73
+ });
74
+ embeddedGatewayHandle = gw;
75
+ gatewayUrl = gw.wsUrl;
76
+ gatewayHttpUrl = gw.httpUrl;
77
+
78
+ // Auto-detect LAN IP for QR code
79
+ const lanIp = getLanIp();
80
+ if (!pairingGateway && lanIp !== "127.0.0.1") {
81
+ pairingGateway = `http://${lanIp}:${gw.port}`;
82
+ }
83
+
84
+ process.stderr.write(`\n Built-in gateway started on port ${gw.port}\n`);
85
+ if (pairingGateway) {
86
+ process.stderr.write(` LAN address: ${pairingGateway}\n`);
87
+ }
88
+ process.stderr.write("\n");
89
+ } else {
90
+ gatewayHttpUrl = gatewayUrl
91
+ .replace(/\/ws\/?$/, "")
92
+ .replace(/^wss:/, "https:")
93
+ .replace(/^ws:/, "http:");
94
+ }
95
+
96
+ const session = new BridgeSession({
97
+ gatewayUrl,
98
+ gatewayHttpUrl,
99
+ pairingGateway,
100
+ sessionId: options.sessionId,
101
+ cols: Number(options.cols),
102
+ rows: Number(options.rows),
103
+ clientName: options.clientName,
104
+ verbose: Boolean(options.verbose),
105
+ providerConfig,
106
+ });
107
+
108
+ // Clean up embedded gateway on exit
109
+ if (embeddedGatewayHandle) {
110
+ const cleanup = async () => {
111
+ await embeddedGatewayHandle!.close();
112
+ };
113
+ process.on("SIGINT", () => { cleanup().then(() => process.exit(0)); });
114
+ process.on("SIGTERM", () => { cleanup().then(() => process.exit(0)); });
115
+ }
116
+
117
+ await session.start();
118
+ });
119
+
120
+ program
121
+ .command("doctor")
122
+ .description("Check your environment and connectivity")
123
+ .option("--gateway <url>", "Gateway URL to test", config.gateway)
124
+ .action(async (options) => {
125
+ await runDoctor(options.gateway);
126
+ });
127
+
128
+ program
129
+ .command("setup")
130
+ .description("Interactive setup wizard")
131
+ .action(async () => {
132
+ await runSetup();
133
+ });
134
+
135
+ program.parseAsync(process.argv).catch((error: unknown) => {
136
+ const message = error instanceof Error ? error.message : String(error);
137
+ process.stderr.write(`${message}\n`);
138
+ process.exit(1);
139
+ });
@@ -0,0 +1,91 @@
1
+ import { execSync } from "node:child_process";
2
+
3
+ export type ProviderName = "claude" | "codex" | "custom";
4
+
5
+ export interface ProviderConfig {
6
+ provider: ProviderName;
7
+ command: string;
8
+ args: string[];
9
+ env: NodeJS.ProcessEnv;
10
+ }
11
+
12
+ function which(bin: string): string | undefined {
13
+ try {
14
+ return execSync(`which ${bin}`, { encoding: "utf8" }).trim() || undefined;
15
+ } catch {
16
+ return undefined;
17
+ }
18
+ }
19
+
20
+ function resolveClaudeProvider(input: {
21
+ command?: string;
22
+ args: string[];
23
+ }): ProviderConfig {
24
+ const command = input.command ?? "claude";
25
+ const resolved = which(command);
26
+ if (!resolved) {
27
+ throw new Error(
28
+ `Claude CLI not found ("${command}"). Install it with: npm install -g @anthropic-ai/claude-code`,
29
+ );
30
+ }
31
+
32
+ // Claude starts an interactive REPL by default — that's exactly what we want in the PTY.
33
+ // Pass through any extra args the user provided.
34
+ return {
35
+ provider: "claude",
36
+ command: resolved,
37
+ args: input.args,
38
+ env: { ...process.env },
39
+ };
40
+ }
41
+
42
+ function resolveCodexProvider(input: {
43
+ command?: string;
44
+ args: string[];
45
+ }): ProviderConfig {
46
+ const command = input.command ?? "codex";
47
+ const resolved = which(command);
48
+ if (!resolved) {
49
+ throw new Error(
50
+ `Codex CLI not found ("${command}"). Install it with: npm install -g @openai/codex`,
51
+ );
52
+ }
53
+
54
+ if (!process.env.OPENAI_API_KEY) {
55
+ process.stderr.write(
56
+ "[warn] OPENAI_API_KEY not set — Codex may fail to authenticate\n",
57
+ );
58
+ }
59
+
60
+ return {
61
+ provider: "codex",
62
+ command: resolved,
63
+ args: input.args,
64
+ env: { ...process.env },
65
+ };
66
+ }
67
+
68
+ export function resolveProviderConfig(input: {
69
+ provider: ProviderName;
70
+ command?: string;
71
+ args: string[];
72
+ }): ProviderConfig {
73
+ switch (input.provider) {
74
+ case "claude":
75
+ return resolveClaudeProvider(input);
76
+ case "codex":
77
+ return resolveCodexProvider(input);
78
+ case "custom": {
79
+ if (!input.command) {
80
+ throw new Error("custom provider requires --command");
81
+ }
82
+ const resolved = which(input.command);
83
+ return {
84
+ provider: "custom",
85
+ command: resolved ?? input.command,
86
+ args: input.args,
87
+ env: { ...process.env },
88
+ };
89
+ }
90
+ }
91
+ }