linkshell-cli 0.4.1 → 0.5.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.
@@ -16,23 +16,11 @@ function ask(
16
16
  });
17
17
  }
18
18
 
19
- function choose(
20
- rl: readline.Interface,
21
- question: string,
22
- options: string[],
23
- defaultIdx = 0,
24
- ): Promise<string> {
25
- return new Promise((resolve) => {
26
- process.stdout.write(` ${question}\n`);
27
- for (let i = 0; i < options.length; i++) {
28
- const marker = i === defaultIdx ? "\x1b[36m>\x1b[0m" : " ";
29
- process.stdout.write(` ${marker} ${i + 1}. ${options[i]}\n`);
30
- }
31
- rl.question(` Choice (${defaultIdx + 1}): `, (answer) => {
32
- const idx = Number(answer.trim()) - 1;
33
- resolve(options[idx >= 0 && idx < options.length ? idx : defaultIdx]!);
34
- });
35
- });
19
+ function defaultShell(): string {
20
+ if (process.platform === "win32") {
21
+ return process.env.COMSPEC ?? "cmd.exe";
22
+ }
23
+ return process.env.SHELL ?? "/bin/bash";
36
24
  }
37
25
 
38
26
  export async function runSetup(): Promise<void> {
@@ -49,17 +37,11 @@ export async function runSetup(): Promise<void> {
49
37
 
50
38
  const gateway = await ask(rl, "Gateway URL (leave empty for built-in)", "");
51
39
 
52
- const provider = (await choose(
40
+ const command = await ask(
53
41
  rl,
54
- "Default provider:",
55
- ["claude", "codex", "gemini", "copilot", "custom"],
56
- ["claude", "codex", "gemini", "copilot", "custom"].indexOf(existing.provider ?? "claude"),
57
- )) as LinkShellConfig["provider"];
58
-
59
- let command: string | undefined;
60
- if (provider === "custom") {
61
- command = await ask(rl, "Custom command", existing.command ?? "bash");
62
- }
42
+ "Terminal command (the shell or CLI you want to bridge)",
43
+ existing.command ?? defaultShell(),
44
+ );
63
45
 
64
46
  const clientName = await ask(
65
47
  rl,
@@ -76,8 +58,8 @@ export async function runSetup(): Promise<void> {
76
58
 
77
59
  const config: LinkShellConfig = {
78
60
  gateway: gateway || undefined,
79
- provider,
80
- command,
61
+ provider: "custom",
62
+ command: command || undefined,
81
63
  clientName,
82
64
  hostname: hostnameName || undefined,
83
65
  };
package/src/index.ts CHANGED
@@ -22,7 +22,7 @@ const program = new Command();
22
22
  program
23
23
  .name("linkshell")
24
24
  .description(
25
- "Bridge a local Claude/Codex terminal session to a remote gateway",
25
+ "Bridge a local terminal session to a remote gateway. Launch any CLI (Claude/Codex/etc.) manually inside the shell.",
26
26
  )
27
27
  .version(pkg.version);
28
28
 
@@ -45,10 +45,14 @@ program
45
45
  .option("--session-id <id>", "Session identifier (auto-created if omitted)")
46
46
  .option(
47
47
  "--provider <provider>",
48
- "claude | codex | gemini | copilot | custom",
49
- config.provider ?? "claude",
48
+ "(deprecated — always custom; launch your CLI manually inside the shell)",
49
+ "custom",
50
+ )
51
+ .option(
52
+ "--command <command>",
53
+ "Shell or CLI to spawn in the PTY (default: $SHELL)",
54
+ config.command,
50
55
  )
51
- .option("--command <command>", "Override provider executable", config.command)
52
56
  .option(
53
57
  "--client-name <name>",
54
58
  "Display name for this CLI",
@@ -485,12 +489,11 @@ program
485
489
  return;
486
490
  }
487
491
 
488
- const provider = config.provider ?? "claude";
489
492
  const childArgs = [
490
493
  "start",
491
494
  "--_foreground-bridge",
492
495
  "--gateway", gwWsUrl,
493
- "--provider", provider,
496
+ "--provider", "custom",
494
497
  "--client-name", config.clientName ?? "local-cli",
495
498
  "--cols", String(config.cols ?? 120),
496
499
  "--rows", String(config.rows ?? 36),
@@ -505,7 +508,7 @@ program
505
508
  daemon.saveMetadata("bridge", { keepAwake, startedAt: Date.now() });
506
509
  process.stderr.write(`\n \x1b[32m✓\x1b[0m Bridge started in background (PID ${pid})\n`);
507
510
  process.stderr.write(` Gateway: ${chosen.name}\n`);
508
- process.stderr.write(` Provider: ${provider}\n`);
511
+ process.stderr.write(` Provider: custom (shell)\n`);
509
512
  process.stderr.write(` Keep awake: ${keepAwake ? "enabled" : "disabled"}\n`);
510
513
  process.stderr.write(` Desktop: enabled\n`);
511
514
  process.stderr.write(` Agent GUI: enabled (Codex app-server)\n`);
@@ -607,7 +610,7 @@ program
607
610
  }
608
611
 
609
612
  process.stderr.write(
610
- " Connect: linkshell start --gateway <url> --provider claude\n\n",
613
+ " Connect: linkshell start --gateway <url>\n\n",
611
614
  );
612
615
  });
613
616
 
package/src/providers.ts CHANGED
@@ -11,6 +11,13 @@ export interface ProviderConfig {
11
11
  env: NodeJS.ProcessEnv;
12
12
  }
13
13
 
14
+ function defaultShell(): string {
15
+ if (process.platform === "win32") {
16
+ return process.env.COMSPEC ?? "cmd.exe";
17
+ }
18
+ return process.env.SHELL ?? "/bin/bash";
19
+ }
20
+
14
21
  function stripOuterQuotes(value: string): string {
15
22
  const trimmed = value.trim();
16
23
  if (
@@ -65,125 +72,26 @@ function which(bin: string): string | undefined {
65
72
  return undefined;
66
73
  }
67
74
 
68
- function resolveClaudeProvider(input: {
69
- command?: string;
70
- args: string[];
71
- }): ProviderConfig {
72
- const command = input.command ?? "claude";
73
- const resolved = which(command);
74
- if (!resolved) {
75
- throw new Error(
76
- `Claude CLI not found ("${command}"). Install it with: npm install -g @anthropic-ai/claude-code`,
77
- );
78
- }
79
-
80
- // Claude starts an interactive REPL by default — that's exactly what we want in the PTY.
81
- // Pass through any extra args the user provided.
82
- return {
83
- provider: "claude",
84
- command: resolved,
85
- args: input.args,
86
- env: { ...process.env },
87
- };
88
- }
89
-
90
- function resolveCodexProvider(input: {
91
- command?: string;
92
- args: string[];
93
- }): ProviderConfig {
94
- const command = input.command ?? "codex";
95
- const resolved = which(command);
96
- if (!resolved) {
97
- throw new Error(
98
- `Codex CLI not found ("${command}"). Install it with: npm install -g @openai/codex`,
99
- );
100
- }
101
-
102
- if (!process.env.OPENAI_API_KEY) {
103
- process.stderr.write(
104
- "[warn] OPENAI_API_KEY not set — Codex may fail to authenticate\n",
105
- );
106
- }
107
-
108
- return {
109
- provider: "codex",
110
- command: resolved,
111
- args: input.args,
112
- env: { ...process.env },
113
- };
114
- }
115
-
116
- function resolveGeminiProvider(input: {
75
+ export function resolveProviderConfig(input: {
76
+ provider?: ProviderName;
117
77
  command?: string;
118
78
  args: string[];
119
79
  }): ProviderConfig {
120
- const command = input.command ?? "gemini";
121
- const resolved = which(command);
122
- if (!resolved) {
123
- throw new Error(
124
- `Gemini CLI not found ("${command}"). Install it with: npm install -g @anthropic-ai/gemini-cli or check https://github.com/anthropics/gemini-cli`,
125
- );
126
- }
127
-
128
- if (!process.env.GOOGLE_API_KEY && !process.env.GEMINI_API_KEY) {
80
+ if (input.provider && input.provider !== "custom") {
129
81
  process.stderr.write(
130
- "[warn] GOOGLE_API_KEY / GEMINI_API_KEY not set — Gemini may fail to authenticate\n",
82
+ `[warn] --provider=${input.provider} is no longer supported; falling back to a shell. ` +
83
+ `Launch your CLI manually inside the terminal (e.g. type "${input.provider}").\n`,
131
84
  );
132
85
  }
133
86
 
134
- return {
135
- provider: "gemini",
136
- command: resolved,
137
- args: input.args,
138
- env: { ...process.env },
139
- };
140
- }
141
-
142
- function resolveCopilotProvider(input: {
143
- command?: string;
144
- args: string[];
145
- }): ProviderConfig {
146
- const command = input.command ?? "github-copilot";
147
- const resolved = which(command);
148
- if (!resolved) {
149
- throw new Error(
150
- `GitHub Copilot CLI not found ("${command}").`,
151
- );
152
- }
87
+ const requested = input.command?.trim();
88
+ const command = requested && requested.length > 0 ? requested : defaultShell();
89
+ const resolved = which(command) ?? command;
153
90
 
154
91
  return {
155
- provider: "copilot",
92
+ provider: "custom",
156
93
  command: resolved,
157
94
  args: input.args,
158
95
  env: { ...process.env },
159
96
  };
160
97
  }
161
-
162
- export function resolveProviderConfig(input: {
163
- provider: ProviderName;
164
- command?: string;
165
- args: string[];
166
- }): ProviderConfig {
167
- switch (input.provider) {
168
- case "claude":
169
- return resolveClaudeProvider(input);
170
- case "codex":
171
- return resolveCodexProvider(input);
172
- case "gemini":
173
- return resolveGeminiProvider(input);
174
- case "copilot":
175
- return resolveCopilotProvider(input);
176
- case "custom": {
177
- if (!input.command) {
178
- throw new Error("custom provider requires --command");
179
- }
180
- const resolved = which(input.command);
181
- return {
182
- provider: "custom",
183
- command: resolved ?? input.command,
184
- args: input.args,
185
- env: { ...process.env },
186
- };
187
- }
188
- }
189
- }
@@ -765,6 +765,13 @@ interface ProviderRuntimeCapabilities {
765
765
 
766
766
  const ALL_REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"] as const;
767
767
  const ALL_PERMISSION_MODES = ["read_only", "workspace_write", "full_access"] as const;
768
+
769
+ // Codex `model/list` is the source of truth — these are only used when the RPC
770
+ // returns nothing so mobile still sees a usable picker.
771
+ const CODEX_FALLBACK_MODELS: AgentModelOption[] = [
772
+ { id: "default", label: "默认模型" },
773
+ ];
774
+ const CODEX_FALLBACK_DEFAULT_MODEL = "default";
768
775
  const CODEX_COMMAND_NAMES = ["plan", "exit-plan", "compact", "clear", "status", "review", "subagents"] as const;
769
776
  const CLAUDE_REMOTE_HIDDEN_COMMANDS = new Set([
770
777
  "add-dir",
@@ -1150,10 +1157,13 @@ function parseModelListCapabilities(value: unknown): ProviderRuntimeCapabilities
1150
1157
  const raw = asRecord(value);
1151
1158
  const modelsValue =
1152
1159
  Array.isArray(value) ? value :
1160
+ Array.isArray(raw?.data) ? raw.data :
1153
1161
  Array.isArray(raw?.models) ? raw.models :
1154
1162
  Array.isArray(raw?.items) ? raw.items :
1155
1163
  Array.isArray(raw?.modelOptions) ? raw.modelOptions :
1156
1164
  [];
1165
+ let inferredDefault: string | undefined;
1166
+ const efforts = new Set<string>();
1157
1167
  const models = modelsValue
1158
1168
  .map((entry, index) => {
1159
1169
  const model = asRecord(entry);
@@ -1162,24 +1172,44 @@ function parseModelListCapabilities(value: unknown): ProviderRuntimeCapabilities
1162
1172
  ? { id: entry, label: entry }
1163
1173
  : undefined;
1164
1174
  }
1175
+ if (model.hidden === true) return undefined;
1165
1176
  const modelId = firstString(model, ["id", "model", "name", "value"]) ?? `model-${index + 1}`;
1166
- const label = firstString(model, ["label", "title", "displayName", "name"]) ?? modelId;
1177
+ const label = firstString(model, ["displayName", "label", "title", "name"]) ?? modelId;
1178
+ if (model.isDefault === true && !inferredDefault) inferredDefault = modelId;
1179
+ const supported = Array.isArray(model.supportedReasoningEfforts) ? model.supportedReasoningEfforts : [];
1180
+ for (const item of supported) {
1181
+ const effort = typeof item === "string"
1182
+ ? item
1183
+ : firstString(asRecord(item), ["reasoningEffort", "id", "value"]);
1184
+ if (effort && ALL_REASONING_EFFORTS.includes(effort as typeof ALL_REASONING_EFFORTS[number])) {
1185
+ efforts.add(effort);
1186
+ }
1187
+ }
1167
1188
  return { id: modelId, label };
1168
1189
  })
1169
1190
  .filter((entry): entry is AgentModelOption => Boolean(entry));
1170
1191
  const defaultModel =
1171
1192
  firstString(raw, ["defaultModel", "default_model", "currentModel"]) ??
1172
- firstString(asRecord(raw?.defaults), ["model"]);
1173
- const effortsValue =
1193
+ firstString(asRecord(raw?.defaults), ["model"]) ??
1194
+ inferredDefault;
1195
+ const explicitEffortsValue =
1174
1196
  Array.isArray(raw?.reasoningEfforts) ? raw.reasoningEfforts :
1175
1197
  Array.isArray(raw?.reasoning_efforts) ? raw.reasoning_efforts :
1176
1198
  Array.isArray(raw?.efforts) ? raw.efforts :
1177
1199
  undefined;
1178
- const reasoningEfforts = effortsValue
1179
- ?.filter((entry): entry is string => typeof entry === "string" && ALL_REASONING_EFFORTS.includes(entry as typeof ALL_REASONING_EFFORTS[number]));
1200
+ if (explicitEffortsValue) {
1201
+ for (const item of explicitEffortsValue) {
1202
+ if (typeof item === "string" && ALL_REASONING_EFFORTS.includes(item as typeof ALL_REASONING_EFFORTS[number])) {
1203
+ efforts.add(item);
1204
+ }
1205
+ }
1206
+ }
1207
+ const reasoningEfforts = efforts.size
1208
+ ? ALL_REASONING_EFFORTS.filter((effort) => efforts.has(effort))
1209
+ : undefined;
1180
1210
  if (models.length === 0 && !defaultModel && !reasoningEfforts?.length) return undefined;
1181
1211
  return {
1182
- ...(models.length > 0 ? { models: [{ id: "default", label: "默认模型" }, ...models] } : {}),
1212
+ ...(models.length > 0 ? { models } : {}),
1183
1213
  ...(defaultModel ? { defaultModel } : {}),
1184
1214
  ...(reasoningEfforts?.length ? { reasoningEfforts } : {}),
1185
1215
  };
@@ -1300,6 +1330,7 @@ export class AgentWorkspaceProxy {
1300
1330
  }
1301
1331
  case "agent.v2.snapshot.request": {
1302
1332
  const payload = parseTypedPayload("agent.v2.snapshot.request", envelope.payload);
1333
+ await this.syncProviderSessions();
1303
1334
  this.sendSnapshot(payload.conversationId);
1304
1335
  break;
1305
1336
  }
@@ -1465,16 +1496,36 @@ export class AgentWorkspaceProxy {
1465
1496
  ): Promise<void> {
1466
1497
  if (client instanceof AcpClient && protocol !== "codex-app-server") return;
1467
1498
  const listModels = (client as { listModels?: () => Promise<unknown> }).listModels;
1468
- if (typeof listModels !== "function") return;
1469
- try {
1470
- const result = await listModels.call(client);
1471
- const runtimeCapabilities = parseModelListCapabilities(result);
1472
- if (runtimeCapabilities) this.providerCapabilities.set(provider, runtimeCapabilities);
1473
- } catch (error) {
1474
- if (this.input.verbose) {
1475
- process.stderr.write(`[agent:v2] model/list failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
1499
+ let runtimeCapabilities: ProviderRuntimeCapabilities | undefined;
1500
+ if (typeof listModels === "function") {
1501
+ try {
1502
+ const result = await listModels.call(client);
1503
+ runtimeCapabilities = parseModelListCapabilities(result);
1504
+ } catch (error) {
1505
+ if (this.input.verbose) {
1506
+ process.stderr.write(`[agent:v2] model/list failed for ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
1507
+ }
1476
1508
  }
1477
1509
  }
1510
+ if (provider === "codex") {
1511
+ // Codex `model/list` is unreliable across versions — make sure mobile always
1512
+ // sees a usable model picker by merging in a static fallback list.
1513
+ const merged: ProviderRuntimeCapabilities = {
1514
+ models: runtimeCapabilities?.models?.length
1515
+ ? runtimeCapabilities.models
1516
+ : [...CODEX_FALLBACK_MODELS],
1517
+ defaultModel: runtimeCapabilities?.defaultModel ?? CODEX_FALLBACK_DEFAULT_MODEL,
1518
+ reasoningEfforts: runtimeCapabilities?.reasoningEfforts?.length
1519
+ ? runtimeCapabilities.reasoningEfforts
1520
+ : [...ALL_REASONING_EFFORTS],
1521
+ commands: runtimeCapabilities?.commands,
1522
+ modes: runtimeCapabilities?.modes,
1523
+ currentMode: runtimeCapabilities?.currentMode,
1524
+ };
1525
+ this.providerCapabilities.set(provider, merged);
1526
+ return;
1527
+ }
1528
+ if (runtimeCapabilities) this.providerCapabilities.set(provider, runtimeCapabilities);
1478
1529
  }
1479
1530
 
1480
1531
  private async syncProviderSessions(): Promise<void> {