linkshell-cli 0.2.100 → 0.2.102

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkshell-cli",
3
- "version": "0.2.100",
3
+ "version": "0.2.102",
4
4
  "description": "Remote terminal bridge for Claude Code / Codex / Gemini / Copilot — control from your phone",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,9 +29,16 @@
29
29
  "type": "git",
30
30
  "url": "git+https://github.com/LiuTianjie/LinkShell.git"
31
31
  },
32
+ "scripts": {
33
+ "build": "tsc -p tsconfig.json",
34
+ "postinstall": "node scripts/postinstall.mjs",
35
+ "typecheck": "tsc -p tsconfig.json --noEmit",
36
+ "dev": "tsx src/index.ts",
37
+ "test": "vitest run"
38
+ },
32
39
  "dependencies": {
33
- "@linkshell/gateway": "^0.2.33",
34
- "@linkshell/protocol": "^0.2.28",
40
+ "@linkshell/gateway": "workspace:^0.2.33",
41
+ "@linkshell/protocol": "workspace:^0.2.28",
35
42
  "commander": "^13.1.0",
36
43
  "node-pty": "^1.0.0",
37
44
  "qrcode-terminal": "^0.12.0",
@@ -46,12 +53,5 @@
46
53
  "@types/ws": "^8.5.14",
47
54
  "tsx": "^4.19.3",
48
55
  "vitest": "^3.2.1"
49
- },
50
- "scripts": {
51
- "build": "tsc -p tsconfig.json",
52
- "postinstall": "node scripts/postinstall.mjs",
53
- "typecheck": "tsc -p tsconfig.json --noEmit",
54
- "dev": "tsx src/index.ts",
55
- "test": "vitest run"
56
56
  }
57
- }
57
+ }
package/src/index.ts CHANGED
@@ -81,6 +81,7 @@ program
81
81
  .option("--verbose", "Enable verbose logging")
82
82
  .option("--_foreground-bridge", undefined) // internal
83
83
  .allowUnknownOption(true)
84
+ .allowExcessArguments(true)
84
85
  .action(async (options, command) => {
85
86
  const daemon = await import("./utils/daemon.js");
86
87
  const keepAwake = shouldKeepAwake(options.keepAwake);
package/src/providers.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { execSync } from "node:child_process";
1
+ import { accessSync, constants, existsSync } from "node:fs";
2
+ import { delimiter, join } from "node:path";
2
3
 
3
4
  export type ProviderName = "claude" | "codex" | "gemini" | "copilot" | "custom";
4
5
 
@@ -9,12 +10,58 @@ export interface ProviderConfig {
9
10
  env: NodeJS.ProcessEnv;
10
11
  }
11
12
 
12
- function which(bin: string): string | undefined {
13
+ function stripOuterQuotes(value: string): string {
14
+ const trimmed = value.trim();
15
+ if (
16
+ (trimmed.startsWith("\"") && trimmed.endsWith("\"")) ||
17
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
18
+ ) {
19
+ return trimmed.slice(1, -1);
20
+ }
21
+ return trimmed;
22
+ }
23
+
24
+ function canExecute(path: string): boolean {
13
25
  try {
14
- return execSync(`which ${bin}`, { encoding: "utf8" }).trim() || undefined;
26
+ accessSync(path, constants.X_OK);
27
+ return true;
15
28
  } catch {
29
+ return process.platform === "win32" && existsSync(path);
30
+ }
31
+ }
32
+
33
+ function executableExtensions(bin: string): string[] {
34
+ if (process.platform !== "win32") return [""];
35
+ if (/\.[^\\/]+$/.test(bin)) return [""];
36
+ const pathext = process.env.PATHEXT?.split(";").filter(Boolean) ?? [];
37
+ return ["", ...pathext.map((ext) => ext.toLowerCase()), ...pathext.map((ext) => ext.toUpperCase())];
38
+ }
39
+
40
+ function commandHasPathSeparator(command: string): boolean {
41
+ return command.includes("/") || command.includes("\\");
42
+ }
43
+
44
+ function which(bin: string): string | undefined {
45
+ const command = stripOuterQuotes(bin);
46
+ if (!command) return undefined;
47
+ const extensions = executableExtensions(command);
48
+ if (commandHasPathSeparator(command)) {
49
+ for (const extension of extensions) {
50
+ const candidate = `${command}${extension}`;
51
+ if (canExecute(candidate)) return candidate;
52
+ }
16
53
  return undefined;
17
54
  }
55
+ const pathEntries = (process.env.PATH ?? "")
56
+ .split(delimiter)
57
+ .filter(Boolean);
58
+ for (const dir of pathEntries) {
59
+ for (const extension of extensions) {
60
+ const candidate = join(dir, `${command}${extension}`);
61
+ if (canExecute(candidate)) return candidate;
62
+ }
63
+ }
64
+ return undefined;
18
65
  }
19
66
 
20
67
  function resolveClaudeProvider(input: {
@@ -997,9 +997,11 @@ export class AgentWorkspaceProxy {
997
997
  client: AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient,
998
998
  protocol: AgentProtocol,
999
999
  ): Promise<void> {
1000
- if (!(client instanceof AcpClient) || protocol !== "codex-app-server") return;
1000
+ if (client instanceof AcpClient && protocol !== "codex-app-server") return;
1001
+ const listModels = (client as { listModels?: () => Promise<unknown> }).listModels;
1002
+ if (typeof listModels !== "function") return;
1001
1003
  try {
1002
- const result = await client.listModels();
1004
+ const result = await listModels.call(client);
1003
1005
  const runtimeCapabilities = parseModelListCapabilities(result);
1004
1006
  if (runtimeCapabilities) this.providerCapabilities.set(provider, runtimeCapabilities);
1005
1007
  } catch (error) {
@@ -1781,10 +1783,11 @@ export class AgentWorkspaceProxy {
1781
1783
  if (normalized === "reasoning" || normalized === "thinking") {
1782
1784
  const text = firstString(item, ["text", "content", "summary", "message"]) ??
1783
1785
  stringifyDefined(item.contentItems ?? item.summary);
1786
+ if (!text?.trim()) return true;
1784
1787
  this.upsertItem(conversationId, {
1785
1788
  ...base,
1786
1789
  kind: "thinking",
1787
- text: text ?? (streaming ? "正在思考" : "完成思考"),
1790
+ text,
1788
1791
  });
1789
1792
  return true;
1790
1793
  }
@@ -1,6 +1,6 @@
1
1
  import { homedir } from "node:os";
2
2
  import { readdirSync, existsSync } from "node:fs";
3
- import { join, resolve } from "node:path";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
4
  import type { AgentFraming, AgentProtocol } from "./provider-resolver.js";
5
5
 
6
6
  type AgentPermissionMode = "read_only" | "workspace_write" | "full_access";
@@ -68,6 +68,29 @@ function isRealClaudeSessionId(value: string | undefined): value is string {
68
68
  return Boolean(value && !value.startsWith("agent-session-"));
69
69
  }
70
70
 
71
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
72
+ return value && typeof value === "object" && !Array.isArray(value)
73
+ ? value as Record<string, unknown>
74
+ : undefined;
75
+ }
76
+
77
+ function stringField(value: unknown, keys: string[]): string | undefined {
78
+ const record = asRecord(value);
79
+ if (!record) return undefined;
80
+ for (const key of keys) {
81
+ const candidate = record[key];
82
+ if (typeof candidate === "string" && candidate.trim()) return candidate;
83
+ }
84
+ return undefined;
85
+ }
86
+
87
+ function isInsideCwd(cwd: string, candidate: string): boolean {
88
+ const root = resolve(cwd);
89
+ const target = resolve(root, candidate);
90
+ const rel = relative(root, target);
91
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
92
+ }
93
+
71
94
  function outcomeFromPermissionResponse(value: unknown): "allow" | "deny" {
72
95
  const raw = value && typeof value === "object" ? value as Record<string, unknown> : {};
73
96
  if (raw.behavior === "allow") return "allow";
@@ -158,9 +181,18 @@ export class ClaudeSdkClient {
158
181
  abortController,
159
182
  canUseTool: async (toolName: string, toolInput: unknown) => {
160
183
  if (input.permissionMode === "full_access") return { behavior: "allow" };
184
+ if (["Read", "Glob", "Grep", "LS", "NotebookRead", "TodoRead"].includes(toolName)) {
185
+ return { behavior: "allow" };
186
+ }
161
187
  if (input.permissionMode === "read_only" && ["Write", "Edit", "MultiEdit", "Bash"].includes(toolName)) {
162
188
  return { behavior: "deny", message: "Read-only mode is active." };
163
189
  }
190
+ if (input.permissionMode === "workspace_write" && ["Write", "Edit", "MultiEdit"].includes(toolName)) {
191
+ const filePath = stringField(toolInput, ["file_path", "path", "notebook_path"]);
192
+ if (filePath && isInsideCwd(input.cwd ?? this.input.cwd, filePath)) {
193
+ return { behavior: "allow" };
194
+ }
195
+ }
164
196
  const requestId = id("claude-perm");
165
197
  const response = await this.input.onRequest("claude/requestApproval", {
166
198
  requestId,
@@ -248,6 +280,19 @@ export class ClaudeSdkClient {
248
280
  return { sessions };
249
281
  }
250
282
 
283
+ async listModels(): Promise<unknown> {
284
+ return {
285
+ defaultModel: "default",
286
+ models: [
287
+ { id: "sonnet", label: "Sonnet" },
288
+ { id: "opus", label: "Opus" },
289
+ { id: "haiku", label: "Haiku" },
290
+ { id: "sonnet[1m]", label: "Sonnet 1M" },
291
+ { id: "opusplan", label: "Opus Plan" },
292
+ ],
293
+ };
294
+ }
295
+
251
296
  stop(): void {
252
297
  this.cancel({});
253
298
  }
@@ -443,6 +443,19 @@ export class ClaudeStreamJsonClient {
443
443
  return { sessions };
444
444
  }
445
445
 
446
+ async listModels(): Promise<unknown> {
447
+ return {
448
+ defaultModel: "default",
449
+ models: [
450
+ { id: "sonnet", label: "Sonnet" },
451
+ { id: "opus", label: "Opus" },
452
+ { id: "haiku", label: "Haiku" },
453
+ { id: "sonnet[1m]", label: "Sonnet 1M" },
454
+ { id: "opusplan", label: "Opus Plan" },
455
+ ],
456
+ };
457
+ }
458
+
446
459
  stop(): void {
447
460
  if (this.child && !this.child.killed) {
448
461
  this.child.kill("SIGTERM");