diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs new file mode 100644 index 0000000000..4a2b6be5f5 --- /dev/null +++ b/.pnpmfile.cjs @@ -0,0 +1,30 @@ +/** + * pnpm install hook. + * + * Strips the per-platform native binary packages bundled by agent SDKs whose + * providers run in "bring-your-own-binary" mode. Trilium's Claude Agent + * provider (apps/server) drives the SDK's JS wrapper but points it at the + * user's own installed CLI via `pathToClaudeCodeExecutable`, so the ~250 MB + * bundled binary is never used — and must not be downloaded into every server + * install and Docker layer. The tiny JS wrapper is kept; only the + * `@anthropic-ai/claude-agent-sdk-` optionalDependencies are removed. + */ + +/** SDK package name → prefix of the platform-binary optionalDependencies to drop. */ +const BYO_BINARY_SDKS = { + "@anthropic-ai/claude-agent-sdk": "@anthropic-ai/claude-agent-sdk-" +}; + +function readPackage(pkg) { + const prefix = BYO_BINARY_SDKS[pkg.name]; + if (prefix && pkg.optionalDependencies) { + for (const dep of Object.keys(pkg.optionalDependencies)) { + if (dep.startsWith(prefix)) { + delete pkg.optionalDependencies[dep]; + } + } + } + return pkg; +} + +module.exports = { hooks: { readPackage } }; diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 77be875944..bab820460d 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -3081,7 +3081,7 @@ "base_url": "Base URL", "base_url_description": "Optional. Override the default API endpoint — useful for self-hosted models (Ollama, LM Studio, vLLM) or proxies.", "base_url_invalid": "Base URL must be a valid http:// or https:// URL", - "claude_agent_description": "Uses your Claude Pro/Max subscription through Claude Code — no API key needed. Sign in once by running \"claude /login\" on the machine where the Trilium server runs. To let the AI access your notes, also enable the MCP server below.", + "claude_agent_description": "Uses your Claude Pro/Max subscription through Claude Code — no API key needed. Claude Code must be installed on the machine running the Trilium server; sign in once by running \"claude /login\" there. To let the AI access your notes, also enable the MCP server below.", "cancel": "Cancel", "mcp_title": "MCP (Model Context Protocol)", "mcp_enabled": "MCP server", diff --git a/apps/server/src/services/llm/providers/claude_agent.spec.ts b/apps/server/src/services/llm/providers/claude_agent.spec.ts index 3b20b00830..b3788444c9 100644 --- a/apps/server/src/services/llm/providers/claude_agent.spec.ts +++ b/apps/server/src/services/llm/providers/claude_agent.spec.ts @@ -27,6 +27,12 @@ vi.mock("../../port.js", () => ({ default: 8080 })); const buildNoteHintMock = vi.hoisted(() => vi.fn((noteId: string) => `NOTE_META(${noteId})`)); vi.mock("./note_hint.js", () => ({ buildNoteHint: buildNoteHintMock })); +// BYO binary resolution shells out to the user's `claude`; stub it so tests +// don't depend on a real install. Returns a path by default; can be made to +// throw (missing binary) per-test. +const resolveClaudeBinaryMock = vi.hoisted(() => vi.fn(() => "/usr/bin/claude")); +vi.mock("./claude_binary.js", () => ({ resolveClaudeBinaryPath: resolveClaudeBinaryMock })); + // Attachment resolution reads bytes out of Becca, which the core mock above // omits — stub it so the multimodal tests drive block construction directly. const resolveAttachmentPartMock = vi.hoisted(() => vi.fn()); @@ -331,6 +337,28 @@ describe("ClaudeAgentProvider.chatChunks", () => { expect(options.settingSources).toEqual([]); }); + it("drives the user's resolved claude binary (bring-your-own-binary)", async () => { + resolveClaudeBinaryMock.mockReturnValueOnce("/opt/homebrew/bin/claude"); + scriptAgent([successResult()]); + const provider = new ClaudeAgentProvider(); + await collect(provider.chatChunks([{ role: "user", content: "hi" }], {})); + + expect(queryMock.mock.calls[0][0].options.pathToClaudeCodeExecutable).toBe("/opt/homebrew/bin/claude"); + }); + + it("surfaces a friendly error (and never spawns) when Claude Code isn't installed", async () => { + resolveClaudeBinaryMock.mockImplementationOnce(() => { + throw new Error("Claude Code CLI not found. Install it and run `claude /login`..."); + }); + const provider = new ClaudeAgentProvider(); + const chunks = await collect(provider.chatChunks([{ role: "user", content: "hi" }], {})); + + expect(queryMock).not.toHaveBeenCalled(); + const errors = chunks.filter(c => c.type === "error"); + expect(errors).toHaveLength(1); + expect(errors[0].error).toContain("Claude Code CLI not found"); + }); + it("uses Trilium's shared system prompt (skill/link/markdown guidance) when note tools are live", async () => { scriptAgent([successResult()]); const provider = new ClaudeAgentProvider(); diff --git a/apps/server/src/services/llm/providers/claude_agent.ts b/apps/server/src/services/llm/providers/claude_agent.ts index 78fd579cae..5b47615f1d 100644 --- a/apps/server/src/services/llm/providers/claude_agent.ts +++ b/apps/server/src/services/llm/providers/claude_agent.ts @@ -5,6 +5,10 @@ * authentication is owned entirely by Claude Code (`claude /login` once on the * machine running the server), and billing goes to the subscription. * + * Bring-your-own-binary: the SDK's ~250 MB bundled native binary is stripped at + * install time (root .pnpmfile.cjs); the provider drives the user's own + * installed `claude` CLI (see claude_binary.ts), keeping the server lean. + * * Unlike the AI-SDK providers, the Agent SDK runs its own agentic loop and is * session-based (it owns conversation history). This provider therefore: * - implements `chatChunks()` (chunk-native streaming) instead of `chat()`, @@ -29,6 +33,7 @@ import port from "../../port.js"; import type { LlmProvider, LlmProviderConfig, ModelInfo, ModelPricing, StreamResult } from "../types.js"; import { resolveAttachmentPart } from "./attachment_content.js"; import { buildModelList } from "./base_provider.js"; +import { resolveClaudeBinaryPath } from "./claude_binary.js"; import { buildNoteHint } from "./note_hint.js"; import { buildSystemPrompt } from "./system_prompt.js"; @@ -371,6 +376,9 @@ export class ClaudeAgentProvider implements LlmProvider { const allowedTools = [...builtinTools]; const options: AgentOptions = { + // Bring-your-own-binary: the SDK's bundled native binary is stripped + // at install time; drive the user's own installed Claude Code CLI. + pathToClaudeCodeExecutable: resolveClaudeBinaryPath(), cwd: getAgentCwd(), tools: builtinTools, settingSources: [], diff --git a/apps/server/src/services/llm/providers/claude_binary.ts b/apps/server/src/services/llm/providers/claude_binary.ts new file mode 100644 index 0000000000..8fbbd6e61b --- /dev/null +++ b/apps/server/src/services/llm/providers/claude_binary.ts @@ -0,0 +1,84 @@ +/** + * Resolves the Claude Code CLI binary the Claude Agent provider drives. + * + * The provider runs in "bring-your-own-binary" mode: the ~250 MB native binary + * that the SDK would otherwise bundle is stripped at install time (see the + * root .pnpmfile.cjs), so we point the SDK at the user's own installed CLI via + * `pathToClaudeCodeExecutable`. This keeps the server install lean and lets each + * platform provide a binary that actually runs there (e.g. the nixpkgs wrapper + * on NixOS, which needs no glibc/nix-ld shim — unlike the SDK's bundled ELF). + * + * Resolution order: the TRILIUM_CLAUDE_CODE_PATH override, then `claude` on + * PATH. The resolved binary is probed with `--version` once so a broken/absent + * install surfaces as a clear, actionable error instead of an opaque spawn + * failure mid-chat. + */ + +import { getLog } from "@triliumnext/core"; +import { execFileSync } from "child_process"; +import { existsSync } from "fs"; +import path from "path"; + +/** Cached only on success, so a later install is picked up without a restart. */ +let cachedPath: string | undefined; + +export function resolveClaudeBinaryPath(): string { + if (cachedPath) { + return cachedPath; + } + + const binary = locateBinary(); + + // Probe once: confirms the binary actually runs on this host (catches a + // wrong-arch/broken install) and records the version for diagnostics. + let version: string; + try { + version = execFileSync(binary, ["--version"], { timeout: 15000, encoding: "utf8" }).trim(); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new Error(`Found Claude Code at "${binary}" but it failed to run (${detail}). Ensure it is installed correctly and that you've run \`claude /login\` on the machine running the Trilium server.`); + } + + getLog().info(`Claude Agent provider: using Claude Code at ${binary} (${version})`); + cachedPath = binary; + return binary; +} + +/** For tests: forget the probed binary so the next call re-resolves. */ +export function resetClaudeBinaryCache(): void { + cachedPath = undefined; +} + +function locateBinary(): string { + const override = process.env.TRILIUM_CLAUDE_CODE_PATH?.trim(); + if (override) { + if (!existsSync(override)) { + throw new Error(`TRILIUM_CLAUDE_CODE_PATH is set to "${override}", but no file exists there.`); + } + return override; + } + + const onPath = findOnPath("claude"); + if (onPath) { + return onPath; + } + + throw new Error("Claude Code CLI not found. Install it (`npm install -g @anthropic-ai/claude-code`) and run `claude /login` on the machine running the Trilium server, or set the TRILIUM_CLAUDE_CODE_PATH environment variable to its location."); +} + +function findOnPath(binary: string): string | undefined { + // Windows resolves executables via PATHEXT; on POSIX the bare name suffices. + const extensions = process.platform === "win32" ? ["", ".cmd", ".exe", ".bat"] : [""]; + for (const dir of (process.env.PATH ?? "").split(path.delimiter)) { + if (!dir) { + continue; + } + for (const ext of extensions) { + const candidate = path.join(dir, binary + ext); + if (existsSync(candidate)) { + return candidate; + } + } + } + return undefined; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46bf48e4e9..61b9192250 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,8 @@ overrides: basic-ftp@=5.2.0: '>=5.2.1' extract-zip>yauzl: 3.4.0 +pnpmfileChecksum: sha256-Y+CmGTGmFhNZN6QTL8GJnY/EuBOU2KPY66OgOmzHWqw= + patchedDependencies: '@ckeditor/ckeditor5-code-block': 1530be090e4f5235e197fb6208a8e8230e3355d5633fc7cad2b2e892659e9c04 '@ckeditor/ckeditor5-mention': 70377e48a04e2f45abba79aa35f3d5527ec7aa7ffce44056f9957a49a24a4a60 @@ -1811,50 +1813,6 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.207': - resolution: {integrity: sha512-08xSo1FDx8h0aLhL5tvcRxa2SMmcUV3aDWeZiEJVTclyiDAs61BgTjAxCg+SZcu1CndjJO8cfO0yM5dhamxz3g==} - cpu: [arm64] - os: [darwin] - - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.207': - resolution: {integrity: sha512-1o7K4EYqyCixZ/oeOZSh7AzSy6TM86xoOuf4VuORjPSS31hBnoqY0NGZd27+2VDs9LGtsdksmsTqcNGx9xd1hA==} - cpu: [x64] - os: [darwin] - - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.207': - resolution: {integrity: sha512-oPj+g2DslhH4Y9nCTs7al4t9wZv78FZwLFQwOCg99BXuz1o0ZOpKmxyvR7J9eBR+GPszeMMS8gYplQTiZC9o2w==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.207': - resolution: {integrity: sha512-X4uezYOifDiNTTmmugfRCdg3nNamrr1LFRY9hg30vWYTShL+bbN+nfC3KaFfSYCl4GTtsEEUbYdOTC2F3bBpcA==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.207': - resolution: {integrity: sha512-uRv+D5oG/7EYr41FAJ9IPo2pZYBe2ZMaA6nSHCeizsgPxCSMtl5bNppmU21+jZJvo4hivObEgkGFERAhdGqygg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.207': - resolution: {integrity: sha512-Kg6BPH8Ee0ny/oEUWJmvT1jCRBne4jVpRSOMsJcYp1Fav1rMEgpU219oJJs+LWwx4ifuuLtNWedqJNnVw7mnKg==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.207': - resolution: {integrity: sha512-9fWpUzfkXlPAg2tf8JpQe7w9avFaomAUbfAwyAmykQgSIf66LwaJjvI5hNqhNqczRKyfsXPn3ei2S5HKlmFP+Q==} - cpu: [arm64] - os: [win32] - - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.207': - resolution: {integrity: sha512-YPjVT0q6aXEM2MgN4CI6/9fqiTXwETji+4NoPOzCYuqAkhXZqp30Jsk7/NHqYGNNSfURKrsuAoliKB0rsbpbjg==} - cpu: [x64] - os: [win32] - '@anthropic-ai/claude-agent-sdk@0.3.207': resolution: {integrity: sha512-y0PkQRmQBi96MHiN5Xzfq+GaddxCZCqI/cXEQBLYBLXGa4i1nDSlulQqkMBj2RorrrSGQJ6Wdw+uhu6OfHNPzA==} engines: {node: '>=18.0.0'} @@ -14003,44 +13961,11 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.1.2 - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.207': - optional: true - - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.207': - optional: true - - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.207': - optional: true - - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.207': - optional: true - - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.207': - optional: true - - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.207': - optional: true - - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.207': - optional: true - - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.207': - optional: true - '@anthropic-ai/claude-agent-sdk@0.3.207(@anthropic-ai/sdk@0.111.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.111.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) zod: 4.4.3 - optionalDependencies: - '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.207 - '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.207 - '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.207 - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.207 - '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.207 - '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.207 - '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.207 - '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.207 '@anthropic-ai/sdk@0.111.0(zod@4.4.3)': dependencies: