From 41370e3750e874c8ec5e72e18c8855bbe3bfd5f2 Mon Sep 17 00:00:00 2001 From: shaun Date: Mon, 3 Aug 2026 15:07:51 +0800 Subject: [PATCH 1/4] Add Lark CLI direct connection --- .gitignore | 6 + docs/architecture.md | 15 +- electron-builder.ts | 4 + electron/agent/binaries.ts | 16 + electron/agent/manager.ts | 24 +- electron/agent/workspace.test.ts | 26 + electron/agent/workspace.ts | 46 +- electron/connections/common.ts | 3 + electron/link-runtime/common.ts | 26 + electron/link-runtime/lark-cli.test.ts | 25 + electron/link-runtime/lark-cli.ts | 675 ++++++++++++++++++ electron/link-runtime/node.ts | 29 +- electron/main.ts | 24 +- package.json | 2 +- scripts/download-lark-cli.ts | 14 + scripts/lark-cli.ts | 195 +++++ scripts/prepare-binaries.ts | 9 +- scripts/ripgrep.ts | 2 +- src/assets/apps/lark.svg | 1 + src/hooks/useLarkCliConnection.ts | 132 ++++ src/i18n/app-messages.en.ts | 14 + src/i18n/app-messages.zh.ts | 13 + .../ConnectionProviderDetailPane.tsx | 28 +- .../connection-route-model.test.ts | 12 + .../Connections/connection-route-model.ts | 3 + src/routes/Connections/index.tsx | 109 ++- 26 files changed, 1394 insertions(+), 59 deletions(-) create mode 100644 electron/link-runtime/lark-cli.test.ts create mode 100644 electron/link-runtime/lark-cli.ts create mode 100644 scripts/download-lark-cli.ts create mode 100644 scripts/lark-cli.ts create mode 100644 src/assets/apps/lark.svg create mode 100644 src/hooks/useLarkCliConnection.ts diff --git a/.gitignore b/.gitignore index 9dbfcbb7..e245ceb7 100644 --- a/.gitignore +++ b/.gitignore @@ -39,12 +39,18 @@ release # Locally downloaded oo binary (dev/build) — produced by scripts/download-oo.ts .oo-bin/ +# Locally downloaded Lark CLI binary (dev/build) — produced by scripts/download-lark-cli.ts +.lark-cli-bin/ + # Bundled oo/opencode binaries staged by scripts/prepare-binaries.ts (not committed) resources/bin # Bundled oo skills exported by scripts/skills.ts (not committed) resources/skills +# Lark CLI skills exported from the pinned binary (not committed) +resources/lark-skills + # Bundled self-contained OpenCode custom-tool runtime (not committed) resources/agent-tool-runtime diff --git a/docs/architecture.md b/docs/architecture.md index 132816c6..2f724f01 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -122,6 +122,19 @@ approval, while credential expansion, environment dumps, login/logout, and endpo are rejected even in Full Access. OOMOL Skill registry maintenance remains tied to the OOMOL account and does not follow the selected Link runtime. +Lark CLI is exposed in the same Connections catalog as a local `direct` provider, but it is not a +Link backend and does not send its credentials through the renderer or connector APIs. +`LarkCliManager` owns the isolated `/lark-cli/config` directory, drives the official +`config init --new` and `auth login --recommend` browser flows, and returns only redacted connection +state through `LinkRuntimeServiceImpl`. The shipped app contains a checksum-verified pinned CLI and +its matching embedded `lark-*` Skills. On Connect, Wanta first checks the official latest version; +an available update is downloaded to a versioned user-data runtime, verified against the release +checksum, and atomically activated. Update failure is non-fatal: authorization continues with the +already usable version. The active binary directory is prepended to the Agent sidecar `PATH`, its +config root is injected through `LARKSUITE_CLI_CONFIG_DIR`, and its matching Skills are copied into +the private Agent workspace. Thus chat uses the same local identity authorized from Connections, +independently of the selected OOMOL/OpenConnector Link runtime. + Vite (`vite-plugin-electron/simple` in `vite.config.ts`) bundles `electron/main.ts` and `electron/preload.ts` into `dist-electron/main.js` + `preload.js`; the main-process build has a **third** rollup input, `electron/chat/spreadsheet-preview-worker.ts` → `dist-electron/spreadsheet-preview-worker.js`, @@ -608,7 +621,7 @@ electron/ chat/ common,node + ~45 modules by far the largest main-process domain: SSE event bridge; per-turn lifecycle & outputs (turn-lifecycle, turn-outputs); structured artifact registration/persistence (artifact-bundles, artifacts) + previews (spreadsheet-preview-worker[-client]); permission / local-access policy (permission-state, project-permission); project-* commands; attachments; stream buffering (stream-event-buffer, context-system). Also thin main-process facades openExternalUrl (shell external open) / setAgentTeam (agent team scope) for the renderer request layer (§4, §5) git/ common,node,status,turn-diff(+test) GitService (serviceName("git-service")): project git status + per-turn diff review knowledge/ common,node,store,runner,uri,thumbnail(+test) WikiGraph knowledge-base import, registration, query runtime & RPC service - link-runtime/ common,node(+test) selected Link runtime, origin-bound OpenConnector token, health/inventory facade + link-runtime/ common,node,lark-cli(+test) selected Link runtime, origin-bound OpenConnector token, health/inventory facade, and isolated direct Lark CLI lifecycle teams/ common types only, no node.ts — team requests moved renderer-side (src/lib/teams-client.ts, §4) connections/ common,summary,usage,executions,federated,domain,summary-model(+test) **pure functions + types, no node.ts** — connector requests moved renderer-side (src/lib/connections-client.ts, §4/§7); electron-free, imported straight into the renderer bundle skills/ common,node,actions,scan,inventory,… skill service (install/scan/inventory); browse GET moved renderer-side (src/lib/skills-catalog-client.ts); actions.ts normalize* reused by the renderer (§4) diff --git a/electron-builder.ts b/electron-builder.ts index 42ab2b1c..44646994 100644 --- a/electron-builder.ts +++ b/electron-builder.ts @@ -68,6 +68,10 @@ export default { from: "resources/skills", to: "skills", }, + { + from: "resources/lark-skills", + to: "lark-skills", + }, { from: "resources/agent-tool-runtime", to: "agent-tool-runtime", diff --git a/electron/agent/binaries.ts b/electron/agent/binaries.ts index 39e4a806..25e35d26 100644 --- a/electron/agent/binaries.ts +++ b/electron/agent/binaries.ts @@ -22,11 +22,19 @@ export function ooBinaryName(platform: NodeJS.Platform = process.platform): stri return platform === "win32" ? "oo.exe" : "oo" } +export function larkCliBinaryName(platform: NodeJS.Platform = process.platform): string { + return platform === "win32" ? "lark-cli.exe" : "lark-cli" +} + /** dev:从项目本地 .oo-bin 解析 oo 二进制(postinstall 下载、prepare-binaries 同源;生产由 extraResources 解析)。 */ export function resolveDevOoBin(repoRoot: string, platform: NodeJS.Platform = process.platform): string { return path.join(repoRoot, ".oo-bin", ooBinaryName(platform)) } +export function resolveDevLarkCliBin(repoRoot: string, platform: NodeJS.Platform = process.platform): string { + return path.join(repoRoot, ".lark-cli-bin", larkCliBinaryName(platform)) +} + /** 生产:从打包的 Resources/bin 解析二进制(prepare-binaries 复制、extraResources 打入)。 */ export function resolveBundledBin(resourcesPath: string, binaryName: string): string { return path.join(resourcesPath, "bin", binaryName) @@ -42,6 +50,14 @@ export function resolveBundledSkillsDir(resourcesPath: string): string { return path.join(resourcesPath, "skills") } +export function resolveDevBundledLarkSkillsDir(repoRoot: string): string { + return path.join(repoRoot, "resources", "lark-skills") +} + +export function resolveBundledLarkSkillsDir(resourcesPath: string): string { + return path.join(resourcesPath, "lark-skills") +} + /** dev:构建期合并的自定义工具 runtime(postinstall 生成)。 */ export function resolveDevBundledToolRuntimePath(repoRoot: string): string { return path.join(repoRoot, "resources", "agent-tool-runtime", "tool.js") diff --git a/electron/agent/manager.ts b/electron/agent/manager.ts index 25c18088..179be91d 100644 --- a/electron/agent/manager.ts +++ b/electron/agent/manager.ts @@ -55,6 +55,12 @@ export interface AgentManagerOptions { listOpenConnectorAuthorizedServices?: (signal?: AbortSignal) => Promise /** 内置 skill 源目录(resources/skills 或打包 Resources/skills);启动时拷进 .opencode/skill/。 */ bundledSkillsDir?: string + /** Official Lark CLI skills, available for the local direct connection. */ + bundledLarkSkillsDir?: string + /** Active Wanta-managed Lark CLI direct-runtime binary. */ + larkCliBinPath?: string + /** Isolated Lark CLI config directory; credentials remain owned by the CLI/keychain. */ + larkCliConfigDir?: string /** 构建期合并的自定义工具 runtime;启动时拷进 .opencode/runtime/tool.js。 */ bundledToolRuntimePath?: string /** App 私有根目录(userData 下):workspace / oo-store / isolation 都在其下。 */ @@ -106,6 +112,8 @@ export interface AgentSidecarEnvOptions { storeDir: string teamName?: string teamScopePath: string + larkCliBinPath?: string + larkCliConfigDir?: string } export function buildAgentSidecarEnv({ @@ -116,6 +124,8 @@ export function buildAgentSidecarEnv({ storeDir, teamName, teamScopePath, + larkCliBinPath, + larkCliConfigDir, }: AgentSidecarEnvOptions): Record { const ooEnv = linkRuntime ? buildAgentLinkEnv({ @@ -132,6 +142,10 @@ export function buildAgentSidecarEnv({ PATH: commandPath, WANTA_BROWSER_CONTROL_TOKEN: browserControl?.token ?? "", WANTA_BROWSER_CONTROL_URL: browserControl?.url ?? "", + WANTA_LARK_CLI_BIN: larkCliBinPath ?? "", + LARKSUITE_CLI_CONFIG_DIR: larkCliConfigDir ?? "", + LARKSUITE_CLI_NO_SKILLS_NOTIFIER: "1", + LARKSUITE_CLI_NO_UPDATE_NOTIFIER: "1", } } @@ -415,6 +429,7 @@ export class AgentManager { await ensureAgentWorkspace(workspaceDir, bundledSkillsDir, bundledToolRuntimePath, { bundledOoSkills: this.options.linkRuntime?.kind === "oomol", + bundledLarkSkillsDir: this.options.bundledLarkSkillsDir, connectors: this.options.linkRuntime !== null, }) this.teamScopePath = teamScopePath @@ -433,6 +448,8 @@ export class AgentManager { defaultModel, wikiGraphCliPath, wikiGraphStateDir, + larkCliBinPath, + larkCliConfigDir, } = this.options const workspaceDir = path.join(rootDir, "workspace") const isolationDir = path.join(rootDir, "isolation") @@ -441,7 +458,10 @@ export class AgentManager { const config = buildOpencodeConfig({ customModels, defaultModel, linkRuntime, modelAccess }) const baseCommandPath = await resolveUserCommandPath({ - preferredDirectories: linkRuntime && ooBinPath ? [path.dirname(ooBinPath)] : [], + preferredDirectories: [ + ...(larkCliBinPath ? [path.dirname(larkCliBinPath)] : []), + ...(linkRuntime && ooBinPath ? [path.dirname(ooBinPath)] : []), + ], }) const wikiGraphBinDir = wikiGraphCliPath && wikiGraphStateDir @@ -462,6 +482,8 @@ export class AgentManager { storeDir, teamName: this.teamName, teamScopePath, + larkCliBinPath, + larkCliConfigDir, }) const sidecar = new OpencodeSidecar({ diff --git a/electron/agent/workspace.test.ts b/electron/agent/workspace.test.ts index 644dbd08..55418b0c 100644 --- a/electron/agent/workspace.test.ts +++ b/electron/agent/workspace.test.ts @@ -159,6 +159,32 @@ test("ensureAgentWorkspace gives OpenConnector Browser and typed tools without O } }) +test("ensureAgentWorkspace installs Lark direct-mode skills independently of the Link runtime", async () => { + const base = await mkdtemp(path.join(os.tmpdir(), "wanta-workspace-")) + try { + const workspaceDir = path.join(base, "workspace") + const bundledSkillsDir = path.join(base, "bundled-skills") + const bundledLarkSkillsDir = path.join(base, "lark-skills") + const bundledToolRuntimePath = await writeToolRuntime(base) + await writeSkill(bundledSkillsDir, "browser") + await writeSkill(bundledSkillsDir, "oo") + await writeSkill(bundledLarkSkillsDir, "lark-calendar") + + await ensureAgentWorkspace(workspaceDir, bundledSkillsDir, bundledToolRuntimePath, { + bundledLarkSkillsDir, + bundledOoSkills: false, + connectors: false, + }) + + const skillRoot = path.join(workspaceDir, ".opencode", "skill") + assert.ok(await exists(path.join(skillRoot, "browser", "SKILL.md"))) + assert.ok(await exists(path.join(skillRoot, "lark-calendar", "SKILL.md"))) + assert.equal(await exists(path.join(skillRoot, "oo")), false) + } finally { + await rm(base, { force: true, recursive: true }) + } +}) + test("ensureAgentWorkspace works without a bundled skills directory", async () => { const base = await mkdtemp(path.join(os.tmpdir(), "wanta-workspace-")) try { diff --git a/electron/agent/workspace.ts b/electron/agent/workspace.ts index 7d7244c1..e8e81b64 100644 --- a/electron/agent/workspace.ts +++ b/electron/agent/workspace.ts @@ -6,6 +6,7 @@ const alwaysAvailableBundledSkillIds = new Set(["browser"]) export interface AgentWorkspaceOptions { bundledOoSkills: boolean + bundledLarkSkillsDir?: string connectors: boolean } @@ -36,7 +37,7 @@ export async function ensureAgentWorkspace( ), ) await syncToolRuntime(opencodeDir, bundledToolRuntimePath) - await syncBundledSkills(opencodeDir, bundledSkillsDir, options.bundledOoSkills) + await syncBundledSkills(opencodeDir, bundledSkillsDir, options.bundledLarkSkillsDir, options.bundledOoSkills) return rootDir } @@ -62,37 +63,50 @@ async function syncToolRuntime(opencodeDir: string, bundledToolRuntimePath: stri async function syncBundledSkills( opencodeDir: string, bundledSkillsDir: string | undefined, + bundledLarkSkillsDir: string | undefined, includeOomolSkills: boolean, ): Promise { const skillDir = path.join(opencodeDir, "skill") - if (!bundledSkillsDir) { + if (!bundledSkillsDir && !bundledLarkSkillsDir) { await rm(skillDir, { force: true, recursive: true }) return } - let entries - try { - entries = await readdir(bundledSkillsDir, { withFileTypes: true }) - } catch (error) { - // 源缺失/不可读(如 dev 跳过 postinstall):非致命——skills 全程 best-effort,不为 4 个可选 skill 阻断 - // agent 启动。但显式告警(不再静默),避免发布包遗漏 Resources/skills 时问题被完全掩盖;保留已有副本不删。 - console.warn(`[wanta] bundled skills source unavailable at ${bundledSkillsDir}; keeping existing skills:`, error) - return + const sources: Array<{ directory: string; names: string[] }> = [] + for (const directory of [bundledSkillsDir, bundledLarkSkillsDir]) { + if (!directory) continue + try { + const entries = await readdir(directory, { withFileTypes: true }) + sources.push({ + directory, + names: entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name), + }) + } catch (error) { + // 源缺失/不可读(如 dev 跳过 postinstall):非致命——skills 全程 best-effort,不为 4 个可选 skill 阻断 + // agent 启动。但显式告警(不再静默),避免发布包遗漏 Resources/skills 时问题被完全掩盖;保留已有副本不删。 + console.warn(`[wanta] bundled skills source unavailable at ${directory}; keeping other skill sources:`, error) + } } + if (sources.length === 0) return + await rm(skillDir, { force: true, recursive: true }) - const skillNames = entries - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .filter((name) => includeOomolSkills || alwaysAvailableBundledSkillIds.has(name)) - if (skillNames.length === 0) { + const skillSources = sources.flatMap((source) => + source.names + .filter( + (name) => + source.directory === bundledLarkSkillsDir || includeOomolSkills || alwaysAvailableBundledSkillIds.has(name), + ) + .map((name) => ({ name, source: source.directory })), + ) + if (skillSources.length === 0) { return } await mkdir(skillDir, { recursive: true }) await Promise.all( - skillNames.map((name) => cp(path.join(bundledSkillsDir, name), path.join(skillDir, name), { recursive: true })), + skillSources.map(({ name, source }) => cp(path.join(source, name), path.join(skillDir, name), { recursive: true })), ) } diff --git a/electron/connections/common.ts b/electron/connections/common.ts index 42a87236..999475bf 100644 --- a/electron/connections/common.ts +++ b/electron/connections/common.ts @@ -65,10 +65,13 @@ export interface ConnectionProviderSummary { categoryLabels: string[] connectedUpdatedAt?: number displayName: string + description?: string + executionMode?: "direct" | "remote" iconUrl?: string oauthClientConfig?: ConnectionProviderOAuthClientConfigSummary | null service: string status: ConnectionProviderStatus + runtimeVersion?: string } export type ConnectionProvider = ConnectionProviderSummary diff --git a/electron/link-runtime/common.ts b/electron/link-runtime/common.ts index 071c1001..eeadf83f 100644 --- a/electron/link-runtime/common.ts +++ b/electron/link-runtime/common.ts @@ -46,10 +46,32 @@ export interface LinkRuntimeState { openConnector?: OpenConnectorSummary } +export type LarkCliConnectionPhase = + | "idle" + | "checking" + | "updating" + | "configuring" + | "authorizing" + | "verifying" + | "disconnecting" + +export interface LarkCliState { + accountLabel?: string + activeVersion: string | null + available: boolean + bundledVersion: string | null + connection: "connected" | "disconnected" | "expired" + error?: string + latestVersion?: string + phase: LarkCliConnectionPhase + updateStatus: "idle" | "checking" | "current" | "updating" | "updated" | "failed" +} + export type LinkRuntimeService = typeof LinkRuntimeService export const LinkRuntimeService = serviceName("link-runtime-service") as ServiceName<{ ServerEvents: { linkRuntimeChanged: LinkRuntimeState + larkCliChanged: LarkCliState } ClientInvokes: { getState(): Promise @@ -60,5 +82,9 @@ export const LinkRuntimeService = serviceName("link-runtime-service") as Service selectRuntime(kind: LinkRuntimeSelection): Promise clearOpenConnectorToken(): Promise removeOpenConnector(): Promise + getLarkCliState(): Promise + connectLarkCli(): Promise + disconnectLarkCli(): Promise + cancelLarkCliConnection(): Promise } }> diff --git a/electron/link-runtime/lark-cli.test.ts b/electron/link-runtime/lark-cli.test.ts new file mode 100644 index 00000000..233403f4 --- /dev/null +++ b/electron/link-runtime/lark-cli.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict" +import { test } from "vitest" +import { findOfficialAuthorizationUrl, isVersionNewer } from "./lark-cli.ts" + +test("Lark CLI update comparison handles stable and prerelease versions", () => { + assert.equal(isVersionNewer("1.0.82", "1.0.81"), true) + assert.equal(isVersionNewer("1.1.0", "1.0.99"), true) + assert.equal(isVersionNewer("1.0.81", "1.0.81"), false) + assert.equal(isVersionNewer("1.0.81-beta.2", "1.0.81-beta.1"), true) + assert.equal(isVersionNewer("1.0.81", "1.0.81-beta.2"), true) + assert.equal(isVersionNewer("invalid", "1.0.81"), false) +}) + +test("Lark CLI authorization URLs are recovered from JSON without widening the host allowlist", () => { + assert.equal( + findOfficialAuthorizationUrl('{"verification_url":"https://open.feishu.cn/device?a=1\\u0026b=2"}'), + "https://open.feishu.cn/device?a=1&b=2", + ) + assert.equal( + findOfficialAuthorizationUrl("https://open.larksuite.com/device?id=1"), + "https://open.larksuite.com/device?id=1", + ) + assert.equal(findOfficialAuthorizationUrl("https://open.feishu.cn.evil.example/device"), undefined) + assert.equal(findOfficialAuthorizationUrl("https://open.feishu.cn:8443/device"), undefined) +}) diff --git a/electron/link-runtime/lark-cli.ts b/electron/link-runtime/lark-cli.ts new file mode 100644 index 00000000..924a4554 --- /dev/null +++ b/electron/link-runtime/lark-cli.ts @@ -0,0 +1,675 @@ +import type { LarkCliState } from "./common.ts" +import type { ChildProcess } from "node:child_process" + +import { execFile, spawn } from "node:child_process" +import { createHash } from "node:crypto" +import { chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises" +import path from "node:path" +import { promisify } from "node:util" +import { gunzipSync, inflateRawSync } from "node:zlib" +import { atomicWriteText } from "../atomic-file.ts" +import { logDiagnostic } from "../diagnostics-log.ts" +import { ServiceEvent } from "../service-events.ts" + +const execFileAsync = promisify(execFile) +const updateCheckTimeoutMs = 3_000 +const downloadTimeoutMs = 60_000 +const authorizationTimeoutMs = 10 * 60_000 +const maxOutputBytes = 512 * 1024 +const maxDownloadBytes = 128 * 1024 * 1024 +const officialReleaseBase = "https://github.com/larksuite/cli/releases/download" +const npmLatestUrl = "https://registry.npmjs.org/@larksuite/cli/latest" + +interface ActiveBundle { + binaryPath: string + skillsDir: string + source: "bundled" | "managed" + version: string +} + +interface PersistedActiveBundle { + version: 1 + activeVersion: string +} + +export interface LarkCliManagerOptions { + bundledBinaryPath: string + bundledSkillsDir: string + fetch?: typeof fetch + onRuntimeChanged?: () => Promise | void + openExternalUrl: (url: string) => void + platform?: NodeJS.Platform + arch?: string + rootDir: string +} + +interface CommandResult { + stderr: string + stdout: string +} + +interface SkillDirectoryEntry { + is_dir?: boolean + path?: string +} + +export class LarkCliManager { + private readonly bundledBinaryPath: string + private readonly bundledSkillsDir: string + private readonly fetch: typeof fetch + private readonly onRuntimeChanged?: () => Promise | void + private readonly openExternalUrl: (url: string) => void + private readonly platform: NodeJS.Platform + private readonly arch: string + private readonly rootDir: string + private operation: Promise | null = null + private activeChild: ChildProcess | null = null + private cancelRequested = false + private state: LarkCliState = { + activeVersion: null, + available: false, + bundledVersion: null, + connection: "disconnected", + phase: "idle", + updateStatus: "idle", + } + public readonly stateChanged = new ServiceEvent() + + public constructor(options: LarkCliManagerOptions) { + this.bundledBinaryPath = options.bundledBinaryPath + this.bundledSkillsDir = options.bundledSkillsDir + this.fetch = options.fetch ?? globalThis.fetch + this.onRuntimeChanged = options.onRuntimeChanged + this.openExternalUrl = options.openExternalUrl + this.platform = options.platform ?? process.platform + this.arch = options.arch ?? process.arch + this.rootDir = options.rootDir + } + + public async getState(): Promise { + if (this.operation) return this.state + try { + const bundle = await this.resolveActiveBundle() + const auth = await this.readAuthState(bundle.binaryPath) + this.state = { + ...this.state, + activeVersion: bundle.version, + available: true, + bundledVersion: await this.readVersion(this.bundledBinaryPath).catch(() => null), + connection: auth.connection, + accountLabel: auth.accountLabel, + phase: "idle", + } + } catch (error) { + this.state = { + ...this.state, + activeVersion: null, + available: false, + connection: "disconnected", + error: errorMessage(error), + phase: "idle", + } + } + return this.state + } + + public connect(): Promise { + if (this.operation) return this.operation + this.cancelRequested = false + const operation = this.connectNow() + .catch((error: unknown) => { + this.setState({ error: this.cancelRequested ? undefined : errorMessage(error), phase: "idle" }) + throw error + }) + .finally(() => { + if (this.operation === operation) this.operation = null + }) + this.operation = operation + return operation + } + + public disconnect(): Promise { + if (this.operation) return Promise.reject(new Error("A Lark CLI connection operation is already running.")) + const operation = this.disconnectNow() + .catch((error: unknown) => { + this.setState({ error: errorMessage(error), phase: "idle" }) + throw error + }) + .finally(() => { + if (this.operation === operation) this.operation = null + }) + this.operation = operation + return operation + } + + public cancelConnection(): void { + this.cancelRequested = true + this.activeChild?.kill() + this.activeChild = null + this.setState({ phase: "idle" }) + } + + public async activeRuntime(): Promise { + try { + return await this.resolveActiveBundle() + } catch { + return null + } + } + + private async connectNow(): Promise { + this.setState({ error: undefined, phase: "checking", updateStatus: "checking" }) + let bundle = await this.resolveActiveBundle() + try { + bundle = await this.installLatestIfAvailable(bundle) + } catch (error) { + logDiagnostic("lark-cli", "Lark CLI update failed; continuing with the current version", { error }, "warn") + this.setState({ error: undefined, updateStatus: "failed" }) + } + this.assertNotCancelled() + + let auth = await this.readAuthState(bundle.binaryPath) + this.assertNotCancelled() + if (auth.connection === "disconnected" && auth.notConfigured) { + this.setState({ phase: "configuring" }) + await this.runAuthorizationCommand(bundle.binaryPath, [ + "config", + "init", + "--new", + "--brand", + "feishu", + "--lang", + "zh", + ]) + this.assertNotCancelled() + auth = await this.readAuthState(bundle.binaryPath) + } + if (auth.connection !== "connected") { + this.setState({ phase: "authorizing" }) + await this.runAuthorizationCommand(bundle.binaryPath, ["auth", "login", "--recommend", "--json"]) + this.assertNotCancelled() + } + + this.setState({ phase: "verifying" }) + auth = await this.readAuthState(bundle.binaryPath, true) + if (auth.connection !== "connected") + throw new Error("Lark CLI authorization did not produce a usable user identity.") + this.state = { + ...this.state, + accountLabel: auth.accountLabel, + activeVersion: bundle.version, + available: true, + connection: "connected", + error: undefined, + phase: "idle", + } + this.stateChanged.emit(this.state) + void Promise.resolve(this.onRuntimeChanged?.()).catch(() => undefined) + return this.state + } + + private async disconnectNow(): Promise { + const bundle = await this.resolveActiveBundle() + this.setState({ phase: "disconnecting" }) + await this.runCommand(bundle.binaryPath, ["auth", "logout"], authorizationTimeoutMs) + this.state = { + ...this.state, + accountLabel: undefined, + connection: "disconnected", + error: undefined, + phase: "idle", + } + this.stateChanged.emit(this.state) + void Promise.resolve(this.onRuntimeChanged?.()).catch(() => undefined) + return this.state + } + + private setState(patch: Partial): void { + this.state = { ...this.state, ...patch } + this.stateChanged.emit(this.state) + } + + private assertNotCancelled(): void { + if (this.cancelRequested) throw new Error("Lark CLI connection was cancelled.") + } + + private commandEnvironment(): NodeJS.ProcessEnv { + return { + ...process.env, + LARKSUITE_CLI_CONFIG_DIR: path.join(this.rootDir, "config"), + LARKSUITE_CLI_NO_UPDATE_NOTIFIER: "1", + LARKSUITE_CLI_NO_SKILLS_NOTIFIER: "1", + } + } + + private async readAuthState( + binaryPath: string, + verify = false, + ): Promise<{ + accountLabel?: string + connection: LarkCliState["connection"] + notConfigured: boolean + }> { + try { + const result = await this.runCommand( + binaryPath, + ["auth", "status", "--json", ...(verify ? ["--verify"] : [])], + 15_000, + ) + const value = JSON.parse(result.stdout) as Record + const identity = typeof value.identity === "string" ? value.identity : "none" + const verified = value.verified + if (identity === "user") { + return { + accountLabel: identityLabel(value), + connection: verified === false ? "expired" : "connected", + notConfigured: false, + } + } + return { connection: "disconnected", notConfigured: false } + } catch (error) { + const message = errorMessage(error) + return { + connection: "disconnected", + notConfigured: /not configured|config init|configuration.*missing/iu.test(message), + } + } + } + + private async runAuthorizationCommand(binaryPath: string, args: string[]): Promise { + await mkdir(path.join(this.rootDir, "config"), { recursive: true, mode: 0o700 }) + const child = spawn(binaryPath, args, { + env: this.commandEnvironment(), + stdio: ["ignore", "pipe", "pipe"], + }) + this.activeChild = child + let output = "" + let openedUrl: string | undefined + const capture = (chunk: Buffer): void => { + output = `${output}${chunk.toString("utf-8")}`.slice(-maxOutputBytes) + if (openedUrl) return + const url = findOfficialAuthorizationUrl(output) + if (!url) return + openedUrl = url + this.openExternalUrl(url) + } + child.stdout.on("data", capture) + child.stderr.on("data", capture) + const timeout = setTimeout(() => child.kill(), authorizationTimeoutMs) + try { + await new Promise((resolve, reject) => { + child.once("error", reject) + child.once("exit", (code, signal) => { + if (code === 0) resolve() + else reject(new Error(redactCommandError(output, code, signal))) + }) + }) + } finally { + clearTimeout(timeout) + if (this.activeChild === child) this.activeChild = null + } + } + + private async runCommand(binaryPath: string, args: string[], timeout: number): Promise { + try { + return await execFileAsync(binaryPath, args, { + encoding: "utf-8", + env: this.commandEnvironment(), + maxBuffer: maxOutputBytes, + timeout, + }) + } catch (error) { + const candidate = error as Error & { stderr?: string; stdout?: string } + throw new Error(redactCommandError(`${candidate.stderr ?? ""}\n${candidate.stdout ?? ""}\n${candidate.message}`)) + } + } + + private async readVersion(binaryPath: string): Promise { + const result = await this.runCommand(binaryPath, ["--version"], 10_000) + const version = `${result.stdout}\n${result.stderr}`.match(/\b(?:v)?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/u)?.[1] + if (!version) throw new Error("Lark CLI returned an unreadable version.") + return version + } + + private async resolveActiveBundle(): Promise { + const persisted = await this.readActiveMarker() + if (persisted) { + const root = path.join(this.rootDir, "runtime", "versions", persisted.activeVersion) + const bundle = { + binaryPath: path.join(root, binaryName(this.platform)), + skillsDir: path.join(root, "skills"), + source: "managed" as const, + version: persisted.activeVersion, + } + if (await bundleReady(bundle)) return bundle + } + const bundled: ActiveBundle = { + binaryPath: this.bundledBinaryPath, + skillsDir: this.bundledSkillsDir, + source: "bundled", + version: await this.readVersion(this.bundledBinaryPath), + } + if (!(await bundleReady(bundled))) throw new Error("The bundled Lark CLI runtime is unavailable.") + return bundled + } + + private async installLatestIfAvailable(current: ActiveBundle): Promise { + const latest = await this.fetchLatestVersion() + if (!isVersionNewer(latest, current.version)) { + this.setState({ latestVersion: latest, updateStatus: "current" }) + return current + } + this.setState({ latestVersion: latest, phase: "updating", updateStatus: "updating" }) + const installed = await this.downloadBundle(latest) + await atomicWriteText( + path.join(this.rootDir, "runtime", "current.json"), + `${JSON.stringify({ activeVersion: latest, version: 1 } satisfies PersistedActiveBundle, null, 2)}\n`, + { mode: 0o600 }, + ) + this.setState({ activeVersion: latest, updateStatus: "updated" }) + void Promise.resolve(this.onRuntimeChanged?.()).catch(() => undefined) + return installed + } + + private async fetchLatestVersion(): Promise { + const response = await this.fetch(npmLatestUrl, { signal: AbortSignal.timeout(updateCheckTimeoutMs) }) + if (!response.ok) throw new Error(`Lark CLI update check failed with HTTP ${response.status}.`) + const value = (await response.json()) as { version?: unknown } + if (typeof value.version !== "string" || !parseVersion(value.version)) { + throw new Error("Lark CLI update check returned an invalid version.") + } + return value.version + } + + private async downloadBundle(version: string): Promise { + const target = releaseTarget(version, this.platform, this.arch) + const base = `${officialReleaseBase}/v${version}` + const [archive, checksums] = await Promise.all([ + this.fetchDownload(`${base}/${target.assetName}`), + this.fetchDownload(`${base}/checksums.txt`), + ]) + const expected = checksumForAsset(checksums.toString("utf-8"), target.assetName) + const actual = createHash("sha256").update(archive).digest("hex") + if (!expected || actual !== expected) + throw new Error(`Lark CLI checksum verification failed for ${target.assetName}.`) + const binary = + target.kind === "zip" + ? extractZipFile(archive, target.binaryName) + : extractTarFile(gunzipSync(archive), target.binaryName) + if (!binary) throw new Error(`Lark CLI binary is missing from ${target.assetName}.`) + + const versionsRoot = path.join(this.rootDir, "runtime", "versions") + const finalDir = path.join(versionsRoot, version) + const staging = path.join(this.rootDir, "runtime", `staging-${version}-${Date.now()}`) + await mkdir(staging, { recursive: true, mode: 0o700 }) + const binaryPath = path.join(staging, target.binaryName) + try { + await writeFile(binaryPath, binary) + await chmod(binaryPath, 0o755) + await this.exportSkills(binaryPath, path.join(staging, "skills")) + const actualVersion = await this.readVersion(binaryPath) + if (actualVersion !== version) + throw new Error(`Downloaded Lark CLI reports ${actualVersion}, expected ${version}.`) + await mkdir(versionsRoot, { recursive: true, mode: 0o700 }) + await rm(finalDir, { force: true, recursive: true }) + await rename(staging, finalDir) + } finally { + await rm(staging, { force: true, recursive: true }) + } + return { + binaryPath: path.join(finalDir, target.binaryName), + skillsDir: path.join(finalDir, "skills"), + source: "managed", + version, + } + } + + private async fetchDownload(url: string): Promise { + const response = await this.fetch(url, { signal: AbortSignal.timeout(downloadTimeoutMs) }) + if (!response.ok) throw new Error(`Lark CLI download failed with HTTP ${response.status}.`) + const length = Number(response.headers.get("content-length") ?? "0") + if (length > maxDownloadBytes) throw new Error("Lark CLI download exceeded the size limit.") + const bytes = Buffer.from(await response.arrayBuffer()) + if (bytes.length > maxDownloadBytes) throw new Error("Lark CLI download exceeded the size limit.") + return bytes + } + + private async exportSkills(binaryPath: string, destination: string): Promise { + const listing = JSON.parse((await this.runCommand(binaryPath, ["skills", "list", "--json"], 30_000)).stdout) as { + skills?: Array<{ name?: unknown }> + } + const names = (listing.skills ?? []) + .map((entry) => entry.name) + .filter((name): name is string => typeof name === "string" && /^lark-[a-z0-9-]+$/u.test(name)) + if (names.length === 0) throw new Error("Lark CLI did not expose embedded skills.") + await mkdir(destination, { recursive: true, mode: 0o700 }) + for (const name of names) await this.exportSkillDirectory(binaryPath, name, "", destination) + } + + private async exportSkillDirectory( + binaryPath: string, + skill: string, + relative: string, + destination: string, + ): Promise { + const target = `${skill}${relative ? `/${relative}` : ""}` + const listed = JSON.parse( + (await this.runCommand(binaryPath, ["skills", "list", target, "--json"], 30_000)).stdout, + ) as { + entries?: SkillDirectoryEntry[] + } + for (const entry of listed.entries ?? []) { + if (typeof entry.path !== "string" || !entry.path.startsWith(`${skill}/`)) continue + const entryRelative = entry.path.slice(skill.length + 1) + if (!safeRelativePath(entryRelative)) continue + if (entry.is_dir) { + await this.exportSkillDirectory(binaryPath, skill, entryRelative, destination) + continue + } + const read = JSON.parse( + (await this.runCommand(binaryPath, ["skills", "read", skill, entryRelative, "--json"], 30_000)).stdout, + ) as { + content?: unknown + } + if (typeof read.content !== "string") throw new Error(`Lark CLI returned no content for ${entry.path}.`) + const output = path.join(destination, skill, entryRelative) + await mkdir(path.dirname(output), { recursive: true, mode: 0o700 }) + await writeFile(output, read.content, "utf-8") + } + } + + private async readActiveMarker(): Promise { + try { + const value = JSON.parse(await readFile(path.join(this.rootDir, "runtime", "current.json"), "utf-8")) as Record< + string, + unknown + > + return value.version === 1 && typeof value.activeVersion === "string" + ? { activeVersion: value.activeVersion, version: 1 } + : null + } catch { + return null + } + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function identityLabel(value: Record): string | undefined { + const identities = value.identities + if (!identities || typeof identities !== "object" || Array.isArray(identities)) return undefined + const user = (identities as Record).user + if (!user || typeof user !== "object" || Array.isArray(user)) return undefined + const item = user as Record + for (const key of ["userName", "name", "display_name", "email", "openId", "open_id"]) { + if (typeof item[key] === "string" && item[key]) return item[key] + } + return undefined +} + +export function findOfficialAuthorizationUrl(output: string): string | undefined { + for (const match of output.matchAll(/https:\/\/[^\s"'<>]+/gu)) { + const raw = decodeJsonUrlEscapes(match[0].replace(/[),.;]+$/u, "")) + try { + const url = new URL(raw) + if (isOfficialLarkHost(url.hostname) && (!url.port || url.port === "443")) return raw + } catch { + // Ignore partial output until a complete URL arrives. + } + } + return undefined +} + +function decodeJsonUrlEscapes(value: string): string { + return value.replaceAll(/\\u([0-9a-f]{4})/giu, (_, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16))) +} + +function isOfficialLarkHost(hostname: string): boolean { + const host = hostname.toLowerCase() + return ( + host === "feishu.cn" || host.endsWith(".feishu.cn") || host === "larksuite.com" || host.endsWith(".larksuite.com") + ) +} + +function redactCommandError(output: string, code?: number | null, signal?: NodeJS.Signals | null): string { + const redacted = output + .replaceAll(/https:\/\/[^\s"'<>]+/gu, "[authorization-url]") + .replaceAll(/("?(?:device_code|app_secret|access_token|refresh_token)"?\s*[:=]\s*)[^\s,}\]]+/giu, "$1[redacted]") + .trim() + const suffix = code === undefined ? "" : ` (exit ${code ?? "null"}${signal ? `, ${signal}` : ""})` + return `${redacted || "Lark CLI command failed"}${suffix}` +} + +function parseVersion(input: string): [number, number, number, string[]] | null { + const match = input.trim().match(/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$/u) + if (!match) return null + return [Number(match[1]), Number(match[2]), Number(match[3]), match[4]?.split(".") ?? []] +} + +export function isVersionNewer(candidate: string, current: string): boolean { + const left = parseVersion(candidate) + const right = parseVersion(current) + if (!left) return false + if (!right) return true + for (let index = 0; index < 3; index += 1) { + if ((left[index] as number) !== (right[index] as number)) return (left[index] as number) > (right[index] as number) + } + const leftPre = left[3] + const rightPre = right[3] + if (leftPre.length === 0 || rightPre.length === 0) return leftPre.length === 0 && rightPre.length > 0 + return leftPre.join(".").localeCompare(rightPre.join("."), undefined, { numeric: true }) > 0 +} + +function releaseTarget(version: string, platform: NodeJS.Platform, arch: string) { + const upstreamArch = arch === "x64" ? "amd64" : arch + if (!new Set(["amd64", "arm64", "riscv64"]).has(upstreamArch)) + throw new Error(`Unsupported Lark CLI architecture: ${arch}`) + if (platform === "darwin" && upstreamArch !== "riscv64") { + return { + assetName: `lark-cli-${version}-darwin-${upstreamArch}.tar.gz`, + binaryName: "lark-cli", + kind: "tar" as const, + } + } + if (platform === "linux") { + return { + assetName: `lark-cli-${version}-linux-${upstreamArch}.tar.gz`, + binaryName: "lark-cli", + kind: "tar" as const, + } + } + if (platform === "win32" && upstreamArch !== "riscv64") { + return { + assetName: `lark-cli-${version}-windows-${upstreamArch}.zip`, + binaryName: "lark-cli.exe", + kind: "zip" as const, + } + } + throw new Error(`Unsupported Lark CLI platform: ${platform} ${arch}`) +} + +function binaryName(platform: NodeJS.Platform): string { + return platform === "win32" ? "lark-cli.exe" : "lark-cli" +} + +function checksumForAsset(checksums: string, assetName: string): string | null { + for (const line of checksums.split(/\r?\n/u)) { + const match = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/iu) + if (match?.[2] === assetName) return match[1]?.toLowerCase() ?? null + } + return null +} + +async function bundleReady(bundle: ActiveBundle): Promise { + try { + const [binary, skills] = await Promise.all([stat(bundle.binaryPath), readdir(bundle.skillsDir)]) + return binary.isFile() && skills.some((name) => name.startsWith("lark-")) + } catch { + return false + } +} + +function safeRelativePath(value: string): boolean { + return Boolean(value) && !value.split(/[\\/]/u).includes("..") && !path.isAbsolute(value) +} + +function extractTarFile(tar: Buffer, wantedPath: string): Buffer | null { + let offset = 0 + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512) + if (header.every((byte) => byte === 0)) break + const name = tarString(header, 0, 100) + const prefix = tarString(header, 345, 155) + const fullName = prefix ? `${prefix}/${name}` : name + const size = Number.parseInt(tarString(header, 124, 12).trim() || "0", 8) + const dataStart = offset + 512 + if (fullName === wantedPath) { + if (dataStart + size > tar.length) throw new Error("Truncated Lark CLI archive.") + return tar.subarray(dataStart, dataStart + size) + } + offset = dataStart + Math.ceil(size / 512) * 512 + } + return null +} + +function tarString(header: Buffer, start: number, length: number): string { + const value = header.subarray(start, start + length) + const nul = value.indexOf(0) + return value.toString("utf-8", 0, nul === -1 ? length : nul) +} + +function extractZipFile(zip: Buffer, wantedPath: string): Buffer | null { + let eocd = -1 + for (let offset = zip.length - 22; offset >= 0 && offset >= zip.length - 65_557; offset -= 1) { + if (zip.readUInt32LE(offset) === 0x06054b50) { + eocd = offset + break + } + } + if (eocd < 0) throw new Error("Invalid Lark CLI zip archive.") + const count = zip.readUInt16LE(eocd + 10) + let central = zip.readUInt32LE(eocd + 16) + for (let index = 0; index < count; index += 1) { + if (zip.readUInt32LE(central) !== 0x02014b50) throw new Error("Invalid Lark CLI zip directory.") + const method = zip.readUInt16LE(central + 10) + const compressedSize = zip.readUInt32LE(central + 20) + const nameLength = zip.readUInt16LE(central + 28) + const extraLength = zip.readUInt16LE(central + 30) + const commentLength = zip.readUInt16LE(central + 32) + const localOffset = zip.readUInt32LE(central + 42) + const name = zip.toString("utf-8", central + 46, central + 46 + nameLength) + if (name === wantedPath) { + const localNameLength = zip.readUInt16LE(localOffset + 26) + const localExtraLength = zip.readUInt16LE(localOffset + 28) + const dataStart = localOffset + 30 + localNameLength + localExtraLength + const data = zip.subarray(dataStart, dataStart + compressedSize) + if (method === 0) return data + if (method === 8) return inflateRawSync(data) + throw new Error(`Unsupported Lark CLI zip compression method ${method}.`) + } + central += 46 + nameLength + extraLength + commentLength + } + return null +} diff --git a/electron/link-runtime/node.ts b/electron/link-runtime/node.ts index 0231e760..03879db1 100644 --- a/electron/link-runtime/node.ts +++ b/electron/link-runtime/node.ts @@ -5,6 +5,7 @@ import type { OpenConnectorAppSummary, OpenConnectorRuntimeStatus, OpenConnectorTestResult, + LarkCliState, } from "./common.ts" import type { IConnectionService } from "@oomol/connection" @@ -14,6 +15,7 @@ import path from "node:path" import { atomicWriteText } from "../atomic-file.ts" import { ServiceEvent } from "../service-events.ts" import { LinkRuntimeService as LinkRuntimeServiceName } from "./common.ts" +import { LarkCliManager } from "./lark-cli.ts" export interface RuntimeCredentialEncryption { decryptString(encrypted: Buffer): string @@ -443,16 +445,24 @@ export class LinkRuntimeServiceImpl implements IConnectionService { private readonly manager: LinkRuntimeManager + private readonly larkCli: LarkCliManager private readonly unsubscribe: () => void + private readonly unsubscribeLarkCli: () => void - public constructor(manager: LinkRuntimeManager) { + public constructor(manager: LinkRuntimeManager, larkCli: LarkCliManager) { super(LinkRuntimeServiceName) this.manager = manager + this.larkCli = larkCli this.unsubscribe = manager.stateChanged.on((state) => { void this.send("linkRuntimeChanged", state).catch((error: unknown) => { console.warn("[wanta] Link runtime broadcast failed:", error) }) }) + this.unsubscribeLarkCli = larkCli.stateChanged.on((state) => { + void this.send("larkCliChanged", state).catch((error: unknown) => { + console.warn("[wanta] Lark CLI state broadcast failed:", error) + }) + }) } public getState(): Promise { @@ -491,8 +501,25 @@ export class LinkRuntimeServiceImpl return this.manager.removeOpenConnector() } + public getLarkCliState(): Promise { + return this.larkCli.getState() + } + + public connectLarkCli(): Promise { + return this.larkCli.connect() + } + + public disconnectLarkCli(): Promise { + return this.larkCli.disconnect() + } + + public async cancelLarkCliConnection(): Promise { + this.larkCli.cancelConnection() + } + public override dispose(): void { this.unsubscribe() + this.unsubscribeLarkCli() super.dispose() } } diff --git a/electron/main.ts b/electron/main.ts index f6ccc7bf..2eaee15b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -23,13 +23,17 @@ import { fileURLToPath, pathToFileURL } from "node:url" import { AgentRefreshScheduler } from "./agent-refresh-scheduler.ts" import { ooBinaryName, + larkCliBinaryName, opencodeBinaryName, resolveBundledBin, resolveBundledSkillsDir, + resolveBundledLarkSkillsDir, resolveBundledToolRuntimePath, resolveDevBundledSkillsDir, + resolveDevBundledLarkSkillsDir, resolveDevBundledToolRuntimePath, resolveDevOoBin, + resolveDevLarkCliBin, resolveDevOpencodeBin, } from "./agent/binaries.ts" import { AgentManager } from "./agent/manager.ts" @@ -63,6 +67,7 @@ import { parseConnectionOAuthCallback } from "./connections/domain.ts" import { configureDiagnosticsLog, flushDiagnosticsLog, logDiagnostic } from "./diagnostics-log.ts" import { GitServiceImpl } from "./git/node.ts" import { KnowledgeServiceImpl } from "./knowledge/node.ts" +import { LarkCliManager } from "./link-runtime/lark-cli.ts" import { LinkRuntimeManager, LinkRuntimeServiceImpl } from "./link-runtime/node.ts" import { isAudioOnlyMediaRequest, isTrustedRendererUrl } from "./media-permission-policy.ts" import { ModelCredentialStore } from "./models/credential-store.ts" @@ -148,12 +153,18 @@ const opencodeBinPath = app.isPackaged ? resolveBundledBin(process.resourcesPath, opencodeBinaryName()) : resolveDevOpencodeBin(appRoot) const ooBinPath = app.isPackaged ? resolveBundledBin(process.resourcesPath, ooBinaryName()) : resolveOoBin() +const bundledLarkCliBinPath = app.isPackaged + ? resolveBundledBin(process.resourcesPath, larkCliBinaryName()) + : resolveDevLarkCliBin(appRoot) process.env.OO_CLI_PATH = ooBinPath // 内置 skill 源目录:生产从打包 Resources/skills,dev 从 resources/skills(postinstall 导出)。 // AgentManager 启动时拷进 OpenCode workspace 的 .opencode/skill/,使 agent 直接读到。 const bundledSkillsDir = app.isPackaged ? resolveBundledSkillsDir(process.resourcesPath) : resolveDevBundledSkillsDir(appRoot) +const bundledLarkSkillsDir = app.isPackaged + ? resolveBundledLarkSkillsDir(process.resourcesPath) + : resolveDevBundledLarkSkillsDir(appRoot) const bundledToolRuntimePath = app.isPackaged ? resolveBundledToolRuntimePath(process.resourcesPath) : resolveDevBundledToolRuntimePath(appRoot) @@ -272,7 +283,14 @@ const linkRuntimeManager = new LinkRuntimeManager({ getOomolAvailable: async () => Boolean(await authManager.currentSessionToken()), onRuntimeChanged: () => agentRefreshScheduler.schedule("Link runtime changed", 0), }) -const linkRuntimeService = new LinkRuntimeServiceImpl(linkRuntimeManager) +const larkCliManager = new LarkCliManager({ + bundledBinaryPath: bundledLarkCliBinPath, + bundledSkillsDir: bundledLarkSkillsDir, + onRuntimeChanged: () => agentRefreshScheduler.schedule("Lark CLI runtime changed", 0), + openExternalUrl, + rootDir: path.join(app.getPath("userData"), "lark-cli"), +}) +const linkRuntimeService = new LinkRuntimeServiceImpl(linkRuntimeManager, larkCliManager) const authService = new AuthServiceImpl(authManager) const skillService = new SkillServiceImpl(authManager, { onRuntimeSkillsChanged: (reason) => agentRefreshScheduler.schedule(reason), @@ -690,6 +708,7 @@ async function applyAuthAccountNow(account: AuthRuntimeAccount | null): Promise< if (isQuitting) { return } + const larkCliRuntime = await larkCliManager.activeRuntime() const nextAgent = new AgentManager({ browserControl: browserControlConnection, defaultModel: runtime.defaultModel, @@ -704,7 +723,10 @@ async function applyAuthAccountNow(account: AuthRuntimeAccount | null): Promise< .filter((item) => item.status === "active") .map((item) => item.service), bundledSkillsDir, + bundledLarkSkillsDir: larkCliRuntime?.skillsDir ?? bundledLarkSkillsDir, bundledToolRuntimePath, + larkCliBinPath: larkCliRuntime?.binaryPath ?? bundledLarkCliBinPath, + larkCliConfigDir: path.join(app.getPath("userData"), "lark-cli", "config"), rootDir: path.join(app.getPath("userData"), "agent"), customModels: runtimeModels.customModels, }) diff --git a/package.json b/package.json index b6ccc2cf..3bb00b8b 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "auth:restore": "node --experimental-strip-types ./scripts/dev-auth-state.ts restore", "auth:clean": "node --experimental-strip-types ./scripts/dev-auth-state.ts clean", "auth:status": "node --experimental-strip-types ./scripts/dev-auth-state.ts status", - "postinstall": "node --experimental-strip-types ./scripts/download-electron.ts && node --experimental-strip-types ./scripts/download-oo.ts && node --experimental-strip-types ./scripts/download-skills.ts && node --experimental-strip-types ./scripts/download-ripgrep.ts && node --experimental-strip-types ./scripts/build-agent-tool-runtime.ts", + "postinstall": "node --experimental-strip-types ./scripts/download-electron.ts && node --experimental-strip-types ./scripts/download-oo.ts && node --experimental-strip-types ./scripts/download-skills.ts && node --experimental-strip-types ./scripts/download-ripgrep.ts && node --experimental-strip-types ./scripts/download-lark-cli.ts && node --experimental-strip-types ./scripts/build-agent-tool-runtime.ts", "predev": "node --experimental-strip-types ./scripts/check-oo.ts", "dev": "node --experimental-strip-types ./scripts/dev.ts", "dev:worktree": "node --experimental-strip-types ./scripts/dev-worktree.ts", diff --git a/scripts/download-lark-cli.ts b/scripts/download-lark-cli.ts new file mode 100644 index 00000000..dfe9f500 --- /dev/null +++ b/scripts/download-lark-cli.ts @@ -0,0 +1,14 @@ +import { downloadLarkCliBinary, exportLarkCliSkills, LARK_CLI_VERSION } from "./lark-cli.ts" + +try { + if (process.env.LARK_CLI_SKIP_BINARY_DOWNLOAD === "1") { + console.log("LARK_CLI_SKIP_BINARY_DOWNLOAD=1, skip downloading Lark CLI.") + } else { + const binary = await downloadLarkCliBinary() + const skills = await exportLarkCliSkills() + console.log(`[wanta] Lark CLI ${LARK_CLI_VERSION} ready at ${binary}`) + console.log(`[wanta] Lark CLI skills ready at ${skills}`) + } +} catch (error) { + console.warn("[wanta] download-lark-cli postinstall failed (non-fatal):", error) +} diff --git a/scripts/lark-cli.ts b/scripts/lark-cli.ts new file mode 100644 index 00000000..41fc816e --- /dev/null +++ b/scripts/lark-cli.ts @@ -0,0 +1,195 @@ +import { execFile } from "node:child_process" +import { createHash } from "node:crypto" +import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { promisify } from "node:util" +import { gunzipSync } from "node:zlib" +import { fetchWithRetry } from "./network-download.ts" +import { extractFileFromTar } from "./oo-cli.ts" +import { extractFileFromZip } from "./ripgrep.ts" + +const execFileAsync = promisify(execFile) +const dirname = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.join(dirname, "..") + +export const LARK_CLI_VERSION = "1.0.81" +export const localLarkCliBinDir = path.join(repoRoot, ".lark-cli-bin") +export const bundledLarkSkillsDir = path.join(repoRoot, "resources", "lark-skills") + +interface LarkCliTarget { + archiveKind: "tar.gz" | "zip" + assetName: string + binaryName: string +} + +interface SkillListEntry { + name?: string +} + +interface SkillDirectoryEntry { + is_dir?: boolean + path?: string +} + +export function larkCliBinaryName(platform: NodeJS.Platform = process.platform): string { + return platform === "win32" ? "lark-cli.exe" : "lark-cli" +} + +export function localLarkCliBinPath(platform: NodeJS.Platform = process.platform): string { + return path.join(localLarkCliBinDir, larkCliBinaryName(platform)) +} + +export function resolveLarkCliTarget( + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, +): LarkCliTarget { + const binaryName = larkCliBinaryName(platform) + const upstreamArch = arch === "x64" ? "amd64" : arch + if (upstreamArch !== "amd64" && upstreamArch !== "arm64" && !(platform === "linux" && upstreamArch === "riscv64")) { + throw new Error(`No prebuilt Lark CLI binary is available for ${platform} ${arch}.`) + } + if (platform === "darwin") { + return { + archiveKind: "tar.gz", + assetName: `lark-cli-${LARK_CLI_VERSION}-darwin-${upstreamArch}.tar.gz`, + binaryName, + } + } + if (platform === "linux") { + return { + archiveKind: "tar.gz", + assetName: `lark-cli-${LARK_CLI_VERSION}-linux-${upstreamArch}.tar.gz`, + binaryName, + } + } + if (platform === "win32" && upstreamArch !== "riscv64") { + return { + archiveKind: "zip", + assetName: `lark-cli-${LARK_CLI_VERSION}-windows-${upstreamArch}.zip`, + binaryName, + } + } + throw new Error(`No prebuilt Lark CLI binary is available for ${platform} ${arch}.`) +} + +function releaseAssetUrl(name: string): string { + return `https://github.com/larksuite/cli/releases/download/v${LARK_CLI_VERSION}/${name}` +} + +function checksumForAsset(checksums: string, assetName: string): string | null { + for (const line of checksums.split(/\r?\n/u)) { + const match = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/iu) + if (match?.[2] === assetName) return match[1]?.toLowerCase() ?? null + } + return null +} + +async function fetchBytes(url: string): Promise { + const response = await fetchWithRetry(url) + if (!response.ok) throw new Error(`download Lark CLI failed: HTTP ${response.status} ${url}`) + return Buffer.from(await response.arrayBuffer()) +} + +async function isPinnedBinaryReady(dest: string, marker: string): Promise { + try { + await stat(dest) + return (await readFile(marker, "utf-8")).trim() === LARK_CLI_VERSION + } catch { + return false + } +} + +export async function downloadLarkCliBinary(): Promise { + const target = resolveLarkCliTarget() + const dest = localLarkCliBinPath() + const marker = path.join(localLarkCliBinDir, ".version") + if (await isPinnedBinaryReady(dest, marker)) return dest + + const [archive, checksums] = await Promise.all([ + fetchBytes(releaseAssetUrl(target.assetName)), + fetchBytes(releaseAssetUrl("checksums.txt")), + ]) + const expected = checksumForAsset(checksums.toString("utf-8"), target.assetName) + const actual = createHash("sha256").update(archive).digest("hex") + if (!expected || expected !== actual) { + throw new Error(`sha256 mismatch for ${target.assetName}: expected ${expected ?? ""}, got ${actual}`) + } + const binary = + target.archiveKind === "zip" + ? extractFileFromZip(archive, target.binaryName) + : extractFileFromTar(gunzipSync(archive), target.binaryName) + if (!binary) throw new Error(`Lark CLI binary not found inside ${target.assetName}`) + + await mkdir(localLarkCliBinDir, { recursive: true }) + const temporary = `${dest}.download` + try { + await writeFile(temporary, binary) + await chmod(temporary, 0o755) + await rename(temporary, dest) + } finally { + await rm(temporary, { force: true }) + } + await writeFile(marker, `${LARK_CLI_VERSION}\n`, "utf-8") + return dest +} + +async function runJson(binary: string, args: string[]): Promise { + const { stdout } = await execFileAsync(binary, args, { + encoding: "utf-8", + env: { + ...process.env, + LARKSUITE_CLI_NO_SKILLS_NOTIFIER: "1", + LARKSUITE_CLI_NO_UPDATE_NOTIFIER: "1", + }, + maxBuffer: 8 * 1024 * 1024, + }) + return JSON.parse(stdout) as unknown +} + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null +} + +async function exportSkillDirectory(binary: string, skillName: string, relativePath: string, outputRoot: string) { + const listed = record( + await runJson(binary, ["skills", "list", `${skillName}${relativePath ? `/${relativePath}` : ""}`, "--json"]), + ) + const entries = Array.isArray(listed?.entries) ? (listed.entries as SkillDirectoryEntry[]) : [] + for (const entry of entries) { + if (typeof entry.path !== "string" || !entry.path) continue + const entryRelative = entry.path.startsWith(`${skillName}/`) ? entry.path.slice(skillName.length + 1) : entry.path + if (!entryRelative || entryRelative.includes("..") || path.isAbsolute(entryRelative)) continue + if (entry.is_dir) { + await exportSkillDirectory(binary, skillName, entryRelative, outputRoot) + continue + } + const content = record(await runJson(binary, ["skills", "read", skillName, entryRelative, "--json"]))?.content + if (typeof content !== "string") throw new Error(`Lark CLI returned no content for ${skillName}/${entryRelative}`) + const output = path.join(outputRoot, skillName, entryRelative) + await mkdir(path.dirname(output), { recursive: true }) + await writeFile(output, content, "utf-8") + } +} + +export async function exportLarkCliSkills(outputRoot: string = bundledLarkSkillsDir): Promise { + const binary = await downloadLarkCliBinary() + const listing = record(await runJson(binary, ["skills", "list", "--json"])) + const skillNames = (Array.isArray(listing?.skills) ? (listing.skills as SkillListEntry[]) : []) + .map((skill) => skill.name) + .filter((name): name is string => typeof name === "string" && /^lark-[a-z0-9-]+$/u.test(name)) + if (skillNames.length === 0) throw new Error("Lark CLI did not expose any embedded skills") + + const staging = `${outputRoot}.staging` + await rm(staging, { force: true, recursive: true }) + await mkdir(staging, { recursive: true }) + try { + for (const skillName of skillNames) await exportSkillDirectory(binary, skillName, "", staging) + await rm(outputRoot, { force: true, recursive: true }) + await rename(staging, outputRoot) + } finally { + await rm(staging, { force: true, recursive: true }) + } + await writeFile(path.join(outputRoot, ".version"), `${LARK_CLI_VERSION}\n`, "utf-8") + return outputRoot +} diff --git a/scripts/prepare-binaries.ts b/scripts/prepare-binaries.ts index 44a7cf9c..efe9433d 100644 --- a/scripts/prepare-binaries.ts +++ b/scripts/prepare-binaries.ts @@ -1,14 +1,16 @@ -// 打包前:把当前平台的 opencode + oo + rg 二进制复制到 resources/bin/,供 electron-builder +// 打包前:把当前平台的 opencode + oo + rg + Lark CLI 二进制复制到 resources/bin/,供 electron-builder // extraResources 打进 app 的 Resources/bin(运行时 app.isPackaged 走 process.resourcesPath/bin)。 // 来源: // - opencode:node_modules/opencode-ai/bin/opencode.exe(opencode-ai postinstall 已为本机选好 // 正确平台/变体并复制到这个固定名,故不自行拼包名,详见 electron/agent/binaries.ts); // - oo:.oo-bin/(download-oo.ts 下载;缺失则此处自行 ensure,故全新检出 / 跳过 postinstall 的 CI 也能打包)。 // - rg:.oo-bin/(download-ripgrep.ts 下载;OpenCode 内置 grep 工具运行时从 PATH 查找)。 +// - Lark CLI:.lark-cli-bin/(download-lark-cli.ts 下载,并从同版本导出 lark-* skills)。 import { chmodSync, copyFileSync, mkdirSync } from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" import { buildAgentToolRuntime } from "./build-agent-tool-runtime.ts" +import { downloadLarkCliBinary, exportLarkCliSkills, larkCliBinaryName } from "./lark-cli.ts" import { downloadOoBinary, ooExecutableName } from "./oo-cli.ts" import { downloadRipgrepBinary, ripgrepExecutableName } from "./ripgrep.ts" import { bundledSkillsDir, exportBundledSkills } from "./skills.ts" @@ -43,6 +45,11 @@ bundle("oo", ooSrc, ooExecutableName()) const ripgrepSrc = await downloadRipgrepBinary() bundle("ripgrep", ripgrepSrc, ripgrepExecutableName()) +const larkCliSrc = await downloadLarkCliBinary() +bundle("Lark CLI", larkCliSrc, larkCliBinaryName()) +await exportLarkCliSkills() +console.log("[wanta] bundled Lark CLI skills") + // 内置 4 个 oo skill:导出到 resources/skills/,由 electron-builder extraResources 打入 Resources/skills, // 运行时拷进 OpenCode workspace 的 .opencode/skill/(见 electron/agent/workspace.ts)。 await exportBundledSkills() diff --git a/scripts/ripgrep.ts b/scripts/ripgrep.ts index 6a7a45e9..afdc5726 100644 --- a/scripts/ripgrep.ts +++ b/scripts/ripgrep.ts @@ -129,7 +129,7 @@ function readUInt32(buffer: Buffer, offset: number): number { return buffer.readUInt32LE(offset) } -function extractFileFromZip(zip: Buffer, wantedPath: string): Buffer | null { +export function extractFileFromZip(zip: Buffer, wantedPath: string): Buffer | null { const eocdSignature = 0x06054b50 let eocd = -1 for (let offset = zip.length - 22; offset >= 0 && offset >= zip.length - 65557; offset -= 1) { diff --git a/src/assets/apps/lark.svg b/src/assets/apps/lark.svg new file mode 100644 index 00000000..c54162e5 --- /dev/null +++ b/src/assets/apps/lark.svg @@ -0,0 +1 @@ + diff --git a/src/hooks/useLarkCliConnection.ts b/src/hooks/useLarkCliConnection.ts new file mode 100644 index 00000000..25b740cc --- /dev/null +++ b/src/hooks/useLarkCliConnection.ts @@ -0,0 +1,132 @@ +import type { + ConnectionAppSummary, + ConnectionProviderDetail, + ConnectionProviderSummary, +} from "../../electron/connections/common.ts" +import type { LarkCliState } from "../../electron/link-runtime/common.ts" + +import * as React from "react" +import { useLinkRuntimeService } from "../components/AppContext.ts" +import { resolveConnectionError } from "../lib/connections-error.ts" +import larkIconUrl from "@/assets/apps/lark.svg" + +const service = "lark-cli" + +function appFromState(state: LarkCliState): ConnectionAppSummary | null { + if (state.connection === "disconnected") return null + const now = Date.now() + return { + accountLabel: state.accountLabel, + authType: "oauth2", + connectionName: "default", + createdAt: now, + displayName: state.accountLabel, + id: "direct:lark-cli:default", + isDefault: true, + service, + status: state.connection === "connected" ? "active" : "reauth_required", + updatedAt: now, + } +} + +export function larkCliProviderFromState( + state: LarkCliState, + copy: { description: string; displayName: string }, +): ConnectionProviderSummary { + const app = appFromState(state) + return { + accountLabel: state.accountLabel, + appAuthType: "oauth2", + appCount: app ? 1 : 0, + apps: app ? [app] : [], + authTypes: ["oauth2"], + actionKind: state.available ? "oauth2" : "unavailable", + canDisconnect: Boolean(app), + categoryLabels: ["Communication", "Documentation", "Productivity"], + connectedUpdatedAt: app?.updatedAt, + description: copy.description, + displayName: copy.displayName, + executionMode: "direct", + iconUrl: larkIconUrl, + runtimeVersion: state.activeVersion ?? undefined, + service, + status: + state.connection === "connected" ? "connected" : state.connection === "expired" ? "needs_attention" : "available", + } +} + +export function larkCliProviderDetail(provider: ConnectionProviderSummary): ConnectionProviderDetail { + return { + ...provider, + apiKeyConfig: null, + customCredentialConfig: null, + federatedCredentialConfig: null, + homepageUrl: "https://github.com/larksuite/cli", + oauthClientConfig: null, + } +} + +export function useLarkCliConnection() { + const linkRuntimeService = useLinkRuntimeService() + const [state, setState] = React.useState(null) + const [error, setError] = React.useState | null>(null) + const cancellationRequestedRef = React.useRef(false) + + React.useEffect(() => { + let active = true + void linkRuntimeService + .invoke("getLarkCliState") + .then((next) => { + if (active) setState(next) + }) + .catch((cause: unknown) => { + if (active) setError(resolveConnectionError(cause, "summary")) + }) + const unsubscribe = linkRuntimeService.serverEvents.on("larkCliChanged", (next) => { + if (active) setState(next) + }) + return () => { + active = false + unsubscribe() + } + }, [linkRuntimeService]) + + const mutate = React.useCallback( + async (method: "connectLarkCli" | "disconnectLarkCli") => { + cancellationRequestedRef.current = false + setError(null) + try { + const next = await linkRuntimeService.invoke(method) + setState(next) + return true + } catch (cause) { + if (!cancellationRequestedRef.current) { + setError(resolveConnectionError(cause, method === "connectLarkCli" ? "connect" : "disconnect")) + } + cancellationRequestedRef.current = false + return false + } + }, + [linkRuntimeService], + ) + + const cancel = React.useCallback(() => { + cancellationRequestedRef.current = true + setError(null) + return linkRuntimeService.invoke("cancelLarkCliConnection") + }, [linkRuntimeService]) + const connect = React.useCallback(() => mutate("connectLarkCli"), [mutate]) + const disconnect = React.useCallback(() => mutate("disconnectLarkCli"), [mutate]) + const stateError = React.useMemo( + () => (state?.error ? resolveConnectionError(new Error(state.error), "summary") : null), + [state?.error], + ) + + return { + cancel, + connect, + disconnect, + error: error ?? stateError, + state, + } +} diff --git a/src/i18n/app-messages.en.ts b/src/i18n/app-messages.en.ts index 16e4f1ba..de3db584 100644 --- a/src/i18n/app-messages.en.ts +++ b/src/i18n/app-messages.en.ts @@ -1132,6 +1132,20 @@ export const enMessages = { "artifacts.infoPath": "Path", "artifacts.infoFolder": "Folder", "connections.title": "Connections", + "connections.directMode": "Direct mode", + "connections.connectionMode": "Connection mode", + "connections.runtimeVersion": "CLI version", + "connections.connectDirectProvider": "Connect and authorize", + "connections.larkCli.name": "Lark CLI", + "connections.larkCli.description": + "Connect directly to Lark messaging, calendars, Docs, Sheets, Base, and other capabilities.", + "connections.larkCli.phase.idle": "Connect and authorize", + "connections.larkCli.phase.checking": "Checking for CLI updates", + "connections.larkCli.phase.updating": "Updating the CLI", + "connections.larkCli.phase.configuring": "Preparing the Lark app", + "connections.larkCli.phase.authorizing": "Waiting for authorization", + "connections.larkCli.phase.verifying": "Verifying the login", + "connections.larkCli.phase.disconnecting": "Disconnecting", "connections.selfHosted.title": "Connect a self-hosted OpenConnector", "connections.selfHosted.description": "Choose and configure a Link runtime in Settings to make connector tools available to the agent.", diff --git a/src/i18n/app-messages.zh.ts b/src/i18n/app-messages.zh.ts index ccc60140..7068899f 100644 --- a/src/i18n/app-messages.zh.ts +++ b/src/i18n/app-messages.zh.ts @@ -1083,6 +1083,19 @@ export const zhCNMessages = { "artifacts.infoPath": "路径", "artifacts.infoFolder": "所在文件夹", "connections.title": "连接", + "connections.directMode": "直连模式", + "connections.connectionMode": "连接模式", + "connections.runtimeVersion": "CLI 版本", + "connections.connectDirectProvider": "连接并授权", + "connections.larkCli.name": "飞书 CLI", + "connections.larkCli.description": "直连飞书/Lark,即时通讯、日历、云文档、电子表格、多维表格等能力。", + "connections.larkCli.phase.idle": "连接并授权", + "connections.larkCli.phase.checking": "正在检查 CLI 更新", + "connections.larkCli.phase.updating": "正在升级 CLI", + "connections.larkCli.phase.configuring": "正在准备飞书应用", + "connections.larkCli.phase.authorizing": "等待授权完成", + "connections.larkCli.phase.verifying": "正在验证登录状态", + "connections.larkCli.phase.disconnecting": "正在断开连接", "connections.selfHosted.title": "连接自部署的 OpenConnector", "connections.selfHosted.description": "请先在设置中选择并配置 Link 运行时,Agent 才能使用连接器工具。", "connections.selfHosted.openSettings": "打开 Link 运行时设置", diff --git a/src/routes/Connections/ConnectionProviderDetailPane.tsx b/src/routes/Connections/ConnectionProviderDetailPane.tsx index ad914507..9ba47f45 100644 --- a/src/routes/Connections/ConnectionProviderDetailPane.tsx +++ b/src/routes/Connections/ConnectionProviderDetailPane.tsx @@ -86,6 +86,7 @@ export function ProviderDetail({ onConnect, onDisconnect, polling, + progressLabel, provider, showCloseButton = false, }: { @@ -107,6 +108,7 @@ export function ProviderDetail({ ) => Promise onDisconnect: (target: DisconnectTarget) => void polling: string | null + progressLabel?: string provider: ConnectionProviderSummary showCloseButton?: boolean }) { @@ -114,6 +116,7 @@ export function ProviderDetail({ const currentAuthType = getDefaultAuthType(provider) const accountValue = getProviderAccountValue(provider, t) const directlyAvailable = isDirectlyAvailableProvider(provider) + const direct = provider.executionMode === "direct" return (
@@ -123,6 +126,7 @@ export function ProviderDetail({

{provider.displayName}

+ {direct ? {t("connections.directMode")} : null}

{getProviderDescription(provider, t)}

@@ -169,6 +173,7 @@ export function ProviderDetail({ onConnect={onConnect} onDisconnect={onDisconnect} polling={polling} + progressLabel={progressLabel} provider={provider} /> )} @@ -180,8 +185,12 @@ export function ProviderDetail({ ) : (
+ {direct ? : null} {directlyAvailable ? null : } + {provider.runtimeVersion ? ( + + ) : null} {directlyAvailable ? null : ( @@ -264,6 +273,7 @@ function ConnectionPanel({ onConnect, onDisconnect, polling, + progressLabel, provider, }: { actionsPending?: boolean @@ -281,6 +291,7 @@ function ConnectionPanel({ ) => Promise onDisconnect: (target: DisconnectTarget) => void polling: string | null + progressLabel?: string provider: ConnectionProviderSummary }) { const t = useT() @@ -297,6 +308,7 @@ function ConnectionPanel({ const isPolling = isConnectionServicePollingTarget(polling, provider.service) const authorizationBlocked = polling !== null && !isPolling const directlyAvailable = isDirectlyAvailableProvider(provider) + const direct = provider.executionMode === "direct" React.useEffect(() => { setSelectedAuthType(currentAuthType) @@ -321,7 +333,7 @@ function ConnectionPanel({ {detailLoading ? : null}
- {configurableAuthTypes.length > 0 ? ( + {!direct && configurableAuthTypes.length > 0 ? ( )}
diff --git a/src/routes/Connections/connection-route-model.test.ts b/src/routes/Connections/connection-route-model.test.ts index 5f6e473b..ca3ae77e 100644 --- a/src/routes/Connections/connection-route-model.test.ts +++ b/src/routes/Connections/connection-route-model.test.ts @@ -83,6 +83,18 @@ test("managed no-auth accounts are not treated as connectionless providers", () assert.equal(shouldLoadProviderDetail(ready), true) }) +test("direct CLI providers use local details and direct-mode catalog metadata", () => { + const direct = provider({ + executionMode: "direct", + runtimeVersion: "1.0.81", + service: "lark-cli", + }) + const t = (key: Parameters[1], vars?: Record) => translate("en", key, vars) + + assert.equal(shouldLoadProviderDetail(direct), false) + assert.equal(getProviderMeta(direct, t), "Direct mode") +}) + test("mixed direct and API key providers are directly available before configuration", () => { const ready = provider({ actionKind: "api_key", diff --git a/src/routes/Connections/connection-route-model.ts b/src/routes/Connections/connection-route-model.ts index 9b5844a2..cd221406 100644 --- a/src/routes/Connections/connection-route-model.ts +++ b/src/routes/Connections/connection-route-model.ts @@ -98,6 +98,7 @@ export function isDirectlyAvailableProvider(provider: ConnectionProviderSummary) } export function shouldLoadProviderDetail(provider: ConnectionProviderSummary): boolean { + if (provider.executionMode === "direct") return false return !isDirectlyAvailableProvider(provider) || provider.authTypes.some((authType) => authType !== "no_auth") } @@ -179,6 +180,7 @@ export function formatDuration(durationMs: number | null, t: TranslateFn): strin } export function getProviderDescription(provider: ConnectionProviderSummary, t: TranslateFn): string { + if (provider.description) return provider.description switch (provider.status) { case "needs_attention": return t("connections.providerNeedsAttentionDescription", { name: provider.displayName }) @@ -278,6 +280,7 @@ export function formatProviderCategoryLabels(provider: ConnectionProviderSummary } export function getProviderMeta(provider: ConnectionProviderSummary, t: TranslateFn): string { + if (provider.executionMode === "direct") return t("connections.directMode") if (isDirectlyAvailableProvider(provider)) { return getProviderCategoryLabel(provider, t) } diff --git a/src/routes/Connections/index.tsx b/src/routes/Connections/index.tsx index 1dc12d49..958315c4 100644 --- a/src/routes/Connections/index.tsx +++ b/src/routes/Connections/index.tsx @@ -45,6 +45,7 @@ import { SplitViewRoot, } from "@/components/ui/split-view" import { isConnectionServicePollingTarget } from "@/hooks/connection-oauth-pending" +import { larkCliProviderDetail, larkCliProviderFromState, useLarkCliConnection } from "@/hooks/useLarkCliConnection" import { useT } from "@/i18n/i18n" import { getOAuthClientConfig } from "@/lib/connections-client" import { userFacingErrorDescription } from "@/lib/user-facing-error" @@ -89,6 +90,7 @@ export function ConnectionsPanel({ summaryWorkspaceKey, summaryError, } = connections + const larkCli = useLarkCliConnection() const [query, setQuery] = React.useState("") const [activeFilter, setActiveFilter] = React.useState(requestedFilter ?? { kind: "all" }) const [selectedProviderService, setSelectedProviderService] = React.useState(null) @@ -108,7 +110,23 @@ export function ConnectionsPanel({ const detailWorkspaceKeyRef = React.useRef(summaryWorkspaceKey) const listPaneRef = React.useRef(null) - const providers = summary?.providers ?? [] + const larkCliProvider = React.useMemo( + () => + larkCli.state + ? larkCliProviderFromState(larkCli.state, { + description: t("connections.larkCli.description"), + displayName: t("connections.larkCli.name"), + }) + : null, + [larkCli.state, t], + ) + const providers = React.useMemo( + () => [ + ...(summary?.providers ?? []).filter((provider) => provider.service !== "lark-cli"), + ...(larkCliProvider ? [larkCliProvider] : []), + ], + [larkCliProvider, summary?.providers], + ) const deferredQuery = React.useDeferredValue(query) const normalizedQuery = deferredQuery.trim().toLowerCase() const categoryFilters = React.useMemo(() => buildCategoryFilters(providers, t), [providers, t]) @@ -133,18 +151,22 @@ export function ConnectionsPanel({ const selectedProvider = selectedProviderService ? (filteredProviders.find((provider) => provider.service === selectedProviderService) ?? null) : null + const selectedProviderIsDirect = selectedProvider?.executionMode === "direct" + const selectedProviderActionsEnabled = selectedProviderIsDirect ? true : connectionActionsEnabled const providerDetail = useConnectionProviderDetail({ - enabled: connectionActionsEnabled, + enabled: selectedProviderActionsEnabled, getProviderDetail, provider: selectedProvider, workspaceKey: summaryWorkspaceKey, }) - const selectedProviderDetail = providerDetail.detail - const selectedProviderDetailLoading = providerDetail.loading - const selectedProviderDetailError = providerDetail.error + const selectedProviderDetail = + selectedProviderIsDirect && selectedProvider ? larkCliProviderDetail(selectedProvider) : providerDetail.detail + const selectedProviderDetailLoading = selectedProviderIsDirect ? false : providerDetail.loading + const selectedProviderDetailError = selectedProviderIsDirect ? larkCli.error : providerDetail.error const selectedProviderActionsBlocked = Boolean( - !connectionActionsEnabled || - !showConnectionState || + !selectedProviderActionsEnabled || + (selectedProviderIsDirect && selectedProvider?.actionKind === "unavailable") || + (!selectedProviderIsDirect && !showConnectionState) || (providerDetail.needsDetail && !selectedProviderDetail && selectedProviderDetailError), ) const selectedProviderActionsPending = Boolean( @@ -152,10 +174,23 @@ export function ConnectionsPanel({ ) const detailErrorNotice = selectedProvider ? getConnectionDetailErrorNotice({ - actionError, + actionError: selectedProviderIsDirect ? larkCli.error : actionError, detailError: selectedProviderDetailError, }) : null + const larkCliBusy: UseConnections["busy"] = + larkCli.state?.phase === "disconnecting" + ? "disconnect" + : larkCli.state && larkCli.state.phase !== "idle" + ? "connect" + : null + const selectedProviderBusy = selectedProviderIsDirect ? larkCliBusy : busy + const selectedProviderPolling = + selectedProviderIsDirect && larkCli.state && larkCli.state.phase !== "idle" ? "lark-cli" : polling + const selectedProviderProgressLabel = selectedProviderIsDirect + ? t(`connections.larkCli.phase.${larkCli.state?.phase ?? "idle"}`) + : undefined + const cancelSelectedProviderPolling = selectedProviderIsDirect ? larkCli.cancel : cancelPolling const summaryLoading = busy === "refresh" && !summary const listErrorNotice = getConnectionListErrorNotice({ summaryError, detailError: detailErrorNotice?.error ?? null }) const deleteCachedDetailForService = providerDetail.invalidate @@ -258,7 +293,7 @@ export function ConnectionsPanel({ }, [activeFilter.kind, showConnectionState]) React.useEffect(() => { - if (!selectedProviderService || !summary) { + if (!selectedProviderService) { return } @@ -270,7 +305,7 @@ export function ConnectionsPanel({ setSelectedProviderService(null) setDetailPaneClosing(false) setNarrowPane("list") - }, [clearDetailCloseTimer, filteredProviders, selectedProviderService, summary]) + }, [clearDetailCloseTimer, filteredProviders, selectedProviderService]) const connectProvider = React.useCallback( async ( @@ -278,6 +313,13 @@ export function ConnectionsPanel({ authType: Exclude, appId?: string, ): Promise => { + if (provider.executionMode === "direct") { + const ok = await larkCli.connect() + if (ok) { + onConnectionReady?.({ service: provider.service, connectionName: "default" }) + } + return + } if (!connectionActionsEnabled) { return } @@ -362,6 +404,7 @@ export function ConnectionsPanel({ connect, deleteCachedDetailForService, getAppDetail, + larkCli, onConnectionReady, polling, providerDetail, @@ -399,7 +442,7 @@ export function ConnectionsPanel({ const requestDisconnectTarget = React.useCallback( (target: DisconnectTarget): void => { - if (!connectionActionsEnabled) { + if (target.provider.executionMode !== "direct" && !connectionActionsEnabled) { return } setConfirmDisconnect(target) @@ -409,13 +452,18 @@ export function ConnectionsPanel({ const confirmDisconnectTarget = React.useCallback( async (target: DisconnectTarget): Promise => { - if (!connectionActionsEnabled) { + if (target.provider.executionMode !== "direct" && !connectionActionsEnabled) { setConfirmDisconnect(null) return } const requestId = connectionActionRequestIdRef.current + 1 connectionActionRequestIdRef.current = requestId - const ok = target.app ? await disconnectAccount(target.app.id) : await disconnect(target.provider.service) + const ok = + target.provider.executionMode === "direct" + ? await larkCli.disconnect() + : target.app + ? await disconnectAccount(target.app.id) + : await disconnect(target.provider.service) if (connectionActionRequestIdRef.current !== requestId) { return } @@ -424,7 +472,7 @@ export function ConnectionsPanel({ setConfirmDisconnect(null) } }, - [connectionActionsEnabled, deleteCachedDetailForService, disconnect, disconnectAccount], + [connectionActionsEnabled, deleteCachedDetailForService, disconnect, disconnectAccount, larkCli], ) if (presentation === "drawer") { @@ -433,19 +481,20 @@ export function ConnectionsPanel({ {selectedProvider ? ( @@ -490,7 +539,7 @@ export function ConnectionsPanel({ /> setConfirmDisconnect(null)} onConfirm={confirmDisconnectTarget} /> @@ -511,7 +560,7 @@ export function ConnectionsPanel({ loading={summaryLoading} query={query} showConnectionState={showConnectionState} - totalCount={summary?.providerCount ?? providers.length} + totalCount={providers.length} onFilterChange={setActiveFilter} onQueryChange={setQuery} /> @@ -560,19 +609,20 @@ export function ConnectionsPanel({ @@ -589,19 +639,20 @@ export function ConnectionsPanel({ > @@ -623,7 +674,7 @@ export function ConnectionsPanel({ setConfirmDisconnect(null)} onConfirm={confirmDisconnectTarget} /> From 05ea6783cc71e219e3b1e32d613e90f45f3ad2c2 Mon Sep 17 00:00:00 2001 From: shaun Date: Mon, 3 Aug 2026 15:16:04 +0800 Subject: [PATCH 2/4] Hide remote account actions for direct connections --- .../Connections/ConnectionAccountsList.tsx | 34 +++++++++++-------- .../connection-route-model.test.ts | 6 ++++ .../Connections/connection-route-model.ts | 5 +++ 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/routes/Connections/ConnectionAccountsList.tsx b/src/routes/Connections/ConnectionAccountsList.tsx index d4ce69e9..c2131f09 100644 --- a/src/routes/Connections/ConnectionAccountsList.tsx +++ b/src/routes/Connections/ConnectionAccountsList.tsx @@ -13,6 +13,7 @@ import { getConnectionAppDisplayLabel, isConnectionAuthType, normalizeConnectionAliasInput, + supportsManagedConnectionAccountActions, } from "./connection-route-model.ts" import { AccountExecutionLogsButton } from "./ConnectionExecutionLogs.tsx" import { authTypeLabel } from "./shared.ts" @@ -103,6 +104,7 @@ function ConnectionAccountItem({ servicePolling: boolean }) { const t = useT() + const managedAccountActions = supportsManagedConnectionAccountActions(provider) const [aliasDraft, setAliasDraft] = React.useState(app.alias ?? "") const [aliasEditing, setAliasEditing] = React.useState(false) const [aliasBusy, setAliasBusy] = React.useState(false) @@ -151,7 +153,7 @@ function ConnectionAccountItem({
- {aliasEditing ? ( + {aliasEditing && managedAccountActions ? (
{ @@ -201,18 +203,20 @@ function ConnectionAccountItem({ ) : ( <> {accountLabel} - + {managedAccountActions ? ( + + ) : null} )} @@ -231,7 +235,9 @@ function ConnectionAccountItem({
- + {managedAccountActions ? ( + + ) : null} {reconnectAuthType ? (