diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 4d64da22e..df114c7ed 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -28,6 +28,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Generate extension bridge + run: pnpm run ext:bridge + - name: Run linter run: pnpm run lint @@ -57,5 +60,8 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Generate extension bridge + run: pnpm run ext:bridge + - name: Build run: pnpm run build:vite diff --git a/.github/workflows/electron-e2e.yml b/.github/workflows/electron-e2e.yml index b1ad8705d..1ab5a17d2 100644 --- a/.github/workflows/electron-e2e.yml +++ b/.github/workflows/electron-e2e.yml @@ -40,6 +40,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Generate extension bridge + run: pnpm run ext:bridge + - name: Rebuild Electron binary run: pnpm rebuild electron diff --git a/.gitignore b/.gitignore index 9de5af241..0cdac6d89 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,7 @@ docs/pr-session-notes-*.md .claude/ .pnpm-store/ package-lock.json + +# Generated extension bridges (created by scripts/generate-ext-bridge.mjs) +electron/extensions/_ext-bridge.generated.ts +src/extensions/_ext-bridge.generated.ts diff --git a/clawx-extensions.json b/clawx-extensions.json new file mode 100644 index 000000000..997f68fe0 --- /dev/null +++ b/clawx-extensions.json @@ -0,0 +1,9 @@ +{ + "extensions": { + "main": [ + "builtin/clawhub-marketplace", + "builtin/diagnostics" + ], + "renderer": [] + } +} diff --git a/electron/api/routes/skills.ts b/electron/api/routes/skills.ts index 23d00aacd..de1f092ce 100644 --- a/electron/api/routes/skills.ts +++ b/electron/api/routes/skills.ts @@ -31,6 +31,18 @@ export async function handleSkillRoutes( return true; } + if (url.pathname === '/api/clawhub/capability' && req.method === 'GET') { + try { + sendJson(res, 200, { + success: true, + capability: await ctx.clawHubService.getMarketplaceCapability(), + }); + } catch (error) { + sendJson(res, 500, { success: false, error: String(error) }); + } + return true; + } + if (url.pathname === '/api/clawhub/search' && req.method === 'POST') { try { const body = await parseJsonBody>(req); diff --git a/electron/api/server.ts b/electron/api/server.ts index 4e16b37c0..d2085cdce 100644 --- a/electron/api/server.ts +++ b/electron/api/server.ts @@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto'; import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import { getPort } from '../utils/config'; import { logger } from '../utils/logger'; +import { extensionRegistry } from '../extensions/registry'; import type { HostApiContext } from './context'; import { handleAppRoutes } from './routes/app'; import { handleGatewayRoutes } from './routes/gateway'; @@ -25,7 +26,7 @@ type RouteHandler = ( ctx: HostApiContext, ) => Promise; -const routeHandlers: RouteHandler[] = [ +const coreRouteHandlers: RouteHandler[] = [ handleAppRoutes, handleGatewayRoutes, handleSettingsRoutes, @@ -41,6 +42,11 @@ const routeHandlers: RouteHandler[] = [ handleUsageRoutes, ]; +function buildRouteHandlers(): RouteHandler[] { + const extensionHandlers = extensionRegistry.getRouteHandlers(); + return [...coreRouteHandlers, ...extensionHandlers]; +} + /** * Per-session secret token used to authenticate Host API requests. * Generated once at server start and shared with the renderer via IPC. @@ -96,6 +102,7 @@ export function startHostApiServer(ctx: HostApiContext, port = getPort('CLAWX_HO return; } + const routeHandlers = buildRouteHandlers(); for (const handler of routeHandlers) { if (await handler(req, res, requestUrl, ctx)) { return; diff --git a/electron/extensions/builtin/clawhub-marketplace.ts b/electron/extensions/builtin/clawhub-marketplace.ts new file mode 100644 index 000000000..6ca203aee --- /dev/null +++ b/electron/extensions/builtin/clawhub-marketplace.ts @@ -0,0 +1,43 @@ +import type { + Extension, + ExtensionContext, + MarketplaceProviderExtension, + MarketplaceCapability, +} from '../types'; +import type { + ClawHubSearchParams, + ClawHubInstallParams, + ClawHubSkillResult, +} from '../../gateway/clawhub'; + +class ClawHubMarketplaceExtension implements MarketplaceProviderExtension { + readonly id = 'builtin/clawhub-marketplace'; + + setup(_ctx: ExtensionContext): void { + // No setup needed -- search/install delegates to the ClawHubService CLI runner + } + + async getCapability(): Promise { + return { + mode: 'clawhub', + canSearch: true, + canInstall: true, + }; + } + + async search(params: ClawHubSearchParams): Promise { + const { ClawHubService } = await import('../../gateway/clawhub'); + const svc = new ClawHubService(); + return svc.search(params); + } + + async install(params: ClawHubInstallParams): Promise { + const { ClawHubService } = await import('../../gateway/clawhub'); + const svc = new ClawHubService(); + return svc.install(params); + } +} + +export function createClawHubMarketplaceExtension(): Extension { + return new ClawHubMarketplaceExtension(); +} diff --git a/electron/extensions/builtin/diagnostics.ts b/electron/extensions/builtin/diagnostics.ts new file mode 100644 index 000000000..82a62dff6 --- /dev/null +++ b/electron/extensions/builtin/diagnostics.ts @@ -0,0 +1,25 @@ +import type { + Extension, + ExtensionContext, + HostApiRouteExtension, + RouteHandler, +} from '../types'; + +class DiagnosticsExtension implements HostApiRouteExtension { + readonly id = 'builtin/diagnostics'; + + setup(_ctx: ExtensionContext): void { + // Diagnostics routes are stateless; no setup needed. + } + + getRouteHandler(): RouteHandler { + return async (req, res, url, ctx) => { + const { handleDiagnosticsRoutes } = await import('../../api/routes/diagnostics'); + return handleDiagnosticsRoutes(req, res, url, ctx); + }; + } +} + +export function createDiagnosticsExtension(): Extension { + return new DiagnosticsExtension(); +} diff --git a/electron/extensions/builtin/index.ts b/electron/extensions/builtin/index.ts new file mode 100644 index 000000000..d1caa02a7 --- /dev/null +++ b/electron/extensions/builtin/index.ts @@ -0,0 +1,8 @@ +import { registerBuiltinExtension } from '../loader'; +import { createClawHubMarketplaceExtension } from './clawhub-marketplace'; +import { createDiagnosticsExtension } from './diagnostics'; + +export function registerAllBuiltinExtensions(): void { + registerBuiltinExtension('builtin/clawhub-marketplace', createClawHubMarketplaceExtension); + registerBuiltinExtension('builtin/diagnostics', createDiagnosticsExtension); +} diff --git a/electron/extensions/index.ts b/electron/extensions/index.ts new file mode 100644 index 000000000..d34d3a96d --- /dev/null +++ b/electron/extensions/index.ts @@ -0,0 +1,17 @@ +export { extensionRegistry } from './registry'; +export { registerBuiltinExtension, loadExtensionsFromManifest } from './loader'; +export type { + Extension, + ExtensionContext, + HostApiRouteExtension, + MarketplaceProviderExtension, + MarketplaceCapability, + AuthProviderExtension, + AuthStatus, + RouteHandler, +} from './types'; +export { + isHostApiRouteExtension, + isMarketplaceProviderExtension, + isAuthProviderExtension, +} from './types'; diff --git a/electron/extensions/loader.ts b/electron/extensions/loader.ts new file mode 100644 index 000000000..b99e41dec --- /dev/null +++ b/electron/extensions/loader.ts @@ -0,0 +1,76 @@ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { app } from 'electron'; +import { logger } from '../utils/logger'; +import { extensionRegistry } from './registry'; +import type { Extension } from './types'; + +interface ExtensionManifest { + extensions?: { + main?: string[]; + }; +} + +const builtinModules = new Map Extension>(); + +export function registerBuiltinExtension(id: string, factory: () => Extension): void { + builtinModules.set(id, factory); +} + +function resolveManifestPath(): string { + if (app.isPackaged) { + return join(process.resourcesPath, 'clawx-extensions.json'); + } + return join(app.getAppPath(), 'clawx-extensions.json'); +} + +export async function loadExtensionsFromManifest(): Promise { + const manifestPath = resolveManifestPath(); + let manifest: ExtensionManifest = {}; + + if (existsSync(manifestPath)) { + try { + manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as ExtensionManifest; + logger.info(`[extensions] Loaded manifest from ${manifestPath}`); + } catch (err) { + logger.warn(`[extensions] Failed to parse ${manifestPath}, using defaults:`, err); + } + } else { + logger.debug('[extensions] No clawx-extensions.json found, loading all builtin extensions'); + } + + const mainExtensions = manifest.extensions?.main; + + if (!mainExtensions || mainExtensions.length === 0) { + for (const [id, factory] of builtinModules) { + extensionRegistry.register(factory()); + logger.debug(`[extensions] Auto-registered builtin extension "${id}"`); + } + return; + } + + for (const extensionId of mainExtensions) { + if (builtinModules.has(extensionId)) { + extensionRegistry.register(builtinModules.get(extensionId)!()); + continue; + } + + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mod = require(extensionId) as { default?: Extension; extension?: Extension }; + const ext = mod.default ?? mod.extension; + if (ext && typeof ext.setup === 'function') { + extensionRegistry.register(ext); + } else { + logger.warn(`[extensions] Module "${extensionId}" does not export a valid Extension`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('Cannot find module')) { + logger.debug(`[extensions] "${extensionId}" not loadable at runtime (expected when using ext-bridge)`); + } else { + logger.warn(`[extensions] Failed to load extension "${extensionId}": ${message}`); + } + } + } +} diff --git a/electron/extensions/registry.ts b/electron/extensions/registry.ts new file mode 100644 index 000000000..05b399784 --- /dev/null +++ b/electron/extensions/registry.ts @@ -0,0 +1,76 @@ +import { logger } from '../utils/logger'; +import type { + Extension, + ExtensionContext, + HostApiRouteExtension, + MarketplaceProviderExtension, + RouteHandler, +} from './types'; +import { + isHostApiRouteExtension, + isMarketplaceProviderExtension, +} from './types'; + +class ExtensionRegistry { + private extensions = new Map(); + private ctx: ExtensionContext | null = null; + + async initialize(ctx: ExtensionContext): Promise { + this.ctx = ctx; + for (const ext of this.extensions.values()) { + try { + await ext.setup(ctx); + logger.info(`[extensions] Extension "${ext.id}" initialized`); + } catch (err) { + logger.error(`[extensions] Extension "${ext.id}" failed to initialize:`, err); + } + } + } + + register(extension: Extension): void { + if (this.extensions.has(extension.id)) { + logger.warn(`[extensions] Extension "${extension.id}" is already registered; skipping duplicate`); + return; + } + this.extensions.set(extension.id, extension); + logger.debug(`[extensions] Registered extension "${extension.id}"`); + + if (this.ctx) { + void Promise.resolve(extension.setup(this.ctx)).catch((err) => { + logger.error(`[extensions] Late-registered extension "${extension.id}" failed to initialize:`, err); + }); + } + } + + get(id: string): Extension | undefined { + return this.extensions.get(id); + } + + getAll(): Extension[] { + return [...this.extensions.values()]; + } + + getRouteHandlers(): RouteHandler[] { + return this.getAll() + .filter(isHostApiRouteExtension) + .map((ext: HostApiRouteExtension) => ext.getRouteHandler()); + } + + getMarketplaceProvider(): MarketplaceProviderExtension | undefined { + return this.getAll().find(isMarketplaceProviderExtension) as MarketplaceProviderExtension | undefined; + } + + async teardownAll(): Promise { + for (const ext of this.extensions.values()) { + try { + await ext.teardown?.(); + } catch (err) { + logger.warn(`[extensions] Extension "${ext.id}" teardown failed:`, err); + } + } + this.extensions.clear(); + this.ctx = null; + } +} + +export const extensionRegistry = new ExtensionRegistry(); diff --git a/electron/extensions/types.ts b/electron/extensions/types.ts new file mode 100644 index 000000000..10383c6a5 --- /dev/null +++ b/electron/extensions/types.ts @@ -0,0 +1,69 @@ +import type { IncomingMessage, ServerResponse } from 'http'; +import type { BrowserWindow } from 'electron'; +import type { GatewayManager } from '../gateway/manager'; +import type { HostEventBus } from '../api/event-bus'; +import type { HostApiContext } from '../api/context'; +import type { + ClawHubSearchParams, + ClawHubInstallParams, + ClawHubSkillResult, +} from '../gateway/clawhub'; + +export type RouteHandler = ( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +) => Promise; + +export interface ExtensionContext { + gatewayManager: GatewayManager; + eventBus: HostEventBus; + getMainWindow: () => BrowserWindow | null; +} + +export interface Extension { + id: string; + setup(ctx: ExtensionContext): void | Promise; + teardown?(): void | Promise; +} + +export interface HostApiRouteExtension extends Extension { + getRouteHandler(): RouteHandler; +} + +export interface MarketplaceCapability { + mode: string; + canSearch: boolean; + canInstall: boolean; + reason?: string; +} + +export interface MarketplaceProviderExtension extends Extension { + getCapability(): Promise; + search(params: ClawHubSearchParams): Promise; + install(params: ClawHubInstallParams): Promise; +} + +export interface AuthStatus { + authenticated: boolean; + expired: boolean; + user: { username: string; displayName: string; email: string } | null; +} + +export interface AuthProviderExtension extends Extension { + getAuthStatus(): Promise; + onStartup?(mainWindow: BrowserWindow): Promise; +} + +export function isHostApiRouteExtension(ext: Extension): ext is HostApiRouteExtension { + return 'getRouteHandler' in ext && typeof (ext as HostApiRouteExtension).getRouteHandler === 'function'; +} + +export function isMarketplaceProviderExtension(ext: Extension): ext is MarketplaceProviderExtension { + return 'getCapability' in ext && 'search' in ext && 'install' in ext; +} + +export function isAuthProviderExtension(ext: Extension): ext is AuthProviderExtension { + return 'getAuthStatus' in ext && typeof (ext as AuthProviderExtension).getAuthStatus === 'function'; +} diff --git a/electron/gateway/clawhub.ts b/electron/gateway/clawhub.ts index 1f45f5a81..9714d04e6 100644 --- a/electron/gateway/clawhub.ts +++ b/electron/gateway/clawhub.ts @@ -40,12 +40,30 @@ export interface ClawHubInstalledSkillResult { baseDir?: string; } +export interface MarketplaceProvider { + getCapability(): Promise<{ mode: string; canSearch: boolean; canInstall: boolean; reason?: string }>; + search(params: ClawHubSearchParams): Promise; + install(params: ClawHubInstallParams): Promise; +} + export class ClawHubService { private workDir: string; private cliPath: string; private cliEntryPath: string; private useNodeRunner: boolean; private ansiRegex: RegExp; + private marketplaceProvider: MarketplaceProvider | null = null; + + setMarketplaceProvider(provider: MarketplaceProvider): void { + this.marketplaceProvider = provider; + } + + async getMarketplaceCapability(): Promise<{ mode: string; canSearch: boolean; canInstall: boolean; reason?: string }> { + if (this.marketplaceProvider) { + return this.marketplaceProvider.getCapability(); + } + return { mode: 'clawhub', canSearch: true, canInstall: true }; + } constructor() { // Use the user's OpenClaw config directory (~/.openclaw) for skill management @@ -194,9 +212,13 @@ export class ClawHubService { } /** - * Search for skills + * Search for skills. Delegates to the marketplace provider if one is set, + * otherwise falls back to the local ClawHub CLI. */ async search(params: ClawHubSearchParams): Promise { + if (this.marketplaceProvider) { + return this.marketplaceProvider.search(params); + } try { // If query is empty, use 'explore' to show trending skills if (!params.query || params.query.trim() === '') { @@ -298,9 +320,13 @@ export class ClawHubService { } /** - * Install a skill + * Install a skill. Delegates to the marketplace provider if one is set, + * otherwise falls back to the local ClawHub CLI. */ async install(params: ClawHubInstallParams): Promise { + if (this.marketplaceProvider) { + return this.marketplaceProvider.install(params); + } const args = ['install', params.slug]; if (params.version) { diff --git a/electron/main/index.ts b/electron/main/index.ts index 69e4c7e57..299e64784 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -16,6 +16,10 @@ import { warmupNetworkOptimization } from '../utils/uv-env'; import { initTelemetry } from '../utils/telemetry'; import { ClawHubService } from '../gateway/clawhub'; +import { extensionRegistry } from '../extensions/registry'; +import { loadExtensionsFromManifest } from '../extensions/loader'; +import { registerAllBuiltinExtensions } from '../extensions/builtin'; +import { loadExternalMainExtensions } from '../extensions/_ext-bridge.generated'; import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace'; import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToProfile } from '../utils/openclaw-cli'; import { isQuitting, setQuitting } from './app-state'; @@ -340,6 +344,19 @@ async function initialize(): Promise { mainWindow: window, }); + // Initialize extension system + await extensionRegistry.initialize({ + gatewayManager, + eventBus: hostEventBus, + getMainWindow: () => mainWindow, + }); + + // Wire marketplace provider to ClawHubService if an extension provides one + const marketplaceProvider = extensionRegistry.getMarketplaceProvider(); + if (marketplaceProvider) { + clawHubService.setMarketplaceProvider(marketplaceProvider); + } + // Register update handlers registerUpdateHandlers(appUpdater, window); @@ -521,6 +538,13 @@ if (gotTheLock) { clawHubService = new ClawHubService(); hostEventBus = new HostEventBus(); + // Register builtin extensions and load manifest + registerAllBuiltinExtensions(); + loadExternalMainExtensions(); + void loadExtensionsFromManifest().catch((err) => { + logger.warn('Failed to load extensions from manifest:', err); + }); + // When a second instance is launched, focus the existing window instead. app.on('second-instance', () => { logger.info('Second ClawX instance detected; redirecting to the existing window'); @@ -578,6 +602,7 @@ if (gotTheLock) { hostEventBus.closeAll(); hostApiServer?.close(); + void extensionRegistry.teardownAll(); const stopPromise = gatewayManager.stop().catch((err) => { logger.warn('gatewayManager.stop() error during quit:', err); diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 4647ab66a..60dcab8f0 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -180,8 +180,7 @@ const electronAPI = { 'openclaw:cli-installed', ]; - if (validChannels.includes(channel)) { - // Wrap the callback to strip the event + if (validChannels.includes(channel) || channel.startsWith('ext:')) { const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => { callback(...args); }; @@ -228,7 +227,7 @@ const electronAPI = { 'oauth:error', ]; - if (validChannels.includes(channel)) { + if (validChannels.includes(channel) || channel.startsWith('ext:')) { ipcRenderer.once(channel, (_event, ...args) => callback(...args)); return; } diff --git a/package.json b/package.json index 8bad5bbe8..73222ed09 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,10 @@ "private": true, "scripts": { "init": "pnpm install && pnpm run uv:download", - "predev": "zx scripts/prepare-preinstalled-skills-dev.mjs", + "predev": "node scripts/generate-ext-bridge.mjs && zx scripts/prepare-preinstalled-skills-dev.mjs", "dev": "vite", - "build": "vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder", + "ext:bridge": "node scripts/generate-ext-bridge.mjs", + "build": "node scripts/generate-ext-bridge.mjs && vite build && zx scripts/bundle-openclaw.mjs && zx scripts/bundle-openclaw-plugins.mjs && zx scripts/bundle-preinstalled-skills.mjs && electron-builder", "build:vite": "vite build", "bundle:openclaw-plugins": "zx scripts/bundle-openclaw-plugins.mjs", "bundle:preinstalled-skills": "zx scripts/bundle-preinstalled-skills.mjs", @@ -80,6 +81,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@larksuite/openclaw-lark": "2026.4.7", + "@playwright/test": "^1.56.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", @@ -92,7 +94,6 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", - "@playwright/test": "^1.56.1", "@soimy/dingtalk": "^3.5.3", "@tencent-weixin/openclaw-weixin": "^2.1.8", "@testing-library/jest-dom": "^6.9.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f63ab752..4d47b8241 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9921,7 +9921,7 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/bun@1.3.11': dependencies: @@ -9946,7 +9946,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/debug@4.1.13': dependencies: @@ -9966,7 +9966,7 @@ snapshots: '@types/express-serve-static-core@5.1.1': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/qs': 6.15.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -10056,12 +10056,12 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/unist@2.0.11': {} diff --git a/scripts/generate-ext-bridge.mjs b/scripts/generate-ext-bridge.mjs new file mode 100644 index 000000000..f47a709e5 --- /dev/null +++ b/scripts/generate-ext-bridge.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node +/** + * Generates extension bridge files based on clawx-extensions.json and + * which packages are actually installed in node_modules. + * + * Outputs: + * electron/extensions/_ext-bridge.generated.ts (main process) + * src/extensions/_ext-bridge.generated.ts (renderer) + * + * Both files are .gitignore'd. When no external extensions are installed, + * they export no-op functions so the core compiles cleanly. + * + * Usage: node scripts/generate-ext-bridge.mjs + */ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); + +const MANIFEST_PATH = resolve(ROOT, 'clawx-extensions.json'); +const MAIN_OUT = resolve(ROOT, 'electron/extensions/_ext-bridge.generated.ts'); +const RENDERER_OUT = resolve(ROOT, 'src/extensions/_ext-bridge.generated.ts'); + +function readManifest() { + if (!existsSync(MANIFEST_PATH)) return { extensions: {} }; + try { + return JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8')); + } catch { + return { extensions: {} }; + } +} + +function isExternalId(id) { + return id && !id.startsWith('builtin/'); +} + +function resolvePackageName(extensionId) { + // Convention: extension IDs like "@scope/pkg/sub" map to package "@scope/pkg" + // IDs like "@scope/pkg" map to package "@scope/pkg" + const parts = extensionId.split('/'); + if (parts[0].startsWith('@') && parts.length >= 2) { + return parts.slice(0, 2).join('/'); + } + return parts[0]; +} + +function isPackageInstalled(pkgName) { + return existsSync(resolve(ROOT, 'node_modules', ...pkgName.split('/'))); +} + +function generateMainBridge(manifest) { + const externalMain = (manifest.extensions?.main ?? []).filter(isExternalId); + const installedExts = externalMain.filter((id) => isPackageInstalled(resolvePackageName(id))); + + if (installedExts.length === 0) { + return [ + '// Auto-generated — no external main-process extensions installed.', + '// To add extensions, configure clawx-extensions.json and link the package.', + 'export function loadExternalMainExtensions(): void { /* no-op */ }', + '', + ].join('\n'); + } + + const lines = [ + '// Auto-generated by scripts/generate-ext-bridge.mjs — do not edit.', + "import { extensionRegistry } from './registry';", + '', + ]; + + installedExts.forEach((id, i) => { + const pkg = resolvePackageName(id); + const subpath = id.slice(pkg.length + 1); // e.g. "enterprise-auth" + const importPath = subpath ? `${pkg}/${subpath}` : pkg; + const factoryName = `ext${i}`; + lines.push(`import { ${guessFactoryExport(subpath || id)} as ${factoryName} } from '${importPath}';`); + }); + + lines.push(''); + lines.push('export function loadExternalMainExtensions(): void {'); + installedExts.forEach((_id, i) => { + lines.push(` const e${i} = ext${i}();`); + lines.push(` if (e${i}) extensionRegistry.register(e${i});`); + }); + lines.push('}'); + lines.push(''); + + return lines.join('\n'); +} + +function generateRendererBridge(manifest) { + const externalRenderer = (manifest.extensions?.renderer ?? []).filter(isExternalId); + const installedExts = externalRenderer.filter((id) => isPackageInstalled(resolvePackageName(id))); + + if (installedExts.length === 0) { + return [ + '// Auto-generated — no external renderer extensions installed.', + '// To add extensions, configure clawx-extensions.json and link the package.', + 'export function loadExternalRendererExtensions(): void { /* no-op */ }', + '', + ].join('\n'); + } + + const lines = [ + '// Auto-generated by scripts/generate-ext-bridge.mjs — do not edit.', + "import { rendererExtensionRegistry } from './registry';", + '', + ]; + + installedExts.forEach((id, i) => { + const pkg = resolvePackageName(id); + const subpath = id.slice(pkg.length + 1); + const importPath = subpath ? `${pkg}/${subpath}` : pkg; + const factoryName = `ext${i}`; + lines.push(`import { ${guessFactoryExport(subpath || id)} as ${factoryName} } from '${importPath}';`); + }); + + lines.push(''); + lines.push('export function loadExternalRendererExtensions(): void {'); + installedExts.forEach((_id, i) => { + lines.push(` const e${i} = ext${i}();`); + lines.push(` if (e${i}) rendererExtensionRegistry.register(e${i});`); + }); + lines.push('}'); + lines.push(''); + + return lines.join('\n'); +} + +function guessFactoryExport(subpath) { + // "enterprise-auth" → "createEnterpriseAuthExtension" + // "enterprise-ui" → "createEnterpriseUIExtension" + // "skillshub-marketplace" → "createSkillshubMarketplaceExtension" + const camel = subpath + .replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase()) + .replace(/^./, (c) => c.toUpperCase()); + return `create${camel}Extension`; +} + +// ─── Main ─── +const manifest = readManifest(); + +mkdirSync(dirname(MAIN_OUT), { recursive: true }); +mkdirSync(dirname(RENDERER_OUT), { recursive: true }); + +writeFileSync(MAIN_OUT, generateMainBridge(manifest)); +writeFileSync(RENDERER_OUT, generateRendererBridge(manifest)); + +const mainExts = (manifest.extensions?.main ?? []).filter(isExternalId); +const rendererExts = (manifest.extensions?.renderer ?? []).filter(isExternalId); +const totalExternal = mainExts.length + rendererExts.length; + +if (totalExternal === 0) { + console.log('[ext-bridge] No external extensions configured — generated empty stubs.'); +} else { + const installedMain = mainExts.filter((id) => isPackageInstalled(resolvePackageName(id))); + const installedRenderer = rendererExts.filter((id) => isPackageInstalled(resolvePackageName(id))); + console.log( + `[ext-bridge] Generated bridges: ${installedMain.length}/${mainExts.length} main, ${installedRenderer.length}/${rendererExts.length} renderer extensions resolved.`, + ); +} diff --git a/src/App.tsx b/src/App.tsx index 202ed93bc..5ff791dca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,8 @@ import { useSettingsStore } from './stores/settings'; import { useGatewayStore } from './stores/gateway'; import { useProviderStore } from './stores/providers'; import { applyGatewayTransportPreference } from './lib/api-client'; +import { rendererExtensionRegistry } from './extensions/registry'; +import { loadExternalRendererExtensions } from './extensions/_ext-bridge.generated'; /** @@ -164,6 +166,16 @@ function App() { applyGatewayTransportPreference(); }, []); + // Load external renderer extensions (generated by scripts/generate-ext-bridge.mjs) + // and initialize all registered extensions. + useEffect(() => { + loadExternalRendererExtensions(); + void rendererExtensionRegistry.initializeAll(); + return () => rendererExtensionRegistry.teardownAll(); + }, []); + + const extraRoutes = rendererExtensionRegistry.getExtraRoutes(); + return ( @@ -180,6 +192,9 @@ function App() { } /> } /> } /> + {extraRoutes.map((r) => ( + } /> + ))} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 9df1d3739..99ba7e602 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -20,6 +20,7 @@ import { Cpu, } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { rendererExtensionRegistry } from '@/extensions/registry'; import { useSettingsStore } from '@/stores/settings'; import { useChatStore } from '@/stores/chat'; import { useGatewayStore } from '@/stores/gateway'; @@ -208,7 +209,10 @@ export function Sidebar() { sessionBucketMap[bucketKey].sessions.push(session); } - const navItems = [ + const hiddenRoutes = rendererExtensionRegistry.getHiddenRoutes(); + const extraNavItems = rendererExtensionRegistry.getExtraNavItems(); + + const coreNavItems = [ { to: '/models', icon: , label: t('sidebar.models'), testId: 'sidebar-nav-models' }, { to: '/agents', icon: , label: t('sidebar.agents'), testId: 'sidebar-nav-agents' }, { to: '/channels', icon: , label: t('sidebar.channels'), testId: 'sidebar-nav-channels' }, @@ -216,6 +220,16 @@ export function Sidebar() { { to: '/cron', icon: , label: t('sidebar.cronTasks'), testId: 'sidebar-nav-cron' }, ]; + const navItems = [ + ...coreNavItems.filter((item) => !hiddenRoutes.has(item.to)), + ...extraNavItems.map((item) => ({ + to: item.to, + icon: , + label: item.labelI18nKey ? t(item.labelI18nKey) : item.label, + testId: item.testId, + })), + ]; + return (