linkshell-cli 0.5.7 → 0.5.9

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.5.7",
3
+ "version": "0.5.9",
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",
@@ -11,6 +11,7 @@
11
11
  "dist",
12
12
  "src",
13
13
  "scripts",
14
+ "web",
14
15
  "README.md",
15
16
  "LICENSE"
16
17
  ],
@@ -35,7 +36,7 @@
35
36
  "qrcode-terminal": "^0.12.0",
36
37
  "ws": "^8.18.1",
37
38
  "zod": "^3.24.3",
38
- "@linkshell/gateway": "0.4.12",
39
+ "@linkshell/gateway": "0.4.27",
39
40
  "@linkshell/protocol": "0.4.4"
40
41
  },
41
42
  "optionalDependencies": {
@@ -48,7 +49,7 @@
48
49
  "vitest": "^3.2.1"
49
50
  },
50
51
  "scripts": {
51
- "build": "tsc -p tsconfig.json",
52
+ "build": "tsc -p tsconfig.json && node scripts/copy-web.mjs",
52
53
  "postinstall": "node scripts/postinstall.mjs",
53
54
  "typecheck": "tsc -p tsconfig.json --noEmit",
54
55
  "dev": "tsx src/index.ts",
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ // Copy the built web-dashboard SPA into the CLI package (packages/cli/web) so
3
+ // it ships in the npm tarball. The CLI's embedded gateway then serves it via
4
+ // WEB_DIST, giving LAN / self-hosted users the same in-app web console that the
5
+ // cloud gateway Docker image bundles separately.
6
+ //
7
+ // Non-fatal when the web build is absent: a CLI-only `tsc` build still
8
+ // succeeds; the embedded gateway just logs "web UI not bundled". Release builds
9
+ // must build apps/web-dashboard FIRST so this copy has something to pick up.
10
+ import { cpSync, existsSync, rmSync } from "node:fs";
11
+ import { dirname, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const here = dirname(fileURLToPath(import.meta.url)); // packages/cli/scripts
15
+ const src = resolve(here, "../../../apps/web-dashboard/dist");
16
+ const dest = resolve(here, "../web"); // packages/cli/web
17
+
18
+ if (!existsSync(resolve(src, "index.html"))) {
19
+ console.warn(
20
+ `[copy-web] web-dashboard build not found at ${src} — skipping. ` +
21
+ `The in-app agent console will be blank for embedded-gateway users. ` +
22
+ `Run \`pnpm --filter @linkshell/web-dashboard build\` first.`,
23
+ );
24
+ process.exit(0);
25
+ }
26
+
27
+ try {
28
+ rmSync(dest, { recursive: true, force: true });
29
+ cpSync(src, dest, { recursive: true });
30
+ console.log(`[copy-web] copied ${src} -> ${dest}`);
31
+ } catch (err) {
32
+ console.error(`[copy-web] failed to copy web dist: ${err}`);
33
+ process.exit(1);
34
+ }
package/src/index.ts CHANGED
@@ -12,10 +12,39 @@ import { getLanIp } from "./utils/lan-ip.js";
12
12
  import { shouldKeepAwake } from "./utils/keep-awake.js";
13
13
 
14
14
  import { createRequire } from "node:module";
15
+ import { fileURLToPath } from "node:url";
16
+ import { dirname, resolve } from "node:path";
17
+ import { existsSync, statSync } from "node:fs";
15
18
 
16
19
  const require = createRequire(import.meta.url);
17
20
  const pkg = require("../../../package.json") as { version: string };
18
21
 
22
+ // Locate the bundled web-dashboard SPA and point the (embedded) gateway's
23
+ // static server at it via WEB_DIST — BEFORE any gateway module is imported,
24
+ // because static-web.ts freezes WEB_DIST at module-load time. This is what lets
25
+ // the in-app WebView render for LAN / self-hosted users whose CLI runs its own
26
+ // embedded gateway (the cloud image bundles web separately). A user-set
27
+ // WEB_DIST always wins. Probed across published-package, monorepo-compiled, and
28
+ // dev (tsx) layouts; the first candidate containing index.html is used.
29
+ if (!process.env.WEB_DIST) {
30
+ const here = dirname(fileURLToPath(import.meta.url));
31
+ const candidates = [
32
+ resolve(here, "../../../web"), // published npm package root, and packages/cli/web (compiled)
33
+ resolve(here, "../../../apps/web-dashboard/dist"), // dev: tsx src/index.ts in the monorepo
34
+ ];
35
+ for (const dir of candidates) {
36
+ try {
37
+ if (existsSync(resolve(dir, "index.html")) && statSync(dir).isDirectory()) {
38
+ process.env.WEB_DIST = dir;
39
+ break;
40
+ }
41
+ } catch {
42
+ // ignore and try the next candidate
43
+ }
44
+ }
45
+ }
46
+
47
+
19
48
  const config = loadConfig();
20
49
  const program = new Command();
21
50
 
@@ -434,8 +434,19 @@ export function claudePermissionModeFor(input: {
434
434
  return "default";
435
435
  }
436
436
 
437
+ const FALLBACK_MODEL_LABELS: Record<string, string> = {
438
+ default: "默认模型",
439
+ sonnet: "Sonnet",
440
+ opus: "Opus",
441
+ opusplan: "Opus Plan",
442
+ haiku: "Haiku",
443
+ "sonnet[1m]": "Sonnet 1M",
444
+ "opus[1m]": "Opus 1M",
445
+ };
446
+
437
447
  export class ClaudeSdkClient {
438
448
  private query: ClaudeQuery | undefined;
449
+ private startup: ((params?: { options?: Record<string, unknown>; initializeTimeoutMs?: number }) => Promise<{ query(prompt: string): AsyncGenerator<unknown> & { supportedModels(): Promise<{ value: string; displayName: string }[]>; interrupt(): Promise<void> }; close(): void }>) | undefined;
439
450
  private claudeSessionId: string | undefined;
440
451
  private abortController: AbortController | undefined;
441
452
  private permissionWaiters = new Map<string, (outcome: "allow" | "deny") => void>();
@@ -461,6 +472,12 @@ export class ClaudeSdkClient {
461
472
  throw new Error("Claude Agent SDK does not export query()");
462
473
  }
463
474
  this.query = query as ClaudeQuery;
475
+ // startup() pre-warms the CLI subprocess so we can discover available models
476
+ // without starting a full conversation (via supportedModels() on the Query).
477
+ const startup = (mod as { startup?: unknown }).startup;
478
+ if (typeof startup === "function") {
479
+ this.startup = startup as typeof this.startup;
480
+ }
464
481
  return { status: "ok", protocol: "claude-agent-sdk" };
465
482
  } catch (error) {
466
483
  throw new Error(
@@ -675,27 +692,63 @@ export class ClaudeSdkClient {
675
692
 
676
693
  async listModels(): Promise<unknown> {
677
694
  let defaultModel: string = "default";
695
+ // Read the user's configured default model from settings.json. This is always
696
+ // consulted (both for live discovery and for the fallback path).
697
+ let availableModels: string[] | undefined;
678
698
  try {
679
699
  const settingsPath = join(homedir(), ".claude", "settings.json");
680
700
  if (existsSync(settingsPath)) {
681
- const raw = JSON.parse(readFileSync(settingsPath, "utf8")) as { model?: unknown };
701
+ const raw = JSON.parse(readFileSync(settingsPath, "utf8")) as { model?: unknown; availableModels?: unknown };
682
702
  if (typeof raw.model === "string" && raw.model.trim().length > 0) {
683
703
  defaultModel = raw.model.trim();
684
704
  }
705
+ if (Array.isArray(raw.availableModels)) {
706
+ availableModels = raw.availableModels.filter((m): m is string => typeof m === "string");
707
+ }
685
708
  }
686
709
  } catch {
687
710
  // Fallback to "default" if settings file is unreadable.
688
711
  }
712
+
713
+ // Dynamic model discovery: use the Claude Agent SDK's startup() to get the
714
+ // REAL model list for THIS API key (different keys/tiers see different models).
715
+ // Falls back to settings.json availableModels → hardcoded list on any failure.
716
+ if (this.startup) {
717
+ let warm: Awaited<ReturnType<NonNullable<typeof this.startup>>> | undefined;
718
+ try {
719
+ warm = await this.startup({ initializeTimeoutMs: 10_000 });
720
+ const q = warm.query("");
721
+ try {
722
+ const sdkModels: { value: string; displayName: string }[] = await (q as unknown as { supportedModels(): Promise<{ value: string; displayName: string }[]> }).supportedModels();
723
+ if (sdkModels.length > 0) {
724
+ // Filter to admin-configured allowlist if present, then map to our format.
725
+ const allow = availableModels;
726
+ const filtered = allow
727
+ ? sdkModels.filter((m) => allow.includes(m.value) || m.value === "default")
728
+ : sdkModels;
729
+ return {
730
+ defaultModel,
731
+ models: filtered.map((m) => ({ id: m.value, label: m.displayName || m.value })),
732
+ };
733
+ }
734
+ } catch {
735
+ // supportedModels() failed — the subprocess may be in a bad state. Close it.
736
+ } finally {
737
+ try { warm.close(); } catch { /* ignore */ }
738
+ }
739
+ } catch {
740
+ // startup() failed — clean up if it partially succeeded.
741
+ try { warm?.close(); } catch { /* ignore */ }
742
+ }
743
+ }
744
+
745
+ // Fallback: admin-configured allowlist, or the full Claude Code alias set.
746
+ const fallbackModels = availableModels ?? [
747
+ "default", "sonnet", "opus", "opusplan", "haiku", "sonnet[1m]", "opus[1m]",
748
+ ];
689
749
  return {
690
750
  defaultModel,
691
- models: [
692
- { id: "default", label: "默认模型" },
693
- { id: "sonnet", label: "Sonnet" },
694
- { id: "opus", label: "Opus" },
695
- { id: "haiku", label: "Haiku" },
696
- { id: "sonnet[1m]", label: "Sonnet 1M" },
697
- { id: "opusplan", label: "Opus Plan" },
698
- ],
751
+ models: fallbackModels.map((id) => ({ id, label: FALLBACK_MODEL_LABELS[id] ?? id })),
699
752
  };
700
753
  }
701
754
 
@@ -574,16 +574,30 @@ export class ClaudeStreamJsonClient {
574
574
  }
575
575
 
576
576
  async listModels(): Promise<unknown> {
577
+ let defaultModel: string = "default";
578
+ let availableModels: string[] | undefined;
579
+ try {
580
+ const settingsPath = join(homedir(), ".claude", "settings.json");
581
+ if (existsSync(settingsPath)) {
582
+ const raw = JSON.parse(readFileSync(settingsPath, "utf8")) as { model?: unknown; availableModels?: unknown };
583
+ if (typeof raw.model === "string" && raw.model.trim().length > 0) {
584
+ defaultModel = raw.model.trim();
585
+ }
586
+ if (Array.isArray(raw.availableModels)) {
587
+ availableModels = raw.availableModels.filter((m): m is string => typeof m === "string");
588
+ }
589
+ }
590
+ } catch {
591
+ // Fallback to defaults if settings file is unreadable.
592
+ }
593
+ const ids = availableModels ?? ["default", "sonnet", "opus", "opusplan", "haiku", "sonnet[1m]", "opus[1m]"];
594
+ const LABELS: Record<string, string> = {
595
+ default: "默认模型", sonnet: "Sonnet", opus: "Opus", opusplan: "Opus Plan",
596
+ haiku: "Haiku", "sonnet[1m]": "Sonnet 1M", "opus[1m]": "Opus 1M",
597
+ };
577
598
  return {
578
- defaultModel: "default",
579
- models: [
580
- { id: "default", label: "默认模型" },
581
- { id: "sonnet", label: "Sonnet" },
582
- { id: "opus", label: "Opus" },
583
- { id: "haiku", label: "Haiku" },
584
- { id: "sonnet[1m]", label: "Sonnet 1M" },
585
- { id: "opusplan", label: "Opus Plan" },
586
- ],
599
+ defaultModel,
600
+ models: ids.map((id) => ({ id, label: LABELS[id] ?? id })),
587
601
  };
588
602
  }
589
603