import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; import * as https from 'https'; const API_BASE = 'https://clawsouls.ai/api/v1'; interface SoulSummary { name: string; owner: string; fullName: string; displayName: string; description: string; category: string; tags: string[]; downloads: number; scanScore: number | null; scanStatus: string; version: string; license: string; files: Record; } function apiGet(urlPath: string): Promise { return new Promise((resolve, reject) => { const url = `${API_BASE}${urlPath}`; https.get(url, { headers: { 'Accept': 'application/json' } }, (res) => { let data = ''; res.on('data', (chunk: string) => data += chunk); res.on('end', () => { try { resolve(JSON.parse(data)); } catch { reject(new Error(`Invalid JSON from ${url}`)); } }); }).on('error', reject); }); } type TreeNode = CategoryNode | RemoteSoulNode | LocalSoulFile; export class SoulExplorerProvider implements vscode.TreeDataProvider { private _onDidChangeTreeData = new vscode.EventEmitter(); readonly onDidChangeTreeData = this._onDidChangeTreeData.event; private remoteCache: SoulSummary[] = []; private searchQuery = ''; private viewMode: 'browse' | 'local' = 'browse'; constructor(private context: vscode.ExtensionContext) { // Watch local soul files const watcher = vscode.workspace.createFileSystemWatcher('**/{soul.json,SOUL.md,AGENTS.md,MEMORY.md,IDENTITY.md}'); watcher.onDidChange(() => this.refresh()); watcher.onDidCreate(() => this.refresh()); watcher.onDidDelete(() => this.refresh()); context.subscriptions.push(watcher); // Register soul explorer commands context.subscriptions.push( vscode.commands.registerCommand('clawsouls.soulExplorer.search', () => this.searchSouls()), vscode.commands.registerCommand('clawsouls.soulExplorer.toggleView', () => this.toggleView()), vscode.commands.registerCommand('clawsouls.soulExplorer.apply', (node: RemoteSoulNode) => this.applySoul(node)), vscode.commands.registerCommand('clawsouls.soulExplorer.preview', (node: RemoteSoulNode) => this.previewSoul(node)), vscode.commands.registerCommand('clawsouls.refresh', () => this.refresh()) ); // Load remote souls on init this.loadRemoteSouls(); } refresh(): void { this._onDidChangeTreeData.fire(); } getTreeItem(element: TreeNode): vscode.TreeItem { return element; } async getChildren(element?: TreeNode): Promise { if (!element) { return this.getRootChildren(); } if (element instanceof CategoryNode) { return element.children; } return []; } private async getRootChildren(): Promise { if (this.viewMode === 'local') { return this.getLocalFiles(); } // Browse mode — group by category let souls = this.remoteCache; if (this.searchQuery) { const q = this.searchQuery.toLowerCase(); souls = souls.filter(s => s.displayName.toLowerCase().includes(q) || s.description.toLowerCase().includes(q) || s.tags.some(t => t.toLowerCase().includes(q)) || s.fullName.toLowerCase().includes(q) ); } if (souls.length === 0) { if (this.remoteCache.length === 0) { return [new MessageNode('Loading souls...')]; } return [new MessageNode(`No results for "${this.searchQuery}"`)]; } // Group by category const groups = new Map(); for (const soul of souls) { const cat = soul.category || 'uncategorized'; if (!groups.has(cat)) groups.set(cat, []); groups.get(cat)!.push(new RemoteSoulNode(soul)); } const categories: CategoryNode[] = []; for (const [cat, children] of [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { categories.push(new CategoryNode(cat, children)); } return categories; } private async loadRemoteSouls(): Promise { try { const resp = await apiGet('/souls?limit=200'); this.remoteCache = resp.souls || []; this.refresh(); } catch (err: any) { console.error('Failed to load remote souls:', err.message); // Retry after 10s setTimeout(() => this.loadRemoteSouls(), 10000); } } private async searchSouls(): Promise { const query = await vscode.window.showInputBox({ prompt: 'Search souls by name, tag, or description', placeHolder: 'e.g. developer, writing, korean...', value: this.searchQuery }); if (query !== undefined) { this.searchQuery = query; this.viewMode = 'browse'; this.refresh(); } } private toggleView(): void { this.viewMode = this.viewMode === 'browse' ? 'local' : 'browse'; this.refresh(); } private async previewSoul(node: RemoteSoulNode): Promise { const soul = node.soul; try { const detail = await apiGet(`/souls/${soul.owner}/${soul.name}`); const panel = vscode.window.createWebviewPanel( 'soulPreview', `${soul.displayName} — Soul Preview`, vscode.ViewColumn.One, { enableScripts: false } ); panel.webview.html = this.getSoulPreviewHtml(detail); } catch (err: any) { vscode.window.showErrorMessage(`Failed to load soul: ${err.message}`); } } private getSoulPreviewHtml(soul: any): string { const tags = (soul.tags || []).map((t: string) => `${t}`).join(' '); const files = Object.entries(soul.files || {}).map(([k, v]) => `
  • ${k}: ${v}
  • `).join(''); const scan = soul.latestScan ? `

    🔍 SoulScan: ${soul.latestScan.score}/100 (${soul.latestScan.status})

    ` : ''; return `

    ${soul.displayName || soul.name}

    ${soul.fullName} · v${soul.version} · ${soul.license} · ⬇ ${soul.downloads}

    ${soul.description || ''}

    ${tags}
    ${scan}

    📁 Files

      ${files || '
    • No files
    • '}

    To apply this soul, use the command: ClawSouls: Apply Soul

    `; } private getOpenClawWorkspaceDir(): string { // Resolve OpenClaw workspace directory const stateDir = process.env.OPENCLAW_STATE_DIR; if (stateDir) { return path.join(stateDir, 'workspace'); } const home = process.env.HOME || process.env.USERPROFILE || ''; return path.join(home, '.openclaw', 'workspace'); } private async applySoul(node: RemoteSoulNode): Promise { const soul = node.soul; const workspaces = vscode.workspace.workspaceFolders; // Write to OpenClaw workspace only (gateway reads from here) const openclawDir = this.getOpenClawWorkspaceDir(); const targetDirs: string[] = [openclawDir]; const targetDir = openclawDir; const confirm = await vscode.window.showInformationMessage( `Apply "${soul.displayName}"? Soul files will be saved to OpenClaw workspace.`, 'Apply', 'Cancel' ); if (confirm !== 'Apply') return; try { // Fetch full soul detail with file contents const detail = await apiGet(`/souls/${soul.owner}/${soul.name}?files=true`); const soulJson = { name: detail.name, displayName: detail.displayName, description: detail.description, version: detail.version, specVersion: '0.5', license: detail.license, tags: detail.tags, category: detail.category, author: detail.author, files: detail.files }; // Map file contents: files array = filenames, fileContents = {index: content} const fileNames = Array.isArray(detail.files) ? detail.files as string[] : []; const fileContentsMap = detail.fileContents || {}; // Filename mapping: soul→SOUL.md, identity→IDENTITY.md, etc. const fileNameMap: Record = { 'soul': 'SOUL.md', 'identity': 'IDENTITY.md', 'style': 'STYLE.md', 'agents': 'AGENTS.md', 'readme': 'README.md', 'heartbeat': 'HEARTBEAT.md', 'user': 'USER.md', 'memory': 'MEMORY.md', 'tools': 'TOOLS.md', 'bootstrap': 'BOOTSTRAP.md', 'soul.json': 'soul.json', // skip, we write our own }; // Write to target directories for (const dir of targetDirs) { fs.mkdirSync(dir, { recursive: true }); // Write soul.json fs.writeFileSync(path.join(dir, 'soul.json'), JSON.stringify(soulJson, null, 2)); // Write file contents for (let i = 0; i < fileNames.length; i++) { const key = fileNames[i]; const content = fileContentsMap[String(i)]; if (!content || key === 'soul.json') continue; const filename = fileNameMap[key] || `${key.toUpperCase()}.md`; fs.writeFileSync(path.join(dir, filename), content); } } // Add .clawsouls to .gitignore if workspace has one if (workspaces && workspaces.length > 0) { const gitignorePath = path.join(workspaces[0].uri.fsPath, '.gitignore'); if (fs.existsSync(gitignorePath)) { const content = fs.readFileSync(gitignorePath, 'utf8'); if (!content.includes('.clawsouls/')) { fs.appendFileSync(gitignorePath, '\n.clawsouls/\n'); } } } const dirs = targetDirs.map(d => path.basename(path.dirname(d)) + '/' + path.basename(d)).join(', '); // Ask to clear previous memory const clearMem = await vscode.window.showInformationMessage( `✅ Soul "${soul.displayName}" applied. Clear previous memory files?`, 'Clear Memory', 'Keep Memory' ); if (clearMem === 'Clear Memory') { const openclawWs = targetDirs[0]; // OpenClaw workspace const memoryFiles = ['MEMORY.md', 'USER.md']; const memoryDir = path.join(openclawWs, 'memory'); for (const f of memoryFiles) { const fp = path.join(openclawWs, f); if (fs.existsSync(fp)) fs.unlinkSync(fp); } if (fs.existsSync(memoryDir)) { fs.rmSync(memoryDir, { recursive: true, force: true }); } } this.refresh(); // Refresh status bar soul name try { await vscode.commands.executeCommand('clawsouls.refreshStatusBar'); } catch {} // Restart gateway so the new soul takes effect try { await vscode.commands.executeCommand('clawsouls.restartGateway'); } catch { // Best effort — gateway may not be running } } catch (err: any) { vscode.window.showErrorMessage(`Failed to apply soul: ${err.message}`); } } private async getLocalFiles(): Promise { const workspaces = vscode.workspace.workspaceFolders; if (!workspaces) return []; const files: LocalSoulFile[] = []; for (const ws of workspaces) { const found = this.findLocalSoulFiles(ws.uri.fsPath); files.push(...found); } return files.sort((a, b) => (a.label as string).localeCompare(b.label as string)); } private findLocalSoulFiles(dirPath: string): LocalSoulFile[] { const results: LocalSoulFile[] = []; const filenames = ['soul.json', 'SOUL.md', 'AGENTS.md', 'MEMORY.md', 'IDENTITY.md']; for (const filename of filenames) { const filePath = path.join(dirPath, filename); if (fs.existsSync(filePath)) { results.push(new LocalSoulFile(filename, filePath)); } } try { const entries = fs.readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') { const sub = this.findLocalSoulFiles(path.join(dirPath, entry.name)); sub.forEach(f => { f.label = `${entry.name}/${f.label}`; }); results.push(...sub); } } } catch {} return results; } } class CategoryNode extends vscode.TreeItem { constructor( public readonly categoryName: string, public readonly children: RemoteSoulNode[] ) { super(categoryName, vscode.TreeItemCollapsibleState.Collapsed); this.description = `${children.length}`; this.iconPath = new vscode.ThemeIcon('folder'); this.contextValue = 'category'; } } class RemoteSoulNode extends vscode.TreeItem { constructor(public readonly soul: SoulSummary) { super(soul.displayName || soul.name, vscode.TreeItemCollapsibleState.None); const scanIcon = soul.scanStatus === 'pass' ? '✅' : soul.scanStatus === 'warn' ? '⚠️' : '❌'; this.description = `${soul.fullName} · ⬇${soul.downloads} ${scanIcon}`; this.tooltip = `${soul.description}\n\nTags: ${soul.tags.join(', ')}\nVersion: ${soul.version}\nScan: ${soul.scanScore ?? '-'}/100`; this.iconPath = new vscode.ThemeIcon('person'); this.contextValue = 'remoteSoul'; this.command = { command: 'clawsouls.soulExplorer.preview', title: 'Preview Soul', arguments: [this] }; } } class LocalSoulFile extends vscode.TreeItem { constructor( public label: string, public readonly filePath: string ) { super(label, vscode.TreeItemCollapsibleState.None); const icon = label.toLowerCase() === 'soul.json' ? 'settings-gear' : label.toLowerCase() === 'soul.md' ? 'person' : label.toLowerCase() === 'agents.md' ? 'organization' : label.toLowerCase() === 'memory.md' ? 'database' : label.toLowerCase() === 'identity.md' ? 'key' : 'file'; this.iconPath = new vscode.ThemeIcon(icon); this.contextValue = 'localSoulFile'; try { const stats = fs.statSync(filePath); const kb = Math.round(stats.size / 1024); this.description = kb > 0 ? `${kb}KB` : `${stats.size}B`; } catch {} this.command = { command: 'vscode.open', title: 'Open', arguments: [vscode.Uri.file(filePath)] }; } } class MessageNode extends vscode.TreeItem { constructor(message: string) { super(message, vscode.TreeItemCollapsibleState.None); this.iconPath = new vscode.ThemeIcon('info'); } }