feature: channels and skills (#2)
Co-authored-by: paisley <8197966+su8su@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
304
electron/gateway/clawhub.ts
Normal file
304
electron/gateway/clawhub.ts
Normal file
@@ -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<string> {
|
||||
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<ClawHubSkillResult[]> {
|
||||
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<ClawHubSkillResult[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Array<{ slug: string; version: string }>> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/<platform>-<arch>
|
||||
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<string, string> = {};
|
||||
@@ -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: '',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
}
|
||||
callback({ responseHeaders: headers });
|
||||
});
|
||||
|
||||
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers(gatewayManager, mainWindow);
|
||||
registerIpcHandlers(gatewayManager, clawHubService, mainWindow);
|
||||
|
||||
// Register update handlers
|
||||
registerUpdateHandlers(appUpdater, mainWindow);
|
||||
|
||||
@@ -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<string, string>;
|
||||
}) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, string>) => {
|
||||
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<typeof app.getPath>[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();
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
579
electron/utils/channel-config.ts
Normal file
579
electron/utils/channel-config.ts
Normal file
@@ -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<string, ChannelConfigData>;
|
||||
[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<string, unknown> = {
|
||||
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<string, string> matching the form field keys, or undefined
|
||||
*/
|
||||
export function getChannelFormValues(channelType: string): Record<string, string> | undefined {
|
||||
const saved = getChannelConfig(channelType);
|
||||
if (!saved) return undefined;
|
||||
|
||||
const values: Record<string, string> = {};
|
||||
|
||||
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<string, Record<string, unknown>> | 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<string, unknown> | 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<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string>
|
||||
): Promise<CredentialValidationResult> {
|
||||
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<string, string>
|
||||
): Promise<CredentialValidationResult> {
|
||||
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<string, string>
|
||||
): Promise<CredentialValidationResult> {
|
||||
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<string, string>
|
||||
): Promise<CredentialValidationResult> {
|
||||
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<string, string> = {
|
||||
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<ValidationResult> {
|
||||
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;
|
||||
}
|
||||
130
electron/utils/skill-config.ts
Normal file
130
electron/utils/skill-config.ts
Normal file
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
interface OpenClawConfig {
|
||||
skills?: {
|
||||
entries?: Record<string, SkillEntry>;
|
||||
[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<string, string> }
|
||||
): { 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<string, string> = {};
|
||||
|
||||
// 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<string, SkillEntry> {
|
||||
const config = readConfig();
|
||||
return config.skills?.entries || {};
|
||||
}
|
||||
114
electron/utils/uv-setup.ts
Normal file
114
electron/utils/uv-setup.ts
Normal file
@@ -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/<platform>-<arch>
|
||||
return join(process.cwd(), 'resources', 'bin', target, binName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if uv is available (either in system PATH or bundled)
|
||||
*/
|
||||
export async function checkUvInstalled(): Promise<boolean> {
|
||||
// 1. Check system PATH first
|
||||
const inPath = await new Promise<boolean>((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<void> {
|
||||
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<void> {
|
||||
// Use 'uv' if in PATH, otherwise use full bundled path
|
||||
const inPath = await new Promise<boolean>((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<void>((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<string>((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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user