From 7aec532b1a85ff09b8f2bc2dec5145a829b4cad2 Mon Sep 17 00:00:00 2001 From: Tobias Graf <2226232+42tg@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:11:33 +0100 Subject: [PATCH] feat: resolve Claude Code plugins from settings and pass to Agent SDK --- .../src/provider/Layers/ClaudeAdapter.ts | 8 + packages/shared/package.json | 4 + packages/shared/src/claude-plugins.ts | 168 ++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 packages/shared/src/claude-plugins.ts diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index d99e2ad203..02121566b3 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -63,6 +63,7 @@ import { import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { resolveEnabledPlugins } from "@t3tools/shared/claude-plugins"; import { ServerSettingsService } from "../../serverSettings.ts"; import { getClaudeModelCapabilities } from "./ClaudeProvider.ts"; import { @@ -163,6 +164,7 @@ interface ClaudeSessionContext { lastAssistantUuid: string | undefined; lastThreadStartedId: string | undefined; stopped: boolean; + interactionMode: "default" | "plan"; } interface ClaudeQueryRuntime extends AsyncIterable { @@ -182,6 +184,8 @@ export interface ClaudeAdapterLiveOptions { readonly nativeEventLogger?: EventNdjsonLogger; } + + function isUuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); } @@ -2699,6 +2703,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(fastMode ? { fastMode: true } : {}), }; + const resolvedPlugins = resolveEnabledPlugins(input.cwd ? { cwd: input.cwd } : undefined); + const sdkPlugins = resolvedPlugins.map((p) => ({ type: "local" as const, path: p.path })); + const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), ...(apiModelId ? { model: apiModelId } : {}), @@ -2716,6 +2723,7 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( canUseTool, env: process.env, ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + ...(sdkPlugins.length > 0 ? { plugins: sdkPlugins } : {}), }; const queryRuntime = yield* Effect.try({ diff --git a/packages/shared/package.json b/packages/shared/package.json index b35d23ef15..2819efb434 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -43,6 +43,10 @@ "./String": { "types": "./src/String.ts", "import": "./src/String.ts" + }, + "./claude-plugins": { + "types": "./src/claude-plugins.ts", + "import": "./src/claude-plugins.ts" } }, "scripts": { diff --git a/packages/shared/src/claude-plugins.ts b/packages/shared/src/claude-plugins.ts new file mode 100644 index 0000000000..bf0ee6efbb --- /dev/null +++ b/packages/shared/src/claude-plugins.ts @@ -0,0 +1,168 @@ +/** + * Resolve enabled Claude Code plugins from settings files to local cache paths. + * + * The Claude Agent SDK doesn't auto-load plugins from `enabledPlugins` in + * settings files. This module bridges that gap by reading the settings, + * resolving each enabled plugin to its cache directory, and returning + * paths suitable for the SDK's `plugins: [{ type: 'local', path }]` option. + * + * @module claude-plugins + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +export interface ResolvedPlugin { + readonly pluginId: string; + readonly marketplaceId: string; + readonly path: string; +} + +export interface PluginResolutionOptions { + /** Project working directory for project/local settings. */ + readonly cwd?: string; + /** Override home directory (useful for testing). Defaults to `os.homedir()`. */ + readonly homeDir?: string; +} + +/** + * Read and merge `enabledPlugins` from user, project, and local settings files. + * Later sources override earlier ones (local > project > user). + */ +export function readEnabledPluginKeys(options?: PluginResolutionOptions): Map { + const home = options?.homeDir ?? os.homedir(); + const cwd = options?.cwd; + + const paths: string[] = [ + path.join(home, ".claude", "settings.json"), + ...(cwd + ? [ + path.join(cwd, ".claude", "settings.json"), + path.join(cwd, ".claude", "settings.local.json"), + ] + : []), + ]; + + const merged = new Map(); + + for (const filePath of paths) { + const plugins = readEnabledPluginsFromFile(filePath); + if (plugins) { + for (const [key, value] of Object.entries(plugins)) { + if (typeof value === "boolean") { + merged.set(key, value); + } + } + } + } + + return merged; +} + +/** + * Resolve all enabled plugins to their local cache paths. + * Skips plugins whose cache directory is missing or has no active version. + */ +export function resolveEnabledPlugins(options?: PluginResolutionOptions): ResolvedPlugin[] { + const home = options?.homeDir ?? os.homedir(); + const cacheRoot = path.join(home, ".claude", "plugins", "cache"); + const enabled = readEnabledPluginKeys(options); + const results: ResolvedPlugin[] = []; + + for (const [key, isEnabled] of enabled) { + if (!isEnabled) continue; + + const parsed = parsePluginKey(key); + if (!parsed) continue; + + const pluginCacheDir = path.join(cacheRoot, parsed.marketplaceId, parsed.pluginId); + const versionPath = resolveActiveVersion(pluginCacheDir); + if (versionPath) { + results.push({ + pluginId: parsed.pluginId, + marketplaceId: parsed.marketplaceId, + path: versionPath, + }); + } + } + + return results; +} + +// ── Internal helpers ──────────────────────────────────────────────── + +function readEnabledPluginsFromFile(filePath: string): Record | undefined { + try { + const content = fs.readFileSync(filePath, "utf8"); + const parsed = JSON.parse(content) as Record; + if (parsed && typeof parsed === "object" && "enabledPlugins" in parsed) { + const plugins = parsed.enabledPlugins; + if (plugins && typeof plugins === "object" && !Array.isArray(plugins)) { + return plugins as Record; + } + } + } catch { + // File missing or malformed — skip silently. + } + return undefined; +} + +function parsePluginKey(key: string): { pluginId: string; marketplaceId: string } | undefined { + const atIndex = key.lastIndexOf("@"); + if (atIndex <= 0 || atIndex === key.length - 1) return undefined; + return { + pluginId: key.slice(0, atIndex), + marketplaceId: key.slice(atIndex + 1), + }; +} + +/** + * Find the active (non-orphaned) version directory inside a plugin cache dir. + * The active version is the one without a `.orphaned_at` sentinel file. + * If multiple non-orphaned versions exist, pick the newest by mtime. + */ +function resolveActiveVersion(pluginCacheDir: string): string | undefined { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(pluginCacheDir, { withFileTypes: true }); + } catch { + return undefined; + } + + let best: { path: string; mtime: number } | undefined; + + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + if (entry.name.startsWith(".")) continue; + + const versionDir = path.join(pluginCacheDir, entry.name); + const realDir = safeRealpath(versionDir); + if (!realDir) continue; + + // Skip orphaned versions. + if (fs.existsSync(path.join(realDir, ".orphaned_at"))) continue; + + const mtime = safeMtime(realDir); + if (!best || mtime > best.mtime) { + best = { path: realDir, mtime }; + } + } + + return best?.path; +} + +function safeRealpath(p: string): string | undefined { + try { + return fs.realpathSync(p); + } catch { + return undefined; + } +} + +function safeMtime(p: string): number { + try { + return fs.statSync(p).mtimeMs; + } catch { + return 0; + } +}