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/dist/cli/src/index.js +28 -0
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/runtime/acp/claude-sdk-client.d.ts +1 -0
- package/dist/cli/src/runtime/acp/claude-sdk-client.js +67 -8
- package/dist/cli/src/runtime/acp/claude-sdk-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js +24 -9
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +2521 -2521
- package/package.json +4 -3
- package/scripts/copy-web.mjs +34 -0
- package/src/index.ts +29 -0
- package/src/runtime/acp/claude-sdk-client.ts +62 -9
- package/src/runtime/acp/claude-stream-json-client.ts +23 -9
- package/web/assets/index-BbeLYptY.js +94 -0
- package/web/assets/index-Dm-i3Xlm.css +33 -0
- package/web/index.html +14 -0
- package/web/logos/claude.png +0 -0
- package/web/logos/codex.png +0 -0
- package/web/logos/copilot.png +0 -0
- package/web/logos/gemini.png +0 -0
- package/web/logos/linkshell-1024.png +0 -0
- package/web/logos/linkshell-splash.png +0 -0
- package/web/logos/linkshell.png +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linkshell-cli",
|
|
3
|
-
"version": "0.5.
|
|
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.
|
|
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
|
|
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
|
|