diff --git a/.gitignore b/.gitignore index 9a6945c0e..b26251cb6 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,8 @@ coverage/ *.deb *.rpm +resources/bin + # Secrets *.p12 *.pem diff --git a/electron-builder.yml b/electron-builder.yml index e551be086..08464907c 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -1,7 +1,7 @@ appId: app.clawx.desktop productName: ClawX copyright: Copyright © 2026 ClawX -compression: maximum +compression: normal artifactName: ${productName}-${version}-${os}-${arch}.${ext} directories: @@ -20,6 +20,7 @@ extraResources: - "**/*" - "!icons/*.md" - "!icons/*.svg" + - "!bin/**" # OpenClaw submodule - include only necessary files for runtime - from: openclaw/ to: openclaw/ @@ -30,6 +31,7 @@ extraResources: - "skills/**/*" - "extensions/**/*" - "scripts/run-node.mjs" + - "!**/node_modules/**" - "!**/*.test.ts" - "!**/*.test.js" - "!**/test/**" @@ -50,6 +52,9 @@ publish: # macOS Configuration mac: + extraResources: + - from: resources/bin/darwin-${arch} + to: bin category: public.app-category.productivity icon: resources/icons/icon.icns target: @@ -84,6 +89,9 @@ dmg: # Windows Configuration win: + extraResources: + - from: resources/bin/win32-${arch} + to: bin icon: resources/icons/icon.ico target: - target: nsis diff --git a/electron/gateway/clawhub.ts b/electron/gateway/clawhub.ts new file mode 100644 index 000000000..591c933c7 --- /dev/null +++ b/electron/gateway/clawhub.ts @@ -0,0 +1,304 @@ +/** + * ClawHub Service + * Manages interactions with the ClawHub CLI for skills management + */ +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import { app, shell } from 'electron'; +import { getOpenClawConfigDir, ensureDir } from '../utils/paths'; + +export interface ClawHubSearchParams { + query: string; + limit?: number; +} + +export interface ClawHubInstallParams { + slug: string; + version?: string; + force?: boolean; +} + +export interface ClawHubUninstallParams { + slug: string; +} + +export interface ClawHubSkillResult { + slug: string; + name: string; + description: string; + version: string; + author?: string; + downloads?: number; + stars?: number; +} + +export class ClawHubService { + private workDir: string; + private cliPath: string; + private ansiRegex: RegExp; + + constructor() { + // Use the user's OpenClaw config directory (~/.openclaw) for skill management + // This avoids installing skills into the project's openclaw submodule + this.workDir = getOpenClawConfigDir(); + ensureDir(this.workDir); + + // In development, we use the locally installed clawhub CLI from node_modules + const isWin = process.platform === 'win32'; + const binName = isWin ? 'clawhub.cmd' : 'clawhub'; + const localCli = path.resolve(app.getAppPath(), 'node_modules', '.bin', binName); + this.cliPath = localCli; + const esc = String.fromCharCode(27); + const csi = String.fromCharCode(155); + const pattern = `(?:${esc}|${csi})[[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]`; + this.ansiRegex = new RegExp(pattern, 'g'); + } + + private stripAnsi(line: string): string { + return line.replace(this.ansiRegex, '').trim(); + } + + /** + * Run a ClawHub CLI command + */ + private async runCommand(args: string[]): Promise { + return new Promise((resolve, reject) => { + console.log(`Running ClawHub command: ${this.cliPath} ${args.join(' ')}`); + + const isWin = process.platform === 'win32'; + const child = spawn(this.cliPath, args, { + cwd: this.workDir, + shell: isWin, + env: { + ...process.env, + CI: 'true', + FORCE_COLOR: '0', // Disable colors for easier parsing + }, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('error', (error) => { + console.error('ClawHub process error:', error); + reject(error); + }); + + child.on('close', (code) => { + if (code !== 0 && code !== null) { + console.error(`ClawHub command failed with code ${code}`); + console.error('Stderr:', stderr); + reject(new Error(`Command failed: ${stderr || stdout}`)); + } else { + resolve(stdout.trim()); + } + }); + }); + } + + /** + * Search for skills + */ + async search(params: ClawHubSearchParams): Promise { + try { + // If query is empty, use 'explore' to show trending skills + if (!params.query || params.query.trim() === '') { + return this.explore({ limit: params.limit }); + } + + const args = ['search', params.query]; + if (params.limit) { + args.push('--limit', String(params.limit)); + } + + const output = await this.runCommand(args); + if (!output || output.includes('No skills found')) { + return []; + } + + const lines = output.split('\n').filter(l => l.trim()); + return lines.map(line => { + const cleanLine = this.stripAnsi(line); + + // Format could be: slug vversion description (score) + // Or sometimes: slug vversion description + const match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)\s+(.+)$/); + if (match) { + const slug = match[1]; + const version = match[2]; + let description = match[3]; + + // Clean up score if present at the end + description = description.replace(/\(\d+\.\d+\)$/, '').trim(); + + return { + slug, + name: slug, + version, + description, + }; + } + return null; + }).filter((s): s is ClawHubSkillResult => s !== null); + } catch (error) { + console.error('ClawHub search error:', error); + return []; + } + } + + /** + * Explore trending skills + */ + async explore(params: { limit?: number } = {}): Promise { + try { + const args = ['explore']; + if (params.limit) { + args.push('--limit', String(params.limit)); + } + + const output = await this.runCommand(args); + if (!output) return []; + + const lines = output.split('\n').filter(l => l.trim()); + return lines.map(line => { + const cleanLine = this.stripAnsi(line); + + // Format: slug vversion time description + // Example: my-skill v1.0.0 2 hours ago A great skill + const match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)\s+(.+? ago|just now|yesterday)\s+(.+)$/i); + if (match) { + return { + slug: match[1], + name: match[1], + version: match[2], + description: match[4], + }; + } + return null; + }).filter((s): s is ClawHubSkillResult => s !== null); + } catch (error) { + console.error('ClawHub explore error:', error); + return []; + } + } + + /** + * Install a skill + */ + async install(params: ClawHubInstallParams): Promise { + const args = ['install', params.slug]; + + if (params.version) { + args.push('--version', params.version); + } + + if (params.force) { + args.push('--force'); + } + + await this.runCommand(args); + } + + /** + * Uninstall a skill + */ + async uninstall(params: ClawHubUninstallParams): Promise { + const fsPromises = fs.promises; + + // 1. Delete the skill directory + const skillDir = path.join(this.workDir, 'skills', params.slug); + if (fs.existsSync(skillDir)) { + console.log(`Deleting skill directory: ${skillDir}`); + await fsPromises.rm(skillDir, { recursive: true, force: true }); + } + + // 2. Remove from lock.json + const lockFile = path.join(this.workDir, '.clawhub', 'lock.json'); + if (fs.existsSync(lockFile)) { + try { + const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf8')); + if (lockData.skills && lockData.skills[params.slug]) { + console.log(`Removing ${params.slug} from lock.json`); + delete lockData.skills[params.slug]; + await fsPromises.writeFile(lockFile, JSON.stringify(lockData, null, 2)); + } + } catch (err) { + console.error('Failed to update ClawHub lock file:', err); + } + } + } + + /** + * List installed skills + */ + async listInstalled(): Promise> { + try { + const output = await this.runCommand(['list']); + if (!output || output.includes('No installed skills')) { + return []; + } + + const lines = output.split('\n').filter(l => l.trim()); + return lines.map(line => { + const cleanLine = this.stripAnsi(line); + const match = cleanLine.match(/^(\S+)\s+v?(\d+\.\S+)/); + if (match) { + return { + slug: match[1], + version: match[2], + }; + } + return null; + }).filter((s): s is { slug: string; version: string } => s !== null); + } catch (error) { + console.error('ClawHub list error:', error); + return []; + } + } + + /** + * Open skill README/manual in default editor + */ + async openSkillReadme(slug: string): Promise { + const skillDir = path.join(this.workDir, 'skills', slug); + + // Try to find documentation file + const possibleFiles = ['SKILL.md', 'README.md', 'skill.md', 'readme.md']; + let targetFile = ''; + + for (const file of possibleFiles) { + const filePath = path.join(skillDir, file); + if (fs.existsSync(filePath)) { + targetFile = filePath; + break; + } + } + + if (!targetFile) { + // If no md file, just open the directory + if (fs.existsSync(skillDir)) { + targetFile = skillDir; + } else { + throw new Error('Skill directory not found'); + } + } + + try { + // Open file with default application + await shell.openPath(targetFile); + return true; + } catch (error) { + console.error('Failed to open skill readme:', error); + throw error; + } + } +} diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 55753c019..41c2f0ccc 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -2,6 +2,8 @@ * Gateway Process Manager * Manages the OpenClaw Gateway process lifecycle */ +import { app } from 'electron'; +import path from 'path'; import { spawn, ChildProcess } from 'child_process'; import { EventEmitter } from 'events'; import { existsSync } from 'fs'; @@ -361,16 +363,40 @@ export class GatewayManager extends EventEmitter { // Production mode: use openclaw.mjs directly console.log('Starting Gateway in production mode (using dist)'); command = 'node'; - args = [entryScript, 'gateway', 'run', '--port', String(this.status.port), '--token', gatewayToken, '--dev', '--allow-unconfigured']; + args = [entryScript, 'gateway', '--port', String(this.status.port), '--token', gatewayToken, '--dev', '--allow-unconfigured']; } else { // Development mode: use pnpm gateway:dev which handles tsx compilation console.log('Starting Gateway in development mode (using pnpm)'); command = 'pnpm'; - args = ['run', 'dev', 'gateway', 'run', '--port', String(this.status.port), '--token', gatewayToken, '--dev', '--allow-unconfigured']; + args = ['run', 'dev', 'gateway', '--port', String(this.status.port), '--token', gatewayToken, '--dev', '--allow-unconfigured']; } console.log(`Spawning Gateway: ${command} ${args.join(' ')}`); console.log(`Working directory: ${openclawDir}`); + + // Resolve bundled bin path for uv + let binPath = ''; + const platform = process.platform; + const arch = process.arch; + // Map arch if necessary (e.g. x64 is standard, but ensure consistency with script) + const target = `${platform}-${arch}`; + + if (app.isPackaged) { + // In production, we flattened the structure to 'bin/' using electron-builder macros + binPath = path.join(process.resourcesPath, 'bin'); + } else { + // In dev, resources are at project root/resources/bin/- + binPath = path.join(process.cwd(), 'resources', 'bin', target); + } + + // Only inject if the bundled directory exists + const finalPath = existsSync(binPath) + ? `${binPath}${path.delimiter}${process.env.PATH || ''}` + : process.env.PATH || ''; + + if (existsSync(binPath)) { + console.log('Injecting bundled bin path:', binPath); + } // Load provider API keys from secure storage to pass as environment variables const providerEnv: Record = {}; @@ -398,13 +424,15 @@ export class GatewayManager extends EventEmitter { shell: process.platform === 'win32', // Use shell on Windows for pnpm env: { ...process.env, + PATH: finalPath, // Inject bundled bin path if it exists // Provider API keys ...providerEnv, - // Skip channel auto-connect during startup for faster boot - OPENCLAW_SKIP_CHANNELS: '1', - CLAWDBOT_SKIP_CHANNELS: '1', // Also set token via environment variable as fallback OPENCLAW_GATEWAY_TOKEN: gatewayToken, + // Ensure OPENCLAW_SKIP_CHANNELS is NOT set so channels auto-start + // and config hot-reload can restart channels when config changes + OPENCLAW_SKIP_CHANNELS: '', + CLAWDBOT_SKIP_CHANNELS: '', }, }); diff --git a/electron/main/index.ts b/electron/main/index.ts index 207d2ac9a..a4b0eca0a 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -14,9 +14,12 @@ import { appUpdater, registerUpdateHandlers } from './updater'; // Disable GPU acceleration for better compatibility app.disableHardwareAcceleration(); +import { ClawHubService } from '../gateway/clawhub'; + // Global references let mainWindow: BrowserWindow | null = null; const gatewayManager = new GatewayManager(); +const clawHubService = new ClawHubService(); /** * Create the main application window @@ -80,12 +83,12 @@ async function initialize(): Promise { // which prevents embedding in an iframe. Only apply to gateway URLs. session.defaultSession.webRequest.onHeadersReceived((details, callback) => { const isGatewayUrl = details.url.includes('127.0.0.1:18789') || details.url.includes('localhost:18789'); - + if (!isGatewayUrl) { callback({ responseHeaders: details.responseHeaders }); return; } - + const headers = { ...details.responseHeaders }; // Remove X-Frame-Options to allow embedding in iframe delete headers['X-Frame-Options']; @@ -103,9 +106,9 @@ async function initialize(): Promise { } callback({ responseHeaders: headers }); }); - + // Register IPC handlers - registerIpcHandlers(gatewayManager, mainWindow); + registerIpcHandlers(gatewayManager, clawHubService, mainWindow); // Register update handlers registerUpdateHandlers(appUpdater, mainWindow); diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 85e130abc..b6daf0367 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -4,6 +4,7 @@ */ import { ipcMain, BrowserWindow, shell, dialog, app } from 'electron'; import { GatewayManager } from '../gateway/manager'; +import { ClawHubService, ClawHubSearchParams, ClawHubInstallParams, ClawHubUninstallParams } from '../gateway/clawhub'; import { storeApiKey, getApiKey, @@ -22,31 +23,107 @@ import { import { getOpenClawStatus } from '../utils/paths'; import { getSetting } from '../utils/store'; import { saveProviderKeyToOpenClaw, setOpenClawDefaultModel } from '../utils/openclaw-auth'; +import { + saveChannelConfig, + getChannelConfig, + getChannelFormValues, + deleteChannelConfig, + listConfiguredChannels, + setChannelEnabled, + validateChannelConfig, + validateChannelCredentials, +} from '../utils/channel-config'; +import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup'; +import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config'; /** * Register all IPC handlers */ export function registerIpcHandlers( gatewayManager: GatewayManager, + clawHubService: ClawHubService, mainWindow: BrowserWindow ): void { // Gateway handlers registerGatewayHandlers(gatewayManager, mainWindow); - + + // ClawHub handlers + registerClawHubHandlers(clawHubService); + // OpenClaw handlers registerOpenClawHandlers(); - + // Provider handlers registerProviderHandlers(); - + // Shell handlers registerShellHandlers(); - + // Dialog handlers registerDialogHandlers(); - + // App handlers registerAppHandlers(); + + // UV handlers + registerUvHandlers(); + + // Skill config handlers (direct file access, no Gateway RPC) + registerSkillConfigHandlers(); +} + +/** + * Skill config IPC handlers + * Direct read/write to ~/.openclaw/openclaw.json (bypasses Gateway RPC) + */ +function registerSkillConfigHandlers(): void { + // Update skill config (apiKey and env) + ipcMain.handle('skill:updateConfig', async (_, params: { + skillKey: string; + apiKey?: string; + env?: Record; + }) => { + return updateSkillConfig(params.skillKey, { + apiKey: params.apiKey, + env: params.env, + }); + }); + + // Get skill config + ipcMain.handle('skill:getConfig', async (_, skillKey: string) => { + return getSkillConfig(skillKey); + }); + + // Get all skill configs + ipcMain.handle('skill:getAllConfigs', async () => { + return getAllSkillConfigs(); + }); +} + +/** + * UV-related IPC handlers + */ +function registerUvHandlers(): void { + // Check if uv is installed + ipcMain.handle('uv:check', async () => { + return await checkUvInstalled(); + }); + + // Install uv and setup managed Python + ipcMain.handle('uv:install-all', async () => { + try { + const isInstalled = await checkUvInstalled(); + if (!isInstalled) { + await installUv(); + } + // Always run python setup to ensure it exists in uv's cache + await setupManagedPython(); + return { success: true }; + } catch (error) { + console.error('Failed to setup uv/python:', error); + return { success: false, error: String(error) }; + } + }); } /** @@ -60,12 +137,12 @@ function registerGatewayHandlers( ipcMain.handle('gateway:status', () => { return gatewayManager.getStatus(); }); - + // Check if Gateway is connected ipcMain.handle('gateway:isConnected', () => { return gatewayManager.isConnected(); }); - + // Start Gateway ipcMain.handle('gateway:start', async () => { try { @@ -75,7 +152,7 @@ function registerGatewayHandlers( return { success: false, error: String(error) }; } }); - + // Stop Gateway ipcMain.handle('gateway:stop', async () => { try { @@ -85,7 +162,7 @@ function registerGatewayHandlers( return { success: false, error: String(error) }; } }); - + // Restart Gateway ipcMain.handle('gateway:restart', async () => { try { @@ -95,7 +172,7 @@ function registerGatewayHandlers( return { success: false, error: String(error) }; } }); - + // Gateway RPC call ipcMain.handle('gateway:rpc', async (_, method: string, params?: unknown, timeoutMs?: number) => { try { @@ -105,7 +182,7 @@ function registerGatewayHandlers( return { success: false, error: String(error) }; } }); - + // Get the Control UI URL with token for embedding ipcMain.handle('gateway:getControlUiUrl', async () => { try { @@ -119,7 +196,7 @@ function registerGatewayHandlers( return { success: false, error: String(error) }; } }); - + // Health check ipcMain.handle('gateway:health', async () => { try { @@ -129,44 +206,44 @@ function registerGatewayHandlers( return { success: false, ok: false, error: String(error) }; } }); - + // Forward Gateway events to renderer gatewayManager.on('status', (status) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('gateway:status-changed', status); } }); - + gatewayManager.on('message', (message) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('gateway:message', message); } }); - + gatewayManager.on('notification', (notification) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('gateway:notification', notification); } }); - + gatewayManager.on('channel:status', (data) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('gateway:channel-status', data); } }); - + gatewayManager.on('chat:message', (data) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('gateway:chat-message', data); } }); - + gatewayManager.on('exit', (code) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('gateway:exit', code); } }); - + gatewayManager.on('error', (error) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send('gateway:error', error.message); @@ -176,21 +253,113 @@ function registerGatewayHandlers( /** * OpenClaw-related IPC handlers - * For checking submodule status + * For checking submodule status and channel configuration */ function registerOpenClawHandlers(): void { + // Get OpenClaw submodule status ipcMain.handle('openclaw:status', () => { return getOpenClawStatus(); }); - + // Check if OpenClaw is ready (submodule present and dependencies installed) ipcMain.handle('openclaw:isReady', () => { const status = getOpenClawStatus(); return status.submoduleExists && status.isInstalled; }); + + // ==================== Channel Configuration Handlers ==================== + + // Save channel configuration + ipcMain.handle('channel:saveConfig', async (_, channelType: string, config: Record) => { + try { + saveChannelConfig(channelType, config); + return { success: true }; + } catch (error) { + console.error('Failed to save channel config:', error); + return { success: false, error: String(error) }; + } + }); + + // Get channel configuration + ipcMain.handle('channel:getConfig', async (_, channelType: string) => { + try { + const config = getChannelConfig(channelType); + return { success: true, config }; + } catch (error) { + console.error('Failed to get channel config:', error); + return { success: false, error: String(error) }; + } + }); + + // Get channel form values (reverse-transformed for UI pre-fill) + ipcMain.handle('channel:getFormValues', async (_, channelType: string) => { + try { + const values = getChannelFormValues(channelType); + return { success: true, values }; + } catch (error) { + console.error('Failed to get channel form values:', error); + return { success: false, error: String(error) }; + } + }); + + // Delete channel configuration + ipcMain.handle('channel:deleteConfig', async (_, channelType: string) => { + try { + deleteChannelConfig(channelType); + return { success: true }; + } catch (error) { + console.error('Failed to delete channel config:', error); + return { success: false, error: String(error) }; + } + }); + + // List configured channels + ipcMain.handle('channel:listConfigured', async () => { + try { + const channels = listConfiguredChannels(); + return { success: true, channels }; + } catch (error) { + console.error('Failed to list channels:', error); + return { success: false, error: String(error) }; + } + }); + + // Enable or disable a channel + ipcMain.handle('channel:setEnabled', async (_, channelType: string, enabled: boolean) => { + try { + setChannelEnabled(channelType, enabled); + return { success: true }; + } catch (error) { + console.error('Failed to set channel enabled:', error); + return { success: false, error: String(error) }; + } + }); + + // Validate channel configuration + ipcMain.handle('channel:validate', async (_, channelType: string) => { + try { + const result = await validateChannelConfig(channelType); + return { success: true, ...result }; + } catch (error) { + console.error('Failed to validate channel:', error); + return { success: false, valid: false, errors: [String(error)], warnings: [] }; + } + }); + + // Validate channel credentials by calling actual service APIs (before saving) + ipcMain.handle('channel:validateCredentials', async (_, channelType: string, config: Record) => { + try { + const result = await validateChannelCredentials(channelType, config); + return { success: true, ...result }; + } catch (error) { + console.error('Failed to validate channel credentials:', error); + return { success: false, valid: false, errors: [String(error)], warnings: [] }; + } + }); } + /** * Provider-related IPC handlers */ @@ -199,27 +368,27 @@ function registerProviderHandlers(): void { ipcMain.handle('provider:encryptionAvailable', () => { return isEncryptionAvailable(); }); - + // Get all providers with key info ipcMain.handle('provider:list', async () => { return await getAllProvidersWithKeyInfo(); }); - + // Get a specific provider ipcMain.handle('provider:get', async (_, providerId: string) => { return await getProvider(providerId); }); - + // Save a provider configuration ipcMain.handle('provider:save', async (_, config: ProviderConfig, apiKey?: string) => { try { // Save the provider config await saveProvider(config); - + // Store the API key if provided if (apiKey) { await storeApiKey(config.id, apiKey); - + // Also write to OpenClaw auth-profiles.json so the gateway can use it try { saveProviderKeyToOpenClaw(config.type, apiKey); @@ -227,20 +396,20 @@ function registerProviderHandlers(): void { console.warn('Failed to save key to OpenClaw auth-profiles:', err); } } - + // Set the default model in OpenClaw config based on provider type try { setOpenClawDefaultModel(config.type); } catch (err) { console.warn('Failed to set OpenClaw default model:', err); } - + return { success: true }; } catch (error) { return { success: false, error: String(error) }; } }); - + // Delete a provider ipcMain.handle('provider:delete', async (_, providerId: string) => { try { @@ -250,12 +419,12 @@ function registerProviderHandlers(): void { return { success: false, error: String(error) }; } }); - + // Update API key for a provider ipcMain.handle('provider:setApiKey', async (_, providerId: string, apiKey: string) => { try { await storeApiKey(providerId, apiKey); - + // Also write to OpenClaw auth-profiles.json // Resolve provider type from stored config, or use providerId as type const provider = await getProvider(providerId); @@ -265,13 +434,13 @@ function registerProviderHandlers(): void { } catch (err) { console.warn('Failed to save key to OpenClaw auth-profiles:', err); } - + return { success: true }; } catch (error) { return { success: false, error: String(error) }; } }); - + // Delete API key for a provider ipcMain.handle('provider:deleteApiKey', async (_, providerId: string) => { try { @@ -281,17 +450,17 @@ function registerProviderHandlers(): void { return { success: false, error: String(error) }; } }); - + // Check if a provider has an API key ipcMain.handle('provider:hasApiKey', async (_, providerId: string) => { return await hasApiKey(providerId); }); - + // Get the actual API key (for internal use only - be careful!) ipcMain.handle('provider:getApiKey', async (_, providerId: string) => { return await getApiKey(providerId); }); - + // Set default provider ipcMain.handle('provider:setDefault', async (_, providerId: string) => { try { @@ -301,23 +470,23 @@ function registerProviderHandlers(): void { return { success: false, error: String(error) }; } }); - + // Get default provider ipcMain.handle('provider:getDefault', async () => { return await getDefaultProvider(); }); - + // Validate API key by making a real test request to the provider // providerId can be either a stored provider ID or a provider type (e.g., 'openrouter', 'anthropic') ipcMain.handle('provider:validateKey', async (_, providerId: string, apiKey: string) => { try { // First try to get existing provider const provider = await getProvider(providerId); - + // Use provider.type if provider exists, otherwise use providerId as the type // This allows validation during setup when provider hasn't been saved yet const providerType = provider?.type || providerId; - + console.log(`Validating API key for provider type: ${providerType}`); return await validateApiKeyWithProvider(providerType, apiKey); } catch (error) { @@ -368,15 +537,15 @@ async function validateApiKeyWithProvider( */ function parseApiError(data: unknown): string { if (!data || typeof data !== 'object') return 'Unknown error'; - + // Anthropic format: { error: { message: "..." } } // OpenAI format: { error: { message: "..." } } // Google format: { error: { message: "..." } } const obj = data as { error?: { message?: string; type?: string }; message?: string }; - + if (obj.error?.message) return obj.error.message; if (obj.message) return obj.message; - + return 'Unknown error'; } @@ -564,17 +733,17 @@ async function validateOpenRouterKey(apiKey: string): Promise<{ valid: boolean; const isAuthError = (d: unknown): boolean => { const errorObj = (d as { error?: { message?: string; code?: number | string; type?: string } })?.error; if (!errorObj) return false; - + const message = (errorObj.message || '').toLowerCase(); const code = errorObj.code; const type = (errorObj.type || '').toLowerCase(); - + // Check for explicit auth-related errors if (code === 401 || code === '401' || code === 403 || code === '403') return true; if (type.includes('auth') || type.includes('invalid')) return true; - if (message.includes('invalid api key') || message.includes('invalid key') || - message.includes('unauthorized') || message.includes('authentication') || - message.includes('invalid credentials') || message.includes('api key is not valid')) { + if (message.includes('invalid api key') || message.includes('invalid key') || + message.includes('unauthorized') || message.includes('authentication') || + message.includes('invalid credentials') || message.includes('api key is not valid')) { return true; } return false; @@ -611,12 +780,12 @@ async function validateOpenRouterKey(apiKey: string): Promise<{ valid: boolean; // But be conservative - require explicit success indication const errorObj = (data as { error?: { message?: string; code?: number } })?.error; const message = (errorObj?.message || '').toLowerCase(); - + // Only consider valid if the error is clearly about the model, not the key if (message.includes('model') && !message.includes('key') && !message.includes('auth')) { return { valid: true }; } - + // Default to invalid for ambiguous 400/404 errors return { valid: false, error: parseApiError(data) || 'Invalid API key or request' }; } @@ -635,18 +804,73 @@ function registerShellHandlers(): void { ipcMain.handle('shell:openExternal', async (_, url: string) => { await shell.openExternal(url); }); - + // Open path in file explorer ipcMain.handle('shell:showItemInFolder', async (_, path: string) => { shell.showItemInFolder(path); }); - + // Open path ipcMain.handle('shell:openPath', async (_, path: string) => { return await shell.openPath(path); }); } +/** + * ClawHub-related IPC handlers + */ +function registerClawHubHandlers(clawHubService: ClawHubService): void { + // Search skills + ipcMain.handle('clawhub:search', async (_, params: ClawHubSearchParams) => { + try { + const results = await clawHubService.search(params); + return { success: true, results }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + + // Install skill + ipcMain.handle('clawhub:install', async (_, params: ClawHubInstallParams) => { + try { + await clawHubService.install(params); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + + // Uninstall skill + ipcMain.handle('clawhub:uninstall', async (_, params: ClawHubUninstallParams) => { + try { + await clawHubService.uninstall(params); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + + // List installed skills + ipcMain.handle('clawhub:list', async () => { + try { + const results = await clawHubService.listInstalled(); + return { success: true, results }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + + // Open skill readme + ipcMain.handle('clawhub:openSkillReadme', async (_, slug: string) => { + try { + await clawHubService.openSkillReadme(slug); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); +} + /** * Dialog-related IPC handlers */ @@ -656,13 +880,13 @@ function registerDialogHandlers(): void { const result = await dialog.showOpenDialog(options); return result; }); - + // Show save dialog ipcMain.handle('dialog:save', async (_, options: Electron.SaveDialogOptions) => { const result = await dialog.showSaveDialog(options); return result; }); - + // Show message box ipcMain.handle('dialog:message', async (_, options: Electron.MessageBoxOptions) => { const result = await dialog.showMessageBox(options); @@ -678,27 +902,27 @@ function registerAppHandlers(): void { ipcMain.handle('app:version', () => { return app.getVersion(); }); - + // Get app name ipcMain.handle('app:name', () => { return app.getName(); }); - + // Get app path ipcMain.handle('app:getPath', (_, name: Parameters[0]) => { return app.getPath(name); }); - + // Get platform ipcMain.handle('app:platform', () => { return process.platform; }); - + // Quit app ipcMain.handle('app:quit', () => { app.quit(); }); - + // Relaunch app ipcMain.handle('app:relaunch', () => { app.relaunch(); diff --git a/electron/preload/index.ts b/electron/preload/index.ts index c4850bef7..f849d7c1d 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -78,15 +78,37 @@ const electronAPI = { 'cron:delete', 'cron:toggle', 'cron:trigger', + // Channel Config + 'channel:saveConfig', + 'channel:getConfig', + 'channel:getFormValues', + 'channel:deleteConfig', + 'channel:listConfigured', + 'channel:setEnabled', + 'channel:validate', + 'channel:validateCredentials', + // ClawHub + 'clawhub:search', + 'clawhub:install', + 'clawhub:uninstall', + 'clawhub:list', + 'clawhub:openSkillReadme', + // UV + 'uv:check', + 'uv:install-all', + // Skill config (direct file access) + 'skill:updateConfig', + 'skill:getConfig', + 'skill:getAllConfigs', ]; - + if (validChannels.includes(channel)) { return ipcRenderer.invoke(channel, ...args); } - + throw new Error(`Invalid IPC channel: ${channel}`); }, - + /** * Listen for events from main process */ @@ -109,23 +131,23 @@ const electronAPI = { 'update:error', 'cron:updated', ]; - + if (validChannels.includes(channel)) { // Wrap the callback to strip the event const subscription = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => { callback(...args); }; ipcRenderer.on(channel, subscription); - + // Return unsubscribe function return () => { ipcRenderer.removeListener(channel, subscription); }; } - + throw new Error(`Invalid IPC channel: ${channel}`); }, - + /** * Listen for a single event from main process */ @@ -147,15 +169,15 @@ const electronAPI = { 'update:downloaded', 'update:error', ]; - + if (validChannels.includes(channel)) { ipcRenderer.once(channel, (_event, ...args) => callback(...args)); return; } - + throw new Error(`Invalid IPC channel: ${channel}`); }, - + /** * Remove all listeners for a channel */ @@ -168,19 +190,19 @@ const electronAPI = { } }, }, - + /** * Open external URL in default browser */ openExternal: (url: string) => { return ipcRenderer.invoke('shell:openExternal', url); }, - + /** * Get current platform */ platform: process.platform, - + /** * Check if running in development */ diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts new file mode 100644 index 000000000..89b5b4caa --- /dev/null +++ b/electron/utils/channel-config.ts @@ -0,0 +1,579 @@ +/** + * Channel Configuration Utilities + * Manages channel configuration in OpenClaw config files + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +const OPENCLAW_DIR = join(homedir(), '.openclaw'); +const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json'); + +export interface ChannelConfigData { + enabled?: boolean; + [key: string]: unknown; +} + +export interface OpenClawConfig { + channels?: Record; + [key: string]: unknown; +} + +/** + * Ensure OpenClaw config directory exists + */ +function ensureConfigDir(): void { + if (!existsSync(OPENCLAW_DIR)) { + mkdirSync(OPENCLAW_DIR, { recursive: true }); + } +} + +/** + * Read OpenClaw configuration + */ +export function readOpenClawConfig(): OpenClawConfig { + ensureConfigDir(); + + if (!existsSync(CONFIG_FILE)) { + return {}; + } + + try { + const content = readFileSync(CONFIG_FILE, 'utf-8'); + return JSON.parse(content) as OpenClawConfig; + } catch (error) { + console.error('Failed to read OpenClaw config:', error); + return {}; + } +} + +/** + * Write OpenClaw configuration + */ +export function writeOpenClawConfig(config: OpenClawConfig): void { + ensureConfigDir(); + + try { + writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8'); + } catch (error) { + console.error('Failed to write OpenClaw config:', error); + throw error; + } +} + +/** + * Save channel configuration + * @param channelType - The channel type (e.g., 'telegram', 'discord') + * @param config - The channel configuration object + */ +export function saveChannelConfig( + channelType: string, + config: ChannelConfigData +): void { + const currentConfig = readOpenClawConfig(); + + if (!currentConfig.channels) { + currentConfig.channels = {}; + } + + // Transform config to match OpenClaw expected format + let transformedConfig: ChannelConfigData = { ...config }; + + // Special handling for Discord: convert guildId/channelId to complete structure + if (channelType === 'discord') { + const { guildId, channelId, ...restConfig } = config; + transformedConfig = { ...restConfig }; + + // Add standard Discord config + transformedConfig.groupPolicy = 'allowlist'; + transformedConfig.dm = { enabled: false }; + transformedConfig.retry = { + attempts: 3, + minDelayMs: 500, + maxDelayMs: 30000, + jitter: 0.1, + }; + + // Build guilds structure + if (guildId && typeof guildId === 'string' && guildId.trim()) { + const guildConfig: Record = { + users: ['*'], + requireMention: true, + }; + + // Add channels config + if (channelId && typeof channelId === 'string' && channelId.trim()) { + // Specific channel + guildConfig.channels = { + [channelId.trim()]: { allow: true, requireMention: true } + }; + } else { + // All channels + guildConfig.channels = { + '*': { allow: true, requireMention: true } + }; + } + + transformedConfig.guilds = { + [guildId.trim()]: guildConfig + }; + } + } + + // Merge with existing config + currentConfig.channels[channelType] = { + ...currentConfig.channels[channelType], + ...transformedConfig, + enabled: transformedConfig.enabled ?? true, + }; + + writeOpenClawConfig(currentConfig); + console.log(`Saved channel config for ${channelType}`); +} + +/** + * Get channel configuration + * @param channelType - The channel type + */ +export function getChannelConfig(channelType: string): ChannelConfigData | undefined { + const config = readOpenClawConfig(); + return config.channels?.[channelType]; +} + +/** + * Get channel configuration as form-friendly values. + * Reverses the transformation done in saveChannelConfig so the + * values can be fed back into the UI form fields. + * + * @param channelType - The channel type + * @returns A flat Record matching the form field keys, or undefined + */ +export function getChannelFormValues(channelType: string): Record | undefined { + const saved = getChannelConfig(channelType); + if (!saved) return undefined; + + const values: Record = {}; + + if (channelType === 'discord') { + // token is stored at top level + if (saved.token && typeof saved.token === 'string') { + values.token = saved.token; + } + + // Extract guildId and channelId from the nested guilds structure + const guilds = saved.guilds as Record> | undefined; + if (guilds) { + const guildIds = Object.keys(guilds); + if (guildIds.length > 0) { + values.guildId = guildIds[0]; + + const guildConfig = guilds[guildIds[0]]; + const channels = guildConfig?.channels as Record | undefined; + if (channels) { + const channelIds = Object.keys(channels).filter((id) => id !== '*'); + if (channelIds.length > 0) { + values.channelId = channelIds[0]; + } + } + } + } + } else { + // For other channel types, extract all string values directly + for (const [key, value] of Object.entries(saved)) { + if (typeof value === 'string' && key !== 'enabled') { + values[key] = value; + } + } + } + + return Object.keys(values).length > 0 ? values : undefined; +} + +/** + * Delete channel configuration + * @param channelType - The channel type + */ +export function deleteChannelConfig(channelType: string): void { + const currentConfig = readOpenClawConfig(); + + if (currentConfig.channels?.[channelType]) { + delete currentConfig.channels[channelType]; + writeOpenClawConfig(currentConfig); + console.log(`Deleted channel config for ${channelType}`); + } +} + +/** + * List all configured channels + */ +export function listConfiguredChannels(): string[] { + const config = readOpenClawConfig(); + if (!config.channels) { + return []; + } + + return Object.keys(config.channels).filter( + (channelType) => config.channels![channelType]?.enabled !== false + ); +} + +/** + * Enable or disable a channel + */ +export function setChannelEnabled(channelType: string, enabled: boolean): void { + const currentConfig = readOpenClawConfig(); + + if (!currentConfig.channels) { + currentConfig.channels = {}; + } + + if (!currentConfig.channels[channelType]) { + currentConfig.channels[channelType] = {}; + } + + currentConfig.channels[channelType].enabled = enabled; + writeOpenClawConfig(currentConfig); + console.log(`Set channel ${channelType} enabled: ${enabled}`); +} + +export interface ValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +export interface CredentialValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; + /** Extra info returned from the API (e.g. bot username, guild name) */ + details?: Record; +} + +/** + * Validate channel credentials by calling the actual service APIs + * This validates the raw config values BEFORE saving them. + * + * @param channelType - The channel type (e.g., 'discord', 'telegram') + * @param config - The raw config values from the form + */ +export async function validateChannelCredentials( + channelType: string, + config: Record +): Promise { + switch (channelType) { + case 'discord': + return validateDiscordCredentials(config); + case 'telegram': + return validateTelegramCredentials(config); + case 'slack': + return validateSlackCredentials(config); + default: + // For channels without specific validation, just check required fields are present + return { valid: true, errors: [], warnings: ['No online validation available for this channel type.'] }; + } +} + +/** + * Validate Discord bot token and optional guild/channel IDs + */ +async function validateDiscordCredentials( + config: Record +): Promise { + const result: CredentialValidationResult = { valid: true, errors: [], warnings: [], details: {} }; + const token = config.token?.trim(); + + if (!token) { + return { valid: false, errors: ['Bot token is required'], warnings: [] }; + } + + // 1) Validate bot token by calling GET /users/@me + try { + const meResponse = await fetch('https://discord.com/api/v10/users/@me', { + headers: { Authorization: `Bot ${token}` }, + }); + + if (!meResponse.ok) { + if (meResponse.status === 401) { + return { valid: false, errors: ['Invalid bot token. Please check and try again.'], warnings: [] }; + } + const errorData = await meResponse.json().catch(() => ({})); + const msg = (errorData as { message?: string }).message || `Discord API error: ${meResponse.status}`; + return { valid: false, errors: [msg], warnings: [] }; + } + + const meData = (await meResponse.json()) as { username?: string; id?: string; bot?: boolean }; + if (!meData.bot) { + return { + valid: false, + errors: ['The provided token belongs to a user account, not a bot. Please use a bot token.'], + warnings: [], + }; + } + result.details!.botUsername = meData.username || 'Unknown'; + result.details!.botId = meData.id || ''; + } catch (error) { + return { + valid: false, + errors: [`Connection error when validating bot token: ${error instanceof Error ? error.message : String(error)}`], + warnings: [], + }; + } + + // 2) Validate guild ID (optional) + const guildId = config.guildId?.trim(); + if (guildId) { + try { + const guildResponse = await fetch(`https://discord.com/api/v10/guilds/${guildId}`, { + headers: { Authorization: `Bot ${token}` }, + }); + + if (!guildResponse.ok) { + if (guildResponse.status === 403 || guildResponse.status === 404) { + result.errors.push( + `Cannot access guild (server) with ID "${guildId}". Make sure the bot has been invited to this server.` + ); + result.valid = false; + } else { + result.errors.push(`Failed to verify guild ID: Discord API returned ${guildResponse.status}`); + result.valid = false; + } + } else { + const guildData = (await guildResponse.json()) as { name?: string }; + result.details!.guildName = guildData.name || 'Unknown'; + } + } catch (error) { + result.warnings.push(`Could not verify guild ID: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // 3) Validate channel ID (optional) + const channelId = config.channelId?.trim(); + if (channelId) { + try { + const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, { + headers: { Authorization: `Bot ${token}` }, + }); + + if (!channelResponse.ok) { + if (channelResponse.status === 403 || channelResponse.status === 404) { + result.errors.push( + `Cannot access channel with ID "${channelId}". Make sure the bot has permission to view this channel.` + ); + result.valid = false; + } else { + result.errors.push(`Failed to verify channel ID: Discord API returned ${channelResponse.status}`); + result.valid = false; + } + } else { + const channelData = (await channelResponse.json()) as { name?: string; guild_id?: string }; + result.details!.channelName = channelData.name || 'Unknown'; + + // Cross-check: if both guild and channel are provided, make sure channel belongs to the guild + if (guildId && channelData.guild_id && channelData.guild_id !== guildId) { + result.errors.push( + `Channel "${channelData.name}" does not belong to the specified guild. It belongs to a different server.` + ); + result.valid = false; + } + } + } catch (error) { + result.warnings.push(`Could not verify channel ID: ${error instanceof Error ? error.message : String(error)}`); + } + } + + return result; +} + +/** + * Validate Telegram bot token + */ +async function validateTelegramCredentials( + config: Record +): Promise { + const botToken = config.botToken?.trim(); + + if (!botToken) { + return { valid: false, errors: ['Bot token is required'], warnings: [] }; + } + + try { + const response = await fetch(`https://api.telegram.org/bot${botToken}/getMe`); + const data = (await response.json()) as { ok?: boolean; description?: string; result?: { username?: string } }; + + if (data.ok) { + return { + valid: true, + errors: [], + warnings: [], + details: { botUsername: data.result?.username || 'Unknown' }, + }; + } + + return { + valid: false, + errors: [data.description || 'Invalid bot token'], + warnings: [], + }; + } catch (error) { + return { + valid: false, + errors: [`Connection error: ${error instanceof Error ? error.message : String(error)}`], + warnings: [], + }; + } +} + +/** + * Validate Slack bot token + */ +async function validateSlackCredentials( + config: Record +): Promise { + const botToken = config.botToken?.trim(); + + if (!botToken) { + return { valid: false, errors: ['Bot token is required'], warnings: [] }; + } + + try { + const response = await fetch('https://slack.com/api/auth.test', { + method: 'POST', + headers: { + Authorization: `Bearer ${botToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + const data = (await response.json()) as { ok?: boolean; error?: string; team?: string; user?: string }; + + if (data.ok) { + return { + valid: true, + errors: [], + warnings: [], + details: { team: data.team || 'Unknown', user: data.user || 'Unknown' }, + }; + } + + const errorMap: Record = { + invalid_auth: 'Invalid bot token', + account_inactive: 'Account is inactive', + token_revoked: 'Token has been revoked', + not_authed: 'No authentication token provided', + }; + + return { + valid: false, + errors: [errorMap[data.error || ''] || `Slack error: ${data.error}`], + warnings: [], + }; + } catch (error) { + return { + valid: false, + errors: [`Connection error: ${error instanceof Error ? error.message : String(error)}`], + warnings: [], + }; + } +} + +/** + * Validate channel configuration using OpenClaw doctor + */ +export async function validateChannelConfig(channelType: string): Promise { + const { execSync } = await import('child_process'); + const { join } = await import('path'); + const { app } = await import('electron'); + + const result: ValidationResult = { + valid: true, + errors: [], + warnings: [], + }; + + try { + // Get OpenClaw path + const openclawPath = app.isPackaged + ? join(process.resourcesPath, 'openclaw') + : join(__dirname, '../../openclaw'); + + // Run openclaw doctor command to validate config + const output = execSync( + `node openclaw.mjs doctor --json 2>&1`, + { + cwd: openclawPath, + encoding: 'utf-8', + timeout: 30000, + } + ); + + // Parse output for errors related to the channel + const lines = output.split('\n'); + for (const line of lines) { + const lowerLine = line.toLowerCase(); + if (lowerLine.includes(channelType) && lowerLine.includes('error')) { + result.errors.push(line.trim()); + result.valid = false; + } else if (lowerLine.includes(channelType) && lowerLine.includes('warning')) { + result.warnings.push(line.trim()); + } else if (lowerLine.includes('unrecognized key') && lowerLine.includes(channelType)) { + result.errors.push(line.trim()); + result.valid = false; + } + } + + // If no specific errors found, check if config exists and is valid + const config = readOpenClawConfig(); + if (!config.channels?.[channelType]) { + result.errors.push(`Channel ${channelType} is not configured`); + result.valid = false; + } else if (!config.channels[channelType].enabled) { + result.warnings.push(`Channel ${channelType} is disabled`); + } + + // Channel-specific validation + if (channelType === 'discord') { + const discordConfig = config.channels?.discord; + if (!discordConfig?.token) { + result.errors.push('Discord: Bot token is required'); + result.valid = false; + } + } else if (channelType === 'telegram') { + const telegramConfig = config.channels?.telegram; + if (!telegramConfig?.botToken) { + result.errors.push('Telegram: Bot token is required'); + result.valid = false; + } + } + + if (result.errors.length === 0 && result.warnings.length === 0) { + result.valid = true; + } + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + // Check for config errors in the error message + if (errorMessage.includes('Unrecognized key') || errorMessage.includes('invalid config')) { + result.errors.push(errorMessage); + result.valid = false; + } else if (errorMessage.includes('ENOENT')) { + result.errors.push('OpenClaw not found. Please ensure OpenClaw is installed.'); + result.valid = false; + } else { + // Doctor command might fail but config could still be valid + // Just log it and do basic validation + console.warn('Doctor command failed:', errorMessage); + + const config = readOpenClawConfig(); + if (config.channels?.[channelType]) { + result.valid = true; + } else { + result.errors.push(`Channel ${channelType} is not configured`); + result.valid = false; + } + } + } + + return result; +} diff --git a/electron/utils/skill-config.ts b/electron/utils/skill-config.ts new file mode 100644 index 000000000..b75e839c1 --- /dev/null +++ b/electron/utils/skill-config.ts @@ -0,0 +1,130 @@ +/** + * Skill Config Utilities + * Direct read/write access to skill configuration in ~/.openclaw/openclaw.json + * This bypasses the Gateway RPC for faster and more reliable config updates + */ +import { readFileSync, writeFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json'); + +interface SkillEntry { + enabled?: boolean; + apiKey?: string; + env?: Record; +} + +interface OpenClawConfig { + skills?: { + entries?: Record; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +/** + * Read the current OpenClaw config + */ +function readConfig(): OpenClawConfig { + if (!existsSync(OPENCLAW_CONFIG_PATH)) { + return {}; + } + try { + const raw = readFileSync(OPENCLAW_CONFIG_PATH, 'utf-8'); + return JSON.parse(raw); + } catch (err) { + console.error('Failed to read openclaw config:', err); + return {}; + } +} + +/** + * Write the OpenClaw config + */ +function writeConfig(config: OpenClawConfig): void { + const json = JSON.stringify(config, null, 2); + writeFileSync(OPENCLAW_CONFIG_PATH, json, 'utf-8'); +} + +/** + * Get skill config + */ +export function getSkillConfig(skillKey: string): SkillEntry | undefined { + const config = readConfig(); + return config.skills?.entries?.[skillKey]; +} + +/** + * Update skill config (apiKey and env) + */ +export function updateSkillConfig( + skillKey: string, + updates: { apiKey?: string; env?: Record } +): { success: boolean; error?: string } { + try { + const config = readConfig(); + + // Ensure skills.entries exists + if (!config.skills) { + config.skills = {}; + } + if (!config.skills.entries) { + config.skills.entries = {}; + } + + // Get or create skill entry + const entry = config.skills.entries[skillKey] || {}; + + // Update apiKey + if (updates.apiKey !== undefined) { + const trimmed = updates.apiKey.trim(); + if (trimmed) { + entry.apiKey = trimmed; + } else { + delete entry.apiKey; + } + } + + // Update env + if (updates.env !== undefined) { + const newEnv: Record = {}; + + // Process all keys from the update + for (const [key, value] of Object.entries(updates.env)) { + const trimmedKey = key.trim(); + if (!trimmedKey) continue; + + const trimmedVal = value.trim(); + if (trimmedVal) { + newEnv[trimmedKey] = trimmedVal; + } + // Empty value = don't include (delete) + } + + // Only set env if there are values, otherwise delete + if (Object.keys(newEnv).length > 0) { + entry.env = newEnv; + } else { + delete entry.env; + } + } + + // Save entry back + config.skills.entries[skillKey] = entry; + + writeConfig(config); + return { success: true }; + } catch (err) { + console.error('Failed to update skill config:', err); + return { success: false, error: String(err) }; + } +} + +/** + * Get all skill configs (for syncing to frontend) + */ +export function getAllSkillConfigs(): Record { + const config = readConfig(); + return config.skills?.entries || {}; +} diff --git a/electron/utils/uv-setup.ts b/electron/utils/uv-setup.ts new file mode 100644 index 000000000..acf7c1377 --- /dev/null +++ b/electron/utils/uv-setup.ts @@ -0,0 +1,114 @@ +import { app } from 'electron'; +import { spawn } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; + +/** + * Get the path to the bundled uv binary + */ +function getBundledUvPath(): string { + const platform = process.platform; + const arch = process.arch; + const target = `${platform}-${arch}`; + const binName = platform === 'win32' ? 'uv.exe' : 'uv'; + + if (app.isPackaged) { + // In production, we flattened the structure to 'bin/' + return join(process.resourcesPath, 'bin', binName); + } else { + // In dev, resources are at project root/resources/bin/- + return join(process.cwd(), 'resources', 'bin', target, binName); + } +} + +/** + * Check if uv is available (either in system PATH or bundled) + */ +export async function checkUvInstalled(): Promise { + // 1. Check system PATH first + const inPath = await new Promise((resolve) => { + const cmd = process.platform === 'win32' ? 'where.exe' : 'which'; + const child = spawn(cmd, ['uv']); + child.on('close', (code) => resolve(code === 0)); + child.on('error', () => resolve(false)); + }); + + if (inPath) return true; + + // 2. Check bundled path + const bin = getBundledUvPath(); + return existsSync(bin); +} + +/** + * "Install" uv - now just verifies that uv is available somewhere. + * Kept for API compatibility with frontend. + */ +export async function installUv(): Promise { + const isAvailable = await checkUvInstalled(); + if (!isAvailable) { + const bin = getBundledUvPath(); + throw new Error(`uv not found in system PATH and bundled binary missing at ${bin}`); + } + console.log('uv is available and ready to use'); +} + +/** + * Use bundled uv to install a managed Python version (default 3.12) + * Automatically picks the best available uv binary + */ +export async function setupManagedPython(): Promise { + // Use 'uv' if in PATH, otherwise use full bundled path + const inPath = await new Promise((resolve) => { + const cmd = process.platform === 'win32' ? 'where.exe' : 'which'; + const child = spawn(cmd, ['uv']); + child.on('close', (code) => resolve(code === 0)); + child.on('error', () => resolve(false)); + }); + + const uvBin = inPath ? 'uv' : getBundledUvPath(); + + console.log(`Setting up python with: ${uvBin}`); + + await new Promise((resolve, reject) => { + const child = spawn(uvBin, ['python', 'install', '3.12'], { + shell: process.platform === 'win32' + }); + + child.stdout?.on('data', (data) => { + console.log(`python setup stdout: ${data}`); + }); + + child.stderr?.on('data', (data) => { + // uv prints progress to stderr, so we log it as info + console.log(`python setup info: ${data.toString().trim()}`); + }); + + child.on('close', (code) => { + if (code === 0) resolve(); + else reject(new Error(`Python installation failed with code ${code}`)); + }); + + child.on('error', (err) => reject(err)); + }); + + // After installation, find and print where the Python executable is + try { + const findPath = await new Promise((resolve) => { + const child = spawn(uvBin, ['python', 'find', '3.12'], { + shell: process.platform === 'win32' + }); + let output = ''; + child.stdout?.on('data', (data) => { output += data; }); + child.on('close', () => resolve(output.trim())); + }); + + if (findPath) { + console.log(`✅ Managed Python 3.12 path: ${findPath}`); + // Note: uv stores environments in a central cache, + // Individual skills will create their own venvs in ~/.cache/uv or similar. + } + } catch (err) { + console.warn('Could not determine Python path:', err); + } +} \ No newline at end of file diff --git a/package.json b/package.json index afe7d192c..b48a0e131 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "scripts": { "dev": "vite", "dev:electron": "electron .", - "build": "pnpm run build:vite && pnpm run package", + "build": "pnpm run uv:download && pnpm run build:vite && pnpm run package", "build:vite": "vite build", "build:electron": "tsc -p tsconfig.node.json", "preview": "vite preview", @@ -26,6 +26,8 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", + "uv:download": "node scripts/download-bundled-uv.mjs", + "uv:download:all": "node scripts/download-bundled-uv.mjs --all", "icons": "bash scripts/generate-icons.sh", "clean": "rm -rf dist dist-electron release", "package": "electron-builder", @@ -78,6 +80,7 @@ "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", + "clawhub": "^0.5.0", "clsx": "^2.1.1", "electron": "^33.3.0", "electron-builder": "^25.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d54bf3a4..af0ccf72d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: class-variance-authority: specifier: ^0.7.1 version: 0.7.1 + clawhub: + specifier: ^0.5.0 + version: 0.5.0 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -184,6 +187,12 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@ark/schema@0.56.0': + resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==} + + '@ark/util@0.56.0': + resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==} + '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} @@ -274,6 +283,12 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@clack/core@0.5.0': + resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} + + '@clack/prompts@0.11.0': + resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -1695,6 +1710,12 @@ packages: aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + arkregex@0.0.5: + resolution: {integrity: sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==} + + arktype@2.1.29: + resolution: {integrity: sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ==} + assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} @@ -1838,6 +1859,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -1872,6 +1897,11 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + clawhub@0.5.0: + resolution: {integrity: sha512-tIPoup8mY3ojR+fzzf85ft+vrhMd6u6188QzBEOf/f5/0NSoWW0fl7ojw6VgVSLbBtLa5MGQDxSuZkf9TqPwIw==} + engines: {node: '>=20'} + hasBin: true + clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -1880,10 +1910,18 @@ packages: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + cli-truncate@2.1.0: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} @@ -1921,6 +1959,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -2321,6 +2363,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2420,6 +2465,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.4.0: + resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2638,9 +2687,17 @@ packages: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-lambda@1.0.1: resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} + is-network-error@1.3.0: + resolution: {integrity: sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==} + engines: {node: '>=16'} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -2656,6 +2713,10 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -2789,6 +2850,10 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -2990,6 +3055,11 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -3151,6 +3221,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3159,6 +3233,10 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + ora@9.3.0: + resolution: {integrity: sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==} + engines: {node: '>=20'} + p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} engines: {node: '>=8'} @@ -3175,6 +3253,10 @@ packages: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} + p-retry@7.1.1: + resolution: {integrity: sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==} + engines: {node: '>=20'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -3482,6 +3564,10 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -3580,6 +3666,9 @@ packages: resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} engines: {node: '>=10'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slice-ansi@3.0.0: resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} engines: {node: '>=8'} @@ -3633,6 +3722,10 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stdin-discarder@0.3.1: + resolution: {integrity: sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==} + engines: {node: '>=18'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -3641,6 +3734,10 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@8.1.1: + resolution: {integrity: sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==} + engines: {node: '>=20'} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -3825,6 +3922,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.20.0: + resolution: {integrity: sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==} + engines: {node: '>=20.18.1'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -4118,6 +4219,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + zip-stream@4.1.1: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} @@ -4151,6 +4256,12 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@ark/schema@0.56.0': + dependencies: + '@ark/util': 0.56.0 + + '@ark/util@0.56.0': {} + '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -4273,6 +4384,17 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@clack/core@0.5.0': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@0.11.0': + dependencies: + '@clack/core': 0.5.0 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -5644,6 +5766,16 @@ snapshots: dependencies: dequal: 2.0.3 + arkregex@0.0.5: + dependencies: + '@ark/util': 0.56.0 + + arktype@2.1.29: + dependencies: + '@ark/schema': 0.56.0 + '@ark/util': 0.56.0 + arkregex: 0.0.5 + assert-plus@1.0.0: optional: true @@ -5827,6 +5959,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -5859,14 +5993,34 @@ snapshots: dependencies: clsx: 2.1.1 + clawhub@0.5.0: + dependencies: + '@clack/prompts': 0.11.0 + arktype: 2.1.29 + commander: 14.0.3 + fflate: 0.8.2 + ignore: 7.0.5 + json5: 2.2.3 + mime: 4.1.0 + ora: 9.3.0 + p-retry: 7.1.1 + semver: 7.7.3 + undici: 7.20.0 + clean-stack@2.2.0: {} cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + cli-spinners@2.9.2: {} + cli-spinners@3.4.0: {} + cli-truncate@2.1.0: dependencies: slice-ansi: 3.0.0 @@ -5901,6 +6055,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@14.0.3: {} + commander@4.1.1: {} commander@5.1.0: {} @@ -6398,6 +6554,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -6502,6 +6660,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.4.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6762,8 +6922,12 @@ snapshots: is-interactive@1.0.0: {} + is-interactive@2.0.0: {} + is-lambda@1.0.1: {} + is-network-error@1.3.0: {} + is-number@7.0.0: {} is-plain-obj@4.1.0: {} @@ -6772,6 +6936,8 @@ snapshots: is-unicode-supported@0.1.0: {} + is-unicode-supported@2.1.0: {} + isarray@1.0.0: {} isbinaryfile@4.0.10: {} @@ -6901,6 +7067,11 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + longest-streak@3.1.0: {} loupe@3.2.1: {} @@ -7319,6 +7490,8 @@ snapshots: mime@2.6.0: {} + mime@4.1.0: {} + mimic-fn@2.1.0: {} mimic-function@5.0.1: {} @@ -7468,6 +7641,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -7489,6 +7666,17 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + ora@9.3.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.1 + string-width: 8.1.1 + p-cancelable@2.1.1: {} p-limit@3.1.0: @@ -7503,6 +7691,10 @@ snapshots: dependencies: aggregate-error: 3.1.0 + p-retry@7.1.1: + dependencies: + is-network-error: 1.3.0 + package-json-from-dist@1.0.1: {} parent-module@1.0.1: @@ -7807,6 +7999,11 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + retry@0.12.0: {} reusify@1.1.0: {} @@ -7914,6 +8111,8 @@ snapshots: dependencies: semver: 7.7.3 + sisteransi@1.0.5: {} + slice-ansi@3.0.0: dependencies: ansi-styles: 4.3.0 @@ -7965,6 +8164,8 @@ snapshots: std-env@3.10.0: {} + stdin-discarder@0.3.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -7977,6 +8178,11 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 + string-width@8.1.1: + dependencies: + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -8178,6 +8384,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.20.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -8436,6 +8644,8 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors@2.1.2: {} + zip-stream@4.1.1: dependencies: archiver-utils: 3.0.4 diff --git a/resources/icons/icon.icns b/resources/icons/icon.icns new file mode 100644 index 000000000..c8a510024 Binary files /dev/null and b/resources/icons/icon.icns differ diff --git a/scripts/download-bundled-uv.mjs b/scripts/download-bundled-uv.mjs new file mode 100644 index 000000000..d14bc7be0 --- /dev/null +++ b/scripts/download-bundled-uv.mjs @@ -0,0 +1,139 @@ +import { spawnSync } from 'node:child_process'; +import { mkdirSync, rmSync, existsSync, chmodSync, renameSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { platform, arch } from 'node:os'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT_DIR = join(__dirname, '..'); + +// Configuration +const UV_VERSION = '0.10.0'; +const BASE_URL = `https://github.com/astral-sh/uv/releases/download/${UV_VERSION}`; +const OUTPUT_BASE = join(ROOT_DIR, 'resources', 'bin'); + +// Mapping Node platforms/archs to uv release naming +const TARGETS = { + 'darwin-arm64': { + filename: 'uv-aarch64-apple-darwin.tar.gz', + binName: 'uv', + extractCmd: (src, dest) => spawnSync('tar', ['-xzf', src, '-C', dest]) + }, + 'darwin-x64': { + filename: 'uv-x86_64-apple-darwin.tar.gz', + binName: 'uv', + extractCmd: (src, dest) => spawnSync('tar', ['-xzf', src, '-C', dest]) + }, + 'win32-x64': { + filename: 'uv-x86_64-pc-windows-msvc.zip', + binName: 'uv.exe', + extractCmd: (src, dest) => { + if (platform() === 'win32') { + return spawnSync('powershell.exe', ['-Command', `Expand-Archive -Path "${src}" -DestinationPath "${dest}" -Force`]); + } else { + return spawnSync('unzip', ['-q', '-o', src, '-d', dest]); + } + } + } +}; + +async function downloadFile(url, dest) { + console.log(`⬇️ Downloading: ${url}`); + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to download: ${response.statusText}`); + const arrayBuffer = await response.arrayBuffer(); + writeFileSync(dest, Buffer.from(arrayBuffer)); +} + +async function setupTarget(id) { + const target = TARGETS[id]; + if (!target) { + console.warn(`⚠️ Target ${id} is not supported by this script.`); + return; + } + + const targetDir = join(OUTPUT_BASE, id); + const tempDir = join(ROOT_DIR, 'temp_uv_extract'); + const archivePath = join(ROOT_DIR, target.filename); + + console.log(` +📦 Setting up uv for ${id}...`); + + // Cleanup & Prep + if (existsSync(targetDir)) rmSync(targetDir, { recursive: true }); + if (existsSync(tempDir)) rmSync(tempDir, { recursive: true }); + mkdirSync(targetDir, { recursive: true }); + mkdirSync(tempDir, { recursive: true }); + + try { + // Download + await downloadFile(`${BASE_URL}/${target.filename}`, archivePath); + + // Extract + console.log('📂 Extracting...'); + target.extractCmd(archivePath, tempDir); + + // Move binary to final location + // uv archives usually contain a folder named after the target + const folderName = target.filename.replace('.tar.gz', '').replace('.zip', ''); + const sourceBin = join(tempDir, folderName, target.binName); + const destBin = join(targetDir, target.binName); + + if (existsSync(sourceBin)) { + renameSync(sourceBin, destBin); + } else { + // Fallback: search for the binary if folder structure changed + console.log('🔍 Binary not found in expected subfolder, searching...'); + const findResult = spawnSync(platform() === 'win32' ? 'where' : 'find', + platform() === 'win32' ? ['/R', tempDir, target.binName] : [tempDir, '-name', target.binName]); + + const foundPath = findResult.stdout.toString().trim().split('\n')[0]; + if (foundPath && existsSync(foundPath)) { + renameSync(foundPath, destBin); + } else { + throw new Error(`Could not find ${target.binName} in extracted files.`); + } + } + + // Permission fix + if (platform() !== 'win32') { + chmodSync(destBin, 0o755); + } + + console.log(`✅ Success: ${destBin}`); + } finally { + // Cleanup + if (existsSync(archivePath)) rmSync(archivePath); + if (existsSync(tempDir)) rmSync(tempDir, { recursive: true }); + } +} + +async function main() { + const args = process.argv.slice(2); + const downloadAll = args.includes('--all'); + + if (downloadAll) { + console.log('🌐 Downloading uv binaries for ALL supported platforms...'); + for (const id of Object.keys(TARGETS)) { + await setupTarget(id); + } + } else { + const currentId = `${platform()}-${arch()}`; + console.log(`💻 Detected system: ${currentId}`); + + if (TARGETS[currentId]) { + await setupTarget(currentId); + } else { + console.error(`❌ Current system ${currentId} is not in the supported download list.`); + console.log('Supported targets:', Object.keys(TARGETS).join(', ')); + process.exit(1); + } + } + + console.log('\n🎉 Done!'); +} + +main().catch(err => { + console.error('\n❌ Error:', err.message); + process.exit(1); +}); diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx index 058ebc9e0..a5fb7b2ea 100644 --- a/src/pages/Channels/index.tsx +++ b/src/pages/Channels/index.tsx @@ -1,120 +1,100 @@ /** * Channels Page - * Manage messaging channel connections + * Manage messaging channel connections with configuration UI */ import { useState, useEffect } from 'react'; -import { - Plus, - Radio, - RefreshCw, - Settings, - Trash2, - Power, +import { + Plus, + Radio, + RefreshCw, + Trash2, + Power, PowerOff, QrCode, Loader2, X, ExternalLink, - Copy, + BookOpen, + Eye, + EyeOff, Check, + AlertCircle, + CheckCircle, + ShieldCheck, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; +import { Badge } from '@/components/ui/badge'; import { useChannelsStore } from '@/stores/channels'; import { useGatewayStore } from '@/stores/gateway'; import { StatusBadge, type Status } from '@/components/common/StatusBadge'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; -import { CHANNEL_ICONS, CHANNEL_NAMES, type ChannelType, type Channel } from '@/types/channel'; +import { + CHANNEL_ICONS, + CHANNEL_NAMES, + CHANNEL_META, + getPrimaryChannels, + getAllChannels, + type ChannelType, + type Channel, + type ChannelMeta, + type ChannelConfigField, +} from '@/types/channel'; import { toast } from 'sonner'; -// Channel type info with connection instructions -const channelInfo: Record = { - whatsapp: { - description: 'Connect WhatsApp by scanning a QR code', - connectionType: 'qr', - instructions: [ - 'Open WhatsApp on your phone', - 'Go to Settings > Linked Devices', - 'Tap "Link a Device"', - 'Scan the QR code below', - ], - docsUrl: 'https://faq.whatsapp.com/1317564962315842', - }, - telegram: { - description: 'Connect Telegram using a bot token', - connectionType: 'token', - instructions: [ - 'Open Telegram and search for @BotFather', - 'Send /newbot and follow the instructions', - 'Copy the bot token provided', - 'Paste it below', - ], - tokenLabel: 'Bot Token', - docsUrl: 'https://core.telegram.org/bots#how-do-i-create-a-bot', - }, - discord: { - description: 'Connect Discord using a bot token', - connectionType: 'token', - instructions: [ - 'Go to Discord Developer Portal', - 'Create a new Application', - 'Go to Bot section and create a bot', - 'Copy the bot token', - ], - tokenLabel: 'Bot Token', - docsUrl: 'https://discord.com/developers/applications', - }, - slack: { - description: 'Connect Slack via OAuth', - connectionType: 'token', - instructions: [ - 'Go to Slack API apps page', - 'Create a new app', - 'Configure OAuth scopes', - 'Install to workspace and copy the token', - ], - tokenLabel: 'Bot Token (xoxb-...)', - docsUrl: 'https://api.slack.com/apps', - }, - wechat: { - description: 'Connect WeChat by scanning a QR code', - connectionType: 'qr', - instructions: [ - 'Open WeChat on your phone', - 'Scan the QR code below', - 'Confirm login on your phone', - ], - }, -}; - export function Channels() { - const { channels, loading, error, fetchChannels, connectChannel, disconnectChannel, deleteChannel } = useChannelsStore(); + const { channels, loading, error, fetchChannels, deleteChannel } = useChannelsStore(); const gatewayStatus = useGatewayStore((state) => state.status); - + const [showAddDialog, setShowAddDialog] = useState(false); const [selectedChannelType, setSelectedChannelType] = useState(null); - const [connectingChannelId, setConnectingChannelId] = useState(null); - + const [showAllChannels, setShowAllChannels] = useState(false); + const [configuredTypes, setConfiguredTypes] = useState([]); + // Fetch channels on mount useEffect(() => { fetchChannels(); }, [fetchChannels]); - - // Supported channel types for adding - const supportedTypes: ChannelType[] = ['whatsapp', 'telegram', 'discord', 'slack']; - + + useEffect(() => { + const unsubscribe = window.electron.ipcRenderer.on('gateway:channel-status', () => { + fetchChannels(); + }); + return () => { + if (typeof unsubscribe === 'function') { + unsubscribe(); + } + }; + }, [fetchChannels]); + + // Fetch configured channel types from config file + const fetchConfiguredTypes = async () => { + try { + const result = await window.electron.ipcRenderer.invoke('channel:listConfigured') as { + success: boolean; + channels?: string[]; + }; + if (result.success && result.channels) { + setConfiguredTypes(result.channels); + } + } catch { + // ignore + } + }; + + useEffect(() => { + fetchConfiguredTypes(); + }, []); + + // Get channel types to display + const displayedChannelTypes = showAllChannels ? getAllChannels() : getPrimaryChannels(); + // Connected/disconnected channel counts const connectedCount = channels.filter((c) => c.status === 'connected').length; - + if (loading) { return (
@@ -122,7 +102,7 @@ export function Channels() {
); } - + return (
{/* Header */} @@ -144,7 +124,7 @@ export function Channels() {
- + {/* Stats */}
@@ -187,19 +167,19 @@ export function Channels() {
- + {/* Gateway Warning */} {gatewayStatus.state !== 'running' && ( -
+ Gateway is not running. Channels cannot connect without an active Gateway. )} - + {/* Error Display */} {error && ( @@ -208,72 +188,87 @@ export function Channels() { )} - - {/* Channels Grid */} - {channels.length === 0 ? ( + + {/* Configured Channels */} + {channels.length > 0 && ( - - -

No channels configured

-

- Connect a messaging channel to start using ClawX -

- + + Configured Channels + Channels you have set up + + +
+ {channels.map((channel) => ( + { + if (confirm('Are you sure you want to delete this channel?')) { + deleteChannel(channel.id); + } + }} + /> + ))} +
- ) : ( -
- {channels.map((channel) => ( - { - setConnectingChannelId(channel.id); - connectChannel(channel.id); - }} - onDisconnect={() => disconnectChannel(channel.id)} - onDelete={() => { - if (confirm('Are you sure you want to delete this channel?')) { - deleteChannel(channel.id); - } - }} - isConnecting={connectingChannelId === channel.id} - /> - ))} -
)} - - {/* Add Channel Section */} + + {/* Available Channels */} - Supported Channels - - Click on a channel type to add it - +
+
+ Available Channels + + Click on a channel type to configure it + +
+ +
- {supportedTypes.map((type) => ( - - ))} + {displayedChannelTypes.map((type) => { + const meta = CHANNEL_META[type]; + const isConfigured = configuredTypes.includes(type); + return ( + + ); + })}
- + {/* Add Channel Dialog */} {showAddDialog && ( { + fetchChannels(); + fetchConfiguredTypes(); + setShowAddDialog(false); + setSelectedChannelType(null); + }} /> )}
@@ -294,24 +294,21 @@ export function Channels() { interface ChannelCardProps { channel: Channel; - onConnect: () => void; - onDisconnect: () => void; onDelete: () => void; - isConnecting: boolean; } -function ChannelCard({ channel, onConnect, onDisconnect, onDelete, isConnecting }: ChannelCardProps) { +function ChannelCard({ channel, onDelete }: ChannelCardProps) { return ( - +
- + {CHANNEL_ICONS[channel.type]}
- {channel.name} - + {channel.name} + {CHANNEL_NAMES[channel.type]}
@@ -319,50 +316,18 @@ function ChannelCard({ channel, onConnect, onDisconnect, onDelete, isConnecting
- - {channel.lastActivity && ( -

- Last activity: {new Date(channel.lastActivity).toLocaleString()} -

- )} + {channel.error && ( -

{channel.error}

+

{channel.error}

)}
- {channel.status === 'connected' ? ( - - ) : ( - - )} - -
@@ -376,64 +341,259 @@ interface AddChannelDialogProps { selectedType: ChannelType | null; onSelectType: (type: ChannelType | null) => void; onClose: () => void; - supportedTypes: ChannelType[]; + onChannelAdded: () => void; } -function AddChannelDialog({ selectedType, onSelectType, onClose, supportedTypes }: AddChannelDialogProps) { +function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded }: AddChannelDialogProps) { const { addChannel } = useChannelsStore(); + const [configValues, setConfigValues] = useState>({}); const [channelName, setChannelName] = useState(''); - const [token, setToken] = useState(''); const [connecting, setConnecting] = useState(false); + const [showSecrets, setShowSecrets] = useState>({}); const [qrCode, setQrCode] = useState(null); - const [copied, setCopied] = useState(false); - - const info = selectedType ? channelInfo[selectedType] : null; - - const handleConnect = async () => { + const [validating, setValidating] = useState(false); + const [loadingConfig, setLoadingConfig] = useState(false); + const [isExistingConfig, setIsExistingConfig] = useState(false); + const [validationResult, setValidationResult] = useState<{ + valid: boolean; + errors: string[]; + warnings: string[]; + } | null>(null); + + const meta: ChannelMeta | null = selectedType ? CHANNEL_META[selectedType] : null; + + // Load existing config when a channel type is selected + useEffect(() => { + if (!selectedType) { + setConfigValues({}); + setChannelName(''); + setIsExistingConfig(false); + return; + } + + let cancelled = false; + setLoadingConfig(true); + + (async () => { + try { + const result = await window.electron.ipcRenderer.invoke( + 'channel:getFormValues', + selectedType + ) as { success: boolean; values?: Record }; + + if (cancelled) return; + + if (result.success && result.values && Object.keys(result.values).length > 0) { + setConfigValues(result.values); + setIsExistingConfig(true); + } else { + setConfigValues({}); + setIsExistingConfig(false); + } + } catch { + if (!cancelled) { + setConfigValues({}); + setIsExistingConfig(false); + } + } finally { + if (!cancelled) setLoadingConfig(false); + } + })(); + + return () => { cancelled = true; }; + }, [selectedType]); + + const handleValidate = async () => { if (!selectedType) return; - - setConnecting(true); - + + setValidating(true); + setValidationResult(null); + try { - // For QR-based channels, we'd request a QR code from the gateway - if (info?.connectionType === 'qr') { - // Simulate QR code generation + const result = await window.electron.ipcRenderer.invoke( + 'channel:validateCredentials', + selectedType, + configValues + ) as { + success: boolean; + valid?: boolean; + errors?: string[]; + warnings?: string[]; + details?: Record; + }; + + const warnings = result.warnings || []; + if (result.valid && result.details) { + const details = result.details; + if (details.botUsername) warnings.push(`Bot: @${details.botUsername}`); + if (details.guildName) warnings.push(`Server: ${details.guildName}`); + if (details.channelName) warnings.push(`Channel: #${details.channelName}`); + } + + setValidationResult({ + valid: result.valid || false, + errors: result.errors || [], + warnings, + }); + } catch (error) { + setValidationResult({ + valid: false, + errors: [String(error)], + warnings: [], + }); + } finally { + setValidating(false); + } + }; + + + const handleConnect = async () => { + if (!selectedType || !meta) return; + + setConnecting(true); + setValidationResult(null); + + try { + // For QR-based channels, request QR code + if (meta.connectionType === 'qr') { + // Simulate QR code generation (in real implementation, call Gateway) await new Promise((resolve) => setTimeout(resolve, 1500)); setQrCode('placeholder-qr'); - } else { - // For token-based, add the channel with the token - await addChannel({ - type: selectedType, - name: channelName || CHANNEL_NAMES[selectedType], - token: token || undefined, - }); - - toast.success(`${CHANNEL_NAMES[selectedType]} channel added`); - onClose(); + setConnecting(false); + return; } + + // Step 1: Validate credentials against the actual service API + if (meta.connectionType === 'token') { + const validationResponse = await window.electron.ipcRenderer.invoke( + 'channel:validateCredentials', + selectedType, + configValues + ) as { + success: boolean; + valid?: boolean; + errors?: string[]; + warnings?: string[]; + details?: Record; + }; + + if (!validationResponse.valid) { + setValidationResult({ + valid: false, + errors: validationResponse.errors || ['Validation failed'], + warnings: validationResponse.warnings || [], + }); + setConnecting(false); + return; + } + + // Show success details (bot name, guild name, etc.) as warnings/info + const warnings = validationResponse.warnings || []; + if (validationResponse.details) { + const details = validationResponse.details; + if (details.botUsername) { + warnings.push(`Bot: @${details.botUsername}`); + } + if (details.guildName) { + warnings.push(`Server: ${details.guildName}`); + } + if (details.channelName) { + warnings.push(`Channel: #${details.channelName}`); + } + } + + // Show validation success with details + setValidationResult({ + valid: true, + errors: [], + warnings, + }); + } + + // Step 2: Save channel configuration via IPC + const config: Record = { ...configValues }; + await window.electron.ipcRenderer.invoke('channel:saveConfig', selectedType, config); + + // Step 3: Add a local channel entry for the UI + await addChannel({ + type: selectedType, + name: channelName || CHANNEL_NAMES[selectedType], + token: configValues[meta.configFields[0]?.key] || undefined, + }); + + toast.success(`${meta.name} channel saved. Restarting Gateway to connect...`); + + // Step 4: Restart the Gateway so it picks up the new channel config + // The Gateway watches the config file, but a restart ensures a clean start + // especially when adding a channel for the first time. + try { + await window.electron.ipcRenderer.invoke('gateway:restart'); + toast.success(`${meta.name} channel is now connecting via Gateway`); + } catch (restartError) { + console.warn('Gateway restart after channel config:', restartError); + toast.info('Config saved. Please restart the Gateway manually for the channel to connect.'); + } + + // Brief delay so user can see the success state before dialog closes + await new Promise((resolve) => setTimeout(resolve, 800)); + onChannelAdded(); } catch (error) { - toast.error(`Failed to add channel: ${error}`); - } finally { + toast.error(`Failed to configure channel: ${error}`); setConnecting(false); } }; - - const copyToken = () => { - navigator.clipboard.writeText(token); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + + const openDocs = () => { + if (meta?.docsUrl) { + try { + if (window.electron?.openExternal) { + window.electron.openExternal(meta.docsUrl); + } else { + // Fallback: open in new window + window.open(meta.docsUrl, '_blank'); + } + } catch (error) { + console.error('Failed to open docs:', error); + // Fallback: open in new window + window.open(meta.docsUrl, '_blank'); + } + } }; - + + + const isFormValid = () => { + if (!meta) return false; + + // Check all required fields are filled + return meta.configFields + .filter((field) => field.required) + .every((field) => configValues[field.key]?.trim()); + }; + + const updateConfigValue = (key: string, value: string) => { + setConfigValues((prev) => ({ ...prev, [key]: value })); + }; + + const toggleSecretVisibility = (key: string) => { + setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] })); + }; + return (
- +
- {selectedType ? `Connect ${CHANNEL_NAMES[selectedType]}` : 'Add Channel'} + {selectedType + ? isExistingConfig + ? `Update ${CHANNEL_NAMES[selectedType]}` + : `Configure ${CHANNEL_NAMES[selectedType]}` + : 'Add Channel'} - {info?.description || 'Select a messaging channel to connect'} + {selectedType && isExistingConfig + ? 'Existing configuration loaded. You can update and re-save.' + : meta?.description || 'Select a messaging channel to connect'}
- ))} + {getPrimaryChannels().map((type) => { + const channelMeta = CHANNEL_META[type]; + return ( + + ); + })}
) : qrCode ? ( // QR Code display @@ -467,7 +630,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, supportedTypes

- Scan this QR code with {CHANNEL_NAMES[selectedType]} to connect + Scan this QR code with {meta?.name} to connect

+ ) : loadingConfig ? ( + // Loading saved config +
+ + Loading configuration... +
) : ( // Connection form
+ {/* Existing config hint */} + {isExistingConfig && ( +
+ + Previously saved configuration has been loaded. Modify if needed and save. +
+ )} + {/* Instructions */} -
-

How to connect:

-
    - {info?.instructions.map((instruction, i) => ( -
  1. {instruction}
  2. - ))} -
- {info?.docsUrl && ( +
+
+

How to connect:

- )} +
+
    + {meta?.instructions.map((instruction, i) => ( +
  1. {instruction}
  2. + ))} +
- + {/* Channel name */}
setChannelName(e.target.value)} />
- - {/* Token input for token-based channels */} - {info?.connectionType === 'token' && ( -
- -
- setToken(e.target.value)} - /> - {token && ( - + + {/* Configuration fields */} + {meta?.configFields.map((field) => ( + updateConfigValue(field.key, value)} + showSecret={showSecrets[field.key] || false} + onToggleSecret={() => toggleSecretVisibility(field.key)} + /> + ))} + + {/* Validation Results */} + {validationResult && ( +
+
+ {validationResult.valid ? ( + + ) : ( + )} +
+

+ {validationResult.valid ? 'Credentials Verified' : 'Validation Failed'} +

+ {validationResult.errors.length > 0 && ( +
    + {validationResult.errors.map((err, i) => ( +
  • {err}
  • + ))} +
+ )} + {validationResult.valid && validationResult.warnings.length > 0 && ( +
+ {validationResult.warnings.map((info, i) => ( +

{info}

+ ))} +
+ )} + {!validationResult.valid && validationResult.warnings.length > 0 && ( +
+

Warnings:

+
    + {validationResult.warnings.map((warn, i) => ( +
  • {warn}
  • + ))} +
+
+ )} +
)} - + - +
- )} - + +
)} @@ -566,4 +803,57 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, supportedTypes ); } +// ==================== Config Field Component ==================== + +interface ConfigFieldProps { + field: ChannelConfigField; + value: string; + onChange: (value: string) => void; + showSecret: boolean; + onToggleSecret: () => void; +} + +function ConfigField({ field, value, onChange, showSecret, onToggleSecret }: ConfigFieldProps) { + const isPassword = field.type === 'password'; + + return ( +
+ +
+ onChange(e.target.value)} + className="font-mono text-sm" + /> + {isPassword && ( + + )} +
+ {field.description && ( +

+ {field.description} +

+ )} + {field.envVar && ( +

+ Or set via environment variable: {field.envVar} +

+ )} +
+ ); +} + export default Channels; diff --git a/src/pages/Setup/index.tsx b/src/pages/Setup/index.tsx index 90d8e6dd0..db7edc70a 100644 --- a/src/pages/Setup/index.tsx +++ b/src/pages/Setup/index.tsx @@ -16,6 +16,8 @@ import { RefreshCw, CheckCircle2, XCircle, + ExternalLink, + BookOpen, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -24,6 +26,13 @@ import { cn } from '@/lib/utils'; import { useGatewayStore } from '@/stores/gateway'; import { useSettingsStore } from '@/stores/settings'; import { toast } from 'sonner'; +import { + CHANNEL_META, + getPrimaryChannels, + type ChannelType, + type ChannelMeta, + type ChannelConfigField, +} from '@/types/channel'; interface SetupStep { id: string; @@ -31,6 +40,15 @@ interface SetupStep { description: string; } +const STEP = { + WELCOME: 0, + RUNTIME: 1, + PROVIDER: 2, + CHANNEL: 3, + INSTALLING: 4, + COMPLETE: 5, +} as const; + const steps: SetupStep[] = [ { id: 'welcome', @@ -47,7 +65,11 @@ const steps: SetupStep[] = [ title: 'AI Provider', description: 'Configure your AI service', }, - // Skills selection removed - auto-install essential components + { + id: 'channel', + title: 'Connect a Channel', + description: 'Connect a messaging platform (optional)', + }, { id: 'installing', title: 'Setting Up', @@ -143,19 +165,23 @@ export function Setup() { // Update canProceed based on current step useEffect(() => { switch (currentStep) { - case 0: // Welcome + case STEP.WELCOME: setCanProceed(true); break; - case 1: // Runtime + case STEP.RUNTIME: // Will be managed by RuntimeContent break; - case 2: // Provider + case STEP.PROVIDER: setCanProceed(selectedProvider !== null && apiKey.length > 0); break; - case 3: // Installing + case STEP.CHANNEL: + // Always allow proceeding — channel step is optional + setCanProceed(true); + break; + case STEP.INSTALLING: setCanProceed(false); // Cannot manually proceed, auto-proceeds when done break; - case 4: // Complete + case STEP.COMPLETE: setCanProceed(true); break; } @@ -213,9 +239,9 @@ export function Setup() { {/* Step-specific content */}
- {currentStep === 0 && } - {currentStep === 1 && } - {currentStep === 2 && ( + {currentStep === STEP.WELCOME && } + {currentStep === STEP.RUNTIME && } + {currentStep === STEP.PROVIDER && ( )} - {currentStep === 3 && ( + {currentStep === STEP.CHANNEL && } + {currentStep === STEP.INSTALLING && ( setCurrentStep((i) => i + 1)} /> )} - {currentStep === 4 && ( + {currentStep === STEP.COMPLETE && ( {/* Navigation - hidden during installation step */} - {currentStep !== 3 && ( + {currentStep !== STEP.INSTALLING && (
{!isFirstStep && ( @@ -250,7 +278,12 @@ export function Setup() { )}
- {!isLastStep && currentStep !== 1 && ( + {currentStep === STEP.CHANNEL && ( + + )} + {!isLastStep && currentStep !== STEP.RUNTIME && currentStep !== STEP.CHANNEL && ( @@ -531,6 +564,45 @@ function ProviderContent({ const [showKey, setShowKey] = useState(false); const [validating, setValidating] = useState(false); const [keyValid, setKeyValid] = useState(null); + useEffect(() => { + let cancelled = false; + (async () => { + try { + const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ id: string; hasKey: boolean }>; + const defaultId = await window.electron.ipcRenderer.invoke('provider:getDefault') as string | null; + const preferred = (defaultId && list.find((p) => p.id === defaultId && p.hasKey)) || list.find((p) => p.hasKey); + if (preferred && !cancelled) { + onSelectProvider(preferred.id); + const storedKey = await window.electron.ipcRenderer.invoke('provider:getApiKey', preferred.id) as string | null; + if (storedKey) { + onApiKeyChange(storedKey); + } + } + } catch (error) { + if (!cancelled) { + console.error('Failed to load provider list:', error); + } + } + })(); + return () => { cancelled = true; }; + }, [onApiKeyChange, onSelectProvider]); + useEffect(() => { + let cancelled = false; + (async () => { + if (!selectedProvider) return; + try { + const storedKey = await window.electron.ipcRenderer.invoke('provider:getApiKey', selectedProvider) as string | null; + if (!cancelled && storedKey) { + onApiKeyChange(storedKey); + } + } catch (error) { + if (!cancelled) { + console.error('Failed to load provider key:', error); + } + } + })(); + return () => { cancelled = true; }; + }, [onApiKeyChange, selectedProvider]); const selectedProviderData = providers.find((p) => p.id === selectedProvider); @@ -628,6 +700,7 @@ function ProviderContent({ onApiKeyChange(e.target.value); setKeyValid(null); }} + autoComplete="off" className="pr-10 bg-white/5 border-white/10" /> +
+ ); + } + + // Channel type not selected — show picker + if (!selectedChannel) { + return ( +
+
+
📡
+

Connect a Messaging Channel

+

+ Choose a platform to connect your AI assistant to. You can add more channels later in Settings. +

+
+
+ {primaryChannels.map((type) => { + const channelMeta = CHANNEL_META[type]; + if (channelMeta.connectionType !== 'token') return null; + return ( + + ); + })} +
+
+ ); + } + + // Channel selected — show config form + return ( +
+
+ +
+

+ {meta?.icon} Configure {meta?.name} +

+

{meta?.description}

+
+
+ + {/* Instructions */} +
+
+

How to connect:

+ {meta?.docsUrl && ( + + )} +
+
    + {meta?.instructions.map((inst, i) => ( +
  1. {inst}
  2. + ))} +
+
+ + {/* Config fields */} + {meta?.configFields.map((field: ChannelConfigField) => { + const isPassword = field.type === 'password'; + return ( +
+ +
+ setConfigValues((prev) => ({ ...prev, [field.key]: e.target.value }))} + autoComplete="off" + className="font-mono text-sm bg-white/5 border-white/10" + /> + {isPassword && ( + + )} +
+ {field.description && ( +

{field.description}

+ )} +
+ ); + })} + + {/* Validation error */} + {validationError && ( +
+ + {validationError} +
+ )} + + {/* Save button */} + +
+ ); +} + // NOTE: SkillsContent component removed - auto-install essential skills // Installation status for each skill @@ -682,55 +1005,55 @@ interface SkillInstallState { interface InstallingContentProps { skills: DefaultSkill[]; onComplete: (installedSkills: string[]) => void; + onSkip: () => void; } -function InstallingContent({ skills, onComplete }: InstallingContentProps) { +function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProps) { const [skillStates, setSkillStates] = useState( skills.map((s) => ({ ...s, status: 'pending' as InstallStatus })) ); const [overallProgress, setOverallProgress] = useState(0); + const [errorMessage, setErrorMessage] = useState(null); const installStarted = useRef(false); - // Simulate installation process + // Real installation process useEffect(() => { if (installStarted.current) return; installStarted.current = true; - const installSkills = async () => { - const installedIds: string[] = []; - - for (let i = 0; i < skills.length; i++) { - // Set current skill to installing - setSkillStates((prev) => - prev.map((s, idx) => - idx === i ? { ...s, status: 'installing' } : s - ) - ); - - // Simulate installation time (1-2 seconds per skill) - const installTime = 1000 + Math.random() * 1000; - await new Promise((resolve) => setTimeout(resolve, installTime)); - - // Mark as completed - setSkillStates((prev) => - prev.map((s, idx) => - idx === i ? { ...s, status: 'completed' } : s - ) - ); - installedIds.push(skills[i].id); - - // Update overall progress - setOverallProgress(Math.round(((i + 1) / skills.length) * 100)); + const runRealInstall = async () => { + try { + // Step 1: Initialize all skills to 'installing' state for UI + setSkillStates(prev => prev.map(s => ({ ...s, status: 'installing' }))); + setOverallProgress(10); + + // Step 2: Call the backend to install uv and setup Python + const result = await window.electron.ipcRenderer.invoke('uv:install-all') as { + success: boolean; + error?: string + }; + + if (result.success) { + setSkillStates(prev => prev.map(s => ({ ...s, status: 'completed' }))); + setOverallProgress(100); + + await new Promise((resolve) => setTimeout(resolve, 800)); + onComplete(skills.map(s => s.id)); + } else { + setSkillStates(prev => prev.map(s => ({ ...s, status: 'failed' }))); + setErrorMessage(result.error || 'Unknown error during installation'); + toast.error('Environment setup failed'); + } + } catch (err) { + setSkillStates(prev => prev.map(s => ({ ...s, status: 'failed' }))); + setErrorMessage(String(err)); + toast.error('Installation error'); } - - // Small delay before completing - await new Promise((resolve) => setTimeout(resolve, 500)); - onComplete(installedIds); }; - installSkills(); + runRealInstall(); }, [skills, onComplete]); - + const getStatusIcon = (status: InstallStatus) => { switch (status) { case 'pending': @@ -784,7 +1107,7 @@ function InstallingContent({ skills, onComplete }: InstallingContentProps) {
{/* Skill list */} -
+
{skillStates.map((skill) => ( ))}
+ + {/* Error Message Display */} + {errorMessage && ( + +
+ +
+

Setup Error:

+
+                {errorMessage}
+              
+ +
+
+
+ )} -

- This may take a few moments... -

+ {!errorMessage && ( +

+ This may take a few moments... +

+ )} +
+ +
); } - interface CompleteContentProps { selectedProvider: string | null; installedSkills: string[]; diff --git a/src/pages/Skills/index.tsx b/src/pages/Skills/index.tsx index f3dd0cd9e..0caa9a278 100644 --- a/src/pages/Skills/index.tsx +++ b/src/pages/Skills/index.tsx @@ -3,13 +3,13 @@ * Browse and manage AI skills */ import { useEffect, useState, useCallback } from 'react'; -import { - Search, - Puzzle, - RefreshCw, - Lock, +import { motion, AnimatePresence } from 'framer-motion'; +import { + Search, + Puzzle, + RefreshCw, + Lock, Package, - Info, X, Settings, CheckCircle2, @@ -17,6 +17,14 @@ import { AlertCircle, ChevronRight, Sparkles, + Download, + Trash2, + Globe, + FileCode, + Plus, + Save, + Key, + ChevronDown, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -29,73 +37,10 @@ import { useGatewayStore } from '@/stores/gateway'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { cn } from '@/lib/utils'; import { toast } from 'sonner'; -import type { Skill, SkillCategory, SkillBundle } from '@/types/skill'; +import type { Skill, MarketplaceSkill } from '@/types/skill'; -const categoryLabels: Record = { - productivity: 'Productivity', - developer: 'Developer', - 'smart-home': 'Smart Home', - media: 'Media', - communication: 'Communication', - security: 'Security', - information: 'Information', - utility: 'Utility', - custom: 'Custom', -}; -const categoryIcons: Record = { - productivity: '📋', - developer: '💻', - 'smart-home': '🏠', - media: '🎬', - communication: '💬', - security: '🔒', - information: '📰', - utility: '🔧', - custom: '⚡', -}; -// Predefined skill bundles -const skillBundles: SkillBundle[] = [ - { - id: 'productivity', - name: 'Productivity Pack', - nameZh: '效率工具包', - description: 'Essential tools for daily productivity including calendar, reminders, and notes', - descriptionZh: '日常效率必备工具,包含日历、提醒和笔记', - icon: '📋', - skills: ['calendar', 'reminders', 'notes', 'tasks', 'timer'], - recommended: true, - }, - { - id: 'developer', - name: 'Developer Tools', - nameZh: '开发者工具', - description: 'Code assistance, git operations, and technical documentation lookup', - descriptionZh: '代码辅助、Git 操作和技术文档查询', - icon: '💻', - skills: ['code-assist', 'git-ops', 'docs-lookup', 'snippet-manager'], - recommended: true, - }, - { - id: 'information', - name: 'Information Hub', - nameZh: '信息中心', - description: 'Stay informed with web search, news, weather, and knowledge base', - descriptionZh: '通过网页搜索、新闻、天气和知识库保持信息畅通', - icon: '📰', - skills: ['web-search', 'news', 'weather', 'wikipedia', 'translate'], - }, - { - id: 'smart-home', - name: 'Smart Home', - nameZh: '智能家居', - description: 'Control your smart home devices and automation routines', - descriptionZh: '控制智能家居设备和自动化场景', - icon: '🏠', - skills: ['lights', 'thermostat', 'security-cam', 'routines'], - }, -]; // Skill detail dialog component interface SkillDetailDialogProps { @@ -105,10 +50,114 @@ interface SkillDetailDialogProps { } function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps) { + const { fetchSkills } = useSkillsStore(); + const [activeTab, setActiveTab] = useState('info'); + const [envVars, setEnvVars] = useState>([]); + const [apiKey, setApiKey] = useState(''); + const [isEnvExpanded, setIsEnvExpanded] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + // Initialize config from skill + useEffect(() => { + // API Key + if (skill.config?.apiKey) { + setApiKey(String(skill.config.apiKey)); + } else { + setApiKey(''); + } + + // Env Vars + if (skill.config?.env) { + const vars = Object.entries(skill.config.env).map(([key, value]) => ({ + key, + value: String(value), + })); + setEnvVars(vars); + } else { + setEnvVars([]); + } + }, [skill.config]); + + const handleOpenClawhub = async () => { + if (skill.slug) { + await window.electron.ipcRenderer.invoke('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`); + } + }; + + const handleOpenEditor = async () => { + if (skill.slug) { + try { + const result = await window.electron.ipcRenderer.invoke('clawhub:openSkillReadme', skill.slug) as { success: boolean; error?: string }; + if (result.success) { + toast.success('Opened in editor'); + } else { + toast.error(result.error || 'Failed to open editor'); + } + } catch (err) { + toast.error('Failed to open editor: ' + String(err)); + } + } + }; + + const handleAddEnv = () => { + setEnvVars([...envVars, { key: '', value: '' }]); + }; + + const handleUpdateEnv = (index: number, field: 'key' | 'value', value: string) => { + const newVars = [...envVars]; + newVars[index] = { ...newVars[index], [field]: value }; + setEnvVars(newVars); + }; + + const handleRemoveEnv = (index: number) => { + const newVars = [...envVars]; + newVars.splice(index, 1); + setEnvVars(newVars); + }; + + const handleSaveConfig = async () => { + if (isSaving) return; + setIsSaving(true); + try { + // Build env object, filtering out empty keys + const envObj = envVars.reduce((acc, curr) => { + const key = curr.key.trim(); + const value = curr.value.trim(); + if (key) { + acc[key] = value; + } + return acc; + }, {} as Record); + + // Use direct file access instead of Gateway RPC for reliability + const result = await window.electron.ipcRenderer.invoke( + 'skill:updateConfig', + { + skillKey: skill.id, + apiKey: apiKey || '', // Empty string will delete the key + env: envObj // Empty object will clear all env vars + } + ) as { success: boolean; error?: string }; + + if (!result.success) { + throw new Error(result.error || 'Unknown error'); + } + + // Refresh skills from gateway to get updated config + await fetchSkills(); + + toast.success('Configuration saved'); + } catch (err) { + toast.error('Failed to save configuration: ' + String(err)); + } finally { + setIsSaving(false); + } + }; + return (
- e.stopPropagation()}> - + e.stopPropagation()}> +
{skill.icon || '🔧'}
@@ -116,43 +165,174 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps) {skill.name} {skill.isCore && } - {categoryLabels[skill.category]} +
+ {skill.slug && !skill.isBundled && !skill.isCore && ( + <> + + + + )} +
- -

{skill.description}

- -
- {skill.version && ( - v{skill.version} - )} - {skill.author && ( - by {skill.author} - )} - {skill.isCore && ( - - - Core Skill - - )} + + +
+ + Information + Configuration +
- - {skill.dependencies && skill.dependencies.length > 0 && ( -
-

Dependencies:

-
- {skill.dependencies.map((dep) => ( - {dep} - ))} -
+ +
+
+ +
+
+

Description

+

{skill.description}

+
+ +
+
+

Version

+

{skill.version}

+
+ {skill.author && ( +
+

Author

+

{skill.author}

+
+ )} +
+ +
+

Source

+ + {skill.isCore ? 'Core System' : skill.isBundled ? 'Bundled' : 'User Installed'} + +
+
+
+ + +
+ {/* API Key Section */} +
+

+ + API Key +

+ setApiKey(e.target.value)} + type="password" + className="font-mono text-sm" + /> +

+ The primary API key for this skill. Leave blank if not required or configured elsewhere. +

+
+ + {/* Environment Variables Section */} +
+
+ + + +
+ + {isEnvExpanded && ( +
+ {envVars.length === 0 && ( +

+ No environment variables configured. +

+ )} + + {envVars.map((env, index) => ( +
+ handleUpdateEnv(index, 'key', e.target.value)} + className="flex-1 font-mono text-xs bg-muted/20" + placeholder="KEY (e.g. BASE_URL)" + /> + = + handleUpdateEnv(index, 'value', e.target.value)} + className="flex-1 font-mono text-xs bg-muted/20" + placeholder="VALUE" + /> + +
+ ))} + + {envVars.length > 0 && ( +

+ Note: Rows with empty keys will be automatically removed during save. +

+ )} +
+ )} +
+
+ +
+ +
+
- )} - -
+
+ +
{skill.enabled ? ( <> @@ -167,12 +347,6 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps) )}
- {skill.configurable && ( - - )} onToggle(!skill.enabled)} @@ -180,115 +354,242 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps) />
- +
); } -// Bundle card component -interface BundleCardProps { - bundle: SkillBundle; - skills: Skill[]; - onApply: () => void; +// Marketplace skill card component +interface MarketplaceSkillCardProps { + skill: MarketplaceSkill; + isInstalling: boolean; + isInstalled: boolean; + onInstall: () => void; + onUninstall: () => void; } -function BundleCard({ bundle, skills, onApply }: BundleCardProps) { - const bundleSkills = skills.filter((s) => bundle.skills.includes(s.id)); - const enabledCount = bundleSkills.filter((s) => s.enabled).length; - const isFullyEnabled = bundleSkills.length > 0 && enabledCount === bundleSkills.length; - +function MarketplaceSkillCard({ + skill, + isInstalling, + isInstalled, + onInstall, + onUninstall +}: MarketplaceSkillCardProps) { + const handleCardClick = () => { + window.electron.ipcRenderer.invoke('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`); + }; + return ( - - + +
- {bundle.icon} +
+ 📦 +
- - {bundle.name} - {bundle.recommended && ( - - - Recommended - + {skill.name} + + v{skill.version} + {skill.author && ( + <> + + {skill.author} + )} - - - {enabledCount}/{bundleSkills.length} skills enabled
+
e.stopPropagation()}> + + {isInstalled ? ( + + + + ) : ( + + + + )} + +
- -

- {bundle.description} + +

+ {skill.description}

-
- {bundleSkills.slice(0, 4).map((skill) => ( - - {skill.icon} {skill.name} - - ))} - {bundleSkills.length > 4 && ( - - +{bundleSkills.length - 4} more - +
+ {skill.downloads !== undefined && ( +
+ + {skill.downloads.toLocaleString()} +
+ )} + {skill.stars !== undefined && ( +
+ + {skill.stars.toLocaleString()} +
)}
- ); } export function Skills() { - const { skills, loading, error, fetchSkills, enableSkill, disableSkill } = useSkillsStore(); + const { + skills, + loading, + error, + fetchSkills, + enableSkill, + disableSkill, + searchResults, + searchSkills, + installSkill, + uninstallSkill, + searching, + installing + } = useSkillsStore(); const gatewayStatus = useGatewayStore((state) => state.status); const [searchQuery, setSearchQuery] = useState(''); - const [selectedCategory, setSelectedCategory] = useState('all'); + const [marketplaceQuery, setMarketplaceQuery] = useState(''); const [selectedSkill, setSelectedSkill] = useState(null); const [activeTab, setActiveTab] = useState('all'); - + const [selectedSource, setSelectedSource] = useState<'all' | 'built-in' | 'marketplace'>('all'); + const isGatewayRunning = gatewayStatus.state === 'running'; - + const [showGatewayWarning, setShowGatewayWarning] = useState(false); + + // Debounce the gateway warning to avoid flickering during brief restarts (like skill toggles) + useEffect(() => { + let timer: NodeJS.Timeout; + if (!isGatewayRunning) { + // Wait 1.5s before showing the warning + timer = setTimeout(() => { + setShowGatewayWarning(true); + }, 1500); + } else { + setShowGatewayWarning(false); + } + return () => clearTimeout(timer); + }, [isGatewayRunning]); + // Fetch skills on mount useEffect(() => { if (isGatewayRunning) { fetchSkills(); } }, [fetchSkills, isGatewayRunning]); - + // Filter skills const filteredSkills = skills.filter((skill) => { const matchesSearch = skill.name.toLowerCase().includes(searchQuery.toLowerCase()) || skill.description.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesCategory = selectedCategory === 'all' || skill.category === selectedCategory; - return matchesSearch && matchesCategory; + + let matchesSource = true; + if (selectedSource === 'built-in') { + matchesSource = !!skill.isBundled; + } else if (selectedSource === 'marketplace') { + matchesSource = !skill.isBundled; + } + + return matchesSearch && matchesSource; + }).sort((a, b) => { + // Enabled skills first + if (a.enabled && !b.enabled) return -1; + if (!a.enabled && b.enabled) return 1; + // Then core/bundled + if (a.isCore && !b.isCore) return -1; + if (!a.isCore && b.isCore) return 1; + // Finally alphabetical + return a.name.localeCompare(b.name); }); - - // Get unique categories with counts - const categoryStats = skills.reduce((acc, skill) => { - acc[skill.category] = (acc[skill.category] || 0) + 1; - return acc; - }, {} as Record); - + + const sourceStats = { + all: skills.length, + builtIn: skills.filter(s => s.isBundled).length, + marketplace: skills.filter(s => !s.isBundled).length, + }; + // Handle toggle const handleToggle = useCallback(async (skillId: string, enable: boolean) => { try { @@ -303,30 +604,45 @@ export function Skills() { toast.error(String(err)); } }, [enableSkill, disableSkill]); - - // Handle bundle apply - const handleBundleApply = useCallback(async (bundle: SkillBundle) => { - const bundleSkills = skills.filter((s) => bundle.skills.includes(s.id)); - const allEnabled = bundleSkills.every((s) => s.enabled); - - try { - for (const skill of bundleSkills) { - if (allEnabled) { - if (!skill.isCore) { - await disableSkill(skill.id); - } - } else { - if (!skill.enabled) { - await enableSkill(skill.id); - } - } - } - toast.success(allEnabled ? 'Bundle disabled' : 'Bundle enabled'); - } catch { - toast.error('Failed to apply bundle'); + + // Handle marketplace search + const handleMarketplaceSearch = useCallback((e: React.FormEvent) => { + e.preventDefault(); + if (marketplaceQuery.trim()) { + searchSkills(marketplaceQuery); } - }, [skills, enableSkill, disableSkill]); - + }, [marketplaceQuery, searchSkills]); + + // Handle install + const handleInstall = useCallback(async (slug: string) => { + try { + await installSkill(slug); + // Automatically enable after install + // We need to find the skill id which is usually the slug + await enableSkill(slug); + toast.success('Skill installed and enabled'); + } catch (err) { + toast.error(`Failed to install: ${String(err)}`); + } + }, [installSkill, enableSkill]); + + // Initial marketplace load (Discovery) + useEffect(() => { + if (activeTab === 'marketplace' && searchResults.length === 0 && !searching) { + searchSkills(''); + } + }, [activeTab, searchResults.length, searching, searchSkills]); + + // Handle uninstall + const handleUninstall = useCallback(async (slug: string) => { + try { + await uninstallSkill(slug); + toast.success('Skill uninstalled successfully'); + } catch (err) { + toast.error(`Failed to uninstall: ${String(err)}`); + } + }, [uninstallSkill]); + if (loading) { return (
@@ -334,7 +650,7 @@ export function Skills() {
); } - + return (
{/* Header */} @@ -350,9 +666,9 @@ export function Skills() { Refresh
- + {/* Gateway Warning */} - {!isGatewayRunning && ( + {showGatewayWarning && ( @@ -362,20 +678,24 @@ export function Skills() { )} - + {/* Tabs */} - All Skills + Installed - + + + Marketplace + + {/* Bundles - + */} - + {/* Search and Filter */}
@@ -388,29 +708,36 @@ export function Skills() { className="pl-9" />
-
+ +
+ + - {Object.entries(categoryStats).map(([category, count]) => ( - - ))}
- + {/* Error Display */} {error && ( @@ -420,7 +747,7 @@ export function Skills() { )} - + {/* Skills Grid */} {filteredSkills.length === 0 ? ( @@ -435,8 +762,8 @@ export function Skills() { ) : (
{filteredSkills.map((skill) => ( -
- {skill.icon || categoryIcons[skill.category]} + {skill.icon || '🧩'}
{skill.name} - {skill.isCore && ( + {skill.isCore ? ( + ) : skill.isBundled ? ( + + ) : ( + )} - - {categoryLabels[skill.category]} -
- { - handleToggle(skill.id, checked); - }} - disabled={skill.isCore} - onClick={(e) => e.stopPropagation()} - /> +
+ {!skill.isBundled && !skill.isCore && ( + + )} + { + handleToggle(skill.id, checked); + }} + disabled={skill.isCore} + onClick={(e) => e.stopPropagation()} + /> +
@@ -492,13 +838,103 @@ export function Skills() {
)}
- - + + +
+
+
+
+ + setMarketplaceQuery(e.target.value)} + className="pl-9" + /> +
+ +
+
+ + {searchResults.length > 0 ? ( +
+ {searchResults.map((skill) => { + const isInstalled = skills.some(s => s.id === skill.slug || s.name === skill.name); // Simple check, ideally check by ID/slug + return ( + handleInstall(skill.slug)} + onUninstall={() => handleUninstall(skill.slug)} + /> + ); + })} +
+ ) : ( + + + +

Marketplace

+

+ {searching + ? 'Searching ClawHub...' + : marketplaceQuery + ? 'No skills found matching your search.' + : 'Search for new skills to expand your capabilities.'} +

+
+
+ )} +
+
+ + {/*

- Skill bundles are pre-configured collections of skills for common use cases. + Skill bundles are pre-configured collections of skills for common use cases. Enable a bundle to quickly set up multiple related skills at once.

- +
{skillBundles.map((bundle) => ( ))}
-
+
*/}
- - {/* Statistics */} - - -
-
- - - {skills.filter((s) => s.enabled).length} - - {' '}of {skills.length} skills enabled - - - - {skills.filter((s) => s.isCore).length} - - {' '}core skills - -
- -
-
-
- + + + {/* Skill Detail Dialog */} {selectedSkill && ( ((set, get) => ({ error: null, fetchChannels: async () => { - // channels.status returns a complex nested object, not a simple array. - // Channel management is deferred to Settings > Channels page. - // For now, just use empty list - channels will be added later. - set({ channels: [], loading: false }); + set({ loading: true, error: null }); + try { + const result = await window.electron.ipcRenderer.invoke( + 'gateway:rpc', + 'channels.status', + { probe: true } + ) as { + success: boolean; + result?: { + channelOrder?: string[]; + channels?: Record; + channelAccounts?: Record>; + channelDefaultAccountId?: Record; + }; + error?: string; + }; + + if (result.success && result.result) { + const data = result.result; + const channels: Channel[] = []; + + // Parse the complex channels.status response into simple Channel objects + const channelOrder = data.channelOrder || Object.keys(data.channels || {}); + for (const channelId of channelOrder) { + const summary = (data.channels as Record | undefined)?.[channelId] as Record | undefined; + const configured = + typeof summary?.configured === 'boolean' + ? summary.configured + : typeof (summary as { running?: boolean })?.running === 'boolean' + ? true + : false; + if (!configured) continue; + + const accounts = data.channelAccounts?.[channelId] || []; + const defaultAccountId = data.channelDefaultAccountId?.[channelId]; + const primaryAccount = + (defaultAccountId ? accounts.find((a) => a.accountId === defaultAccountId) : undefined) || + accounts.find((a) => a.connected === true || a.linked === true) || + accounts[0]; + + // Map gateway status to our status format + let status: Channel['status'] = 'disconnected'; + const now = Date.now(); + const RECENT_MS = 10 * 60 * 1000; + const hasRecentActivity = (a: { lastInboundAt?: number | null; lastOutboundAt?: number | null; lastConnectedAt?: number | null }) => + (typeof a.lastInboundAt === 'number' && now - a.lastInboundAt < RECENT_MS) || + (typeof a.lastOutboundAt === 'number' && now - a.lastOutboundAt < RECENT_MS) || + (typeof a.lastConnectedAt === 'number' && now - a.lastConnectedAt < RECENT_MS); + const anyConnected = accounts.some((a) => a.connected === true || a.linked === true || hasRecentActivity(a)); + const anyRunning = accounts.some((a) => a.running === true); + const summaryError = + typeof (summary as { error?: string })?.error === 'string' + ? (summary as { error?: string }).error + : typeof (summary as { lastError?: string })?.lastError === 'string' + ? (summary as { lastError?: string }).lastError + : undefined; + const anyError = + accounts.some((a) => typeof a.lastError === 'string' && a.lastError) || Boolean(summaryError); + + if (anyConnected) { + status = 'connected'; + } else if (anyRunning && !anyError) { + status = 'connected'; + } else if (anyError) { + status = 'error'; + } else if (anyRunning) { + status = 'connecting'; + } + + channels.push({ + id: `${channelId}-${primaryAccount?.accountId || 'default'}`, + type: channelId as ChannelType, + name: primaryAccount?.name || channelId, + status, + accountId: primaryAccount?.accountId, + error: + (typeof primaryAccount?.lastError === 'string' ? primaryAccount.lastError : undefined) || + (typeof summaryError === 'string' ? summaryError : undefined), + }); + } + + set({ channels, loading: false }); + } else { + // Gateway not available - try to show channels from local config + set({ channels: [], loading: false }); + } + } catch { + // Gateway not connected, show empty + set({ channels: [], loading: false }); + } }, addChannel: async (params) => { diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 1bf62046f..05b05a9a4 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -242,17 +242,31 @@ export const useChatStore = create((set, get) => ({ // Message complete - add to history and clear streaming const finalMsg = event.message as RawMessage | undefined; if (finalMsg) { - set((s) => ({ - messages: [...s.messages, { - ...finalMsg, - role: finalMsg.role || 'assistant', - id: finalMsg.id || `run-${runId}`, - }], - streamingText: '', - streamingMessage: null, - sending: false, - activeRunId: null, - })); + const msgId = finalMsg.id || `run-${runId}`; + set((s) => { + // Check if message already exists (prevent duplicates) + const alreadyExists = s.messages.some(m => m.id === msgId); + if (alreadyExists) { + // Just clear streaming state, don't add duplicate + return { + streamingText: '', + streamingMessage: null, + sending: false, + activeRunId: null, + }; + } + return { + messages: [...s.messages, { + ...finalMsg, + role: finalMsg.role || 'assistant', + id: msgId, + }], + streamingText: '', + streamingMessage: null, + sending: false, + activeRunId: null, + }; + }); } else { // No message in final event - reload history to get complete data set({ streamingText: '', streamingMessage: null, sending: false, activeRunId: null }); diff --git a/src/stores/gateway.ts b/src/stores/gateway.ts index f46370fb1..f6e6547e1 100644 --- a/src/stores/gateway.ts +++ b/src/stores/gateway.ts @@ -16,7 +16,7 @@ interface GatewayState { health: GatewayHealth | null; isInitialized: boolean; lastError: string | null; - + // Actions init: () => Promise; start: () => Promise; @@ -36,37 +36,37 @@ export const useGatewayStore = create((set, get) => ({ health: null, isInitialized: false, lastError: null, - + init: async () => { if (get().isInitialized) return; - + try { // Get initial status const status = await window.electron.ipcRenderer.invoke('gateway:status') as GatewayStatus; set({ status, isInitialized: true }); - + // Listen for status changes window.electron.ipcRenderer.on('gateway:status-changed', (newStatus) => { set({ status: newStatus as GatewayStatus }); }); - + // Listen for errors window.electron.ipcRenderer.on('gateway:error', (error) => { set({ lastError: String(error) }); }); - + // Listen for notifications window.electron.ipcRenderer.on('gateway:notification', (notification) => { console.log('Gateway notification:', notification); }); - + // Listen for chat events from the gateway and forward to chat store window.electron.ipcRenderer.on('gateway:chat-message', (data) => { try { // Dynamic import to avoid circular dependency import('./chat').then(({ useChatStore }) => { const chatData = data as { message?: Record } | Record; - const event = ('message' in chatData && typeof chatData.message === 'object') + const event = ('message' in chatData && typeof chatData.message === 'object') ? chatData.message as Record : chatData as Record; useChatStore.getState().handleChatEvent(event); @@ -75,32 +75,32 @@ export const useGatewayStore = create((set, get) => ({ console.warn('Failed to forward chat event:', err); } }); - + } catch (error) { console.error('Failed to initialize Gateway:', error); set({ lastError: String(error) }); } }, - + start: async () => { try { set({ status: { ...get().status, state: 'starting' }, lastError: null }); const result = await window.electron.ipcRenderer.invoke('gateway:start') as { success: boolean; error?: string }; - + if (!result.success) { - set({ + set({ status: { ...get().status, state: 'error', error: result.error }, lastError: result.error || 'Failed to start Gateway' }); } } catch (error) { - set({ + set({ status: { ...get().status, state: 'error', error: String(error) }, lastError: String(error) }); } }, - + stop: async () => { try { await window.electron.ipcRenderer.invoke('gateway:stop'); @@ -110,41 +110,41 @@ export const useGatewayStore = create((set, get) => ({ set({ lastError: String(error) }); } }, - + restart: async () => { try { set({ status: { ...get().status, state: 'starting' }, lastError: null }); const result = await window.electron.ipcRenderer.invoke('gateway:restart') as { success: boolean; error?: string }; - + if (!result.success) { - set({ + set({ status: { ...get().status, state: 'error', error: result.error }, lastError: result.error || 'Failed to restart Gateway' }); } } catch (error) { - set({ + set({ status: { ...get().status, state: 'error', error: String(error) }, lastError: String(error) }); } }, - + checkHealth: async () => { try { - const result = await window.electron.ipcRenderer.invoke('gateway:health') as { - success: boolean; - ok: boolean; - error?: string; - uptime?: number + const result = await window.electron.ipcRenderer.invoke('gateway:health') as { + success: boolean; + ok: boolean; + error?: string; + uptime?: number }; - + const health: GatewayHealth = { ok: result.ok, error: result.error, uptime: result.uptime, }; - + set({ health }); return health; } catch (error) { @@ -153,22 +153,22 @@ export const useGatewayStore = create((set, get) => ({ return health; } }, - + rpc: async (method: string, params?: unknown, timeoutMs?: number): Promise => { const result = await window.electron.ipcRenderer.invoke('gateway:rpc', method, params, timeoutMs) as { success: boolean; result?: T; error?: string; }; - + if (!result.success) { throw new Error(result.error || `RPC call failed: ${method}`); } - + return result.result as T; }, - + setStatus: (status) => set({ status }), - + clearError: () => set({ lastError: null }), })); diff --git a/src/stores/skills.ts b/src/stores/skills.ts index 2aea6d2b1..042fa4681 100644 --- a/src/stores/skills.ts +++ b/src/stores/skills.ts @@ -3,15 +3,50 @@ * Manages skill/plugin state */ import { create } from 'zustand'; -import type { Skill } from '../types/skill'; +import type { Skill, MarketplaceSkill } from '../types/skill'; + +type GatewaySkillStatus = { + skillKey: string; + slug?: string; + name?: string; + description?: string; + disabled?: boolean; + emoji?: string; + version?: string; + author?: string; + config?: Record; + bundled?: boolean; + always?: boolean; +}; + +type GatewaySkillsStatusResult = { + skills?: GatewaySkillStatus[]; +}; + +type GatewayRpcResponse = { + success: boolean; + result?: T; + error?: string; +}; + +type ClawHubListResult = { + slug: string; + version?: string; +}; interface SkillsState { skills: Skill[]; + searchResults: MarketplaceSkill[]; loading: boolean; + searching: boolean; + installing: Record; // slug -> boolean error: string | null; - + // Actions fetchSkills: () => Promise; + searchSkills: (query: string) => Promise; + installSkill: (slug: string, version?: string) => Promise; + uninstallSkill: (slug: string) => Promise; enableSkill: (skillId: string) => Promise; disableSkill: (skillId: string) => Promise; setSkills: (skills: Skill[]) => void; @@ -20,26 +55,163 @@ interface SkillsState { export const useSkillsStore = create((set, get) => ({ skills: [], + searchResults: [], loading: false, + searching: false, + installing: {}, error: null, - + fetchSkills: async () => { - // skills.status returns a complex nested object, not a simple Skill[] array. - // Skill management is handled in the Skills page. - // For now, use empty list - will be properly integrated later. - set({ skills: [], loading: false }); + // Only show loading state if we have no skills yet (initial load) + if (get().skills.length === 0) { + set({ loading: true, error: null }); + } + try { + // 1. Fetch from Gateway (running skills) + const gatewayResult = await window.electron.ipcRenderer.invoke( + 'gateway:rpc', + 'skills.status' + ) as GatewayRpcResponse; + + // 2. Fetch from ClawHub (installed on disk) + const clawhubResult = await window.electron.ipcRenderer.invoke( + 'clawhub:list' + ) as { success: boolean; results?: ClawHubListResult[]; error?: string }; + + // 3. Fetch configurations directly from Electron (since Gateway doesn't return them) + const configResult = await window.electron.ipcRenderer.invoke( + 'skill:getAllConfigs' + ) as Record }>; + + let combinedSkills: Skill[] = []; + const currentSkills = get().skills; + + // Map gateway skills info + if (gatewayResult.success && gatewayResult.result?.skills) { + combinedSkills = gatewayResult.result.skills.map((s: GatewaySkillStatus) => { + // Merge with direct config if available + const directConfig = configResult[s.skillKey] || {}; + + return { + id: s.skillKey, + slug: s.slug || s.skillKey, + name: s.name || s.skillKey, + description: s.description || '', + enabled: !s.disabled, + icon: s.emoji || '📦', + version: s.version || '1.0.0', + author: s.author, + config: { + ...(s.config || {}), + ...directConfig, + }, + isCore: s.bundled && s.always, + isBundled: s.bundled, + }; + }); + } else if (currentSkills.length > 0) { + // ... if gateway down ... + combinedSkills = [...currentSkills]; + } + + // Merge with ClawHub results + if (clawhubResult.success && clawhubResult.results) { + clawhubResult.results.forEach((cs: ClawHubListResult) => { + const existing = combinedSkills.find(s => s.id === cs.slug); + if (!existing) { + const directConfig = configResult[cs.slug] || {}; + combinedSkills.push({ + id: cs.slug, + slug: cs.slug, + name: cs.slug, + description: 'Recently installed, initializing...', + enabled: false, + icon: '⌛', + version: cs.version || 'unknown', + author: undefined, + config: directConfig, + isCore: false, + isBundled: false, + }); + } + }); + } + + set({ skills: combinedSkills, loading: false }); + } catch (error) { + console.error('Failed to fetch skills:', error); + set({ loading: false }); + } }, - + + searchSkills: async (query: string) => { + set({ searching: true, error: null }); + try { + const result = await window.electron.ipcRenderer.invoke('clawhub:search', { query }) as { success: boolean; results?: MarketplaceSkill[]; error?: string }; + if (result.success) { + set({ searchResults: result.results || [] }); + } else { + throw new Error(result.error || 'Search failed'); + } + } catch (error) { + set({ error: String(error) }); + } finally { + set({ searching: false }); + } + }, + + installSkill: async (slug: string, version?: string) => { + set((state) => ({ installing: { ...state.installing, [slug]: true } })); + try { + const result = await window.electron.ipcRenderer.invoke('clawhub:install', { slug, version }) as { success: boolean; error?: string }; + if (!result.success) { + throw new Error(result.error || 'Install failed'); + } + // Refresh skills after install + await get().fetchSkills(); + } catch (error) { + console.error('Install error:', error); + throw error; + } finally { + set((state) => { + const newInstalling = { ...state.installing }; + delete newInstalling[slug]; + return { installing: newInstalling }; + }); + } + }, + + uninstallSkill: async (slug: string) => { + set((state) => ({ installing: { ...state.installing, [slug]: true } })); + try { + const result = await window.electron.ipcRenderer.invoke('clawhub:uninstall', { slug }) as { success: boolean; error?: string }; + if (!result.success) { + throw new Error(result.error || 'Uninstall failed'); + } + // Refresh skills after uninstall + await get().fetchSkills(); + } catch (error) { + console.error('Uninstall error:', error); + throw error; + } finally { + set((state) => { + const newInstalling = { ...state.installing }; + delete newInstalling[slug]; + return { installing: newInstalling }; + }); + } + }, + enableSkill: async (skillId) => { const { updateSkill } = get(); - + try { const result = await window.electron.ipcRenderer.invoke( 'gateway:rpc', - 'skills.enable', - { skillId } - ) as { success: boolean; error?: string }; - + 'skills.update', + { skillKey: skillId, enabled: true } + ) as GatewayRpcResponse; + if (result.success) { updateSkill(skillId, { enabled: true }); } else { @@ -50,23 +222,22 @@ export const useSkillsStore = create((set, get) => ({ throw error; } }, - + disableSkill: async (skillId) => { const { updateSkill, skills } = get(); - - // Check if skill is a core skill + const skill = skills.find((s) => s.id === skillId); if (skill?.isCore) { throw new Error('Cannot disable core skill'); } - + try { const result = await window.electron.ipcRenderer.invoke( 'gateway:rpc', - 'skills.disable', - { skillId } - ) as { success: boolean; error?: string }; - + 'skills.update', + { skillKey: skillId, enabled: false } + ) as GatewayRpcResponse; + if (result.success) { updateSkill(skillId, { enabled: false }); } else { @@ -77,9 +248,9 @@ export const useSkillsStore = create((set, get) => ({ throw error; } }, - + setSkills: (skills) => set({ skills }), - + updateSkill: (skillId, updates) => { set((state) => ({ skills: state.skills.map((skill) => diff --git a/src/types/channel.ts b/src/types/channel.ts index 5bfde9758..433e3b40a 100644 --- a/src/types/channel.ts +++ b/src/types/channel.ts @@ -6,13 +6,30 @@ /** * Supported channel types */ -export type ChannelType = 'whatsapp' | 'telegram' | 'discord' | 'slack' | 'wechat'; +export type ChannelType = + | 'whatsapp' + | 'telegram' + | 'discord' + | 'slack' + | 'signal' + | 'feishu' + | 'imessage' + | 'matrix' + | 'line' + | 'msteams' + | 'googlechat' + | 'mattermost'; /** * Channel connection status */ export type ChannelStatus = 'connected' | 'disconnected' | 'connecting' | 'error'; +/** + * Channel connection type + */ +export type ChannelConnectionType = 'token' | 'qr' | 'oauth' | 'webhook'; + /** * Channel data structure */ @@ -21,6 +38,7 @@ export interface Channel { type: ChannelType; name: string; status: ChannelStatus; + accountId?: string; lastActivity?: string; error?: string; avatar?: string; @@ -28,27 +46,32 @@ export interface Channel { } /** - * Channel configuration for each type + * Channel configuration field definition */ -export interface ChannelConfig { - whatsapp: { - phoneNumber?: string; - }; - telegram: { - botToken?: string; - chatId?: string; - }; - discord: { - botToken?: string; - guildId?: string; - }; - slack: { - botToken?: string; - appToken?: string; - }; - wechat: { - appId?: string; - }; +export interface ChannelConfigField { + key: string; + label: string; + type: 'text' | 'password' | 'select'; + placeholder?: string; + required?: boolean; + envVar?: string; + description?: string; + options?: { value: string; label: string }[]; +} + +/** + * Channel metadata with configuration info + */ +export interface ChannelMeta { + id: ChannelType; + name: string; + icon: string; + description: string; + connectionType: ChannelConnectionType; + docsUrl: string; + configFields: ChannelConfigField[]; + instructions: string[]; + isPlugin?: boolean; } /** @@ -59,7 +82,14 @@ export const CHANNEL_ICONS: Record = { telegram: '✈️', discord: '🎮', slack: '💼', - wechat: '💬', + signal: '🔒', + feishu: '🐦', + imessage: '💬', + matrix: '🔗', + line: '🟢', + msteams: '👔', + googlechat: '💭', + mattermost: '💠', }; /** @@ -70,5 +100,377 @@ export const CHANNEL_NAMES: Record = { telegram: 'Telegram', discord: 'Discord', slack: 'Slack', - wechat: 'WeChat', + signal: 'Signal', + feishu: 'Feishu / Lark', + imessage: 'iMessage', + matrix: 'Matrix', + line: 'LINE', + msteams: 'Microsoft Teams', + googlechat: 'Google Chat', + mattermost: 'Mattermost', }; + +/** + * Channel metadata with configuration information + */ +export const CHANNEL_META: Record = { + telegram: { + id: 'telegram', + name: 'Telegram', + icon: '✈️', + description: 'Connect Telegram using a bot token from @BotFather', + connectionType: 'token', + docsUrl: 'https://docs.openclaw.ai/channels/telegram', + configFields: [ + { + key: 'botToken', + label: 'Bot Token', + type: 'password', + placeholder: '123456:ABC-DEF...', + required: true, + envVar: 'TELEGRAM_BOT_TOKEN', + }, + ], + instructions: [ + 'Open Telegram and search for @BotFather', + 'Send /newbot and follow the instructions', + 'Copy the bot token provided', + 'Paste the token below', + ], + }, + discord: { + id: 'discord', + name: 'Discord', + icon: '🎮', + description: 'Connect Discord using a bot token from Developer Portal', + connectionType: 'token', + docsUrl: 'https://docs.openclaw.ai/channels/discord', + configFields: [ + { + key: 'token', + label: 'Bot Token', + type: 'password', + placeholder: 'Your Discord bot token', + required: true, + envVar: 'DISCORD_BOT_TOKEN', + }, + { + key: 'guildId', + label: 'Guild/Server ID (optional)', + type: 'text', + placeholder: 'e.g., 123456789012345678', + required: false, + description: 'Limit bot to a specific server. Right-click server → Copy Server ID.', + }, + { + key: 'channelId', + label: 'Channel ID (optional)', + type: 'text', + placeholder: 'e.g., 123456789012345678', + required: false, + description: 'Limit bot to a specific channel. Right-click channel → Copy Channel ID.', + }, + ], + instructions: [ + 'Go to Discord Developer Portal → Applications → New Application', + 'In Bot section: Add Bot, then copy the Bot Token', + 'Enable Message Content Intent + Server Members Intent in Bot → Privileged Gateway Intents', + 'In OAuth2 → URL Generator: select "bot" + "applications.commands", add message permissions', + 'Invite the bot to your server using the generated URL', + 'Paste the bot token below', + ], + }, + slack: { + id: 'slack', + name: 'Slack', + icon: '💼', + description: 'Connect Slack using bot and app tokens', + connectionType: 'token', + docsUrl: 'https://docs.openclaw.ai/channels/slack', + configFields: [ + { + key: 'botToken', + label: 'Bot Token (xoxb-...)', + type: 'password', + placeholder: 'xoxb-...', + required: true, + envVar: 'SLACK_BOT_TOKEN', + }, + { + key: 'appToken', + label: 'App Token (xapp-...)', + type: 'password', + placeholder: 'xapp-...', + required: false, + envVar: 'SLACK_APP_TOKEN', + }, + ], + instructions: [ + 'Go to api.slack.com/apps', + 'Create a new app from scratch', + 'Add required OAuth scopes', + 'Install to workspace and copy tokens', + ], + }, + whatsapp: { + id: 'whatsapp', + name: 'WhatsApp', + icon: '📱', + description: 'Connect WhatsApp by scanning a QR code', + connectionType: 'qr', + docsUrl: 'https://docs.openclaw.ai/channels/whatsapp', + configFields: [], + instructions: [ + 'Open WhatsApp on your phone', + 'Go to Settings > Linked Devices', + 'Tap "Link a Device"', + 'Scan the QR code shown below', + ], + }, + signal: { + id: 'signal', + name: 'Signal', + icon: '🔒', + description: 'Connect Signal using signal-cli', + connectionType: 'token', + docsUrl: 'https://docs.openclaw.ai/channels/signal', + configFields: [ + { + key: 'phoneNumber', + label: 'Phone Number', + type: 'text', + placeholder: '+1234567890', + required: true, + }, + ], + instructions: [ + 'Install signal-cli on your system', + 'Register or link your phone number', + 'Enter your phone number below', + ], + }, + feishu: { + id: 'feishu', + name: 'Feishu / Lark', + icon: '🐦', + description: 'Connect Feishu/Lark bot via WebSocket', + connectionType: 'token', + docsUrl: 'https://docs.openclaw.ai/channels/feishu', + configFields: [ + { + key: 'appId', + label: 'App ID', + type: 'text', + placeholder: 'cli_xxxxxx', + required: true, + envVar: 'FEISHU_APP_ID', + }, + { + key: 'appSecret', + label: 'App Secret', + type: 'password', + placeholder: 'Your app secret', + required: true, + envVar: 'FEISHU_APP_SECRET', + }, + ], + instructions: [ + 'Go to Feishu Open Platform', + 'Create a new application', + 'Get App ID and App Secret', + 'Configure event subscription', + ], + isPlugin: true, + }, + imessage: { + id: 'imessage', + name: 'iMessage', + icon: '💬', + description: 'Connect iMessage via BlueBubbles (macOS)', + connectionType: 'token', + docsUrl: 'https://docs.openclaw.ai/channels/bluebubbles', + configFields: [ + { + key: 'serverUrl', + label: 'BlueBubbles Server URL', + type: 'text', + placeholder: 'http://localhost:1234', + required: true, + }, + { + key: 'password', + label: 'Server Password', + type: 'password', + placeholder: 'Your server password', + required: true, + }, + ], + instructions: [ + 'Install BlueBubbles server on your Mac', + 'Note the server URL and password', + 'Enter the connection details below', + ], + }, + matrix: { + id: 'matrix', + name: 'Matrix', + icon: '🔗', + description: 'Connect to Matrix protocol', + connectionType: 'token', + docsUrl: 'https://docs.openclaw.ai/channels/matrix', + configFields: [ + { + key: 'homeserver', + label: 'Homeserver URL', + type: 'text', + placeholder: 'https://matrix.org', + required: true, + }, + { + key: 'accessToken', + label: 'Access Token', + type: 'password', + placeholder: 'Your access token', + required: true, + }, + ], + instructions: [ + 'Create a Matrix account or use existing', + 'Get an access token from your client', + 'Enter the homeserver and token below', + ], + isPlugin: true, + }, + line: { + id: 'line', + name: 'LINE', + icon: '🟢', + description: 'Connect LINE Messaging API', + connectionType: 'token', + docsUrl: 'https://docs.openclaw.ai/channels/line', + configFields: [ + { + key: 'channelAccessToken', + label: 'Channel Access Token', + type: 'password', + placeholder: 'Your LINE channel access token', + required: true, + envVar: 'LINE_CHANNEL_ACCESS_TOKEN', + }, + { + key: 'channelSecret', + label: 'Channel Secret', + type: 'password', + placeholder: 'Your LINE channel secret', + required: true, + envVar: 'LINE_CHANNEL_SECRET', + }, + ], + instructions: [ + 'Go to LINE Developers Console', + 'Create a Messaging API channel', + 'Get Channel Access Token and Secret', + ], + isPlugin: true, + }, + msteams: { + id: 'msteams', + name: 'Microsoft Teams', + icon: '👔', + description: 'Connect Microsoft Teams via Bot Framework', + connectionType: 'token', + docsUrl: 'https://docs.openclaw.ai/channels/msteams', + configFields: [ + { + key: 'appId', + label: 'App ID', + type: 'text', + placeholder: 'Your Microsoft App ID', + required: true, + envVar: 'MSTEAMS_APP_ID', + }, + { + key: 'appPassword', + label: 'App Password', + type: 'password', + placeholder: 'Your Microsoft App Password', + required: true, + envVar: 'MSTEAMS_APP_PASSWORD', + }, + ], + instructions: [ + 'Go to Azure Portal', + 'Register a new Bot application', + 'Get App ID and create a password', + 'Configure Teams channel', + ], + isPlugin: true, + }, + googlechat: { + id: 'googlechat', + name: 'Google Chat', + icon: '💭', + description: 'Connect Google Chat via webhook', + connectionType: 'webhook', + docsUrl: 'https://docs.openclaw.ai/channels/googlechat', + configFields: [ + { + key: 'serviceAccountKey', + label: 'Service Account JSON Path', + type: 'text', + placeholder: '/path/to/service-account.json', + required: true, + }, + ], + instructions: [ + 'Create a Google Cloud project', + 'Enable Google Chat API', + 'Create a service account', + 'Download the JSON key file', + ], + }, + mattermost: { + id: 'mattermost', + name: 'Mattermost', + icon: '💠', + description: 'Connect Mattermost via Bot API', + connectionType: 'token', + docsUrl: 'https://docs.openclaw.ai/channels/mattermost', + configFields: [ + { + key: 'serverUrl', + label: 'Server URL', + type: 'text', + placeholder: 'https://your-mattermost.com', + required: true, + }, + { + key: 'botToken', + label: 'Bot Access Token', + type: 'password', + placeholder: 'Your bot access token', + required: true, + }, + ], + instructions: [ + 'Go to Mattermost Integrations', + 'Create a new Bot Account', + 'Copy the access token', + ], + isPlugin: true, + }, +}; + +/** + * Get primary supported channels (non-plugin, commonly used) + */ +export function getPrimaryChannels(): ChannelType[] { + return ['telegram', 'discord', 'slack', 'whatsapp', 'feishu']; +} + +/** + * Get all available channels including plugins + */ +export function getAllChannels(): ChannelType[] { + return Object.keys(CHANNEL_META) as ChannelType[]; +} diff --git a/src/types/skill.ts b/src/types/skill.ts index f7c7efc1d..d1889e24a 100644 --- a/src/types/skill.ts +++ b/src/types/skill.ts @@ -3,34 +3,22 @@ * Types for skills/plugins */ -/** - * Skill category - */ -export type SkillCategory = - | 'productivity' - | 'developer' - | 'smart-home' - | 'media' - | 'communication' - | 'security' - | 'information' - | 'utility' - | 'custom'; - /** * Skill data structure */ export interface Skill { id: string; + slug?: string; name: string; description: string; enabled: boolean; - category: SkillCategory; icon?: string; version?: string; author?: string; configurable?: boolean; + config?: Record; isCore?: boolean; + isBundled?: boolean; dependencies?: string[]; } @@ -48,6 +36,20 @@ export interface SkillBundle { recommended?: boolean; } + +/** + * Marketplace skill data + */ +export interface MarketplaceSkill { + slug: string; + name: string; + description: string; + version: string; + author?: string; + downloads?: number; + stars?: number; +} + /** * Skill configuration schema */