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:
Felix
2026-02-06 18:26:06 +08:00
committed by GitHub
Unverified
parent f9845023c3
commit fa6c23b82a
23 changed files with 4315 additions and 802 deletions

304
electron/gateway/clawhub.ts Normal file
View 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;
}
}
}

View File

@@ -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: '',
},
});