From 0b28a07f9c3f5cd3ffb34136c091326a9596fd57 Mon Sep 17 00:00:00 2001 From: APPLO <1134226402@qq.com> Date: Sat, 28 Feb 2026 17:40:56 +0800 Subject: [PATCH 1/3] feat: add monorepo structure with core package, Electron shell, and webview bridge - Create packages/core with platform-agnostic interfaces (IFileSystem, IMessageTransport, IWorkflowProvider, ILogger, IDialogService) - Move shared types and validation utils to @cc-wf-studio/core - Refactor McpServerManager to support both UI mode (transport) and headless mode (workflow provider) - Add platform-agnostic FileService, NodeFileSystem, ConsoleLogger, and FileSystemWorkflowProvider - Create webview bridge abstraction (IHostBridge) with VSCode and Electron adapters - Refactor main.tsx to auto-detect VSCode/Electron/Dev environment - Add Electron desktop app shell with IPC handlers, theme variables, and preload script - Add standalone MCP server CLI entry point (cc-wf-mcp-server) - Create VSCode adapters (VSCodeFileSystem, VSCodeMessageTransport, VSCodeLogger) - Add npm workspaces configuration and tsconfig.base.json --- .gitignore | 1 + package-lock.json | 22 + package.json | 9 +- packages/core/package.json | 32 + packages/core/src/adapters/console-logger.ts | 25 + .../core/src/adapters/node-file-system.ts | 54 + packages/core/src/cli/mcp-server-cli.ts | 57 + packages/core/src/i18n/i18n-service.ts | 117 + packages/core/src/i18n/translation-keys.ts | 15 + packages/core/src/i18n/translations/en.ts | 16 + packages/core/src/i18n/translations/ja.ts | 16 + packages/core/src/i18n/translations/ko.ts | 16 + packages/core/src/i18n/translations/zh-CN.ts | 15 + packages/core/src/i18n/translations/zh-TW.ts | 15 + packages/core/src/index.ts | 51 + .../core/src/interfaces/dialog-service.ts | 10 + packages/core/src/interfaces/file-system.ts | 10 + packages/core/src/interfaces/logger.ts | 5 + .../core/src/interfaces/message-transport.ts | 6 + .../core/src/interfaces/workflow-provider.ts | 6 + packages/core/src/services/file-service.ts | 70 + .../core/src/services/fs-workflow-provider.ts | 30 + packages/core/src/services/logger-holder.ts | 46 + .../core/src/services/mcp-server-service.ts | 305 +++ .../core/src/services/mcp-server-tools.ts | 222 ++ .../src/services/schema-loader-service.ts | 202 ++ packages/core/src/types/ai-metrics.ts | 49 + packages/core/src/types/mcp-node.ts | 304 +++ packages/core/src/types/messages.ts | 1929 +++++++++++++++++ .../core/src/types/slack-integration-types.ts | 232 ++ .../core/src/types/workflow-definition.ts | 676 ++++++ packages/core/src/utils/migrate-workflow.ts | 203 ++ packages/core/src/utils/path-utils.ts | 140 ++ packages/core/src/utils/schema-parser.ts | 348 +++ .../core/src/utils/sensitive-data-detector.ts | 207 ++ .../core/src/utils/slack-error-handler.ts | 261 +++ .../core/src/utils/slack-message-builder.ts | 141 ++ packages/core/src/utils/validate-workflow.ts | 1069 +++++++++ packages/core/src/utils/workflow-validator.ts | 101 + packages/core/tsconfig.json | 11 + packages/electron/package.json | 20 + packages/electron/renderer/index.html | 13 + .../main/adapters/electron-dialog-service.ts | 71 + .../adapters/electron-message-transport.ts | 40 + .../electron/src/main/ipc/ipc-handlers.ts | 131 ++ packages/electron/src/main/main.ts | 229 ++ packages/electron/src/main/preload.ts | 20 + packages/electron/tsconfig.main.json | 13 + packages/electron/tsconfig.preload.json | 13 + .../vscode/src/adapters/vscode-file-system.ts | 58 + packages/vscode/src/adapters/vscode-logger.ts | 42 + .../src/adapters/vscode-message-transport.ts | 39 + src/webview/src/main.tsx | 80 +- src/webview/src/services/bridge.ts | 26 + .../src/services/electron-bridge-adapter.ts | 35 + .../src/services/vscode-bridge-adapter.ts | 26 + tsconfig.base.json | 21 + 57 files changed, 7894 insertions(+), 27 deletions(-) create mode 100644 packages/core/package.json create mode 100644 packages/core/src/adapters/console-logger.ts create mode 100644 packages/core/src/adapters/node-file-system.ts create mode 100644 packages/core/src/cli/mcp-server-cli.ts create mode 100644 packages/core/src/i18n/i18n-service.ts create mode 100644 packages/core/src/i18n/translation-keys.ts create mode 100644 packages/core/src/i18n/translations/en.ts create mode 100644 packages/core/src/i18n/translations/ja.ts create mode 100644 packages/core/src/i18n/translations/ko.ts create mode 100644 packages/core/src/i18n/translations/zh-CN.ts create mode 100644 packages/core/src/i18n/translations/zh-TW.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/interfaces/dialog-service.ts create mode 100644 packages/core/src/interfaces/file-system.ts create mode 100644 packages/core/src/interfaces/logger.ts create mode 100644 packages/core/src/interfaces/message-transport.ts create mode 100644 packages/core/src/interfaces/workflow-provider.ts create mode 100644 packages/core/src/services/file-service.ts create mode 100644 packages/core/src/services/fs-workflow-provider.ts create mode 100644 packages/core/src/services/logger-holder.ts create mode 100644 packages/core/src/services/mcp-server-service.ts create mode 100644 packages/core/src/services/mcp-server-tools.ts create mode 100644 packages/core/src/services/schema-loader-service.ts create mode 100644 packages/core/src/types/ai-metrics.ts create mode 100644 packages/core/src/types/mcp-node.ts create mode 100644 packages/core/src/types/messages.ts create mode 100644 packages/core/src/types/slack-integration-types.ts create mode 100644 packages/core/src/types/workflow-definition.ts create mode 100644 packages/core/src/utils/migrate-workflow.ts create mode 100644 packages/core/src/utils/path-utils.ts create mode 100644 packages/core/src/utils/schema-parser.ts create mode 100644 packages/core/src/utils/sensitive-data-detector.ts create mode 100644 packages/core/src/utils/slack-error-handler.ts create mode 100644 packages/core/src/utils/slack-message-builder.ts create mode 100644 packages/core/src/utils/validate-workflow.ts create mode 100644 packages/core/src/utils/workflow-validator.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/electron/package.json create mode 100644 packages/electron/renderer/index.html create mode 100644 packages/electron/src/main/adapters/electron-dialog-service.ts create mode 100644 packages/electron/src/main/adapters/electron-message-transport.ts create mode 100644 packages/electron/src/main/ipc/ipc-handlers.ts create mode 100644 packages/electron/src/main/main.ts create mode 100644 packages/electron/src/main/preload.ts create mode 100644 packages/electron/tsconfig.main.json create mode 100644 packages/electron/tsconfig.preload.json create mode 100644 packages/vscode/src/adapters/vscode-file-system.ts create mode 100644 packages/vscode/src/adapters/vscode-logger.ts create mode 100644 packages/vscode/src/adapters/vscode-message-transport.ts create mode 100644 src/webview/src/services/bridge.ts create mode 100644 src/webview/src/services/electron-bridge-adapter.ts create mode 100644 src/webview/src/services/vscode-bridge-adapter.ts create mode 100644 tsconfig.base.json diff --git a/.gitignore b/.gitignore index 69fd76c0..d3a13fb9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules/ out/ dist/ *.vsix +*.tsbuildinfo # Generated files *.generated.ts diff --git a/package-lock.json b/package-lock.json index dab7fdec..9d2f7bbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "cc-wf-studio", "version": "3.26.0", "license": "AGPL-3.0-or-later", + "workspaces": [ + "packages/*" + ], "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", "@slack/web-api": "^7.13.0", @@ -280,6 +283,10 @@ "node": ">=14.21.3" } }, + "node_modules/@cc-wf-studio/core": { + "resolved": "packages/core", + "link": true + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -10214,6 +10221,21 @@ "peerDependencies": { "zod": "^3.25 || ^4" } + }, + "packages/core": { + "name": "@cc-wf-studio/core", + "version": "3.26.0", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", + "@toon-format/toon": "^2.1.0", + "nano-spawn": "^2.0.0", + "smol-toml": "^1.6.0" + }, + "devDependencies": { + "@types/node": "^25.0.3", + "typescript": "^5.3.0" + } } } } diff --git a/package.json b/package.json index 9a24ad33..97d1a6ee 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,10 @@ "displayName": "Claude Code Workflow Studio", "description": "Visual Workflow Editor for Claude Code, GitHub Copilot, and more AI agents", "version": "3.26.0", + "private": true, + "workspaces": [ + "packages/*" + ], "publisher": "breaking-brake", "icon": "resources/icon.png", "repository": { @@ -90,7 +94,10 @@ "vscode:prepublish": "npm run build", "generate:toon": "npx tsx scripts/generate-toon-schema.ts", "generate:editing-flow": "npx tsx scripts/generate-editing-flow.ts", - "build": "npm run generate:toon && npm run generate:editing-flow && npm run build:webview && npm run build:extension", + "build": "npm run build:core && npm run generate:toon && npm run generate:editing-flow && npm run build:webview && npm run build:extension", + "build:core": "npm run build -w @cc-wf-studio/core", + "build:vscode": "npm run build:core && npm run generate:toon && npm run generate:editing-flow && npm run build:webview && npm run build:extension", + "build:electron": "npm run build:core && npm run build:webview && npm run build -w @cc-wf-studio/electron", "build:dev": "npm run build:webview:dev && npm run build:extension:dev", "build:webview": "cd src/webview && npm run build", "build:webview:dev": "cd src/webview && npm run build:dev", diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 00000000..95047d6f --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,32 @@ +{ + "name": "@cc-wf-studio/core", + "version": "3.26.0", + "private": true, + "license": "AGPL-3.0-or-later", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "cc-wf-mcp-server": "./dist/cli/mcp-server-cli.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "clean": "rm -rf dist" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.26.0", + "@toon-format/toon": "^2.1.0", + "nano-spawn": "^2.0.0", + "smol-toml": "^1.6.0" + }, + "devDependencies": { + "typescript": "^5.3.0", + "@types/node": "^25.0.3" + } +} diff --git a/packages/core/src/adapters/console-logger.ts b/packages/core/src/adapters/console-logger.ts new file mode 100644 index 00000000..ae890cf8 --- /dev/null +++ b/packages/core/src/adapters/console-logger.ts @@ -0,0 +1,25 @@ +/** + * Console Logger Implementation + * + * ILogger implementation using console.log/warn/error. + * Used by Electron and CLI headless mode. + */ + +import type { ILogger } from '../interfaces/logger.js'; + +export class ConsoleLogger implements ILogger { + info(message: string, data?: unknown): void { + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}] [INFO] ${message}`, data ?? ''); + } + + warn(message: string, data?: unknown): void { + const timestamp = new Date().toISOString(); + console.warn(`[${timestamp}] [WARN] ${message}`, data ?? ''); + } + + error(message: string, data?: unknown): void { + const timestamp = new Date().toISOString(); + console.error(`[${timestamp}] [ERROR] ${message}`, data ?? ''); + } +} diff --git a/packages/core/src/adapters/node-file-system.ts b/packages/core/src/adapters/node-file-system.ts new file mode 100644 index 00000000..a695effb --- /dev/null +++ b/packages/core/src/adapters/node-file-system.ts @@ -0,0 +1,54 @@ +/** + * Node.js File System Implementation + * + * IFileSystem implementation using node:fs/promises. + * Used by Electron and CLI headless mode. + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import type { IFileSystem } from '../interfaces/file-system.js'; + +export class NodeFileSystem implements IFileSystem { + async readFile(filePath: string): Promise { + return fs.readFile(filePath, 'utf-8'); + } + + async writeFile(filePath: string, content: string): Promise { + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(filePath, content, 'utf-8'); + } + + async fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } + } + + async createDirectory(dirPath: string): Promise { + await fs.mkdir(dirPath, { recursive: true }); + } + + async readDirectory( + dirPath: string + ): Promise> { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + return entries.map((entry) => ({ + name: entry.name, + isFile: entry.isFile(), + isDirectory: entry.isDirectory(), + })); + } + + async stat(filePath: string): Promise<{ isFile: boolean; isDirectory: boolean }> { + const stats = await fs.stat(filePath); + return { + isFile: stats.isFile(), + isDirectory: stats.isDirectory(), + }; + } +} diff --git a/packages/core/src/cli/mcp-server-cli.ts b/packages/core/src/cli/mcp-server-cli.ts new file mode 100644 index 00000000..570a2835 --- /dev/null +++ b/packages/core/src/cli/mcp-server-cli.ts @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * CC Workflow Studio - Standalone MCP Server CLI + * + * Runs the MCP server in headless mode, operating directly on workflow JSON files. + * No UI required — AI agents can create and edit workflows via MCP tools. + * + * Usage: + * npx @cc-wf-studio/core mcp-server ./path/to/workflow.json + * npx @cc-wf-studio/core mcp-server (uses default .vscode/workflows/workflow.json) + */ + +import * as path from 'node:path'; +import { ConsoleLogger } from '../adapters/console-logger.js'; +import { NodeFileSystem } from '../adapters/node-file-system.js'; +import { FileSystemWorkflowProvider } from '../services/fs-workflow-provider.js'; +import { setLogger } from '../services/logger-holder.js'; +import { McpServerManager } from '../services/mcp-server-service.js'; + +async function main(): Promise { + const logger = new ConsoleLogger(); + setLogger(logger); + + const workflowPath = + process.argv[2] || path.join(process.cwd(), '.vscode', 'workflows', 'workflow.json'); + const resolvedPath = path.resolve(workflowPath); + + logger.info(`CC Workflow Studio - Headless MCP Server`); + logger.info(`Workflow file: ${resolvedPath}`); + + const fs = new NodeFileSystem(); + const manager = new McpServerManager(); + + // Set up headless mode with filesystem provider + manager.setWorkflowProvider(new FileSystemWorkflowProvider(fs, resolvedPath)); + + // Start the MCP server + const port = await manager.start(process.cwd()); + + logger.info(`MCP Server running on http://127.0.0.1:${port}/mcp`); + logger.info(`Press Ctrl+C to stop`); + + // Handle graceful shutdown + const shutdown = async (): Promise => { + logger.info('Shutting down MCP server...'); + await manager.stop(); + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +main().catch((error) => { + console.error('Failed to start MCP server:', error); + process.exit(1); +}); diff --git a/packages/core/src/i18n/i18n-service.ts b/packages/core/src/i18n/i18n-service.ts new file mode 100644 index 00000000..c8a587c4 --- /dev/null +++ b/packages/core/src/i18n/i18n-service.ts @@ -0,0 +1,117 @@ +/** + * Claude Code Workflow Studio - i18n Service + * + * Handles internationalization for workflow exports + * Detects VSCode locale and provides translations + */ + +import * as vscode from 'vscode'; +import type { TranslationKeys } from './translation-keys'; +import { enTranslations } from './translations/en'; +import { jaTranslations } from './translations/ja'; +import { koTranslations } from './translations/ko'; +import { zhCNTranslations } from './translations/zh-CN'; +import { zhTWTranslations } from './translations/zh-TW'; + +type Translations = typeof enTranslations; + +/** + * Get current locale from VSCode + * + * @returns Locale code (e.g., 'en', 'ja', 'zh-CN', 'zh-TW') + */ +export function getCurrentLocale(): string { + // Get VSCode's display language + return vscode.env.language; +} + +/** + * Get translations for the current locale + * + * @returns Translation object for current locale (defaults to English) + */ +export function getTranslations(): Translations { + const locale = getCurrentLocale(); + const languageCode = locale.split('-')[0]; + + // Check full locale first (e.g., zh-CN, zh-TW) + if (locale === 'zh-CN') { + return zhCNTranslations; + } + if (locale === 'zh-TW' || locale === 'zh-HK') { + return zhTWTranslations; + } + + // Check language code (e.g., ja, ko) + switch (languageCode) { + case 'ja': + return jaTranslations; + case 'ko': + return koTranslations; + case 'zh': + // Default to Simplified Chinese if no region specified + return zhCNTranslations; + default: + return enTranslations; + } +} + +/** + * Translate a key to the current locale + * + * @param key - Translation key + * @param params - Optional parameters for string interpolation + * @returns Translated string + */ +export function translate( + key: K, + params?: Record +): string { + const translations = getTranslations(); + let text = translations[key] as string; + + // Handle nested keys (e.g., 'mermaid.start') + if (text === undefined) { + const parts = (key as string).split('.'); + // biome-ignore lint/suspicious/noExplicitAny: Dynamic nested property access requires any + let current: any = translations; + + for (const part of parts) { + current = current[part]; + if (current === undefined) { + // Fallback to English if translation is missing + current = enTranslations; + for (const part of parts) { + current = current[part]; + if (current === undefined) { + return key as string; + } + } + break; + } + } + + text = current as string; + } + + // Replace parameters (e.g., {{name}} -> value) + if (params) { + for (const [paramKey, paramValue] of Object.entries(params)) { + text = text.replace(`{{${paramKey}}}`, String(paramValue)); + } + } + + return text; +} + +/** + * Get shorthand translation function for a specific namespace + * + * @returns Translation function + */ +export function useTranslation() { + return { + t: translate, + locale: getCurrentLocale(), + }; +} diff --git a/packages/core/src/i18n/translation-keys.ts b/packages/core/src/i18n/translation-keys.ts new file mode 100644 index 00000000..3b05b16b --- /dev/null +++ b/packages/core/src/i18n/translation-keys.ts @@ -0,0 +1,15 @@ +/** + * Claude Code Workflow Studio - Translation Keys Type Definition + * + * Defines the structure of translation keys for type safety + */ + +export interface TranslationKeys { + // Error messages + 'error.noWorkspaceOpen': string; + + // File picker + 'filePicker.title': string; + 'filePicker.error.invalidWorkflow': string; + 'filePicker.error.loadFailed': string; +} diff --git a/packages/core/src/i18n/translations/en.ts b/packages/core/src/i18n/translations/en.ts new file mode 100644 index 00000000..b9680824 --- /dev/null +++ b/packages/core/src/i18n/translations/en.ts @@ -0,0 +1,16 @@ +/** + * Claude Code Workflow Studio - English Translations + */ + +import type { TranslationKeys } from '../translation-keys'; + +export const enTranslations: TranslationKeys = { + // Error messages + 'error.noWorkspaceOpen': 'Please open a folder or workspace first.', + + // File picker + 'filePicker.title': 'Select Workflow File', + 'filePicker.error.invalidWorkflow': + 'Invalid workflow file. Please select a valid JSON workflow file.', + 'filePicker.error.loadFailed': 'Failed to load workflow file.', +}; diff --git a/packages/core/src/i18n/translations/ja.ts b/packages/core/src/i18n/translations/ja.ts new file mode 100644 index 00000000..31f3c24b --- /dev/null +++ b/packages/core/src/i18n/translations/ja.ts @@ -0,0 +1,16 @@ +/** + * Claude Code Workflow Studio - Japanese Translations + */ + +import type { TranslationKeys } from '../translation-keys'; + +export const jaTranslations: TranslationKeys = { + // Error messages + 'error.noWorkspaceOpen': 'フォルダまたはワークスペースを開いてから実行してください。', + + // File picker + 'filePicker.title': 'ワークフローファイルを選択', + 'filePicker.error.invalidWorkflow': + '無効なワークフローファイルです。有効なJSONワークフローファイルを選択してください。', + 'filePicker.error.loadFailed': 'ワークフローファイルの読み込みに失敗しました。', +}; diff --git a/packages/core/src/i18n/translations/ko.ts b/packages/core/src/i18n/translations/ko.ts new file mode 100644 index 00000000..1fe3ff53 --- /dev/null +++ b/packages/core/src/i18n/translations/ko.ts @@ -0,0 +1,16 @@ +/** + * Claude Code Workflow Studio - Korean Translations + */ + +import type { TranslationKeys } from '../translation-keys'; + +export const koTranslations: TranslationKeys = { + // Error messages + 'error.noWorkspaceOpen': '폴더 또는 워크스페이스를 먼저 열어주세요.', + + // File picker + 'filePicker.title': '워크플로 파일 선택', + 'filePicker.error.invalidWorkflow': + '잘못된 워크플로 파일입니다. 유효한 JSON 워크플로 파일을 선택해주세요.', + 'filePicker.error.loadFailed': '워크플로 파일을 불러오는데 실패했습니다.', +}; diff --git a/packages/core/src/i18n/translations/zh-CN.ts b/packages/core/src/i18n/translations/zh-CN.ts new file mode 100644 index 00000000..af680529 --- /dev/null +++ b/packages/core/src/i18n/translations/zh-CN.ts @@ -0,0 +1,15 @@ +/** + * Claude Code Workflow Studio - Simplified Chinese Translations + */ + +import type { TranslationKeys } from '../translation-keys'; + +export const zhCNTranslations: TranslationKeys = { + // Error messages + 'error.noWorkspaceOpen': '请先打开文件夹或工作区。', + + // File picker + 'filePicker.title': '选择工作流文件', + 'filePicker.error.invalidWorkflow': '无效的工作流文件。请选择有效的JSON工作流文件。', + 'filePicker.error.loadFailed': '加载工作流文件失败。', +}; diff --git a/packages/core/src/i18n/translations/zh-TW.ts b/packages/core/src/i18n/translations/zh-TW.ts new file mode 100644 index 00000000..817cce69 --- /dev/null +++ b/packages/core/src/i18n/translations/zh-TW.ts @@ -0,0 +1,15 @@ +/** + * Claude Code Workflow Studio - Traditional Chinese Translations + */ + +import type { TranslationKeys } from '../translation-keys'; + +export const zhTWTranslations: TranslationKeys = { + // Error messages + 'error.noWorkspaceOpen': '請先開啟資料夾或工作區。', + + // File picker + 'filePicker.title': '選擇工作流程檔案', + 'filePicker.error.invalidWorkflow': '無效的工作流程檔案。請選擇有效的JSON工作流程檔案。', + 'filePicker.error.loadFailed': '載入工作流程檔案失敗。', +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 00000000..00323150 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,51 @@ +// Interfaces + +export { ConsoleLogger } from './adapters/console-logger.js'; +// Adapters +export { NodeFileSystem } from './adapters/node-file-system.js'; +export type { IDialogService } from './interfaces/dialog-service.js'; +export type { IFileSystem } from './interfaces/file-system.js'; +export type { ILogger } from './interfaces/logger.js'; +export type { IMessageTransport } from './interfaces/message-transport.js'; +export type { IWorkflowProvider } from './interfaces/workflow-provider.js'; +export { FileService } from './services/file-service.js'; +export { FileSystemWorkflowProvider } from './services/fs-workflow-provider.js'; +// Logger holder (for setting global logger) +export { getLogger, log, setLogger } from './services/logger-holder.js'; +// Services +export { McpServerManager } from './services/mcp-server-service.js'; +export type { + AiEditingProvider, + ApplyWorkflowFromMcpResponsePayload, + ExtensionMessage, + GetCurrentWorkflowResponsePayload, + McpConfigTarget, +} from './types/messages.js'; +// Types (re-export key types) +export type { + AskUserQuestionData, + BranchNodeData, + CodexNodeData, + Connection, + EndNodeData, + IfElseNodeData, + McpNodeData, + NodeType, + PromptNodeData, + SkillNodeData, + SlashCommandOptions, + StartNodeData, + SubAgentData, + SubAgentFlow, + SubAgentFlowNodeData, + SwitchNodeData, + ToolParameter, + Workflow, + WorkflowHooks, + WorkflowMetadata, + WorkflowNode, +} from './types/workflow-definition.js'; +export { VALIDATION_RULES } from './types/workflow-definition.js'; +export { migrateWorkflow } from './utils/migrate-workflow.js'; +// Utils +export { validateAIGeneratedWorkflow } from './utils/validate-workflow.js'; diff --git a/packages/core/src/interfaces/dialog-service.ts b/packages/core/src/interfaces/dialog-service.ts new file mode 100644 index 00000000..219c6fff --- /dev/null +++ b/packages/core/src/interfaces/dialog-service.ts @@ -0,0 +1,10 @@ +export interface IDialogService { + showInformationMessage(message: string): void; + showWarningMessage(message: string): void; + showErrorMessage(message: string): void; + showConfirmDialog(message: string, confirmLabel: string): Promise; + showOpenFileDialog(options: { + filters?: Record; + title?: string; + }): Promise; +} diff --git a/packages/core/src/interfaces/file-system.ts b/packages/core/src/interfaces/file-system.ts new file mode 100644 index 00000000..5f01ae74 --- /dev/null +++ b/packages/core/src/interfaces/file-system.ts @@ -0,0 +1,10 @@ +export interface IFileSystem { + readFile(filePath: string): Promise; + writeFile(filePath: string, content: string): Promise; + fileExists(filePath: string): Promise; + createDirectory(dirPath: string): Promise; + readDirectory( + dirPath: string + ): Promise>; + stat(filePath: string): Promise<{ isFile: boolean; isDirectory: boolean }>; +} diff --git a/packages/core/src/interfaces/logger.ts b/packages/core/src/interfaces/logger.ts new file mode 100644 index 00000000..6ccf4ede --- /dev/null +++ b/packages/core/src/interfaces/logger.ts @@ -0,0 +1,5 @@ +export interface ILogger { + info(message: string, data?: unknown): void; + warn(message: string, data?: unknown): void; + error(message: string, data?: unknown): void; +} diff --git a/packages/core/src/interfaces/message-transport.ts b/packages/core/src/interfaces/message-transport.ts new file mode 100644 index 00000000..705ac3a3 --- /dev/null +++ b/packages/core/src/interfaces/message-transport.ts @@ -0,0 +1,6 @@ +export interface IMessageTransport { + postMessage(message: { type: string; requestId?: string; payload?: unknown }): void; + onMessage( + handler: (message: { type: string; requestId?: string; payload?: unknown }) => void + ): void; +} diff --git a/packages/core/src/interfaces/workflow-provider.ts b/packages/core/src/interfaces/workflow-provider.ts new file mode 100644 index 00000000..30584dc0 --- /dev/null +++ b/packages/core/src/interfaces/workflow-provider.ts @@ -0,0 +1,6 @@ +import type { Workflow } from '../types/workflow-definition.js'; + +export interface IWorkflowProvider { + getCurrentWorkflow(): Promise<{ workflow: Workflow | null; isStale: boolean }>; + applyWorkflow(workflow: Workflow, description?: string): Promise; +} diff --git a/packages/core/src/services/file-service.ts b/packages/core/src/services/file-service.ts new file mode 100644 index 00000000..fe43f992 --- /dev/null +++ b/packages/core/src/services/file-service.ts @@ -0,0 +1,70 @@ +/** + * CC Workflow Studio - File Service (Core) + * + * Platform-agnostic file service using IFileSystem interface. + */ + +import * as path from 'node:path'; +import type { IFileSystem } from '../interfaces/file-system.js'; + +export class FileService { + private readonly workspacePath: string; + private readonly workflowsDirectory: string; + + constructor( + private readonly fs: IFileSystem, + workspacePath: string + ) { + this.workspacePath = workspacePath; + this.workflowsDirectory = path.join(this.workspacePath, '.vscode', 'workflows'); + } + + async ensureWorkflowsDirectory(): Promise { + const exists = await this.fs + .stat(this.workflowsDirectory) + .then(() => true) + .catch(() => false); + if (!exists) { + await this.fs.createDirectory(this.workflowsDirectory); + } + } + + async readFile(filePath: string): Promise { + return this.fs.readFile(filePath); + } + + async writeFile(filePath: string, content: string): Promise { + return this.fs.writeFile(filePath, content); + } + + async fileExists(filePath: string): Promise { + return this.fs.fileExists(filePath); + } + + async createDirectory(dirPath: string): Promise { + return this.fs.createDirectory(dirPath); + } + + getWorkflowsDirectory(): string { + return this.workflowsDirectory; + } + + getWorkspacePath(): string { + return this.workspacePath; + } + + getWorkflowFilePath(workflowName: string): string { + return path.join(this.workflowsDirectory, `${workflowName}.json`); + } + + async listWorkflowFiles(): Promise { + try { + const entries = await this.fs.readDirectory(this.workflowsDirectory); + return entries + .filter((entry) => entry.isFile && entry.name.endsWith('.json')) + .map((entry) => entry.name.replace(/\.json$/, '')); + } catch { + return []; + } + } +} diff --git a/packages/core/src/services/fs-workflow-provider.ts b/packages/core/src/services/fs-workflow-provider.ts new file mode 100644 index 00000000..beb35146 --- /dev/null +++ b/packages/core/src/services/fs-workflow-provider.ts @@ -0,0 +1,30 @@ +/** + * File System Workflow Provider + * + * IWorkflowProvider implementation that reads/writes workflow JSON files directly. + * Used for headless MCP server mode. + */ + +import type { IFileSystem } from '../interfaces/file-system.js'; +import type { IWorkflowProvider } from '../interfaces/workflow-provider.js'; +import type { Workflow } from '../types/workflow-definition.js'; + +export class FileSystemWorkflowProvider implements IWorkflowProvider { + constructor( + private readonly fs: IFileSystem, + private readonly workflowPath: string + ) {} + + async getCurrentWorkflow(): Promise<{ workflow: Workflow | null; isStale: boolean }> { + if (await this.fs.fileExists(this.workflowPath)) { + const content = await this.fs.readFile(this.workflowPath); + return { workflow: JSON.parse(content), isStale: false }; + } + return { workflow: null, isStale: false }; + } + + async applyWorkflow(workflow: Workflow): Promise { + await this.fs.writeFile(this.workflowPath, JSON.stringify(workflow, null, 2)); + return true; + } +} diff --git a/packages/core/src/services/logger-holder.ts b/packages/core/src/services/logger-holder.ts new file mode 100644 index 00000000..6fe2b54a --- /dev/null +++ b/packages/core/src/services/logger-holder.ts @@ -0,0 +1,46 @@ +/** + * Module-level logger holder + * + * Provides a default console logger that can be overridden + * by platform-specific implementations (VSCode, Electron). + */ +import type { ILogger } from '../interfaces/logger.js'; + +const consoleLogger: ILogger = { + info(message: string, data?: unknown): void { + console.log(`[INFO] ${message}`, data ?? ''); + }, + warn(message: string, data?: unknown): void { + console.warn(`[WARN] ${message}`, data ?? ''); + }, + error(message: string, data?: unknown): void { + console.error(`[ERROR] ${message}`, data ?? ''); + }, +}; + +let currentLogger: ILogger = consoleLogger; + +export function setLogger(logger: ILogger): void { + currentLogger = logger; +} + +export function getLogger(): ILogger { + return currentLogger; +} + +/** + * Compatibility bridge for code that used `log('INFO'|'WARN'|'ERROR', message, data?)` + */ +export function log(level: 'INFO' | 'WARN' | 'ERROR', message: string, data?: unknown): void { + switch (level) { + case 'INFO': + currentLogger.info(message, data); + break; + case 'WARN': + currentLogger.warn(message, data); + break; + case 'ERROR': + currentLogger.error(message, data); + break; + } +} diff --git a/packages/core/src/services/mcp-server-service.ts b/packages/core/src/services/mcp-server-service.ts new file mode 100644 index 00000000..434136d5 --- /dev/null +++ b/packages/core/src/services/mcp-server-service.ts @@ -0,0 +1,305 @@ +/** + * CC Workflow Studio - Built-in MCP Server Manager (Core) + * + * Platform-agnostic MCP server that external AI agents can connect to + * for workflow CRUD operations. + * + * Supports two modes: + * - UI mode: communicates with canvas via IMessageTransport (VSCode/Electron) + * - Headless mode: directly operates on files via IWorkflowProvider + */ + +import * as http from 'node:http'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import type { IMessageTransport } from '../interfaces/message-transport.js'; +import type { IWorkflowProvider } from '../interfaces/workflow-provider.js'; +import type { + AiEditingProvider, + ApplyWorkflowFromMcpResponsePayload, + GetCurrentWorkflowResponsePayload, + McpConfigTarget, +} from '../types/messages.js'; +import type { Workflow } from '../types/workflow-definition.js'; +import { log } from './logger-holder.js'; +import { registerMcpTools } from './mcp-server-tools.js'; + +const REQUEST_TIMEOUT_MS = 10000; +const APPLY_WITH_REVIEW_TIMEOUT_MS = 120000; + +interface PendingRequest { + resolve: (value: T) => void; + reject: (reason: Error) => void; + timer: ReturnType; +} + +export class McpServerManager { + private httpServer: http.Server | null = null; + private port: number | null = null; + private lastKnownWorkflow: Workflow | null = null; + private transport: IMessageTransport | null = null; + private workflowProvider: IWorkflowProvider | null = null; + private extensionPath: string | null = null; + private writtenConfigs = new Set(); + private currentProvider: AiEditingProvider | null = null; + private reviewBeforeApply = true; + + private pendingWorkflowRequests = new Map< + string, + PendingRequest<{ workflow: Workflow | null; isStale: boolean }> + >(); + private pendingApplyRequests = new Map>(); + + async start(extensionPath: string): Promise { + if (this.httpServer) { + throw new Error('MCP server is already running'); + } + + this.extensionPath = extensionPath; + + // Create HTTP server + this.httpServer = http.createServer(async (req, res) => { + const url = new URL(req.url || '/', `http://${req.headers.host}`); + + if (url.pathname !== '/mcp') { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + return; + } + + // Handle MCP requests + if (req.method === 'POST' || req.method === 'GET' || req.method === 'DELETE') { + let mcpServer: McpServer | undefined; + try { + mcpServer = new McpServer({ + name: 'cc-workflow-studio', + version: '1.0.0', + }); + registerMcpTools(mcpServer, this); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + + await mcpServer.connect(transport); + await transport.handleRequest(req, res); + } catch (error) { + log('ERROR', 'MCP Server: Failed to handle request', { + method: req.method, + error: error instanceof Error ? error.message : String(error), + }); + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal server error' })); + } + } finally { + if (mcpServer) { + await mcpServer.close().catch(() => {}); + } + } + } else { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method not allowed' })); + } + }); + + // Start listening on dynamic port, localhost only + const httpServer = this.httpServer; + return new Promise((resolve, reject) => { + httpServer.listen(0, '127.0.0.1', () => { + const address = httpServer.address(); + if (address && typeof address !== 'string') { + this.port = address.port; + log('INFO', `MCP Server: Started on port ${this.port}`); + resolve(this.port); + } else { + reject(new Error('Failed to get server address')); + } + }); + + httpServer.on('error', (error) => { + log('ERROR', 'MCP Server: HTTP server error', { + error: error.message, + }); + reject(error); + }); + }); + } + + async stop(): Promise { + this.writtenConfigs.clear(); + this.currentProvider = null; + + if (this.httpServer) { + const server = this.httpServer; + this.httpServer = null; + this.port = null; + + return new Promise((resolve) => { + const forceCloseTimer = setTimeout(() => { + log('WARN', 'MCP Server: Force closing after timeout'); + server.closeAllConnections(); + resolve(); + }, 3000); + + server.close(() => { + clearTimeout(forceCloseTimer); + log('INFO', 'MCP Server: Stopped'); + resolve(); + }); + }); + } + + this.port = null; + } + + isRunning(): boolean { + return !!this.httpServer?.listening; + } + + getPort(): number | null { + return this.port; + } + + getExtensionPath(): string | null { + return this.extensionPath; + } + + getWrittenConfigs(): Set { + return this.writtenConfigs; + } + + addWrittenConfigs(targets: McpConfigTarget[]): void { + for (const t of targets) { + this.writtenConfigs.add(t); + } + } + + setCurrentProvider(provider: AiEditingProvider | null): void { + this.currentProvider = provider; + } + + getCurrentProvider(): AiEditingProvider | null { + return this.currentProvider; + } + + setReviewBeforeApply(value: boolean): void { + this.reviewBeforeApply = value; + } + + getReviewBeforeApply(): boolean { + return this.reviewBeforeApply; + } + + // UI mode: connect to canvas via message transport + setTransport(transport: IMessageTransport | null): void { + this.transport = transport; + } + + // Headless mode: directly operate on filesystem + setWorkflowProvider(provider: IWorkflowProvider): void { + this.workflowProvider = provider; + } + + updateWorkflowCache(workflow: Workflow): void { + this.lastKnownWorkflow = workflow; + } + + // Called by MCP tools to get current workflow + async requestCurrentWorkflow(): Promise<{ workflow: Workflow | null; isStale: boolean }> { + // UI mode: request fresh data from canvas via transport + if (this.transport) { + const correlationId = `mcp-get-${Date.now()}-${Math.random()}`; + + return new Promise<{ workflow: Workflow | null; isStale: boolean }>((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingWorkflowRequests.delete(correlationId); + if (this.lastKnownWorkflow) { + resolve({ workflow: this.lastKnownWorkflow, isStale: true }); + } else { + reject(new Error('Timeout waiting for workflow from canvas')); + } + }, REQUEST_TIMEOUT_MS); + + this.pendingWorkflowRequests.set(correlationId, { resolve, reject, timer }); + + this.transport?.postMessage({ + type: 'GET_CURRENT_WORKFLOW_REQUEST', + payload: { correlationId }, + }); + }); + } + + // Headless mode: read directly from filesystem + if (this.workflowProvider) { + return this.workflowProvider.getCurrentWorkflow(); + } + + // Fallback: return cached workflow + if (this.lastKnownWorkflow) { + return { workflow: this.lastKnownWorkflow, isStale: true }; + } + + return { workflow: null, isStale: false }; + } + + // Called by MCP tools to apply workflow to canvas + async applyWorkflowToCanvas(workflow: Workflow, description?: string): Promise { + // UI mode: send to canvas via transport + if (this.transport) { + const requireConfirmation = this.reviewBeforeApply; + const timeoutMs = requireConfirmation ? APPLY_WITH_REVIEW_TIMEOUT_MS : REQUEST_TIMEOUT_MS; + const correlationId = `mcp-apply-${Date.now()}-${Math.random()}`; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingApplyRequests.delete(correlationId); + reject(new Error('Timeout waiting for workflow apply confirmation')); + }, timeoutMs); + + this.pendingApplyRequests.set(correlationId, { resolve, reject, timer }); + + this.transport?.postMessage({ + type: 'APPLY_WORKFLOW_FROM_MCP', + payload: { correlationId, workflow, requireConfirmation, description }, + }); + }); + } + + // Headless mode: write directly to filesystem + if (this.workflowProvider) { + return this.workflowProvider.applyWorkflow(workflow, description); + } + + throw new Error('No transport or workflow provider available. Please open CC Workflow Studio.'); + } + + // Response handlers called from host (VSCode/Electron) + handleWorkflowResponse(payload: GetCurrentWorkflowResponsePayload): void { + const pending = this.pendingWorkflowRequests.get(payload.correlationId); + if (pending) { + clearTimeout(pending.timer); + this.pendingWorkflowRequests.delete(payload.correlationId); + + if (payload.workflow) { + this.lastKnownWorkflow = payload.workflow; + } + + pending.resolve({ workflow: payload.workflow, isStale: false }); + } + } + + handleApplyResponse(payload: ApplyWorkflowFromMcpResponsePayload): void { + const pending = this.pendingApplyRequests.get(payload.correlationId); + if (pending) { + clearTimeout(pending.timer); + this.pendingApplyRequests.delete(payload.correlationId); + + if (payload.success) { + pending.resolve(true); + } else { + pending.reject(new Error(payload.error || 'Failed to apply workflow')); + } + } + } +} diff --git a/packages/core/src/services/mcp-server-tools.ts b/packages/core/src/services/mcp-server-tools.ts new file mode 100644 index 00000000..f7a78c97 --- /dev/null +++ b/packages/core/src/services/mcp-server-tools.ts @@ -0,0 +1,222 @@ +/** + * CC Workflow Studio - MCP Server Tool Definitions + * + * Registers tools on the built-in MCP server that external AI agents + * can call to interact with the workflow editor. + * + * Tools: + * - get_current_workflow: Get the currently active workflow from the canvas + * - get_workflow_schema: Get the workflow JSON schema for generating valid workflows + * - apply_workflow: Apply a workflow to the canvas (validates first) + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { validateAIGeneratedWorkflow } from '../utils/validate-workflow.js'; +import type { McpServerManager } from './mcp-server-service.js'; +import { getDefaultSchemaPath, loadWorkflowSchemaToon } from './schema-loader-service.js'; + +export function registerMcpTools(server: McpServer, manager: McpServerManager): void { + // Tool 1: get_current_workflow + server.tool( + 'get_current_workflow', + 'Get the currently active workflow from CC Workflow Studio canvas. Returns the workflow JSON and whether it is stale (from cache when the editor is closed).', + {}, + async () => { + try { + const result = await manager.requestCurrentWorkflow(); + + if (!result.workflow) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: 'No active workflow. Please open a workflow in CC Workflow Studio first.', + }), + }, + ], + }; + } + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: true, + isStale: result.isStale, + workflow: result.workflow, + }), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : String(error), + }), + }, + ], + isError: true, + }; + } + } + ); + + // Tool 2: get_workflow_schema + server.tool( + 'get_workflow_schema', + 'Get the workflow schema documentation in optimized TOON format. Use this to understand the valid structure for creating or modifying workflows.', + {}, + async () => { + try { + const extensionPath = manager.getExtensionPath(); + if (!extensionPath) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: 'Extension path not available', + }), + }, + ], + isError: true, + }; + } + + const schemaPath = getDefaultSchemaPath(extensionPath); + const result = await loadWorkflowSchemaToon(schemaPath); + + if (!result.success || !result.schemaString) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: result.error?.message || 'Failed to load schema', + }), + }, + ], + isError: true, + }; + } + + return { + content: [ + { + type: 'text' as const, + text: result.schemaString, + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : String(error), + }), + }, + ], + isError: true, + }; + } + } + ); + + // Tool 3: apply_workflow + server.tool( + 'apply_workflow', + 'Apply a workflow to the CC Workflow Studio canvas. The workflow is validated before being applied. If the user has review mode enabled, they will see a diff preview and must accept changes before they are applied. If rejected, an error with message "User rejected the changes" is returned. The editor must be open.', + { + workflow: z.string().describe('The workflow JSON string to apply to the canvas'), + description: z + .string() + .optional() + .describe( + 'A brief description of the changes being made (e.g., "Added error handling step after API call"). Shown to the user in the review dialog.' + ), + }, + async ({ workflow: workflowJson, description }) => { + try { + // Parse JSON + let parsedWorkflow: unknown; + try { + parsedWorkflow = JSON.parse(workflowJson); + } catch { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: 'Invalid JSON: Failed to parse workflow string', + }), + }, + ], + isError: true, + }; + } + + // Validate + const validation = validateAIGeneratedWorkflow(parsedWorkflow); + if (!validation.valid) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: 'Validation failed', + validationErrors: validation.errors, + }), + }, + ], + isError: true, + }; + } + + // Apply to canvas + const applied = await manager.applyWorkflowToCanvas( + parsedWorkflow as import('../types/workflow-definition.js').Workflow, + description + ); + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: applied, + }), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: error instanceof Error ? error.message : String(error), + }), + }, + ], + isError: true, + }; + } + } + ); +} diff --git a/packages/core/src/services/schema-loader-service.ts b/packages/core/src/services/schema-loader-service.ts new file mode 100644 index 00000000..753185d9 --- /dev/null +++ b/packages/core/src/services/schema-loader-service.ts @@ -0,0 +1,202 @@ +/** + * Workflow Schema Loader Service + * + * Loads and caches the workflow schema documentation for AI context. + * Supports both JSON and TOON formats for A/B testing. + * Based on: /specs/001-ai-workflow-generation/research.md Q2 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import type { SchemaFormat } from '../types/ai-metrics.js'; + +// In-memory caches for loaded schemas +let cachedJsonSchema: unknown | undefined; +let cachedToonSchema: string | undefined; + +export interface SchemaLoadResult { + success: boolean; + schema?: unknown; + schemaString?: string; // For TOON format (already serialized) + format: SchemaFormat; + sizeBytes: number; + error?: { + code: 'FILE_NOT_FOUND' | 'PARSE_ERROR' | 'UNKNOWN_ERROR'; + message: string; + details?: string; + }; +} + +/** + * Load workflow schema in JSON format (existing behavior) + * + * @param schemaPath - Absolute path to workflow-schema.json file + * @returns Load result with success status and schema/error + */ +export async function loadWorkflowSchema(schemaPath: string): Promise { + // Return cached schema if available + if (cachedJsonSchema !== undefined) { + return { + success: true, + schema: cachedJsonSchema, + format: 'json', + sizeBytes: JSON.stringify(cachedJsonSchema).length, + }; + } + + try { + // Read schema file + const schemaContent = await fs.readFile(schemaPath, 'utf-8'); + + // Parse JSON + const schema = JSON.parse(schemaContent); + + // Cache for future use + cachedJsonSchema = schema; + + return { + success: true, + schema, + format: 'json', + sizeBytes: schemaContent.length, + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { + success: false, + format: 'json', + sizeBytes: 0, + error: { + code: 'FILE_NOT_FOUND', + message: 'Workflow schema file not found', + details: `Path: ${schemaPath}`, + }, + }; + } + + if (error instanceof SyntaxError) { + return { + success: false, + format: 'json', + sizeBytes: 0, + error: { + code: 'PARSE_ERROR', + message: 'Failed to parse workflow schema JSON', + details: error.message, + }, + }; + } + + return { + success: false, + format: 'json', + sizeBytes: 0, + error: { + code: 'UNKNOWN_ERROR', + message: 'An unexpected error occurred while loading schema', + details: error instanceof Error ? error.message : String(error), + }, + }; + } +} + +/** + * Load workflow schema in TOON format + * Returns the raw TOON string for direct inclusion in prompts + * + * @param schemaPath - Absolute path to workflow-schema.json file (TOON path derived from it) + * @returns Load result with success status and schemaString/error + */ +export async function loadWorkflowSchemaToon(schemaPath: string): Promise { + // Derive TOON path from JSON path + const toonPath = schemaPath.replace('.json', '.toon'); + + // Return cached schema if available + if (cachedToonSchema !== undefined) { + return { + success: true, + schemaString: cachedToonSchema, + format: 'toon', + sizeBytes: cachedToonSchema.length, + }; + } + + try { + const toonContent = await fs.readFile(toonPath, 'utf-8'); + cachedToonSchema = toonContent; + + return { + success: true, + schemaString: toonContent, + format: 'toon', + sizeBytes: toonContent.length, + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { + success: false, + format: 'toon', + sizeBytes: 0, + error: { + code: 'FILE_NOT_FOUND', + message: 'TOON schema file not found. Falling back to JSON.', + details: `Path: ${toonPath}`, + }, + }; + } + + return { + success: false, + format: 'toon', + sizeBytes: 0, + error: { + code: 'UNKNOWN_ERROR', + message: 'An unexpected error occurred while loading TOON schema', + details: error instanceof Error ? error.message : String(error), + }, + }; + } +} + +/** + * Load workflow schema in specified format + * + * @param extensionPath - The extension's root path + * @param format - Schema format to load ('json' or 'toon') + * @returns Load result with schema data + */ +export async function loadWorkflowSchemaByFormat( + extensionPath: string, + format: SchemaFormat +): Promise { + const jsonPath = getDefaultSchemaPath(extensionPath); + + if (format === 'toon') { + const result = await loadWorkflowSchemaToon(jsonPath); + if (result.success) { + return result; + } + // Fallback to JSON if TOON fails + console.warn('TOON schema load failed, falling back to JSON'); + } + + return loadWorkflowSchema(jsonPath); +} + +/** + * Clear both schema caches (useful for testing or schema updates) + */ +export function clearSchemaCache(): void { + cachedJsonSchema = undefined; + cachedToonSchema = undefined; +} + +/** + * Get the default schema path for the extension + * + * @param extensionPath - The extension's root path from context.extensionPath + * @returns Absolute path to workflow-schema.json + */ +export function getDefaultSchemaPath(extensionPath: string): string { + return path.join(extensionPath, 'resources', 'workflow-schema.json'); +} diff --git a/packages/core/src/types/ai-metrics.ts b/packages/core/src/types/ai-metrics.ts new file mode 100644 index 00000000..a8124c8e --- /dev/null +++ b/packages/core/src/types/ai-metrics.ts @@ -0,0 +1,49 @@ +/** + * AI Generation Metrics for A/B comparison + */ + +export type SchemaFormat = 'json' | 'toon'; +export type PromptFormat = 'freetext' | 'json' | 'toon'; + +export interface AIGenerationMetrics { + /** Unique request ID */ + requestId: string; + + /** Schema format used */ + schemaFormat: SchemaFormat; + + /** Prompt structure format used */ + promptFormat: PromptFormat; + + /** Total prompt size in characters */ + promptSizeChars: number; + + /** Schema portion size in characters */ + schemaSizeChars: number; + + /** Estimated token count (chars / 4 approximation) */ + estimatedTokens: number; + + /** CLI execution time in milliseconds */ + executionTimeMs: number; + + /** Whether generation succeeded */ + success: boolean; + + /** Timestamp of generation */ + timestamp: string; + + /** User description length (for normalization) */ + userDescriptionLength: number; +} + +export interface MetricsSummary { + jsonMetrics: AIGenerationMetrics[]; + toonMetrics: AIGenerationMetrics[]; + comparison: { + averagePromptSizeReduction: number; // percentage + averageExecutionTimeDiff: number; // milliseconds + jsonSuccessRate: number; + toonSuccessRate: number; + }; +} diff --git a/packages/core/src/types/mcp-node.ts b/packages/core/src/types/mcp-node.ts new file mode 100644 index 00000000..00e86688 --- /dev/null +++ b/packages/core/src/types/mcp-node.ts @@ -0,0 +1,304 @@ +/** + * MCP (Model Context Protocol) Node Type Definitions + * + * Defines TypeScript types for MCP tool nodes in workflows. + * These types map to the JSON schemas defined in contracts/workflow-mcp-node.schema.json + * and contracts/mcp-cli.schema.json. + */ + +/** + * MCP configuration source provider + */ +export type McpConfigSource = + | 'claude' + | 'copilot' + | 'codex' + | 'gemini' + | 'roo' + | 'antigravity' + | 'cursor'; + +/** + * MCP server reference information (from 'claude mcp list') + */ +export interface McpServerReference { + /** Server identifier (e.g., 'aws-knowledge-mcp') */ + id: string; + /** Display name of the MCP server */ + name: string; + /** Configuration scope */ + scope: 'user' | 'project' | 'enterprise'; + /** Connection status (only available for Claude Code servers) */ + status?: 'connected' | 'disconnected'; + /** Executable command */ + command: string; + /** Command arguments */ + args: string[]; + /** MCP transport type */ + type: 'stdio' | 'sse' | 'http'; + /** URL for HTTP/SSE transport (optional, not used for stdio) */ + url?: string; + /** Environment variables (optional) */ + environment?: Record; + /** Source provider (defaults to 'claude' if undefined for backwards compatibility) */ + source?: McpConfigSource; +} + +/** + * MCP tool reference information (from 'claude mcp get') + */ +export interface McpToolReference { + /** Server identifier this tool belongs to */ + serverId: string; + /** Tool function name */ + name: string; + /** Human-readable description of the tool's functionality */ + description: string; + /** Array of parameter schemas for this tool */ + parameters: ToolParameter[]; +} + +/** + * Parameter validation constraints + */ +export interface ParameterValidation { + /** Minimum string length */ + minLength?: number; + /** Maximum string length */ + maxLength?: number; + /** Regex pattern for string validation */ + pattern?: string; + /** Minimum numeric value */ + minimum?: number; + /** Maximum numeric value */ + maximum?: number; + /** Enumerated valid values */ + enum?: (string | number)[]; +} + +/** + * Tool parameter schema definition + * + * Recursive structure to support array and object types. + */ +export interface ToolParameter { + /** Parameter identifier (e.g., 'region') */ + name: string; + /** Parameter data type */ + type: 'string' | 'number' | 'boolean' | 'integer' | 'array' | 'object'; + /** User-friendly description of the parameter */ + description?: string | null; + /** Whether this parameter is mandatory for tool execution */ + required: boolean; + /** Default value if not provided by user */ + default?: unknown; + /** Constraints and validation rules */ + validation?: ParameterValidation; + /** For array types: schema of array items */ + items?: ToolParameter; + /** For object types: schema of nested properties */ + properties?: Record; +} + +/** + * Tool selection mode for MCP node creation wizard + * + * Determines how the user selects the MCP tool: + * - 'manual': User manually selects a tool from the list + * - 'auto': AI automatically selects the best tool based on task description + */ +export type ToolSelectionMode = 'manual' | 'auto'; + +/** + * Parameter configuration mode for MCP node creation wizard + * + * Determines how the user configures tool parameters: + * - 'manual': User manually fills in parameter values + * - 'auto': AI configures parameters based on natural language description + */ +export type ParameterConfigMode = 'manual' | 'auto'; + +/** + * MCP node configuration mode + * + * Determines how the MCP tool node is configured and executed: + * - 'manualParameterConfig': User explicitly configures server, tool, and all parameters + * - 'aiParameterConfig': User selects server/tool, describes parameters in natural language + * - 'aiToolSelection': User selects server only, describes entire task in natural language + */ +export type McpNodeMode = 'manualParameterConfig' | 'aiParameterConfig' | 'aiToolSelection'; + +/** + * AI Parameter Configuration Mode configuration + * + * Used when user selects a specific tool but describes parameters in natural language. + * Claude Code will interpret this description to set appropriate parameter values. + */ +export interface AiParameterConfig { + /** Natural language description of desired parameter values */ + description: string; + /** Timestamp when this description was created (ISO 8601 format) */ + timestamp: string; +} + +/** + * AI Tool Selection Mode configuration + * + * Used when user describes the entire task in natural language without selecting a tool. + * Claude Code will choose the most appropriate tool from the available tools list. + */ +export interface AiToolSelectionConfig { + /** Natural language description of the task to accomplish */ + taskDescription: string; + /** Timestamp when this configuration was created (ISO 8601 format) */ + timestamp: string; +} + +/** + * Preserved Manual Parameter Configuration Mode configuration + * + * Stores manual parameter config mode configuration when user switches to an AI mode. + * This allows switching back to manual parameter config mode without losing the explicit configuration. + */ +export interface PreservedManualParameterConfig { + /** Previously configured tool name */ + toolName: string; + /** Previously configured parameter values */ + parameterValues: Record; + /** Timestamp when this configuration was preserved (ISO 8601 format) */ + timestamp: string; +} + +/** + * MCP node data + * + * Contains MCP-specific configuration and tool information. + * Supports three configuration modes: manualParameterConfig, aiParameterConfig, and aiToolSelection. + */ +export interface McpNodeData { + /** MCP server identifier (from 'claude mcp list') */ + serverId: string; + /** Source provider of the MCP server (claude, copilot, codex) */ + source?: McpConfigSource; + /** Tool function name from the MCP server */ + toolName: string; + /** Human-readable description of the tool's functionality */ + toolDescription: string; + /** Array of parameter schemas for this tool (immutable, from MCP definition) */ + parameters: ToolParameter[]; + /** User-configured values for the tool's parameters */ + parameterValues: Record; + /** Validation state (computed during workflow load) */ + validationStatus: 'valid' | 'missing' | 'invalid'; + /** Number of output ports (fixed at 1 for MCP nodes) */ + outputPorts: 1; + + // AI Mode fields (optional, for backwards compatibility) + + /** Configuration mode (defaults to 'manualParameterConfig' if undefined) */ + mode?: McpNodeMode; + /** AI Parameter Configuration Mode configuration (only if mode === 'aiParameterConfig') */ + aiParameterConfig?: AiParameterConfig; + /** AI Tool Selection Mode configuration (only if mode === 'aiToolSelection') */ + aiToolSelectionConfig?: AiToolSelectionConfig; + /** Preserved manual parameter configuration (stores data when switching away from manual parameter config mode) */ + preservedManualParameterConfig?: PreservedManualParameterConfig; +} + +/** + * Export metadata for Manual Parameter Configuration Mode + * + * Contains explicit parameter values for reproduction. + */ +export interface ManualParameterConfigMetadata { + /** Mode discriminator */ + mode: 'manualParameterConfig'; + /** MCP server identifier */ + serverId: string; + /** Tool function name */ + toolName: string; + /** Explicit parameter values configured by user */ + parameterValues: Record; +} + +/** + * Export metadata for AI Parameter Configuration Mode + * + * Contains natural language description and parameter schema for Claude Code interpretation. + */ +export interface AiParameterConfigMetadata { + /** Mode discriminator */ + mode: 'aiParameterConfig'; + /** MCP server identifier */ + serverId: string; + /** Tool function name */ + toolName: string; + /** Natural language description of desired parameter values */ + userIntent: string; + /** Parameter schema for Claude Code to map description to values */ + parameterSchema: ToolParameter[]; +} + +/** + * Export metadata for AI Tool Selection Mode + * + * Contains task description and available tools list for Claude Code to select tool and parameters. + */ +export interface AiToolSelectionMetadata { + /** Mode discriminator */ + mode: 'aiToolSelection'; + /** MCP server identifier */ + serverId: string; + /** Natural language description of the entire task */ + userIntent: string; +} + +/** + * Export metadata (discriminated union) + * + * Embedded in exported slash commands to help Claude Code interpret user intent. + * The specific metadata type is determined by the 'mode' discriminator. + */ +export type ModeExportMetadata = + | ManualParameterConfigMetadata + | AiParameterConfigMetadata + | AiToolSelectionMetadata; + +/** + * Normalize MCP node data for backwards compatibility + * + * Ensures that mode field is set to 'manualParameterConfig' if undefined (for v1.2.0 workflows). + * Also migrates old mode values ('detailed', 'naturalLanguageParam', 'fullNaturalLanguage') to new values. + * This function should be called when loading workflows from disk or receiving + * AI-generated workflows. + * + * @param data - Raw MCP node data (potentially missing mode field) + * @returns Normalized MCP node data with mode field set + */ +export function normalizeMcpNodeData(data: McpNodeData): McpNodeData { + // Legacy mode mapping for backwards compatibility + const legacyModeMap: Record = { + detailed: 'manualParameterConfig', + naturalLanguageParam: 'aiParameterConfig', + fullNaturalLanguage: 'aiToolSelection', + }; + + // Get raw mode value (may be undefined or legacy value) + const rawMode = (data.mode as string | undefined) ?? 'manualParameterConfig'; + + // Map legacy mode to new mode, or use raw mode if already valid + const mode = legacyModeMap[rawMode] ?? (rawMode as McpNodeMode); + + return { + ...data, + mode, + }; +} + +/** + * MCP node definition + * + * Note: The actual McpNode interface that extends BaseNode + * will be defined in workflow-definition.ts to avoid circular dependencies. + * This file only contains the data structure definitions. + */ diff --git a/packages/core/src/types/messages.ts b/packages/core/src/types/messages.ts new file mode 100644 index 00000000..a7d3517a --- /dev/null +++ b/packages/core/src/types/messages.ts @@ -0,0 +1,1929 @@ +/** + * Claude Code Workflow Studio - Extension ↔ Webview Message Types + * + * Based on: /specs/001-cc-wf-studio/contracts/extension-webview-api.md + */ + +import type { Connection, Workflow, WorkflowNode } from './workflow-definition.js'; + +// Re-export Workflow for convenience +export type { Workflow, WorkflowNode, Connection }; + +// ============================================================================ +// Base Message +// ============================================================================ + +export interface Message { + type: K; + payload?: T; + requestId?: string; +} + +// ============================================================================ +// Extension → Webview Payloads +// ============================================================================ + +export interface LoadWorkflowPayload { + workflow: Workflow; +} + +export interface SaveSuccessPayload { + filePath: string; + timestamp: string; // ISO 8601 +} + +export interface ExportSuccessPayload { + exportedFiles: string[]; + timestamp: string; // ISO 8601 +} + +export interface ErrorPayload { + code: string; + message: string; + details?: unknown; +} + +export interface WorkflowListPayload { + workflows: Array<{ + id: string; + name: string; + description?: string; + updatedAt: string; // ISO 8601 + }>; +} + +export interface InitialStatePayload { + hasAcceptedTerms: boolean; +} + +// ============================================================================ +// Workflow Preview Payloads +// ============================================================================ + +/** + * Preview mode initialization payload + * Sent when opening a workflow file in preview mode + */ +export interface PreviewModeInitPayload { + /** Workflow to display in preview */ + workflow: Workflow; + /** Whether this is a historical version (git diff "before" side) */ + isHistoricalVersion?: boolean; + /** Whether the file has uncommitted git changes (for showing "After" badge) */ + hasGitChanges?: boolean; +} + +/** + * Preview update payload + * Sent when the source JSON file is modified + */ +export interface PreviewUpdatePayload { + /** Updated workflow to display */ + workflow: Workflow; +} + +/** + * Preview parse error payload + * Sent when the source JSON cannot be parsed + */ +export interface PreviewParseErrorPayload { + /** Error message describing the parse failure */ + error: string; +} + +/** + * Prepare workflow load payload + * Sent before loading a new workflow to show loading state + */ +export interface PrepareWorkflowLoadPayload { + /** Workflow ID being loaded */ + workflowId: string; +} + +// ============================================================================ +// Webview → Extension Payloads +// ============================================================================ + +export interface SaveWorkflowPayload { + workflow: Workflow; +} + +export interface ExportWorkflowPayload { + workflow: Workflow; + overwriteExisting?: boolean; +} + +export interface ConfirmOverwritePayload { + confirmed: boolean; + filePath: string; +} + +export interface StateUpdatePayload { + nodes: WorkflowNode[]; + edges: Connection[]; + selectedNodeId?: string | null; +} + +export interface LoadWorkflowRequestPayload { + workflowId: string; +} + +/** + * Confirm workflow load payload + * Sent from Webview to Extension after user confirms loading (or no unsaved changes) + */ +export interface ConfirmWorkflowLoadPayload { + workflowId: string; +} + +export interface CancelRefinementPayload { + requestId: string; // キャンセル対象のリクエストID +} + +// ============================================================================ +// Run as Slash Command Payloads +// ============================================================================ + +/** + * Run workflow as slash command request payload + * Converts workflow to slash command and runs it in VSCode terminal + */ +export interface RunAsSlashCommandPayload { + /** Workflow to run */ + workflow: Workflow; +} + +/** + * Run as slash command success payload + */ +export interface RunAsSlashCommandSuccessPayload { + /** Workflow name that was run */ + workflowName: string; + /** Terminal name where command is running */ + terminalName: string; + /** Timestamp of run */ + timestamp: string; // ISO 8601 +} + +// ============================================================================ +// Skill Node Payloads (001-skill-node) +// ============================================================================ + +export interface SkillReference { + /** Absolute path to SKILL.md file */ + skillPath: string; + /** Skill name (from YAML frontmatter) */ + name: string; + /** Skill description (from YAML frontmatter) */ + description: string; + /** Skill scope: user, project, or local */ + scope: 'user' | 'project' | 'local'; + /** Validation status */ + validationStatus: 'valid' | 'missing' | 'invalid'; + /** Optional: Allowed tools (from YAML frontmatter) */ + allowedTools?: string; + /** + * Source directory for skills + * - 'claude': from ~/.claude/skills/ (user) or .claude/skills/ (project) + * - 'copilot': from ~/.copilot/skills/ (user) or .github/skills/ (project) + * - 'codex': from ~/.codex/skills/ (user) or .codex/skills/ (project) + * - 'roo': from ~/.roo/skills/ (user) or .roo/skills/ (project) + * - 'gemini': from ~/.gemini/skills/ (user) or .gemini/skills/ (project) + * - 'antigravity': from ~/.agent/skills/ (user) or .agent/skills/ (project) + * - 'cursor': from ~/.cursor/skills/ (user) or .cursor/skills/ (project) + * - undefined: for local scope or legacy data + */ + source?: 'claude' | 'copilot' | 'codex' | 'roo' | 'gemini' | 'antigravity' | 'cursor'; +} + +export interface CreateSkillPayload { + /** Skill name (lowercase, hyphens, max 64 chars) */ + name: string; + /** Skill description (max 1024 chars) */ + description: string; + /** Markdown content for Skill instructions */ + instructions: string; + /** Optional: Comma-separated allowed tools */ + allowedTools?: string; + /** Scope: user or project */ + scope: 'user' | 'project'; +} + +export interface SkillCreationSuccessPayload { + /** Path to created SKILL.md file */ + skillPath: string; + /** Skill name */ + name: string; + /** Skill description */ + description: string; + /** Scope */ + scope: 'user' | 'project'; + /** Timestamp of creation */ + timestamp: string; // ISO 8601 +} + +export interface SkillValidationErrorPayload { + /** Error code for i18n lookup */ + errorCode: + | 'SKILL_NOT_FOUND' + | 'INVALID_FRONTMATTER' + | 'NAME_CONFLICT' + | 'INVALID_NAME_FORMAT' + | 'DESCRIPTION_TOO_LONG' + | 'INSTRUCTIONS_EMPTY' + | 'FILE_WRITE_ERROR' + | 'UNKNOWN_ERROR'; + /** Human-readable error message (English fallback) */ + errorMessage: string; + /** Optional: File path related to error */ + filePath?: string; + /** Optional: Additional details for debugging */ + details?: string; +} + +export interface SkillListLoadedPayload { + /** Array of available Skills (user + project + local) */ + skills: SkillReference[]; + /** Timestamp of scan */ + timestamp: string; // ISO 8601 + /** Number of user-scope Skills found */ + userCount: number; + /** Number of project-scope Skills found */ + projectCount: number; + /** Number of local-scope Skills found (from plugins) */ + localCount: number; +} + +export interface ValidateSkillFilePayload { + /** Path to SKILL.md file to validate */ + skillPath: string; +} + +export interface SkillValidationSuccessPayload { + /** Validated Skill reference */ + skill: SkillReference; +} + +// ============================================================================ +// AI Workflow Refinement Payloads (001-ai-workflow-refinement) +// ============================================================================ + +import type { ConversationHistory, ConversationMessage } from './workflow-definition.js'; + +/** + * Claude model selection for AI refinement + * - sonnet: Claude Sonnet (default, balanced performance) + * - opus: Claude Opus (highest capability) + * - haiku: Claude Haiku (fastest, most economical) + */ +export type ClaudeModel = 'sonnet' | 'opus' | 'haiku'; + +/** + * AI CLI provider selection + * - claude-code: Claude Code CLI (default) + * - copilot: VS Code Language Model API (Copilot) + * - codex: OpenAI Codex CLI + */ +export type AiCliProvider = 'claude-code' | 'copilot' | 'codex'; + +/** + * Copilot model selection (for VS Code Language Model API) + * This type represents model family strings returned by vscode.lm API. + * The list is dynamic and fetched at runtime from vscode.lm.selectChatModels(). + */ +export type CopilotModel = string; + +/** + * Codex model selection (for OpenAI Codex CLI) + * Common models include 'o3', 'o4-mini', etc. + * The list is dynamic and can be configured in ~/.codex/config.toml + */ +export type CodexModel = string; + +/** + * Codex CLI reasoning effort levels + * Controls how much reasoning effort the model applies + * Note: Only 'low', 'medium', 'high' are supported across all Codex models + */ +export type CodexReasoningEffort = 'low' | 'medium' | 'high'; + +/** + * Information about a Copilot model available via VS Code LM API + */ +export interface CopilotModelInfo { + /** Model ID (e.g., 'gpt-4o') */ + id: string; + /** Display name (e.g., 'GPT-4o') */ + name: string; + /** Model family (e.g., 'gpt-4o') - used for selection */ + family: string; + /** Vendor (always 'copilot' for Copilot models) */ + vendor: string; +} + +/** + * Payload for listing available Copilot models + */ +export interface CopilotModelsListPayload { + /** List of available Copilot models */ + models: CopilotModelInfo[]; + /** Whether the LM API is available */ + available: boolean; + /** Error reason if not available */ + unavailableReason?: string; +} + +export interface RefineWorkflowPayload { + /** ID of the workflow being refined */ + workflowId: string; + /** User's refinement request (1-5000 characters) */ + userMessage: string; + /** Current workflow state (full Workflow object) */ + currentWorkflow: Workflow; + /** Existing conversation history */ + conversationHistory: ConversationHistory; + /** Whether to include skills in refinement (default: true) */ + useSkills?: boolean; + /** Optional timeout in milliseconds (default: 60000, min: 10000, max: 120000) */ + timeoutMs?: number; + /** Target type for refinement (default: 'workflow') */ + targetType?: 'workflow' | 'subAgentFlow'; + /** SubAgentFlow ID (required when targetType is 'subAgentFlow') */ + subAgentFlowId?: string; + /** Claude model to use (default: 'sonnet') */ + model?: ClaudeModel; + /** Allowed tools for Claude Code CLI (optional, e.g., ['Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch']) */ + allowedTools?: string[]; + /** Previous validation errors from failed refinement attempt (for retry with error context) */ + previousValidationErrors?: Array<{ + code: string; + message: string; + field?: string; + }>; + /** AI CLI provider to use (default: 'claude-code') */ + provider?: AiCliProvider; + /** Copilot model to use when provider is 'copilot' (default: 'gpt-4o') */ + copilotModel?: CopilotModel; + /** Codex model to use when provider is 'codex' (default: '' = inherit from CLI config) */ + codexModel?: CodexModel; + /** Codex reasoning effort level (default: 'low') */ + codexReasoningEffort?: CodexReasoningEffort; + /** Whether to include Codex Agent node in AI prompt (default: false) */ + useCodex?: boolean; +} + +export interface RefinementSuccessPayload { + /** The refined workflow (full Workflow object) */ + refinedWorkflow: Workflow; + /** AI's response message */ + aiMessage: ConversationMessage; + /** Updated conversation history with new messages */ + updatedConversationHistory: ConversationHistory; + /** Optional: brief summary of changes made (max 500 chars) */ + changesSummary?: string; + /** Time taken to execute refinement (in milliseconds) */ + executionTimeMs: number; + /** Response timestamp */ + timestamp: string; // ISO 8601 + /** Whether session was reconnected due to session expiration (fallback occurred) */ + sessionReconnected?: boolean; +} + +export interface RefinementFailedPayload { + /** Structured error information */ + error: { + /** Error code for i18n lookup */ + code: + | 'COMMAND_NOT_FOUND' + | 'TIMEOUT' + | 'PARSE_ERROR' + | 'VALIDATION_ERROR' + | 'ITERATION_LIMIT_REACHED' + | 'CANCELLED' + | 'PROHIBITED_NODE_TYPE' + | 'UNKNOWN_ERROR'; + /** Human-readable error message */ + message: string; + /** Optional: detailed error information */ + details?: string; + }; + /** Time taken before error occurred */ + executionTimeMs: number; + /** Error timestamp */ + timestamp: string; // ISO 8601 + /** Validation errors for VALIDATION_ERROR code (used for retry with error context) */ + validationErrors?: Array<{ + code: string; + message: string; + field?: string; + }>; +} + +export interface ClearConversationPayload { + /** ID of the workflow to clear conversation for */ + workflowId: string; +} + +export interface ConversationClearedPayload { + /** ID of the workflow that was cleared */ + workflowId: string; +} + +export interface RefinementCancelledPayload { + /** Time taken before cancellation (in milliseconds) */ + executionTimeMs: number; + /** Cancellation timestamp */ + timestamp: string; // ISO 8601 +} + +export interface RefinementClarificationPayload { + /** AI's clarification message asking for more information */ + aiMessage: ConversationMessage; + /** Updated conversation history with the clarification message */ + updatedConversationHistory: ConversationHistory; + /** Time taken to execute refinement before clarification */ + executionTimeMs: number; + /** Response timestamp */ + timestamp: string; // ISO 8601 + /** Whether session was reconnected due to session expiration (fallback occurred) */ + sessionReconnected?: boolean; +} + +export interface RefinementProgressPayload { + /** New text chunk from streaming output */ + chunk: string; + /** Display text (may include tool usage info) - for streaming display */ + accumulatedText: string; + /** Explanatory text only (no tool info) - for preserving in chat history */ + explanatoryText?: string; + /** Content type from Claude streaming response ('tool_use' or 'text') */ + contentType?: 'tool_use' | 'text'; + /** Progress timestamp */ + timestamp: string; // ISO 8601 +} + +// ============================================================================ +// SubAgentFlow Refinement Payloads +// ============================================================================ + +export interface SubAgentFlowRefinementSuccessPayload { + /** ID of the SubAgentFlow that was refined */ + subAgentFlowId: string; + /** The refined inner workflow (nodes and connections only) */ + refinedInnerWorkflow: { + nodes: Workflow['nodes']; + connections: Workflow['connections']; + }; + /** AI's response message */ + aiMessage: ConversationMessage; + /** Updated conversation history with new messages */ + updatedConversationHistory: ConversationHistory; + /** Time taken to execute refinement (in milliseconds) */ + executionTimeMs: number; + /** Response timestamp */ + timestamp: string; // ISO 8601 + /** Whether session was reconnected due to session expiration (fallback occurred) */ + sessionReconnected?: boolean; +} + +// ============================================================================ +// MCP Node Payloads (001-mcp-node) +// ============================================================================ + +import type { McpServerReference, McpToolReference } from './mcp-node.js'; + +// Re-export for Webview usage +export type { McpServerReference, McpToolReference }; + +/** + * Options for filtering MCP servers + */ +export interface ListMcpServersOptions { + /** Filter by scope (optional) */ + filterByScope?: Array<'user' | 'project' | 'enterprise'>; +} + +/** + * MCP Server list request payload + */ +export interface ListMcpServersPayload { + /** Request options */ + options?: ListMcpServersOptions; +} + +/** + * MCP Server list result payload + */ +export interface McpServersResultPayload { + /** Whether the request succeeded */ + success: boolean; + /** List of MCP servers (if success) */ + servers?: McpServerReference[]; + /** Error information (if failure) */ + error?: { + code: + | 'MCP_CLI_NOT_FOUND' + | 'MCP_CLI_TIMEOUT' + | 'MCP_SERVER_NOT_FOUND' + | 'MCP_CONNECTION_FAILED' + | 'MCP_PARSE_ERROR' + | 'MCP_UNKNOWN_ERROR' + | 'MCP_UNSUPPORTED_TRANSPORT' + | 'MCP_INVALID_CONFIG' + | 'MCP_CONNECTION_TIMEOUT' + | 'MCP_CONNECTION_ERROR'; + message: string; + details?: string; + }; + /** Request timestamp */ + timestamp: string; // ISO 8601 + /** Execution time in milliseconds */ + executionTimeMs: number; +} + +/** + * Get MCP tools request payload + */ +export interface GetMcpToolsPayload { + /** MCP server identifier */ + serverId: string; +} + +/** + * MCP Tools result payload + */ +export interface McpToolsResultPayload { + /** Whether the request succeeded */ + success: boolean; + /** Server identifier */ + serverId: string; + /** List of MCP tools (if success) */ + tools?: McpToolReference[]; + /** Error information (if failure) */ + error?: { + code: + | 'MCP_CLI_NOT_FOUND' + | 'MCP_CLI_TIMEOUT' + | 'MCP_SERVER_NOT_FOUND' + | 'MCP_CONNECTION_FAILED' + | 'MCP_PARSE_ERROR' + | 'MCP_UNKNOWN_ERROR' + | 'MCP_UNSUPPORTED_TRANSPORT' + | 'MCP_INVALID_CONFIG' + | 'MCP_CONNECTION_TIMEOUT' + | 'MCP_CONNECTION_ERROR'; + message: string; + details?: string; + }; + /** Request timestamp */ + timestamp: string; // ISO 8601 + /** Execution time in milliseconds */ + executionTimeMs: number; +} + +/** + * Get MCP tool schema request payload + */ +export interface GetMcpToolSchemaPayload { + /** MCP server identifier */ + serverId: string; + /** Tool name */ + toolName: string; +} + +/** + * MCP Tool schema result payload + */ +export interface McpToolSchemaResultPayload { + /** Whether the request succeeded */ + success: boolean; + /** Server identifier */ + serverId: string; + /** Tool name */ + toolName: string; + /** Tool schema (if success) */ + schema?: McpToolReference; + /** Error information (if failure) */ + error?: { + code: + | 'MCP_CLI_NOT_FOUND' + | 'MCP_CLI_TIMEOUT' + | 'MCP_SERVER_NOT_FOUND' + | 'MCP_TOOL_NOT_FOUND' + | 'MCP_PARSE_ERROR' + | 'MCP_UNKNOWN_ERROR' + | 'MCP_CONNECTION_FAILED' + | 'MCP_CONNECTION_TIMEOUT' + | 'MCP_CONNECTION_ERROR' + | 'MCP_UNSUPPORTED_TRANSPORT' + | 'MCP_INVALID_CONFIG'; + message: string; + details?: string; + }; + /** Request timestamp */ + timestamp: string; // ISO 8601 + /** Execution time in milliseconds */ + executionTimeMs: number; +} + +/** + * Validate MCP node payload + */ +export interface ValidateMcpNodePayload { + /** MCP server identifier */ + serverId: string; + /** Tool name */ + toolName: string; + /** Parameter values to validate */ + parameterValues: Record; +} + +/** + * MCP node validation result payload + */ +export interface McpNodeValidationResultPayload { + /** Whether validation succeeded */ + success: boolean; + /** Validation status */ + validationStatus: 'valid' | 'invalid'; + /** Validation errors (if invalid) */ + errors?: Array<{ + /** Parameter name */ + parameterName: string; + /** Error code */ + code: 'MISSING_REQUIRED' | 'INVALID_TYPE' | 'VALIDATION_FAILED'; + /** Error message */ + message: string; + }>; +} + +/** + * Update MCP node payload + */ +export interface UpdateMcpNodePayload { + /** Node ID */ + nodeId: string; + /** Updated parameter values */ + parameterValues: Record; +} + +/** + * MCP error payload + */ +export interface McpErrorPayload { + /** Error code */ + code: + | 'MCP_CLI_NOT_FOUND' + | 'MCP_CLI_TIMEOUT' + | 'MCP_SERVER_NOT_FOUND' + | 'MCP_CONNECTION_FAILED' + | 'MCP_TOOL_NOT_FOUND' + | 'MCP_PARSE_ERROR' + | 'MCP_VALIDATION_ERROR' + | 'MCP_UNKNOWN_ERROR'; + /** Error message */ + message: string; + /** Optional: detailed error information */ + details?: string; + /** Request timestamp */ + timestamp: string; // ISO 8601 +} + +/** + * Refresh MCP cache request payload + * + * Invalidates all in-memory MCP cache (server list, tools, schemas). + * Useful when MCP servers are added/removed after initial load. + */ +export type RefreshMcpCachePayload = Record; + +/** + * MCP cache refreshed result payload + */ +export interface McpCacheRefreshedPayload { + /** Whether the cache refresh succeeded */ + success: boolean; + /** Request timestamp */ + timestamp: string; // ISO 8601 +} + +// ============================================================================ +// Extension → Webview Messages +// ============================================================================ + +export type ExtensionMessage = + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message; + +// ============================================================================ +// AI Slack Description Generation Payloads +// ============================================================================ + +/** + * Generate Slack description request payload + */ +export interface GenerateSlackDescriptionPayload { + /** Serialized workflow JSON for AI analysis */ + workflowJson: string; + /** Current UI language (en, ja, ko, zh-CN, zh-TW) */ + targetLanguage: string; + /** Optional timeout in milliseconds (default: 30000) */ + timeoutMs?: number; +} + +/** + * Slack description generation success payload + */ +export interface SlackDescriptionSuccessPayload { + /** Generated description (max 500 chars) */ + description: string; + /** Execution time in milliseconds */ + executionTimeMs: number; + /** Timestamp ISO 8601 */ + timestamp: string; +} + +/** + * Slack description generation failed payload + */ +export interface SlackDescriptionFailedPayload { + error: { + code: 'COMMAND_NOT_FOUND' | 'TIMEOUT' | 'PARSE_ERROR' | 'CANCELLED' | 'UNKNOWN_ERROR'; + message: string; + details?: string; + }; + executionTimeMs: number; + timestamp: string; +} + +// ============================================================================ +// AI Workflow Name Generation Payloads +// ============================================================================ + +/** + * Generate workflow name request payload + */ +export interface GenerateWorkflowNamePayload { + /** Serialized workflow JSON for AI analysis */ + workflowJson: string; + /** Current UI language (en, ja, ko, zh-CN, zh-TW) */ + targetLanguage: string; + /** Optional timeout in milliseconds (default: 30000) */ + timeoutMs?: number; +} + +/** + * Workflow name generation success payload + */ +export interface WorkflowNameSuccessPayload { + /** Generated name (max 64 chars, kebab-case) */ + name: string; + /** Execution time in milliseconds */ + executionTimeMs: number; + /** Timestamp ISO 8601 */ + timestamp: string; +} + +/** + * Workflow name generation failed payload + */ +export interface WorkflowNameFailedPayload { + error: { + code: 'COMMAND_NOT_FOUND' | 'TIMEOUT' | 'PARSE_ERROR' | 'CANCELLED' | 'UNKNOWN_ERROR'; + message: string; + details?: string; + }; + executionTimeMs: number; + timestamp: string; +} + +// ============================================================================ +// Slack Integration Payloads (001-slack-workflow-sharing) +// ============================================================================ + +/** + * Slack channel information + */ +export interface SlackChannel { + id: string; + name: string; + isPrivate: boolean; + isMember: boolean; + memberCount?: number; + purpose?: string; + topic?: string; +} + +/** + * Workflow search result + */ +export interface SearchResult { + messageTs: string; + channelId: string; + channelName: string; + text: string; + userId: string; + permalink: string; + fileId?: string; + fileName?: string; + timestamp: string; +} + +/** + * Slack connection request payload + */ +export interface SlackConnectPayload { + /** Force reconnection (delete existing token and reconnect) */ + forceReconnect?: boolean; +} + +/** + * Slack connection success payload + */ +export interface SlackConnectSuccessPayload { + workspaceName: string; +} + +/** + * Get OAuth redirect URI success payload + * @deprecated OAuth flow will be removed in favor of manual token input + */ +export interface GetOAuthRedirectUriSuccessPayload { + redirectUri: string; +} + +/** + * Manual Slack connection request payload + */ +export interface ConnectSlackManualPayload { + /** Slack Bot User OAuth Token (xoxb-...) */ + botToken: string; + /** Slack User OAuth Token (xoxp-...) - Required for secure channel listing */ + userToken: string; +} + +/** + * Manual Slack connection success payload + */ +export interface ConnectSlackManualSuccessPayload { + /** Workspace ID that was connected */ + workspaceId: string; + /** Workspace name */ + workspaceName: string; +} + +/** + * Slack error payload (for FAILED messages) + */ +export interface SlackErrorPayload { + message: string; +} + +/** + * Slack OAuth initiated payload + * + * Sent when OAuth flow is started, containing session ID for tracking + * and authorization URL for browser redirect. + */ +export interface SlackOAuthInitiatedPayload { + /** Session ID for tracking OAuth flow */ + sessionId: string; + /** Slack authorization URL to open in browser */ + authorizationUrl: string; +} + +/** + * Slack OAuth success payload + * + * Sent when OAuth flow completes successfully. + */ +export interface SlackOAuthSuccessPayload { + /** Workspace ID (Team ID) */ + workspaceId: string; + /** Workspace name */ + workspaceName: string; +} + +/** + * Get Slack channels request payload + */ +export interface GetSlackChannelsPayload { + /** Target workspace ID */ + workspaceId: string; + /** Include private channels (default: true) */ + includePrivate?: boolean; + /** Only show channels user is a member of (default: true) */ + onlyMember?: boolean; +} + +/** + * Get Slack channels success payload + */ +export interface GetSlackChannelsSuccessPayload { + channels: SlackChannel[]; +} + +/** + * Slack workspace information (for workspace selection) + */ +export interface SlackWorkspace { + /** Workspace ID (Team ID) */ + workspaceId: string; + /** Workspace name */ + workspaceName: string; + /** Team ID */ + teamId: string; + /** When the workspace was authorized */ + authorizedAt: string; + /** Last validation timestamp (optional) */ + lastValidatedAt?: string; +} + +/** + * List Slack workspaces success payload + */ +export interface ListSlackWorkspacesSuccessPayload { + workspaces: SlackWorkspace[]; +} + +/** + * Import workflow from Slack request payload + */ +export interface ImportWorkflowFromSlackPayload { + /** Workflow ID to import */ + workflowId: string; + /** Slack file ID */ + fileId: string; + /** Slack message timestamp */ + messageTs: string; + /** Slack channel ID */ + channelId: string; + /** Target workspace ID */ + workspaceId: string; + /** Workspace name for display in error dialogs (decoded from Base64) */ + workspaceName?: string; + /** Override existing file without confirmation (default: false) */ + overwriteExisting?: boolean; +} + +/** + * Import workflow success payload + */ +export interface ImportWorkflowSuccessPayload { + /** Workflow ID that was imported */ + workflowId: string; + /** Local file path where workflow was saved */ + filePath: string; + /** Workflow name */ + workflowName: string; +} + +/** + * Import workflow confirm overwrite payload + */ +export interface ImportWorkflowConfirmOverwritePayload { + /** Workflow ID to import */ + workflowId: string; + /** Existing file path that will be overwritten */ + existingFilePath: string; +} + +/** + * Import workflow failed payload + */ +export interface ImportWorkflowFailedPayload { + /** Workflow ID that failed to import */ + workflowId: string; + /** Error code */ + errorCode: + | 'NOT_AUTHENTICATED' + | 'FILE_DOWNLOAD_FAILED' + | 'INVALID_WORKFLOW_FILE' + | 'FILE_WRITE_ERROR' + | 'NETWORK_ERROR' + | 'WORKSPACE_NOT_CONNECTED' + | 'UNKNOWN_ERROR'; + /** @deprecated Use messageKey for i18n */ + errorMessage?: string; + /** i18n message key for translation */ + messageKey: string; + /** i18n suggested action key for translation */ + suggestedActionKey?: string; + /** Parameters for message interpolation (e.g., retryAfter seconds) */ + messageParams?: Record; + /** Workspace ID that is not connected (for WORKSPACE_NOT_CONNECTED error) */ + workspaceId?: string; + /** Workspace name for display in error dialogs (decoded from Base64) */ + workspaceName?: string; +} + +/** + * Search Slack workflows success payload + */ +export interface SearchSlackWorkflowsSuccessPayload { + results: SearchResult[]; +} + +/** + * Share workflow to Slack channel payload + */ +export interface ShareWorkflowToSlackPayload { + /** Target workspace ID */ + workspaceId: string; + /** Workflow ID to share (for identification purposes) */ + workflowId: string; + /** Workflow name (for display purposes) */ + workflowName: string; + /** Complete workflow object (current canvas state) */ + workflow: Workflow; + /** Target Slack channel ID */ + channelId: string; + /** Workflow description (optional) */ + description?: string; + /** Override sensitive data warning (default: false) */ + overrideSensitiveWarning?: boolean; +} + +/** + * Sensitive data finding + */ +export interface SensitiveDataFinding { + type: string; + maskedValue: string; + position: number; + context?: string; + severity: 'low' | 'medium' | 'high'; +} + +/** + * Slack channel information + */ +export interface SlackChannelInfo { + id: string; + name: string; +} + +/** + * Share workflow success payload + */ +export interface ShareWorkflowSuccessPayload { + workflowId: string; + channelId: string; + channelName: string; + messageTs: string; + fileId: string; + permalink: string; +} + +/** + * Sensitive data warning payload + */ +export interface SensitiveDataWarningPayload { + workflowId: string; + findings: SensitiveDataFinding[]; +} + +/** + * Share workflow failed payload + */ +export interface ShareWorkflowFailedPayload { + workflowId: string; + errorCode: + | 'NOT_AUTHENTICATED' + | 'CHANNEL_NOT_FOUND' + | 'FILE_UPLOAD_FAILED' + | 'MESSAGE_POST_FAILED' + | 'NETWORK_ERROR' + | 'UNKNOWN_ERROR'; + /** @deprecated Use messageKey for i18n */ + errorMessage?: string; + /** i18n message key for translation */ + messageKey: string; + /** i18n suggested action key for translation */ + suggestedActionKey?: string; + /** Parameters for message interpolation (e.g., retryAfter seconds) */ + messageParams?: Record; +} + +// ============================================================================ +// Copilot Integration Payloads (Beta) +// ============================================================================ + +/** + * Export workflow for Copilot payload + */ +export interface ExportForCopilotPayload { + /** Workflow to export */ + workflow: Workflow; +} + +/** + * Export for Copilot success payload + */ +export interface ExportForCopilotSuccessPayload { + /** Exported file paths */ + exportedFiles: string[]; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +/** + * Run workflow for Copilot payload + */ +export interface RunForCopilotPayload { + /** Workflow to run */ + workflow: Workflow; +} + +/** + * Run for Copilot success payload + */ +export interface RunForCopilotSuccessPayload { + /** Workflow name */ + workflowName: string; + /** Whether Copilot Chat was opened */ + copilotChatOpened: boolean; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +/** + * Export/Run for Copilot failed payload + */ +export interface CopilotOperationFailedPayload { + /** Error code */ + errorCode: 'COPILOT_NOT_INSTALLED' | 'EXPORT_FAILED' | 'CHAT_OPEN_FAILED' | 'UNKNOWN_ERROR'; + /** Error message */ + errorMessage: string; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +/** + * Run workflow for Copilot CLI payload + * Uses Claude Code terminal with copilot-cli-slash-command skill + */ +export interface RunForCopilotCliPayload { + /** Workflow to run */ + workflow: Workflow; +} + +/** + * Run for Copilot CLI success payload + */ +export interface RunForCopilotCliSuccessPayload { + /** Workflow name */ + workflowName: string; + /** Terminal name where command is running */ + terminalName: string; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +/** + * Export workflow for Copilot CLI payload (Skills format) + */ +export interface ExportForCopilotCliPayload { + /** Workflow to export */ + workflow: Workflow; +} + +/** + * Export for Copilot CLI success payload + */ +export interface ExportForCopilotCliSuccessPayload { + /** Skill name */ + skillName: string; + /** Skill file path */ + skillPath: string; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +// ============================================================================ +// Codex CLI Integration Payloads (Beta) +// ============================================================================ + +/** + * Export workflow for Codex CLI payload (Skills format) + * Exports to .codex/skills/{name}/SKILL.md + */ +export interface ExportForCodexCliPayload { + /** Workflow to export */ + workflow: Workflow; +} + +/** + * Export for Codex CLI success payload + */ +export interface ExportForCodexCliSuccessPayload { + /** Skill name */ + skillName: string; + /** Skill file path */ + skillPath: string; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +/** + * Run workflow for Codex CLI payload + * Uses Codex CLI with $skill-name format + */ +export interface RunForCodexCliPayload { + /** Workflow to run */ + workflow: Workflow; +} + +/** + * Run for Codex CLI success payload + */ +export interface RunForCodexCliSuccessPayload { + /** Workflow name */ + workflowName: string; + /** Terminal name where command is running */ + terminalName: string; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +/** + * Codex operation failed payload + */ +export interface CodexOperationFailedPayload { + /** Error code */ + errorCode: 'CODEX_NOT_INSTALLED' | 'EXPORT_FAILED' | 'UNKNOWN_ERROR'; + /** Error message */ + errorMessage: string; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +// ============================================================================ +// Roo Code Integration Payloads (Beta) +// ============================================================================ + +/** + * Export workflow for Roo Code payload (Skills format) + * Exports to .roo/skills/{name}/SKILL.md + */ +export interface ExportForRooCodePayload { + /** Workflow to export */ + workflow: Workflow; +} + +/** + * Export for Roo Code success payload + */ +export interface ExportForRooCodeSuccessPayload { + /** Skill name */ + skillName: string; + /** Skill file path */ + skillPath: string; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +/** + * Run workflow for Roo Code payload + * Exports and runs via Roo Code Extension API + */ +export interface RunForRooCodePayload { + /** Workflow to run */ + workflow: Workflow; +} + +/** + * Run for Roo Code success payload + */ +export interface RunForRooCodeSuccessPayload { + /** Workflow name */ + workflowName: string; + /** Whether Roo Code was opened */ + rooCodeOpened: boolean; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +/** + * Roo Code operation failed payload + */ +export interface RooCodeOperationFailedPayload { + /** Error code */ + errorCode: 'ROO_CODE_NOT_INSTALLED' | 'EXPORT_FAILED' | 'UNKNOWN_ERROR'; + /** Error message */ + errorMessage: string; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +// ============================================================================ +// Gemini CLI Integration Payloads (Beta) +// ============================================================================ + +/** + * Export workflow for Gemini CLI payload (Skills format) + * Exports to .gemini/skills/{name}/SKILL.md + */ +export interface ExportForGeminiCliPayload { + /** Workflow to export */ + workflow: Workflow; +} + +/** + * Export for Gemini CLI success payload + */ +export interface ExportForGeminiCliSuccessPayload { + /** Skill name */ + skillName: string; + /** Skill file path */ + skillPath: string; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +/** + * Run workflow for Gemini CLI payload + */ +export interface RunForGeminiCliPayload { + /** Workflow to run */ + workflow: Workflow; +} + +/** + * Run for Gemini CLI success payload + */ +export interface RunForGeminiCliSuccessPayload { + /** Workflow name */ + workflowName: string; + /** Terminal name where command is running */ + terminalName: string; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +/** + * Gemini CLI operation failed payload + */ +export interface GeminiOperationFailedPayload { + /** Error code */ + errorCode: 'GEMINI_NOT_INSTALLED' | 'EXPORT_FAILED' | 'UNKNOWN_ERROR'; + /** Error message */ + errorMessage: string; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +// ============================================================================ +// Antigravity Integration Payloads (Beta) +// ============================================================================ + +/** + * Export workflow for Antigravity payload (Skills format) + * Exports to .claude/skills/{name}/SKILL.md + */ +export interface ExportForAntigravityPayload { + /** Workflow to export */ + workflow: Workflow; +} + +/** + * Export for Antigravity success payload + */ +export interface ExportForAntigravitySuccessPayload { + /** Skill name */ + skillName: string; + /** Skill file path */ + skillPath: string; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +/** + * Run workflow for Antigravity payload + */ +export interface RunForAntigravityPayload { + /** Workflow to run */ + workflow: Workflow; +} + +/** + * Run for Antigravity success payload + */ +export interface RunForAntigravitySuccessPayload { + /** Workflow name */ + workflowName: string; + /** Whether Antigravity Cascade was opened */ + antigravityOpened: boolean; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +/** + * Antigravity operation failed payload + */ +export interface AntigravityOperationFailedPayload { + /** Error code */ + errorCode: 'ANTIGRAVITY_NOT_INSTALLED' | 'EXPORT_FAILED' | 'UNKNOWN_ERROR'; + /** Error message */ + errorMessage: string; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +// ============================================================================ +// Cursor Integration Payloads (Beta) +// ============================================================================ + +/** + * Export workflow for Cursor payload (Skills format) + * Exports to .cursor/skills/{name}/SKILL.md + */ +export interface ExportForCursorPayload { + /** Workflow to export */ + workflow: Workflow; +} + +/** + * Export for Cursor success payload + */ +export interface ExportForCursorSuccessPayload { + /** Skill name */ + skillName: string; + /** Skill file path */ + skillPath: string; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +/** + * Run workflow for Cursor payload + */ +export interface RunForCursorPayload { + /** Workflow to run */ + workflow: Workflow; +} + +/** + * Run for Cursor success payload + */ +export interface RunForCursorSuccessPayload { + /** Workflow name */ + workflowName: string; + /** Whether Cursor was opened */ + cursorOpened: boolean; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +/** + * Cursor operation failed payload + */ +export interface CursorOperationFailedPayload { + /** Error code */ + errorCode: 'CURSOR_NOT_INSTALLED' | 'EXPORT_FAILED' | 'UNKNOWN_ERROR'; + /** Error message */ + errorMessage: string; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +// ============================================================================ +// AI Editing Skill Payloads (MCP-based AI editing) +// ============================================================================ + +/** + * AI editing provider selection + */ +export type AiEditingProvider = + | 'claude-code' + | 'copilot-cli' + | 'copilot-chat' + | 'codex' + | 'roo-code' + | 'gemini' + | 'antigravity' + | 'cursor'; + +/** + * Run AI editing skill request payload (Webview → Extension) + */ +export interface RunAiEditingSkillPayload { + /** Provider to use */ + provider: AiEditingProvider; +} + +/** + * Run AI editing skill success payload (Extension → Webview) + */ +export interface RunAiEditingSkillSuccessPayload { + /** Provider that was launched */ + provider: AiEditingProvider; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +/** + * Run AI editing skill failed payload (Extension → Webview) + */ +export interface RunAiEditingSkillFailedPayload { + /** Error message */ + errorMessage: string; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +/** + * Launch AI agent request payload (Webview → Extension) + * One-click orchestration: start server → write config → launch skill + */ +export interface LaunchAiAgentPayload { + /** AI editing provider to launch */ + provider: AiEditingProvider; +} + +/** + * Launch AI agent success payload (Extension → Webview) + */ +export interface LaunchAiAgentSuccessPayload { + /** Provider that was launched */ + provider: AiEditingProvider; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +/** + * Launch AI agent failed payload (Extension → Webview) + */ +export interface LaunchAiAgentFailedPayload { + /** Error message */ + errorMessage: string; + /** Timestamp */ + timestamp: string; // ISO 8601 +} + +// ============================================================================ +// MCP Server Management Payloads (Built-in MCP Server) +// ============================================================================ + +/** + * AI agent config target for MCP server registration + */ +export type McpConfigTarget = + | 'claude-code' + | 'roo-code' + | 'copilot-chat' + | 'copilot-cli' + | 'codex' + | 'gemini' + | 'antigravity' + | 'cursor'; + +/** + * Start MCP Server request payload (Webview → Extension) + */ +export interface StartMcpServerPayload { + /** Config targets to write server URL to */ + configTargets: McpConfigTarget[]; +} + +/** + * MCP Server status payload (Extension → Webview) + */ +export interface McpServerStatusPayload { + /** Whether the server is running */ + running: boolean; + /** Port number (null when stopped) */ + port: number | null; + /** Config files that were written to */ + configsWritten: McpConfigTarget[]; + /** Whether to show diff preview before applying AI changes */ + reviewBeforeApply: boolean; +} + +/** + * Set review before apply setting payload (Webview → Extension) + */ +export interface SetReviewBeforeApplyPayload { + value: boolean; +} + +/** + * Get current workflow request payload (Extension → Webview) + */ +export interface GetCurrentWorkflowRequestPayload { + /** Request ID for correlating response */ + correlationId: string; +} + +/** + * Get current workflow response payload (Webview → Extension) + */ +export interface GetCurrentWorkflowResponsePayload { + /** Correlation ID from request */ + correlationId: string; + /** Current workflow (null if no active workflow) */ + workflow: Workflow | null; +} + +/** + * Apply workflow from MCP payload (Extension → Webview) + */ +export interface ApplyWorkflowFromMcpPayload { + /** Correlation ID for response */ + correlationId: string; + /** Workflow to apply */ + workflow: Workflow; + /** Whether to show diff preview dialog before applying */ + requireConfirmation: boolean; + /** AI agent's description of the changes (optional) */ + description?: string; +} + +/** + * Apply workflow from MCP response payload (Webview → Extension) + */ +export interface ApplyWorkflowFromMcpResponsePayload { + /** Correlation ID from request */ + correlationId: string; + /** Whether the workflow was successfully applied */ + success: boolean; + /** Error message if failed */ + error?: string; +} + +// ============================================================================ +// Edit in VSCode Editor Payloads +// ============================================================================ + +/** + * Open content in VSCode Editor payload + * Feature: Edit in VSCode Editor functionality + */ +export interface OpenInEditorPayload { + /** Unique identifier for this edit session */ + sessionId: string; + /** Current text content to edit */ + content: string; + /** Label for the editor tab (optional) */ + label?: string; + /** Language mode for syntax highlighting (default: 'markdown') */ + language?: 'markdown' | 'plaintext'; +} + +/** + * Editor content updated payload (sent when user saves or closes editor) + * Feature: Edit in VSCode Editor functionality + */ +export interface EditorContentUpdatedPayload { + /** Session ID matching the original request */ + sessionId: string; + /** Updated text content */ + content: string; + /** Whether the user saved (true) or cancelled/closed without saving (false) */ + saved: boolean; +} + +// ============================================================================ +// Utility Payloads +// ============================================================================ + +/** + * Open external URL payload + */ +export interface OpenExternalUrlPayload { + url: string; +} + +/** + * Set last shared channel payload + */ +export interface SetLastSharedChannelPayload { + /** Channel ID that was last used for sharing */ + channelId: string; +} + +/** + * Get last shared channel success payload + */ +export interface GetLastSharedChannelSuccessPayload { + /** Channel ID that was last used for sharing (null if none) */ + channelId: string | null; +} + +// ============================================================================ +// Webview → Extension Messages +// ============================================================================ + +export type WebviewMessage = + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message // @deprecated Will be removed in favor of CONNECT_SLACK_MANUAL + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message; + +// ============================================================================ +// Error Codes +// ============================================================================ + +export const ERROR_CODES = { + SAVE_FAILED: 'SAVE_FAILED', + LOAD_FAILED: 'LOAD_FAILED', + EXPORT_FAILED: 'EXPORT_FAILED', + VALIDATION_ERROR: 'VALIDATION_ERROR', + FILE_EXISTS: 'FILE_EXISTS', + PARSE_ERROR: 'PARSE_ERROR', +} as const; + +export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; + +// ============================================================================ +// Type Guards +// ============================================================================ + +export function isExtensionMessage(message: unknown): message is ExtensionMessage { + return ( + typeof message === 'object' && + message !== null && + 'type' in message && + typeof message.type === 'string' + ); +} + +export function isWebviewMessage(message: unknown): message is WebviewMessage { + return ( + typeof message === 'object' && + message !== null && + 'type' in message && + typeof message.type === 'string' + ); +} diff --git a/packages/core/src/types/slack-integration-types.ts b/packages/core/src/types/slack-integration-types.ts new file mode 100644 index 00000000..e03e2f7e --- /dev/null +++ b/packages/core/src/types/slack-integration-types.ts @@ -0,0 +1,232 @@ +/** + * Slack Integration Data Models + * + * This file defines TypeScript types for Slack integration feature. + * Based on specs/001-slack-workflow-sharing/data-model.md + */ + +// ============================================================================ +// 1. SlackWorkspaceConnection +// ============================================================================ + +/** + * Slack workspace connection information + * + * Manages workspace connections using Bot User OAuth Tokens. + * Access tokens are stored in VSCode Secret Storage (encrypted). + */ +export interface SlackWorkspaceConnection { + /** Slack Workspace ID (e.g., T01234ABCD) */ + workspaceId: string; + + /** Workspace display name */ + workspaceName: string; + + /** Slack Team ID */ + teamId: string; + + /** Bot User OAuth Token (stored in VSCode Secret Storage only) */ + accessToken: string; + + /** User OAuth Token for user-specific operations like channel listing (xoxp-...) */ + userAccessToken?: string; + + /** Token scopes (e.g., ['chat:write', 'files:write']) - Optional for manual token input */ + tokenScope?: string[]; + + /** Authenticated user's Slack User ID (e.g., U01234EFGH) */ + userId: string; + + /** Bot User ID for membership check (e.g., U01234ABCD) */ + botUserId?: string; + + /** Authorization timestamp (ISO 8601) */ + authorizedAt: Date; + + /** Last token validation timestamp (ISO 8601) */ + lastValidatedAt?: Date; +} + +// ============================================================================ +// 2. SensitiveDataFinding & SensitiveDataType +// ============================================================================ + +/** + * Types of sensitive data that can be detected + */ +export enum SensitiveDataType { + AWS_ACCESS_KEY = 'AWS_ACCESS_KEY', + AWS_SECRET_KEY = 'AWS_SECRET_KEY', + API_KEY = 'API_KEY', + TOKEN = 'TOKEN', + SLACK_TOKEN = 'SLACK_TOKEN', + GITHUB_TOKEN = 'GITHUB_TOKEN', + PRIVATE_KEY = 'PRIVATE_KEY', + PASSWORD = 'PASSWORD', + CUSTOM = 'CUSTOM', // User-defined pattern +} + +/** + * Sensitive data detection result + * + * Contains masked values and severity information. + * Original values are never stored. + */ +export interface SensitiveDataFinding { + /** Type of sensitive data detected */ + type: SensitiveDataType; + + /** Masked value (first 4 + last 4 chars only, e.g., 'AKIA...X7Z9') */ + maskedValue: string; + + /** Character offset in file */ + position: number; + + /** Surrounding context (max 100 chars) */ + context?: string; + + /** Severity level (high = AWS keys, medium = API keys, low = passwords) */ + severity: 'low' | 'medium' | 'high'; +} + +// ============================================================================ +// 3. SlackChannel +// ============================================================================ + +/** + * Slack channel information + * + * Retrieved from Slack API conversations.list + */ +export interface SlackChannel { + /** Channel ID (e.g., C01234ABCD) */ + id: string; + + /** Channel name (e.g., 'general', 'team-announcements') */ + name: string; + + /** Whether channel is private */ + isPrivate: boolean; + + /** Whether user is a member of the channel */ + isMember: boolean; + + /** Number of members in the channel */ + memberCount?: number; + + /** Channel purpose (max 250 chars) */ + purpose?: string; + + /** Channel topic (max 250 chars) */ + topic?: string; +} + +// ============================================================================ +// 4. SharedWorkflowMetadata +// ============================================================================ + +/** + * Metadata for workflows shared to Slack + * + * Embedded in Slack message block kit as metadata. + * Used for workflow search and import. + */ +export interface SharedWorkflowMetadata { + /** Workflow unique ID (UUID v4) */ + id: string; + + /** Workflow name (1-100 chars) */ + name: string; + + /** Workflow description (max 500 chars) */ + description?: string; + + /** Semantic versioning (e.g., '1.0.0') */ + version: string; + + /** Author's name (from VS Code settings) */ + authorName: string; + + /** Author's email address (optional) */ + authorEmail?: string; + + /** Timestamp when shared to Slack (ISO 8601) */ + sharedAt: Date; + + /** Slack channel ID where shared */ + channelId: string; + + /** Slack channel name (for display) */ + channelName: string; + + /** Slack message timestamp (e.g., '1234567890.123456') */ + messageTs: string; + + /** Slack file ID (e.g., F01234ABCD) */ + fileId: string; + + /** Slack file download URL (private URL) */ + fileUrl: string; + + /** Number of nodes in workflow */ + nodeCount: number; + + /** Tags for search (max 10 tags, each max 30 chars) */ + tags?: string[]; + + /** Whether sensitive data was detected */ + hasSensitiveData: boolean; + + /** Whether user overrode sensitive data warning */ + sensitiveDataOverride?: boolean; +} + +// ============================================================================ +// 5. WorkflowImportRequest & ImportStatus +// ============================================================================ + +/** + * Workflow import status + */ +export enum ImportStatus { + PENDING = 'pending', // Import queued + DOWNLOADING = 'downloading', // Downloading file from Slack + VALIDATING = 'validating', // Validating file format + WRITING = 'writing', // Writing file to disk + COMPLETED = 'completed', // Import completed + FAILED = 'failed', // Import failed +} + +/** + * Workflow import request + * + * Tracks the state of importing a workflow from Slack. + */ +export interface WorkflowImportRequest { + /** Workflow ID to import (UUID v4) */ + workflowId: string; + + /** Source Slack message timestamp */ + sourceMessageTs: string; + + /** Source Slack channel ID */ + sourceChannelId: string; + + /** Slack file ID to download */ + fileId: string; + + /** Target directory (absolute path, e.g., '/Users/.../workflows/') */ + targetDirectory: string; + + /** Whether to overwrite existing file */ + overwriteExisting: boolean; + + /** Request timestamp (ISO 8601) */ + requestedAt: Date; + + /** Current import status */ + status: ImportStatus; + + /** Error message (only when status === 'failed') */ + errorMessage?: string; +} diff --git a/packages/core/src/types/workflow-definition.ts b/packages/core/src/types/workflow-definition.ts new file mode 100644 index 00000000..c2e51dfd --- /dev/null +++ b/packages/core/src/types/workflow-definition.ts @@ -0,0 +1,676 @@ +/** + * Claude Code Workflow Studio - Workflow Definition Types + * + * Based on: /specs/001-cc-wf-studio/data-model.md + */ + +// ============================================================================ +// Core Enums +// ============================================================================ + +export enum NodeType { + SubAgent = 'subAgent', + AskUserQuestion = 'askUserQuestion', + Branch = 'branch', // Legacy: 後方互換性のため維持 + IfElse = 'ifElse', // New: 2分岐専用 + Switch = 'switch', // New: 多分岐専用 + Start = 'start', + End = 'end', + Prompt = 'prompt', + Skill = 'skill', // New: Claude Code Skill integration + Mcp = 'mcp', // New: MCP (Model Context Protocol) tool integration + SubAgentFlow = 'subAgentFlow', // New: Sub-Agent Flow reference node + Codex = 'codex', // New: OpenAI Codex CLI integration +} + +// ============================================================================ +// Base Types +// ============================================================================ + +export interface Position { + x: number; + y: number; +} + +export interface WorkflowMetadata { + tags?: string[]; + author?: string; + [key: string]: unknown; +} + +/** + * Slash Command export options + * + * Options that affect how the workflow is exported as a Slash Command (.md file) + * @see https://code.claude.com/docs/en/slash-commands#frontmatter + */ +/** Context options for Slash Command execution */ +export type SlashCommandContext = 'default' | 'fork'; + +/** Model options for Slash Command execution */ +export type SlashCommandModel = 'default' | 'sonnet' | 'opus' | 'haiku' | 'inherit'; + +export interface SlashCommandOptions { + /** Context mode for execution. 'default' means no context line in output */ + context?: SlashCommandContext; + /** Model to use for Slash Command execution. 'default' means no model line in output */ + model?: SlashCommandModel; + /** Hooks configuration for workflow execution */ + hooks?: WorkflowHooks; + /** Comma-separated list of allowed tools for Slash Command execution */ + allowedTools?: string; + /** Disable model invocation. When true, prevents the Skill tool from invoking this command. */ + disableModelInvocation?: boolean; + /** Argument hint for Slash Command auto-completion. Format: "[arg1] [arg2] | [alt1] | [alt2]" */ + argumentHint?: string; +} + +// ============================================================================ +// Hooks Configuration Types (Claude Code Docs compliant) +// https://code.claude.com/docs/en/hooks +// ============================================================================ + +/** Supported hook types for workflow execution (frontmatter-compatible only) */ +export type HookType = 'PreToolUse' | 'PostToolUse' | 'Stop'; + +/** Single hook action definition */ +export interface HookAction { + /** Hook type: 'command' for shell commands, 'prompt' for LLM-based (Stop only) */ + type: 'command' | 'prompt'; + /** Shell command to execute (required for type: 'command') */ + command: string; + /** Run hook only once per session (optional) */ + once?: boolean; +} + +/** Hook entry with matcher and actions */ +export interface HookEntry { + /** Tool name pattern to match (e.g., "Bash", "Edit|Write", "*") + * Required for PreToolUse/PostToolUse, optional for Stop */ + matcher?: string; + /** Array of hook actions to execute */ + hooks: HookAction[]; +} + +/** Hooks configuration for workflow execution */ +export interface WorkflowHooks { + /** Hooks to execute before a tool is used */ + PreToolUse?: HookEntry[]; + /** Hooks to execute after a tool is used */ + PostToolUse?: HookEntry[]; + /** Hooks to execute when the agent stops */ + Stop?: HookEntry[]; +} + +// ============================================================================ +// Node Data Types +// ============================================================================ + +export interface SubAgentData { + description: string; + prompt: string; + tools?: string; + model?: 'sonnet' | 'opus' | 'haiku' | 'inherit'; + /** Persistent memory scope for cross-conversation knowledge retention */ + memory?: 'user' | 'project' | 'local'; + color?: 'red' | 'blue' | 'green' | 'yellow' | 'purple' | 'orange' | 'pink' | 'cyan'; + outputPorts: number; +} + +// Color codes for SubAgent color property +export const SUB_AGENT_COLORS = { + red: '#C33531', + blue: '#475DE3', + green: '#54A254', + yellow: '#BC8D2E', + purple: '#892CE2', + orange: '#D2602A', + pink: '#C33476', + cyan: '#4E8FAF', +} as const; + +export interface QuestionOption { + id?: string; // Unique identifier for the option (optional for backward compatibility) + label: string; + description: string; +} + +export interface AskUserQuestionData { + questionText: string; + options: QuestionOption[]; // Empty array when useAiSuggestions is true + multiSelect?: boolean; // If true, user can select multiple options (default: false) + useAiSuggestions?: boolean; // If true, AI will suggest options dynamically (default: false) + outputPorts: number; // 2-4 for single select, 1 for multi-select or AI suggestions +} + +export interface StartNodeData { + label?: string; +} + +export interface EndNodeData { + label?: string; +} + +export interface PromptNodeData { + label?: string; + prompt: string; + variables?: Record; +} + +export interface BranchCondition { + id?: string; // Unique identifier for the branch + label: string; // Branch label (e.g., "Success", "Error") + condition: string; // Natural language condition (e.g., "前の処理が成功した場合") +} + +export interface BranchNodeData { + branchType: 'conditional' | 'switch'; // 2-way (true/false) or multi-way (switch) + branches: BranchCondition[]; // 2 for conditional, 2-N for switch + outputPorts: number; // Number of output ports (2 for conditional, 2-N for switch) +} + +// Condition type alias for IfElse (same structure as BranchCondition) +export type IfElseCondition = BranchCondition; + +// Switch condition extends BranchCondition with isDefault flag +export interface SwitchCondition extends BranchCondition { + /** If true, this is the default branch (must be last, cannot be deleted or edited) */ + isDefault?: boolean; +} + +export interface IfElseNodeData { + evaluationTarget?: string; // Natural language description of what to evaluate (e.g., "前のステップの実行結果") + branches: IfElseCondition[]; // Fixed: exactly 2 branches (True/False, Yes/No, etc.) + outputPorts: 2; // Fixed: 2 output ports +} + +export interface SwitchNodeData { + evaluationTarget?: string; // Natural language description of what to evaluate (e.g., "HTTPステータスコード") + branches: SwitchCondition[]; // Variable: 2-N branches + outputPorts: number; // Variable: 2-N output ports +} + +export interface SkillNodeData { + /** Skill name (extracted from SKILL.md frontmatter) */ + name: string; + /** Skill description (extracted from SKILL.md frontmatter) */ + description: string; + /** Path to SKILL.md file (absolute for user/local, relative for project) */ + skillPath: string; + /** Skill scope: user, project, or local */ + scope: 'user' | 'project' | 'local'; + /** Optional: Allowed tools (extracted from SKILL.md frontmatter) */ + allowedTools?: string; + /** Validation status (checked when workflow loads) */ + validationStatus: 'valid' | 'missing' | 'invalid'; + /** Number of output ports (always 1 for Skill nodes) */ + outputPorts: 1; + /** + * Source directory for project-scope skills + * - 'claude': from .claude/skills/ (Claude Code skills) + * - 'copilot': from .github/skills/ (Copilot skills) + * - undefined: for user/local scope or legacy data + */ + source?: 'claude' | 'copilot'; + /** + * Execution mode for this Skill node + * - 'execute': Execute the Skill (default behavior) + * - 'load': Load the Skill as knowledge context without executing it + * - undefined: treated as 'execute' for backward compatibility + */ + executionMode?: 'load' | 'execute'; + /** + * Custom execution prompt for 'execute' mode + * Provides additional instructions when executing the Skill. + * Only used when executionMode is 'execute' (or undefined). + */ + executionPrompt?: string; +} + +/** + * Tool parameter schema definition (from MCP) + */ +export interface ToolParameter { + /** Parameter identifier (e.g., 'region') */ + name: string; + /** Parameter data type */ + type: 'string' | 'number' | 'boolean' | 'integer' | 'array' | 'object'; + /** User-friendly description of the parameter */ + description?: string | null; + /** Whether this parameter is mandatory for tool execution */ + required: boolean; + /** Default value if not provided by user */ + default?: unknown; + /** Constraints and validation rules */ + validation?: { + minLength?: number; + maxLength?: number; + pattern?: string; + minimum?: number; + maximum?: number; + enum?: (string | number)[]; + }; + /** For array types: schema of array items */ + items?: ToolParameter; + /** For object types: schema of nested properties */ + properties?: Record; +} + +// ============================================================================ +// Sub-Agent Flow Types +// ============================================================================ + +/** + * Sub-Agent Flow definition + * + * Represents a reusable sub-agent flow that can be referenced from the main workflow. + * Sub-Agent Flows are stored in the same workflow file under the `subAgentFlows` array. + * At runtime, Sub-Agent Flows are executed as Sub-Agents. + */ +export interface SubAgentFlow { + /** Unique identifier for the sub-agent flow */ + id: string; + /** Display name of the sub-agent flow */ + name: string; + /** Optional description of the sub-agent flow's purpose */ + description?: string; + /** Nodes within the sub-agent flow (must include Start and End nodes) */ + nodes: WorkflowNode[]; + /** Connections between nodes within the sub-agent flow */ + connections: Connection[]; + /** Optional conversation history for AI-assisted refinement */ + conversationHistory?: ConversationHistory; +} + +/** + * SubAgentFlow node data + * + * References and executes a sub-agent flow defined in the same workflow file. + */ +export interface SubAgentFlowNodeData { + /** ID of the sub-agent flow to execute */ + subAgentFlowId: string; + /** Display label for the node */ + label: string; + /** Optional description */ + description?: string; + /** Number of output ports (fixed at 1 for SubAgentFlow nodes) */ + outputPorts: 1; + /** Model to use for this sub-agent flow execution */ + model?: 'sonnet' | 'opus' | 'haiku' | 'inherit'; + /** Persistent memory scope for cross-conversation knowledge retention */ + memory?: 'user' | 'project' | 'local'; + /** Comma-separated list of allowed tools */ + tools?: string; + /** Visual color for the node */ + color?: keyof typeof SUB_AGENT_COLORS; +} + +export interface McpNodeData { + /** MCP server identifier (from 'claude mcp list') */ + serverId: string; + /** Source provider of the MCP server (claude, copilot, codex, gemini, roo) */ + source?: 'claude' | 'copilot' | 'codex' | 'gemini' | 'roo'; + /** Tool function name from the MCP server (optional for aiToolSelection mode) */ + toolName?: string; + /** Human-readable description of the tool's functionality (optional for aiToolSelection mode) */ + toolDescription?: string; + /** Array of parameter schemas for this tool (immutable, from MCP definition; optional for aiToolSelection mode) */ + parameters?: ToolParameter[]; + /** User-configured values for the tool's parameters (optional for aiToolSelection mode) */ + parameterValues?: Record; + /** Validation status (computed during workflow load) */ + validationStatus: 'valid' | 'missing' | 'invalid'; + /** Number of output ports (fixed at 1 for MCP nodes) */ + outputPorts: 1; + + // AI Mode fields (optional, for backwards compatibility) + + /** Configuration mode (defaults to 'manualParameterConfig' if undefined) */ + mode?: 'manualParameterConfig' | 'aiParameterConfig' | 'aiToolSelection'; + /** AI Parameter Configuration Mode configuration (only if mode === 'aiParameterConfig') */ + aiParameterConfig?: { + description: string; + timestamp: string; + }; + /** AI Tool Selection Mode configuration (only if mode === 'aiToolSelection') */ + aiToolSelectionConfig?: { + taskDescription: string; + timestamp: string; + }; + /** Preserved manual parameter configuration (stores data when switching away from manual parameter config mode) */ + preservedManualParameterConfig?: { + parameterValues: Record; + }; +} + +/** + * Codex Agent node data + * + * Represents an OpenAI Codex CLI execution node for multi-agent workflows. + * Phase 1: UI/data model only (CLI execution is out of scope) + */ +export interface CodexNodeData { + /** Display label for the Codex agent node. Must match pattern /^[a-zA-Z0-9_-]+$/ */ + label: string; + /** + * Prompt mode for Codex execution. + * - 'fixed': Use the prompt field as-is (default) + * - 'ai-generated': Let the orchestrating AI agent generate the prompt dynamically + */ + promptMode: 'fixed' | 'ai-generated'; + /** Prompt/instructions for the Codex agent (required for 'fixed', optional guidance for 'ai-generated') */ + prompt: string; + /** Model to use for Codex execution. Predefined options or custom model name. */ + model: string; + /** Reasoning effort level */ + reasoningEffort: 'low' | 'medium' | 'high'; + /** Sandbox mode for file system access. If undefined, Codex default is used (no -s option). */ + sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access'; + /** Number of output ports (fixed at 1) */ + outputPorts: 1; + /** + * Skip Git repository trust check. + * When true, allows execution outside trusted Git repositories. + * Default: false (respects Codex's security model) + */ + skipGitRepoCheck?: boolean; +} + +// ============================================================================ +// Node Types +// ============================================================================ + +export interface BaseNode { + id: string; + type: NodeType; + name: string; + position: Position; +} + +export interface SubAgentNode extends BaseNode { + type: NodeType.SubAgent; + data: SubAgentData; +} + +export interface AskUserQuestionNode extends BaseNode { + type: NodeType.AskUserQuestion; + data: AskUserQuestionData; +} + +export interface StartNode extends BaseNode { + type: NodeType.Start; + data: StartNodeData; +} + +export interface EndNode extends BaseNode { + type: NodeType.End; + data: EndNodeData; +} + +export interface PromptNode extends BaseNode { + type: NodeType.Prompt; + data: PromptNodeData; +} + +export interface BranchNode extends BaseNode { + type: NodeType.Branch; + data: BranchNodeData; +} + +export interface IfElseNode extends BaseNode { + type: NodeType.IfElse; + data: IfElseNodeData; +} + +export interface SwitchNode extends BaseNode { + type: NodeType.Switch; + data: SwitchNodeData; +} + +export interface SkillNode extends BaseNode { + type: NodeType.Skill; + data: SkillNodeData; +} + +export interface McpNode extends BaseNode { + type: NodeType.Mcp; + data: McpNodeData; +} + +export interface SubAgentFlowNode extends BaseNode { + type: NodeType.SubAgentFlow; + data: SubAgentFlowNodeData; +} + +export interface CodexNode extends BaseNode { + type: NodeType.Codex; + data: CodexNodeData; +} + +export type WorkflowNode = + | SubAgentNode + | AskUserQuestionNode + | BranchNode // Legacy: kept for backward compatibility + | IfElseNode + | SwitchNode + | StartNode + | EndNode + | PromptNode + | SkillNode + | McpNode + | SubAgentFlowNode + | CodexNode; + +// ============================================================================ +// Connection Type +// ============================================================================ + +export interface Connection { + id: string; + from: string; // Node ID + to: string; // Node ID + fromPort: string; // Handle ID + toPort: string; // Handle ID + condition?: string; // Option label for AskUserQuestion branches +} + +// ============================================================================ +// Conversation Types (for AI-assisted workflow refinement) +// ============================================================================ + +/** + * Individual conversation message + * + * Represents a single message in the conversation history. + * Based on: /specs/001-ai-workflow-refinement/data-model.md + */ +export interface ConversationMessage { + /** Message ID (UUID v4) */ + id: string; + /** Message sender type */ + sender: 'user' | 'ai'; + /** Message content (1-5000 characters) */ + content: string; + /** Message timestamp (ISO 8601) */ + timestamp: string; + /** Optional reference to workflow snapshot */ + workflowSnapshotId?: string; + /** Optional i18n translation key (if set, content is used as fallback) */ + translationKey?: string; + /** Loading state flag (for AI messages during processing) */ + isLoading?: boolean; + /** Error state flag (for AI messages that failed) */ + isError?: boolean; + /** Error code (for AI messages that failed) */ + errorCode?: + | 'COMMAND_NOT_FOUND' + | 'TIMEOUT' + | 'PARSE_ERROR' + | 'VALIDATION_ERROR' + | 'PROHIBITED_NODE_TYPE' + | 'UNKNOWN_ERROR'; + /** Tool execution information (e.g., "Bash: npm run build") */ + toolInfo?: string | null; +} + +/** + * Conversation history for workflow refinement + * + * Stores the entire conversation history associated with a workflow. + * Based on: /specs/001-ai-workflow-refinement/data-model.md + */ +export interface ConversationHistory { + /** Schema version (for future migrations) */ + schemaVersion: '1.0.0'; + /** Array of conversation messages */ + messages: ConversationMessage[]; + /** Current iteration count (0-20) */ + currentIteration: number; + /** Maximum iteration count (fixed at 20) */ + maxIterations: 20; + /** Conversation start timestamp (ISO 8601) */ + createdAt: string; + /** Last update timestamp (ISO 8601) */ + updatedAt: string; + /** Claude Code CLI session ID for context continuation (optional) */ + sessionId?: string; +} + +// ============================================================================ +// Workflow Type +// ============================================================================ + +export interface Workflow { + id: string; + name: string; + description?: string; + version: string; + /** + * スキーマバージョン (省略可能) + * + * ワークフローファイル形式のバージョンを示します。 + * - 省略時: "1.0.0" (既存形式、新規ノードタイプ非対応) + * - "1.1.0": Start/End/Promptノードをサポート + * - "1.2.0": SubWorkflowノードをサポート + * + * @default "1.0.0" + */ + schemaVersion?: string; + nodes: WorkflowNode[]; + connections: Connection[]; + createdAt: Date; + updatedAt: Date; + metadata?: WorkflowMetadata; + /** Optional conversation history for AI-assisted workflow refinement */ + conversationHistory?: ConversationHistory; + /** Optional sub-agent flows defined within this workflow */ + subAgentFlows?: SubAgentFlow[]; + /** Optional Slash Command export options (includes hooks) */ + slashCommandOptions?: SlashCommandOptions; +} + +// ============================================================================ +// Validation Rules (for reference) +// ============================================================================ + +export const VALIDATION_RULES = { + WORKFLOW: { + MAX_NODES: 50, + NAME_MIN_LENGTH: 1, + NAME_MAX_LENGTH: 100, + NAME_PATTERN: /^[a-z0-9_-]+$/, // Lowercase only (for cross-platform file system compatibility) + VERSION_PATTERN: /^\d+\.\d+\.\d+$/, + }, + NODE: { + NAME_MIN_LENGTH: 1, + NAME_MAX_LENGTH: 50, + NAME_PATTERN: /^[a-zA-Z0-9_-]+$/, + }, + SUB_AGENT: { + DESCRIPTION_MIN_LENGTH: 1, + DESCRIPTION_MAX_LENGTH: 200, + PROMPT_MIN_LENGTH: 1, + PROMPT_MAX_LENGTH: 10000, + OUTPUT_PORTS: 1, + }, + ASK_USER_QUESTION: { + QUESTION_MIN_LENGTH: 1, + QUESTION_MAX_LENGTH: 500, + OPTIONS_MIN_COUNT: 2, + OPTIONS_MAX_COUNT: 4, + OPTION_LABEL_MIN_LENGTH: 1, + OPTION_LABEL_MAX_LENGTH: 50, + OPTION_DESCRIPTION_MIN_LENGTH: 1, + OPTION_DESCRIPTION_MAX_LENGTH: 200, + }, + BRANCH: { + CONDITION_MIN_LENGTH: 1, + CONDITION_MAX_LENGTH: 500, + LABEL_MIN_LENGTH: 1, + LABEL_MAX_LENGTH: 50, + MIN_BRANCHES: 2, + MAX_BRANCHES: 10, + }, + IF_ELSE: { + CONDITION_MIN_LENGTH: 1, + CONDITION_MAX_LENGTH: 500, + LABEL_MIN_LENGTH: 1, + LABEL_MAX_LENGTH: 50, + BRANCHES: 2, // Fixed: exactly 2 branches + OUTPUT_PORTS: 2, // Fixed: 2 output ports + }, + SWITCH: { + CONDITION_MIN_LENGTH: 1, + CONDITION_MAX_LENGTH: 500, + LABEL_MIN_LENGTH: 1, + LABEL_MAX_LENGTH: 50, + MIN_BRANCHES: 2, + MAX_BRANCHES: 10, + }, + SKILL: { + NAME_MIN_LENGTH: 1, + NAME_MAX_LENGTH: 64, + NAME_PATTERN: /^[a-z0-9-]+$/, // Lowercase, numbers, hyphens only + DESCRIPTION_MIN_LENGTH: 1, + DESCRIPTION_MAX_LENGTH: 1024, + OUTPUT_PORTS: 1, // Fixed: 1 output port + EXECUTION_PROMPT_MAX_LENGTH: 2000, + }, + MCP: { + NAME_MIN_LENGTH: 1, + NAME_MAX_LENGTH: 64, + NAME_PATTERN: /^[a-z0-9-]+$/, // Lowercase, numbers, hyphens only (same as workflow-mcp-node.schema.json) + SERVER_ID_MIN_LENGTH: 1, + SERVER_ID_MAX_LENGTH: 100, + TOOL_NAME_MIN_LENGTH: 1, + TOOL_NAME_MAX_LENGTH: 200, + TOOL_DESCRIPTION_MAX_LENGTH: 2048, + OUTPUT_PORTS: 1, // Fixed: 1 output port + }, + SUB_AGENT_FLOW: { + NAME_MIN_LENGTH: 1, + NAME_MAX_LENGTH: 50, + NAME_PATTERN: /^[a-z0-9_-]+$/, // Lowercase only (for cross-platform file system compatibility) + DESCRIPTION_MAX_LENGTH: 200, + MAX_NODES: 30, // Smaller than main workflow + // Node-specific validation (for SubAgentFlow reference nodes) + LABEL_MIN_LENGTH: 1, + LABEL_MAX_LENGTH: 50, + OUTPUT_PORTS: 1, // Fixed: 1 output port + }, + HOOKS: { + COMMAND_MIN_LENGTH: 1, + COMMAND_MAX_LENGTH: 2000, + MATCHER_MAX_LENGTH: 200, + MAX_ENTRIES_PER_HOOK: 10, + MAX_ACTIONS_PER_ENTRY: 5, + }, + CODEX: { + NAME_MIN_LENGTH: 1, + NAME_MAX_LENGTH: 64, + PROMPT_MIN_LENGTH: 1, + PROMPT_MAX_LENGTH: 10000, + OUTPUT_PORTS: 1, // Fixed: 1 output port + }, +} as const; diff --git a/packages/core/src/utils/migrate-workflow.ts b/packages/core/src/utils/migrate-workflow.ts new file mode 100644 index 00000000..232c7d5e --- /dev/null +++ b/packages/core/src/utils/migrate-workflow.ts @@ -0,0 +1,203 @@ +/** + * Workflow Migration Utility + * + * Migrates older workflow formats to current version. + * Handles backward compatibility for workflow structure changes. + */ + +import type { + SkillNodeData, + SwitchCondition, + SwitchNodeData, + Workflow, + WorkflowNode, +} from '../types/workflow-definition.js'; + +/** + * Generate a unique branch ID + */ +function generateBranchId(): string { + return `branch_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * Migrate Switch nodes to include default branch + * + * For existing workflows without default branch: + * - Adds a default branch at the end + * - Updates outputPorts count + * + * For existing workflows with default branch: + * - Ensures default branch is last + * - Ensures only one default branch exists + * + * @param workflow - The workflow to migrate + * @returns Migrated workflow with updated Switch nodes + */ +export function migrateSwitchNodes(workflow: Workflow): Workflow { + const migratedNodes = workflow.nodes.map((node) => { + if (node.type !== 'switch') return node; + + const switchData = node.data as SwitchNodeData; + const branches = switchData.branches || []; + + // Check if any branch has isDefault + const hasDefault = branches.some((b: SwitchCondition) => b.isDefault); + + if (hasDefault) { + // Ensure default branch is last + const defaultIndex = branches.findIndex((b: SwitchCondition) => b.isDefault); + if (defaultIndex !== branches.length - 1) { + const defaultBranch = branches[defaultIndex]; + const newBranches = [ + ...branches.slice(0, defaultIndex), + ...branches.slice(defaultIndex + 1), + defaultBranch, + ]; + return { + ...node, + data: { + ...switchData, + branches: newBranches, + outputPorts: newBranches.length, + }, + } as WorkflowNode; + } + return node; + } + + // Add default branch for legacy workflows + const newBranches: SwitchCondition[] = [ + ...branches.map((b: SwitchCondition) => ({ + ...b, + isDefault: false, + })), + { + id: generateBranchId(), + label: 'default', + condition: 'Other cases', + isDefault: true, + }, + ]; + + return { + ...node, + data: { + ...switchData, + branches: newBranches, + outputPorts: newBranches.length, + }, + } as WorkflowNode; + }); + + return { + ...workflow, + nodes: migratedNodes, + }; +} + +/** + * Migrate Skill nodes to use new scope terminology + * + * Converts legacy scope values to Anthropic official terminology: + * - 'personal' → 'user' + * + * This migration supports backward compatibility for existing workflows + * saved before the scope terminology update. + * + * @param workflow - The workflow to migrate + * @returns Migrated workflow with updated Skill node scopes + * + * @see Issue #364 - Tech Debt: Remove this migration after deprecation period + */ +export function migrateSkillScopes(workflow: Workflow): Workflow { + const migratedNodes = workflow.nodes.map((node) => { + if (node.type !== 'skill') return node; + + const data = node.data as SkillNodeData; + // Cast to allow checking for legacy 'personal' value + const currentScope = data.scope as string; + + // Migrate 'personal' → 'user' + if (currentScope === 'personal') { + console.warn( + `[Workflow Migration] Migrating Skill "${data.name}" scope: 'personal' → 'user'` + ); + return { + ...node, + data: { + ...data, + scope: 'user' as const, + }, + } as WorkflowNode; + } + + return node; + }); + + return { + ...workflow, + nodes: migratedNodes, + }; +} + +/** + * Migrate Skill nodes to include explicit executionMode + * + * For existing workflows without executionMode: + * - Sets executionMode to 'execute' (preserving existing behavior) + * + * @param workflow - The workflow to migrate + * @returns Migrated workflow with updated Skill nodes + */ +export function migrateSkillExecutionMode(workflow: Workflow): Workflow { + const migratedNodes = workflow.nodes.map((node) => { + if (node.type !== 'skill') return node; + + const data = node.data as SkillNodeData; + + if (data.executionMode === undefined) { + return { + ...node, + data: { + ...data, + executionMode: 'execute' as const, + }, + } as WorkflowNode; + } + + return node; + }); + + return { + ...workflow, + nodes: migratedNodes, + }; +} + +/** + * Apply all workflow migrations + * + * Runs all migration functions in sequence. + * Add new migration functions here as the schema evolves. + * + * @param workflow - The workflow to migrate + * @returns Fully migrated workflow + */ +export function migrateWorkflow(workflow: Workflow): Workflow { + // Apply migrations in order + let migrated = workflow; + + // Migration 1: Add default branch to Switch nodes + migrated = migrateSwitchNodes(migrated); + + // Migration 2: Update Skill node scope terminology ('personal' → 'user') + migrated = migrateSkillScopes(migrated); + + // Migration 3: Set explicit executionMode on Skill nodes + migrated = migrateSkillExecutionMode(migrated); + + // Add future migrations here... + + return migrated; +} diff --git a/packages/core/src/utils/path-utils.ts b/packages/core/src/utils/path-utils.ts new file mode 100644 index 00000000..e26984ef --- /dev/null +++ b/packages/core/src/utils/path-utils.ts @@ -0,0 +1,140 @@ +import os from 'node:os'; +import path from 'node:path'; + +export function getUserSkillsDir(): string { + return path.join(os.homedir(), '.claude', 'skills'); +} + +/** @deprecated Use getUserSkillsDir() instead. */ +export function getPersonalSkillsDir(): string { + return getUserSkillsDir(); +} + +export function getProjectSkillsDirFromRoot(workspaceRoot: string): string { + return path.join(workspaceRoot, '.claude', 'skills'); +} + +export function getGithubSkillsDirFromRoot(workspaceRoot: string): string { + return path.join(workspaceRoot, '.github', 'skills'); +} + +export function getCopilotUserSkillsDir(): string { + return path.join(os.homedir(), '.copilot', 'skills'); +} + +export function getCodexUserSkillsDir(): string { + return path.join(os.homedir(), '.codex', 'skills'); +} + +export function getCodexProjectSkillsDirFromRoot(workspaceRoot: string): string { + return path.join(workspaceRoot, '.codex', 'skills'); +} + +export function getRooUserSkillsDir(): string { + return path.join(os.homedir(), '.roo', 'skills'); +} + +export function getRooProjectSkillsDirFromRoot(workspaceRoot: string): string { + return path.join(workspaceRoot, '.roo', 'skills'); +} + +export function getGeminiUserSkillsDir(): string { + return path.join(os.homedir(), '.gemini', 'skills'); +} + +export function getGeminiProjectSkillsDirFromRoot(workspaceRoot: string): string { + return path.join(workspaceRoot, '.gemini', 'skills'); +} + +export function getAntigravityUserSkillsDir(): string { + return path.join(os.homedir(), '.gemini', 'antigravity', 'skills'); +} + +export function getAntigravityProjectSkillsDirFromRoot(workspaceRoot: string): string { + return path.join(workspaceRoot, '.agent', 'skills'); +} + +export function getCursorUserSkillsDir(): string { + return path.join(os.homedir(), '.cursor', 'skills'); +} + +export function getCursorProjectSkillsDirFromRoot(workspaceRoot: string): string { + return path.join(workspaceRoot, '.cursor', 'skills'); +} + +// MCP Configuration Paths + +export function getCopilotUserMcpConfigPath(): string { + return path.join(os.homedir(), '.copilot', 'mcp-config.json'); +} + +export function getVSCodeMcpConfigPathFromRoot(workspaceRoot: string): string { + return path.join(workspaceRoot, '.vscode', 'mcp.json'); +} + +export function getCodexUserMcpConfigPath(): string { + return path.join(os.homedir(), '.codex', 'config.toml'); +} + +export function getGeminiUserMcpConfigPath(): string { + return path.join(os.homedir(), '.gemini', 'settings.json'); +} + +export function getGeminiProjectMcpConfigPathFromRoot(workspaceRoot: string): string { + return path.join(workspaceRoot, '.gemini', 'settings.json'); +} + +export function getAntigravityUserMcpConfigPath(): string { + return path.join(os.homedir(), '.gemini', 'antigravity', 'mcp_config.json'); +} + +export function getCursorUserMcpConfigPath(): string { + return path.join(os.homedir(), '.cursor', 'mcp.json'); +} + +export function getRooProjectMcpConfigPathFromRoot(workspaceRoot: string): string { + return path.join(workspaceRoot, '.roo', 'mcp.json'); +} + +export function getInstalledPluginsJsonPath(): string { + return path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json'); +} + +export function getClaudeSettingsJsonPath(): string { + return path.join(os.homedir(), '.claude', 'settings.json'); +} + +export function getKnownMarketplacesJsonPath(): string { + return path.join(os.homedir(), '.claude', 'plugins', 'known_marketplaces.json'); +} + +export function resolveSkillPathWithRoot( + skillPath: string, + scope: 'user' | 'project' | 'local', + workspaceRoot?: string +): string { + if (scope === 'user' || scope === 'local') { + return skillPath; + } + if (!workspaceRoot) { + throw new Error('No workspace folder found for project Skill resolution'); + } + if (path.isAbsolute(skillPath)) { + return skillPath; + } + return path.resolve(workspaceRoot, skillPath); +} + +export function toRelativePathWithRoot( + absolutePath: string, + scope: 'user' | 'project' | 'local', + workspaceRoot?: string +): string { + if (scope === 'user' || scope === 'local') { + return absolutePath; + } + if (!workspaceRoot) { + return absolutePath; + } + return path.relative(workspaceRoot, absolutePath); +} diff --git a/packages/core/src/utils/schema-parser.ts b/packages/core/src/utils/schema-parser.ts new file mode 100644 index 00000000..fd15a3ed --- /dev/null +++ b/packages/core/src/utils/schema-parser.ts @@ -0,0 +1,348 @@ +/** + * JSON Schema Parser + * + * Feature: 001-mcp-node + * Purpose: Parse and validate JSON Schema for MCP tool parameters + * + * Based on: JSON Schema Draft 7 + * Task: T030 + */ + +import type { ToolParameter } from '../types/mcp-node.js'; + +/** + * JSON Schema property definition + */ +export interface JsonSchemaProperty { + type?: string | string[]; + description?: string; + enum?: unknown[]; + default?: unknown; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + pattern?: string; + items?: JsonSchemaProperty; + properties?: Record; + required?: string[]; + additionalProperties?: boolean | JsonSchemaProperty; +} + +/** + * JSON Schema root definition + */ +export interface JsonSchema { + type?: string; + properties?: Record; + required?: string[]; + additionalProperties?: boolean | JsonSchemaProperty; +} + +/** + * Extended ToolParameter with additional validation metadata + */ +export interface ExtendedToolParameter extends ToolParameter { + /** Allowed enum values (if defined) */ + enum?: unknown[]; + /** Minimum value for numbers */ + minimum?: number; + /** Maximum value for numbers */ + maximum?: number; + /** Minimum length for strings */ + minLength?: number; + /** Maximum length for strings */ + maxLength?: number; + /** Regex pattern for string validation */ + pattern?: string; +} + +/** + * Parse JSON Schema and convert to ToolParameter array + * + * Extracts parameter definitions with validation metadata from JSON Schema. + * Supports string, number, boolean, integer, array, and object types. + * + * @param schema - JSON Schema object + * @returns Array of tool parameters with validation metadata + * + * @example + * ```typescript + * const schema = { + * type: 'object', + * properties: { + * region: { + * type: 'string', + * description: 'AWS region', + * enum: ['us-east-1', 'us-west-2'] + * }, + * limit: { + * type: 'integer', + * description: 'Result limit', + * minimum: 1, + * maximum: 100, + * default: 10 + * } + * }, + * required: ['region'] + * }; + * + * const params = parseJsonSchema(schema); + * // [ + * // { name: 'region', type: 'string', required: true, enum: ['us-east-1', 'us-west-2'], ... }, + * // { name: 'limit', type: 'integer', required: false, minimum: 1, maximum: 100, default: 10, ... } + * // ] + * ``` + */ +export function parseJsonSchema(schema: JsonSchema): ExtendedToolParameter[] { + if (!schema.properties) { + return []; + } + + const required = schema.required || []; + + return Object.entries(schema.properties).map(([name, propSchema]) => { + // Determine parameter type + const paramType = normalizeSchemaType(propSchema.type); + + // Base parameter + const param: ExtendedToolParameter = { + name, + type: paramType, + description: propSchema.description || '', + required: required.includes(name), + }; + + // Add enum values if defined + if (propSchema.enum) { + param.enum = propSchema.enum; + } + + // Add default value if defined + if (propSchema.default !== undefined) { + param.default = propSchema.default; + } + + // Add numeric constraints + if (paramType === 'number' || paramType === 'integer') { + if (propSchema.minimum !== undefined) { + param.minimum = propSchema.minimum; + } + if (propSchema.maximum !== undefined) { + param.maximum = propSchema.maximum; + } + } + + // Add string constraints + if (paramType === 'string') { + if (propSchema.minLength !== undefined) { + param.minLength = propSchema.minLength; + } + if (propSchema.maxLength !== undefined) { + param.maxLength = propSchema.maxLength; + } + if (propSchema.pattern) { + param.pattern = propSchema.pattern; + } + } + + // Note: Array items and object properties are not directly mapped + // because JsonSchemaProperty and ToolParameter have incompatible structures. + // These should be handled by the consumer if needed. + + return param; + }); +} + +/** + * Normalize JSON Schema type to ToolParameter type + * + * Handles both single type strings and type arrays. + * + * @param type - JSON Schema type (string or string[]) + * @returns Normalized type string + */ +function normalizeSchemaType( + type?: string | string[] +): 'string' | 'number' | 'boolean' | 'integer' | 'array' | 'object' { + // Default to string if type is not defined + if (!type) { + return 'string'; + } + + // If type is an array, use the first non-null type + if (Array.isArray(type)) { + const firstType = type.find((t) => t !== 'null'); + if (!firstType) { + return 'string'; + } + type = firstType; + } + + // Validate and return type + if ( + type === 'string' || + type === 'number' || + type === 'boolean' || + type === 'integer' || + type === 'array' || + type === 'object' + ) { + return type; + } + + // Unknown type defaults to string + return 'string'; +} + +/** + * Validate parameter value against JSON Schema constraints + * + * Checks if a value satisfies the constraints defined in the parameter schema. + * + * @param value - Value to validate + * @param param - Parameter schema with constraints + * @returns Validation result with error message if invalid + * + * @example + * ```typescript + * const param = { name: 'region', type: 'string', enum: ['us-east-1', 'us-west-2'], required: true }; + * const result = validateParameterValue('us-east-1', param); + * // { valid: true } + * + * const invalidResult = validateParameterValue('invalid-region', param); + * // { valid: false, error: 'Value must be one of: us-east-1, us-west-2' } + * ``` + */ +export function validateParameterValue( + value: unknown, + param: ExtendedToolParameter +): { valid: boolean; error?: string } { + // Check required constraint + if (param.required && (value === undefined || value === null || value === '')) { + return { valid: false, error: 'This field is required' }; + } + + // Skip validation if value is empty and not required + if (!param.required && (value === undefined || value === null || value === '')) { + return { valid: true }; + } + + // Validate by type + switch (param.type) { + case 'string': + return validateStringValue(value, param); + case 'number': + case 'integer': + return validateNumberValue(value, param); + case 'boolean': + return validateBooleanValue(value); + case 'array': + return validateArrayValue(value); + case 'object': + return validateObjectValue(value); + default: + return { valid: true }; + } +} + +/** + * Validate string value + */ +function validateStringValue( + value: unknown, + param: ExtendedToolParameter +): { valid: boolean; error?: string } { + if (typeof value !== 'string') { + return { valid: false, error: 'Value must be a string' }; + } + + // Check enum constraint + if (param.enum && !param.enum.includes(value)) { + return { valid: false, error: `Value must be one of: ${param.enum.join(', ')}` }; + } + + // Check minLength constraint + if (param.minLength !== undefined && value.length < param.minLength) { + return { valid: false, error: `Minimum length is ${param.minLength}` }; + } + + // Check maxLength constraint + if (param.maxLength !== undefined && value.length > param.maxLength) { + return { valid: false, error: `Maximum length is ${param.maxLength}` }; + } + + // Check pattern constraint + if (param.pattern) { + const regex = new RegExp(param.pattern); + if (!regex.test(value)) { + return { valid: false, error: `Value must match pattern: ${param.pattern}` }; + } + } + + return { valid: true }; +} + +/** + * Validate number value + */ +function validateNumberValue( + value: unknown, + param: ExtendedToolParameter +): { valid: boolean; error?: string } { + const num = Number(value); + + if (Number.isNaN(num)) { + return { valid: false, error: 'Value must be a number' }; + } + + // Check integer constraint + if (param.type === 'integer' && !Number.isInteger(num)) { + return { valid: false, error: 'Value must be an integer' }; + } + + // Check minimum constraint + if (param.minimum !== undefined && num < param.minimum) { + return { valid: false, error: `Minimum value is ${param.minimum}` }; + } + + // Check maximum constraint + if (param.maximum !== undefined && num > param.maximum) { + return { valid: false, error: `Maximum value is ${param.maximum}` }; + } + + return { valid: true }; +} + +/** + * Validate boolean value + */ +function validateBooleanValue(value: unknown): { valid: boolean; error?: string } { + if (typeof value !== 'boolean' && value !== 'true' && value !== 'false') { + return { valid: false, error: 'Value must be a boolean' }; + } + + return { valid: true }; +} + +/** + * Validate array value + */ +function validateArrayValue(value: unknown): { valid: boolean; error?: string } { + if (!Array.isArray(value)) { + return { valid: false, error: 'Value must be an array' }; + } + + return { valid: true }; +} + +/** + * Validate object value + */ +function validateObjectValue(value: unknown): { valid: boolean; error?: string } { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return { valid: false, error: 'Value must be an object' }; + } + + return { valid: true }; +} diff --git a/packages/core/src/utils/sensitive-data-detector.ts b/packages/core/src/utils/sensitive-data-detector.ts new file mode 100644 index 00000000..65771b87 --- /dev/null +++ b/packages/core/src/utils/sensitive-data-detector.ts @@ -0,0 +1,207 @@ +/** + * Sensitive Data Detector Utility + * + * Detects and masks sensitive information (API keys, tokens, passwords, etc.) + * in workflow JSON files before sharing to Slack. + * + * Based on specs/001-slack-workflow-sharing/data-model.md + */ + +import { type SensitiveDataFinding, SensitiveDataType } from '../types/slack-integration-types.js'; + +/** + * Detection pattern definition + */ +interface DetectionPattern { + /** Pattern type */ + type: SensitiveDataType; + /** Regular expression for detection */ + pattern: RegExp; + /** Severity level */ + severity: 'low' | 'medium' | 'high'; + /** Minimum length for valid matches */ + minLength?: number; +} + +/** + * Built-in detection patterns + * + * Patterns are based on common secret formats and best practices. + */ +const DETECTION_PATTERNS: DetectionPattern[] = [ + // AWS Access Key (AKIA followed by 16 alphanumeric chars) + { + type: SensitiveDataType.AWS_ACCESS_KEY, + pattern: /AKIA[0-9A-Z]{16}/g, + severity: 'high', + }, + + // AWS Secret Key (40 chars base64-like string) + { + type: SensitiveDataType.AWS_SECRET_KEY, + pattern: /(?:aws_secret_access_key|aws[_-]?secret)["\s]*[:=]["\s]*([A-Za-z0-9/+=]{40})/gi, + severity: 'high', + minLength: 40, + }, + + // Slack Token (xoxb-, xoxp-, xoxa-, xoxo- prefixes) + { + type: SensitiveDataType.SLACK_TOKEN, + pattern: /xox[bpoa]-[A-Za-z0-9-]{10,}/g, + severity: 'high', + }, + + // GitHub Personal Access Token (ghp_ prefix, 36 chars) + { + type: SensitiveDataType.GITHUB_TOKEN, + pattern: /ghp_[A-Za-z0-9]{36}/g, + severity: 'high', + }, + + // Generic API Key patterns + { + type: SensitiveDataType.API_KEY, + pattern: /(?:api[_-]?key|apikey)["\s]*[:=]["\s]*["']?([A-Za-z0-9_-]{20,})["']?/gi, + severity: 'medium', + minLength: 20, + }, + + // Generic Token patterns + { + type: SensitiveDataType.TOKEN, + pattern: + /(?:token|auth[_-]?token|access[_-]?token)["\s]*[:=]["\s]*["']?([A-Za-z0-9_\-.]{20,})["']?/gi, + severity: 'medium', + minLength: 20, + }, + + // Private Key markers + { + type: SensitiveDataType.PRIVATE_KEY, + pattern: /-----BEGIN [A-Z ]+PRIVATE KEY-----/g, + severity: 'high', + }, + + // Password patterns + { + type: SensitiveDataType.PASSWORD, + pattern: /(?:password|passwd|pwd)["\s]*[:=]["\s]*["']?([^\s"']{8,})["']?/gi, + severity: 'low', + minLength: 8, + }, +]; + +/** + * Masks a sensitive value + * + * Shows only first 4 and last 4 characters. + * Example: "AKIAIOSFODNN7EXAMPLE" → "AKIA...MPLE" + * + * @param value - Original value to mask + * @returns Masked value + */ +function maskValue(value: string): string { + if (value.length <= 8) { + // Too short to mask meaningfully, mask completely + return '****'; + } + + const first4 = value.substring(0, 4); + const last4 = value.substring(value.length - 4); + return `${first4}...${last4}`; +} + +/** + * Extracts context around a match + * + * @param content - Full content + * @param position - Match position + * @param contextLength - Context length (default: 50 chars on each side) + * @returns Context string + */ +function extractContext(content: string, position: number, contextLength = 50): string { + const start = Math.max(0, position - contextLength); + const end = Math.min(content.length, position + contextLength); + + const contextBefore = content.substring(start, position); + const contextAfter = content.substring(position, end); + + return `...${contextBefore}[REDACTED]${contextAfter}...`; +} + +/** + * Detects sensitive data in content + * + * @param content - Content to scan (workflow JSON as string) + * @returns Array of sensitive data findings + */ +export function detectSensitiveData(content: string): SensitiveDataFinding[] { + const findings: SensitiveDataFinding[] = []; + + for (const patternDef of DETECTION_PATTERNS) { + // Reset regex state + patternDef.pattern.lastIndex = 0; + + let match: RegExpExecArray | null; + // biome-ignore lint/suspicious/noAssignInExpressions: Standard regex exec loop pattern + while ((match = patternDef.pattern.exec(content)) !== null) { + const matchedValue = match[1] || match[0]; // Use capture group if available + const position = match.index; + + // Validate minimum length if specified + if (patternDef.minLength && matchedValue.length < patternDef.minLength) { + continue; + } + + findings.push({ + type: patternDef.type, + maskedValue: maskValue(matchedValue), + position, + context: extractContext(content, position), + severity: patternDef.severity, + }); + } + } + + return findings; +} + +/** + * Checks if workflow content contains sensitive data + * + * @param workflowContent - Workflow JSON as string + * @returns True if sensitive data detected, false otherwise + */ +export function hasSensitiveData(workflowContent: string): boolean { + return detectSensitiveData(workflowContent).length > 0; +} + +/** + * Gets high severity findings only + * + * @param findings - All findings + * @returns High severity findings + */ +export function getHighSeverityFindings(findings: SensitiveDataFinding[]): SensitiveDataFinding[] { + return findings.filter((finding) => finding.severity === 'high'); +} + +/** + * Groups findings by type + * + * @param findings - All findings + * @returns Findings grouped by type + */ +export function groupFindingsByType( + findings: SensitiveDataFinding[] +): Map { + const grouped = new Map(); + + for (const finding of findings) { + const existing = grouped.get(finding.type) || []; + existing.push(finding); + grouped.set(finding.type, existing); + } + + return grouped; +} diff --git a/packages/core/src/utils/slack-error-handler.ts b/packages/core/src/utils/slack-error-handler.ts new file mode 100644 index 00000000..e8a90775 --- /dev/null +++ b/packages/core/src/utils/slack-error-handler.ts @@ -0,0 +1,261 @@ +/** + * Slack Error Handler Utility + * + * Provides unified error handling for Slack API operations. + * Maps Slack API errors to i18n translation keys. + * + * Based on specs/001-slack-workflow-sharing/contracts/slack-api-contracts.md + */ + +/** + * Slack error information with i18n keys + */ +export interface SlackErrorInfo { + /** Error code (for programmatic handling) */ + code: string; + /** i18n message key for translation */ + messageKey: string; + /** Whether error is recoverable */ + recoverable: boolean; + /** i18n suggested action key for translation */ + suggestedActionKey?: string; + /** Retry after seconds (for rate limiting) */ + retryAfter?: number; + /** Workspace ID (for WORKSPACE_NOT_CONNECTED errors) */ + workspaceId?: string; +} + +/** + * Error code mappings with i18n keys + */ +const ERROR_MAPPINGS: Record> = { + invalid_auth: { + messageKey: 'slack.error.invalidAuth', + recoverable: true, + suggestedActionKey: 'slack.error.action.reconnect', + }, + missing_scope: { + messageKey: 'slack.error.missingScope', + recoverable: true, + suggestedActionKey: 'slack.error.action.addPermission', + }, + rate_limited: { + messageKey: 'slack.error.rateLimited', + recoverable: true, + suggestedActionKey: 'slack.error.action.waitAndRetry', + }, + channel_not_found: { + messageKey: 'slack.error.channelNotFound', + recoverable: false, + suggestedActionKey: 'slack.error.action.checkChannelId', + }, + not_in_channel: { + messageKey: 'slack.error.notInChannel', + recoverable: true, + suggestedActionKey: 'slack.error.action.inviteBot', + }, + file_too_large: { + messageKey: 'slack.error.fileTooLarge', + recoverable: false, + suggestedActionKey: 'slack.error.action.reduceFileSize', + }, + invalid_file_type: { + messageKey: 'slack.error.invalidFileType', + recoverable: false, + suggestedActionKey: 'slack.error.action.useJsonFormat', + }, + internal_error: { + messageKey: 'slack.error.internalError', + recoverable: true, + suggestedActionKey: 'slack.error.action.waitAndRetry', + }, + not_authed: { + messageKey: 'slack.error.notAuthed', + recoverable: true, + suggestedActionKey: 'slack.error.action.connect', + }, + invalid_code: { + messageKey: 'slack.error.invalidCode', + recoverable: true, + suggestedActionKey: 'slack.error.action.restartAuth', + }, + bad_client_secret: { + messageKey: 'slack.error.badClientSecret', + recoverable: false, + suggestedActionKey: 'slack.error.action.checkAppSettings', + }, + invalid_grant_type: { + messageKey: 'slack.error.invalidGrantType', + recoverable: false, + suggestedActionKey: 'slack.error.action.checkAppSettings', + }, + account_inactive: { + messageKey: 'slack.error.accountInactive', + recoverable: false, + suggestedActionKey: 'slack.error.action.checkAccountStatus', + }, + invalid_query: { + messageKey: 'slack.error.invalidQuery', + recoverable: false, + suggestedActionKey: 'slack.error.action.checkSearchKeyword', + }, + msg_too_long: { + messageKey: 'slack.error.msgTooLong', + recoverable: false, + suggestedActionKey: 'slack.error.action.reduceDescription', + }, +}; + +/** + * Handles Slack API errors + * + * @param error - Error from Slack API call + * @returns Structured error information with i18n keys + */ +export function handleSlackError(error: unknown): SlackErrorInfo { + // Check for WORKSPACE_NOT_CONNECTED custom error + if ( + error && + typeof error === 'object' && + 'code' in error && + (error as { code: string }).code === 'WORKSPACE_NOT_CONNECTED' + ) { + const workspaceError = error as { code: string; workspaceId?: string; message?: string }; + return { + code: 'WORKSPACE_NOT_CONNECTED', + messageKey: 'slack.error.workspaceNotConnected', + recoverable: true, + suggestedActionKey: 'slack.error.action.connectAndImport', + workspaceId: workspaceError.workspaceId, + }; + } + + // Check if it's a Slack Web API error (property-based check instead of instanceof) + // This works even when @slack/web-api is an external dependency + if ( + error && + typeof error === 'object' && + 'data' in error && + error.data && + typeof error.data === 'object' + ) { + // Type assertion for Slack Web API error structure + const slackError = error as { data: { error?: string; retryAfter?: number } }; + const errorCode = slackError.data.error || 'unknown_error'; + + // Get error mapping + const mapping = ERROR_MAPPINGS[errorCode] || { + messageKey: 'slack.error.unknownApiError', + recoverable: false, + suggestedActionKey: 'slack.error.action.contactSupport', + }; + + // Extract retry-after for rate limiting + const retryAfter = slackError.data.retryAfter ? Number(slackError.data.retryAfter) : undefined; + + return { + code: errorCode, + ...mapping, + retryAfter, + }; + } + + // Network or other errors + if (error instanceof Error) { + return { + code: 'NETWORK_ERROR', + messageKey: 'slack.error.networkError', + recoverable: true, + suggestedActionKey: 'slack.error.action.checkConnection', + }; + } + + // Unknown error + return { + code: 'UNKNOWN_ERROR', + messageKey: 'slack.error.unknownError', + recoverable: false, + suggestedActionKey: 'slack.error.action.contactSupport', + }; +} + +/** + * Formats error for user display (deprecated - use i18n on Webview side) + * + * @param errorInfo - Error information + * @returns Formatted error message key (for debugging purposes) + * @deprecated Use messageKey and suggestedActionKey for i18n translation on Webview side + */ +export function formatErrorMessage(errorInfo: SlackErrorInfo): string { + // Return messageKey for debugging - actual translation happens on Webview side + let message = errorInfo.messageKey; + + if (errorInfo.suggestedActionKey) { + message += ` | ${errorInfo.suggestedActionKey}`; + } + + if (errorInfo.retryAfter) { + message += ` | retryAfter: ${errorInfo.retryAfter}`; + } + + return message; +} + +/** + * Checks if error is recoverable + * + * @param error - Error from Slack API call + * @returns True if error is recoverable + */ +export function isRecoverableError(error: unknown): boolean { + const errorInfo = handleSlackError(error); + return errorInfo.recoverable; +} + +/** + * Checks if error is authentication-related + * + * @param error - Error from Slack API call + * @returns True if authentication error + */ +export function isAuthenticationError(error: unknown): boolean { + const errorInfo = handleSlackError(error); + return ['invalid_auth', 'not_authed', 'account_inactive'].includes(errorInfo.code); +} + +/** + * Checks if error is permission-related + * + * @param error - Error from Slack API call + * @returns True if permission error + */ +export function isPermissionError(error: unknown): boolean { + const errorInfo = handleSlackError(error); + return ['missing_scope', 'not_in_channel'].includes(errorInfo.code); +} + +/** + * Checks if error is rate limiting + * + * @param error - Error from Slack API call + * @returns True if rate limiting error + */ +export function isRateLimitError(error: unknown): boolean { + const errorInfo = handleSlackError(error); + return errorInfo.code === 'rate_limited'; +} + +/** + * Gets retry delay for exponential backoff + * + * @param attempt - Retry attempt number (1-indexed) + * @param maxDelay - Maximum delay in seconds (default: 60) + * @returns Delay in seconds + */ +export function getRetryDelay(attempt: number, maxDelay = 60): number { + // Exponential backoff: 2^attempt seconds, capped at maxDelay + const delay = Math.min(2 ** attempt, maxDelay); + // Add jitter (random 0-20%) + const jitter = delay * 0.2 * Math.random(); + return delay + jitter; +} diff --git a/packages/core/src/utils/slack-message-builder.ts b/packages/core/src/utils/slack-message-builder.ts new file mode 100644 index 00000000..bac00419 --- /dev/null +++ b/packages/core/src/utils/slack-message-builder.ts @@ -0,0 +1,141 @@ +/** + * Slack Block Kit Message Builder + * + * Builds rich message blocks for Slack using Block Kit format. + * Used for displaying workflow metadata in Slack channels. + * + * Based on specs/001-slack-workflow-sharing/contracts/slack-api-contracts.md + */ + +/** + * Supported editors for workflow import + * Each editor uses a custom URI scheme for deep linking + */ +const SUPPORTED_EDITORS = [ + { name: 'VS Code', scheme: 'vscode' }, + { name: 'Cursor', scheme: 'cursor' }, + { name: 'Windsurf', scheme: 'windsurf' }, + { name: 'Kiro', scheme: 'kiro' }, + { name: 'Antigravity', scheme: 'antigravity' }, +] as const; + +/** + * Extension ID for the workflow studio extension + */ +const EXTENSION_ID = 'breaking-brake.cc-wf-studio'; + +/** + * Builds an import URI for a specific editor + * + * @param scheme - The URI scheme for the editor (e.g., 'vscode', 'cursor') + * @param block - The workflow message block containing import parameters + * @returns The complete import URI + */ +function buildImportUri(scheme: string, block: WorkflowMessageBlock): string { + const params = new URLSearchParams({ + workflowId: block.workflowId, + fileId: block.fileId, + workspaceId: block.workspaceId || '', + channelId: block.channelId || '', + messageTs: block.messageTs || '', + }); + + // Add workspace name as Base64-encoded parameter for display in error dialogs + if (block.workspaceName) { + params.set('workspaceName', Buffer.from(block.workspaceName, 'utf-8').toString('base64')); + } + + return `${scheme}://${EXTENSION_ID}/import?${params.toString()}`; +} + +/** + * Workflow message block (Block Kit format) + */ +export interface WorkflowMessageBlock { + /** Workflow ID */ + workflowId: string; + /** Workflow name */ + name: string; + /** Workflow description */ + description?: string; + /** Workflow version */ + version: string; + /** Node count */ + nodeCount: number; + /** File ID (after upload) */ + fileId: string; + /** Workspace ID (for deep link) */ + workspaceId?: string; + /** Workspace name (for display in error dialogs, Base64 encoded in URI) */ + workspaceName?: string; + /** Channel ID (for deep link) */ + channelId?: string; + /** Message timestamp (for deep link) */ + messageTs?: string; +} + +/** + * Builds Block Kit blocks for workflow message + * + * Creates a rich message card with: + * - Header with workflow name + * - Description section (if provided) + * - Metadata fields (Date) + * - Import link with deep link to VS Code + * + * @param block - Workflow message block + * @returns Block Kit blocks array + */ +export function buildWorkflowMessageBlocks( + block: WorkflowMessageBlock +): Array> { + return [ + // Header + { + type: 'header', + text: { + type: 'plain_text', + text: block.name, + }, + }, + // Description (if provided) + ...(block.description + ? [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: block.description, + }, + }, + { type: 'divider' }, + ] + : [{ type: 'divider' }]), + // Import links section + ...(block.workspaceId && block.channelId && block.messageTs && block.fileId + ? [ + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `📥 *Import to:* ${SUPPORTED_EDITORS.map( + (editor) => `<${buildImportUri(editor.scheme, block)}|${editor.name}>` + ).join(' · ')}`, + }, + ], + }, + ] + : [ + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: '_Import link will be available after file upload_', + }, + ], + }, + ]), + ]; +} diff --git a/packages/core/src/utils/validate-workflow.ts b/packages/core/src/utils/validate-workflow.ts new file mode 100644 index 00000000..01181c43 --- /dev/null +++ b/packages/core/src/utils/validate-workflow.ts @@ -0,0 +1,1069 @@ +/** + * Workflow Validation Utility + * + * Validates AI-generated workflows against schema rules. + * Based on: /specs/001-ai-workflow-generation/research.md Q3 + */ + +import { + type Connection, + type HookType, + type McpNodeData, + NodeType, + type SkillNodeData, + type SubAgentFlow, + type SubAgentFlowNodeData, + type SwitchNodeData, + VALIDATION_RULES, + type Workflow, + type WorkflowHooks, + type WorkflowNode, +} from '../types/workflow-definition.js'; + +export interface ValidationError { + code: string; + message: string; + field?: string; +} + +export interface ValidationResult { + valid: boolean; + errors: ValidationError[]; +} + +/** + * Validate a workflow generated by AI + * + * @param workflow - The workflow object to validate + * @returns Validation result with errors if invalid + */ +export function validateAIGeneratedWorkflow(workflow: unknown): ValidationResult { + const errors: ValidationError[] = []; + + // Type check: Is it an object? + if (typeof workflow !== 'object' || workflow === null) { + return { + valid: false, + errors: [{ code: 'INVALID_TYPE', message: 'Workflow must be an object' }], + }; + } + + const wf = workflow as Partial; + + // Required fields check + if (!wf.id || typeof wf.id !== 'string') { + errors.push({ code: 'MISSING_FIELD', message: 'Workflow must have an id', field: 'id' }); + } + + if (!wf.name || typeof wf.name !== 'string') { + errors.push({ code: 'MISSING_FIELD', message: 'Workflow must have a name', field: 'name' }); + } else if (!VALIDATION_RULES.WORKFLOW.NAME_PATTERN.test(wf.name)) { + errors.push({ + code: 'INVALID_FORMAT', + message: + 'Workflow name must contain only lowercase letters (a-z), numbers, hyphens, and underscores', + field: 'name', + }); + } + + if (!wf.version || typeof wf.version !== 'string') { + errors.push({ + code: 'MISSING_FIELD', + message: 'Workflow must have a version', + field: 'version', + }); + } else if (!VALIDATION_RULES.WORKFLOW.VERSION_PATTERN.test(wf.version)) { + errors.push({ + code: 'INVALID_FORMAT', + message: 'Version must follow semantic versioning (e.g., 1.0.0)', + field: 'version', + }); + } + + if (!Array.isArray(wf.nodes)) { + errors.push({ + code: 'MISSING_FIELD', + message: 'Workflow must have a nodes array', + field: 'nodes', + }); + // Cannot continue validation without nodes + return { valid: false, errors }; + } + + if (!Array.isArray(wf.connections)) { + errors.push({ + code: 'MISSING_FIELD', + message: 'Workflow must have a connections array', + field: 'connections', + }); + } + + // Node count validation + if (wf.nodes.length > VALIDATION_RULES.WORKFLOW.MAX_NODES) { + errors.push({ + code: 'MAX_NODES_EXCEEDED', + message: `Generated workflow exceeds maximum node limit (${VALIDATION_RULES.WORKFLOW.MAX_NODES}). Please simplify your description.`, + field: 'nodes', + }); + } + + // Node-specific validation + const nodeErrors = validateNodes(wf.nodes); + errors.push(...nodeErrors); + + // Connection validation (only if connections array exists) + if (Array.isArray(wf.connections)) { + const connectionErrors = validateConnections(wf.connections, wf.nodes); + errors.push(...connectionErrors); + } + + // Start/End node validation + const startNodes = wf.nodes.filter((n) => n.type === NodeType.Start); + const endNodes = wf.nodes.filter((n) => n.type === NodeType.End); + + if (startNodes.length === 0) { + errors.push({ + code: 'MISSING_START_NODE', + message: 'Workflow must have at least one Start node', + }); + } + + if (startNodes.length > 1) { + errors.push({ + code: 'MULTIPLE_START_NODES', + message: 'Workflow must have exactly one Start node', + }); + } + + if (endNodes.length === 0) { + errors.push({ + code: 'MISSING_END_NODE', + message: 'Workflow must have at least one End node', + }); + } + + // SubAgentFlow reference validation + const subAgentFlowErrors = validateSubAgentFlowReferences(wf as Workflow); + errors.push(...subAgentFlowErrors); + + // Issue #413: Hooks validation + const rawWf = workflow as Record; + if (rawWf.hooks) { + const hooksErrors = validateHooks(rawWf.hooks as WorkflowHooks); + errors.push(...hooksErrors); + } + + return { + valid: errors.length === 0, + errors, + }; +} + +/** + * Validate SubAgentFlow references in workflow + * + * Ensures all subAgentFlow nodes have corresponding SubAgentFlow definitions + * and all SubAgentFlow definitions are valid. + * + * @param workflow - Workflow to validate + * @returns Array of validation errors + */ +function validateSubAgentFlowReferences(workflow: Workflow): ValidationError[] { + const errors: ValidationError[] = []; + + const subAgentFlowNodes = workflow.nodes.filter((n) => n.type === NodeType.SubAgentFlow); + + if (subAgentFlowNodes.length === 0) { + return errors; // No SubAgentFlow nodes, nothing to validate + } + + const subAgentFlowIds = new Set((workflow.subAgentFlows || []).map((sf) => sf.id)); + + // Check each subAgentFlow node has a corresponding definition + for (const node of subAgentFlowNodes) { + const refData = node.data as SubAgentFlowNodeData; + + if (!subAgentFlowIds.has(refData.subAgentFlowId)) { + errors.push({ + code: 'SUBAGENTFLOW_MISSING_DEFINITION', + message: `SubAgentFlow node "${node.id}" references non-existent SubAgentFlow "${refData.subAgentFlowId}"`, + field: `nodes[${node.id}].data.subAgentFlowId`, + }); + } + } + + // Validate each SubAgentFlow definition + for (const subAgentFlow of workflow.subAgentFlows || []) { + const subAgentFlowErrors = validateSubAgentFlow(subAgentFlow); + errors.push(...subAgentFlowErrors); + } + + return errors; +} + +/** + * Validate all nodes in the workflow + */ +function validateNodes(nodes: WorkflowNode[]): ValidationError[] { + const errors: ValidationError[] = []; + const nodeIds = new Set(); + + for (const node of nodes) { + // Check for duplicate IDs + if (nodeIds.has(node.id)) { + errors.push({ + code: 'DUPLICATE_NODE_ID', + message: `Duplicate node ID: ${node.id}`, + field: `nodes[${node.id}]`, + }); + } + nodeIds.add(node.id); + + // Validate node name + if (!node.name || !VALIDATION_RULES.NODE.NAME_PATTERN.test(node.name)) { + errors.push({ + code: 'INVALID_NODE_NAME', + message: `Node name must match pattern ${VALIDATION_RULES.NODE.NAME_PATTERN}`, + field: `nodes[${node.id}].name`, + }); + } + + // Validate node type + if (!Object.values(NodeType).includes(node.type)) { + errors.push({ + code: 'INVALID_NODE_TYPE', + message: `Invalid node type: ${node.type}`, + field: `nodes[${node.id}].type`, + }); + } + + // Validate position + if ( + !node.position || + typeof node.position.x !== 'number' || + typeof node.position.y !== 'number' + ) { + errors.push({ + code: 'INVALID_POSITION', + message: 'Node must have valid position with x and y coordinates', + field: `nodes[${node.id}].position`, + }); + } + + // Validate Skill nodes (T026) + if (node.type === NodeType.Skill) { + const skillErrors = validateSkillNode(node); + errors.push(...skillErrors); + } + + // Validate MCP nodes (T017) + if (node.type === NodeType.Mcp) { + const mcpErrors = validateMcpNode(node); + errors.push(...mcpErrors); + } + + // Validate Switch nodes + if (node.type === NodeType.Switch) { + const switchErrors = validateSwitchNode(node); + errors.push(...switchErrors); + } + + // Validate SubAgentFlow nodes (Feature: 089-subworkflow) + if (node.type === NodeType.SubAgentFlow) { + const subAgentFlowErrors = validateSubAgentFlowNode(node); + errors.push(...subAgentFlowErrors); + } + + // Validate SubAgent memory enum (Feature: 540-persistent-memory) + if (node.type === NodeType.SubAgent) { + const subAgentData = node.data as { memory?: string }; + if (subAgentData.memory !== undefined) { + const validMemoryScopes = ['user', 'project', 'local']; + if (!validMemoryScopes.includes(subAgentData.memory)) { + errors.push({ + code: 'SUBAGENT_INVALID_MEMORY', + message: `SubAgent memory must be one of: ${validMemoryScopes.join(', ')}`, + field: `nodes[${node.id}].data.memory`, + }); + } + } + } + } + + return errors; +} + +/** + * Validate Switch node structure and default branch rules + * + * @param node - Switch node to validate + * @returns Array of validation errors + */ +function validateSwitchNode(node: WorkflowNode): ValidationError[] { + const errors: ValidationError[] = []; + const switchData = node.data as Partial; + + if (!switchData.branches || !Array.isArray(switchData.branches)) { + errors.push({ + code: 'SWITCH_MISSING_BRANCHES', + message: 'Switch node must have branches array', + field: `nodes[${node.id}].data.branches`, + }); + return errors; + } + + // Check for multiple default branches + const defaultBranches = switchData.branches.filter((b) => b.isDefault); + if (defaultBranches.length > 1) { + errors.push({ + code: 'SWITCH_MULTIPLE_DEFAULT', + message: 'Switch node can only have one default branch', + field: `nodes[${node.id}].data.branches`, + }); + } + + // Check that default branch is last (if it exists) + if (defaultBranches.length === 1) { + const lastBranch = switchData.branches[switchData.branches.length - 1]; + if (!lastBranch?.isDefault) { + errors.push({ + code: 'SWITCH_DEFAULT_NOT_LAST', + message: 'Default branch must be the last branch in Switch node', + field: `nodes[${node.id}].data.branches`, + }); + } + } + + return errors; +} + +/** + * Validate SubAgentFlow node structure and fields + * + * Feature: 089-subworkflow + * + * @param node - SubAgentFlow node to validate + * @returns Array of validation errors + */ +function validateSubAgentFlowNode(node: WorkflowNode): ValidationError[] { + const errors: ValidationError[] = []; + const refData = node.data as Partial; + + // Required field: subAgentFlowId + if (!refData.subAgentFlowId || typeof refData.subAgentFlowId !== 'string') { + errors.push({ + code: 'SUBAGENTFLOW_MISSING_REF_ID', + message: 'SubAgentFlow node must have a subAgentFlowId', + field: `nodes[${node.id}].data.subAgentFlowId`, + }); + } + + // Required field: label + if (!refData.label || typeof refData.label !== 'string') { + errors.push({ + code: 'SUBAGENTFLOW_MISSING_LABEL', + message: 'SubAgentFlow node must have a label', + field: `nodes[${node.id}].data.label`, + }); + } + + // Output ports validation + if (refData.outputPorts !== VALIDATION_RULES.SUB_AGENT_FLOW.OUTPUT_PORTS) { + errors.push({ + code: 'SUBAGENTFLOW_INVALID_PORTS', + message: 'SubAgentFlow outputPorts must equal 1', + field: `nodes[${node.id}].data.outputPorts`, + }); + } + + // Memory enum validation + if (refData.memory !== undefined) { + const validMemoryScopes = ['user', 'project', 'local']; + if (!validMemoryScopes.includes(refData.memory)) { + errors.push({ + code: 'SUBAGENTFLOW_INVALID_MEMORY', + message: `SubAgentFlow memory must be one of: ${validMemoryScopes.join(', ')}`, + field: `nodes[${node.id}].data.memory`, + }); + } + } + + return errors; +} + +/** + * Validate SubAgentFlow structure (for use within a workflow) + * + * Feature: 089-subworkflow + * MVP constraints: + * - No SubAgent nodes allowed + * - No nested SubAgentFlowRef nodes allowed + * - Must have exactly one Start node and at least one End node + * + * @param subAgentFlow - SubAgentFlow to validate + * @returns Array of validation errors + */ +export function validateSubAgentFlow(subAgentFlow: SubAgentFlow): ValidationError[] { + const errors: ValidationError[] = []; + + // Required fields + if (!subAgentFlow.id) { + errors.push({ + code: 'SUBAGENTFLOW_MISSING_ID', + message: 'SubAgentFlow must have an id', + field: 'id', + }); + } + + if (!subAgentFlow.name) { + errors.push({ + code: 'SUBAGENTFLOW_MISSING_NAME', + message: 'SubAgentFlow must have a name', + field: 'name', + }); + } + + if (!Array.isArray(subAgentFlow.nodes)) { + errors.push({ + code: 'SUBAGENTFLOW_MISSING_NODES', + message: 'SubAgentFlow must have a nodes array', + field: 'nodes', + }); + return errors; + } + + // Start/End node validation + const startNodes = subAgentFlow.nodes.filter((n) => n.type === NodeType.Start); + const endNodes = subAgentFlow.nodes.filter((n) => n.type === NodeType.End); + + if (startNodes.length === 0) { + errors.push({ + code: 'SUBAGENTFLOW_INVALID_START', + message: `SubAgentFlow "${subAgentFlow.name}" must have a Start node`, + }); + } + + if (startNodes.length > 1) { + errors.push({ + code: 'SUBAGENTFLOW_MULTIPLE_START', + message: `SubAgentFlow "${subAgentFlow.name}" must have exactly one Start node`, + }); + } + + if (endNodes.length === 0) { + errors.push({ + code: 'SUBAGENTFLOW_MISSING_END', + message: `SubAgentFlow "${subAgentFlow.name}" must have at least one End node`, + }); + } + + // MVP constraint: No SubAgent nodes in SubAgentFlows + const subAgentNodes = subAgentFlow.nodes.filter((n) => n.type === NodeType.SubAgent); + if (subAgentNodes.length > 0) { + errors.push({ + code: 'SUBAGENTFLOW_CONTAINS_SUBAGENT', + message: `SubAgentFlow "${subAgentFlow.name}" cannot contain SubAgent nodes (MVP constraint)`, + }); + } + + // MVP constraint: No nested SubAgentFlow nodes + const nestedRefs = subAgentFlow.nodes.filter((n) => n.type === NodeType.SubAgentFlow); + if (nestedRefs.length > 0) { + errors.push({ + code: 'SUBAGENTFLOW_NESTED_REF', + message: `SubAgentFlow "${subAgentFlow.name}" cannot contain SubAgentFlow nodes (no nesting allowed in MVP)`, + }); + } + + return errors; +} + +/** + * Validate Skill node structure and fields + * + * Based on: /specs/001-ai-skill-generation/contracts/skill-scanning-api.md Section 5.1 + * + * @param node - Skill node to validate + * @returns Array of validation errors (T024-T025) + */ +function validateSkillNode(node: WorkflowNode): ValidationError[] { + const errors: ValidationError[] = []; + const skillData = node.data as Partial; + + // Required fields check + // Note: skillPath is optional when validationStatus is 'missing' (skill not found) + const requiredFields: (keyof SkillNodeData)[] = [ + 'name', + 'description', + 'scope', + 'validationStatus', + 'outputPorts', + ]; + + for (const field of requiredFields) { + if (!skillData[field]) { + errors.push({ + code: 'SKILL_MISSING_FIELD', + message: `Skill node missing required field: ${field}`, + field: `nodes[${node.id}].data.${field}`, + }); + } + } + + // skillPath is required only when skill is valid (not missing) + if (skillData.validationStatus !== 'missing' && !skillData.skillPath) { + errors.push({ + code: 'SKILL_MISSING_FIELD', + message: 'Skill node missing required field: skillPath', + field: `nodes[${node.id}].data.skillPath`, + }); + } + + // Name validation + if (skillData.name) { + if (!VALIDATION_RULES.SKILL.NAME_PATTERN.test(skillData.name)) { + errors.push({ + code: 'SKILL_INVALID_NAME', + message: 'Skill name must be lowercase with hyphens only', + field: `nodes[${node.id}].data.name`, + }); + } + + if (skillData.name.length > VALIDATION_RULES.SKILL.NAME_MAX_LENGTH) { + errors.push({ + code: 'SKILL_NAME_TOO_LONG', + message: `Skill name exceeds ${VALIDATION_RULES.SKILL.NAME_MAX_LENGTH} characters`, + field: `nodes[${node.id}].data.name`, + }); + } + } + + // Description validation + if (skillData.description) { + if (skillData.description.length > VALIDATION_RULES.SKILL.DESCRIPTION_MAX_LENGTH) { + errors.push({ + code: 'SKILL_DESC_TOO_LONG', + message: `Skill description exceeds ${VALIDATION_RULES.SKILL.DESCRIPTION_MAX_LENGTH} characters`, + field: `nodes[${node.id}].data.description`, + }); + } + } + + // Output ports validation + if (skillData.outputPorts !== VALIDATION_RULES.SKILL.OUTPUT_PORTS) { + errors.push({ + code: 'SKILL_INVALID_PORTS', + message: + 'Skill outputPorts must equal 1. For branching, use ifElse or switch nodes after the Skill node.', + field: `nodes[${node.id}].data.outputPorts`, + }); + } + + // Execution mode validation + if (skillData.executionMode !== undefined) { + const validModes = ['load', 'execute']; + if (!validModes.includes(skillData.executionMode)) { + errors.push({ + code: 'SKILL_INVALID_EXECUTION_MODE', + message: `Skill executionMode must be one of: ${validModes.join(', ')}`, + field: `nodes[${node.id}].data.executionMode`, + }); + } + } + + // Execution prompt length validation + if (skillData.executionPrompt) { + if (skillData.executionPrompt.length > VALIDATION_RULES.SKILL.EXECUTION_PROMPT_MAX_LENGTH) { + errors.push({ + code: 'SKILL_EXECUTION_PROMPT_TOO_LONG', + message: `Skill executionPrompt exceeds ${VALIDATION_RULES.SKILL.EXECUTION_PROMPT_MAX_LENGTH} characters`, + field: `nodes[${node.id}].data.executionPrompt`, + }); + } + } + + return errors; +} + +/** + * Validate MCP node structure and fields + * + * Based on: contracts/workflow-mcp-node.schema.json + * + * @param node - MCP node to validate + * @returns Array of validation errors (T017) + */ +function validateMcpNode(node: WorkflowNode): ValidationError[] { + const errors: ValidationError[] = []; + const mcpData = node.data as Partial; + + // Common required fields (all modes) + const commonRequiredFields: (keyof McpNodeData)[] = [ + 'serverId', + 'validationStatus', + 'outputPorts', + ]; + + for (const field of commonRequiredFields) { + const value = mcpData[field as keyof typeof mcpData]; + if (value === undefined || value === null || value === '') { + errors.push({ + code: 'MCP_MISSING_FIELD', + message: `MCP node missing required field: ${field}`, + field: `nodes[${node.id}].data.${field}`, + }); + } + } + + // Server ID validation + if (mcpData.serverId) { + if (mcpData.serverId.length < VALIDATION_RULES.MCP.SERVER_ID_MIN_LENGTH) { + errors.push({ + code: 'MCP_INVALID_SERVER_ID', + message: `MCP server ID too short (min ${VALIDATION_RULES.MCP.SERVER_ID_MIN_LENGTH} characters)`, + field: `nodes[${node.id}].data.serverId`, + }); + } + + if (mcpData.serverId.length > VALIDATION_RULES.MCP.SERVER_ID_MAX_LENGTH) { + errors.push({ + code: 'MCP_SERVER_ID_TOO_LONG', + message: `MCP server ID exceeds ${VALIDATION_RULES.MCP.SERVER_ID_MAX_LENGTH} characters`, + field: `nodes[${node.id}].data.serverId`, + }); + } + } + + // Tool name validation + if (mcpData.toolName) { + if (mcpData.toolName.length < VALIDATION_RULES.MCP.TOOL_NAME_MIN_LENGTH) { + errors.push({ + code: 'MCP_INVALID_TOOL_NAME', + message: `MCP tool name too short (min ${VALIDATION_RULES.MCP.TOOL_NAME_MIN_LENGTH} characters)`, + field: `nodes[${node.id}].data.toolName`, + }); + } + + if (mcpData.toolName.length > VALIDATION_RULES.MCP.TOOL_NAME_MAX_LENGTH) { + errors.push({ + code: 'MCP_TOOL_NAME_TOO_LONG', + message: `MCP tool name exceeds ${VALIDATION_RULES.MCP.TOOL_NAME_MAX_LENGTH} characters`, + field: `nodes[${node.id}].data.toolName`, + }); + } + } + + // Tool description validation + if ( + mcpData.toolDescription && + mcpData.toolDescription.length > VALIDATION_RULES.MCP.TOOL_DESCRIPTION_MAX_LENGTH + ) { + errors.push({ + code: 'MCP_TOOL_DESC_TOO_LONG', + message: `MCP tool description exceeds ${VALIDATION_RULES.MCP.TOOL_DESCRIPTION_MAX_LENGTH} characters`, + field: `nodes[${node.id}].data.toolDescription`, + }); + } + + // Parameters array validation + if (mcpData.parameters) { + if (!Array.isArray(mcpData.parameters)) { + errors.push({ + code: 'MCP_INVALID_PARAMETERS', + message: 'MCP parameters must be an array', + field: `nodes[${node.id}].data.parameters`, + }); + } + } + + // Parameter values validation + if (mcpData.parameterValues) { + if ( + typeof mcpData.parameterValues !== 'object' || + mcpData.parameterValues === null || + Array.isArray(mcpData.parameterValues) + ) { + errors.push({ + code: 'MCP_INVALID_PARAMETER_VALUES', + message: 'MCP parameterValues must be an object', + field: `nodes[${node.id}].data.parameterValues`, + }); + } + } + + // Validation status check + if (mcpData.validationStatus) { + const validStatuses = ['valid', 'missing', 'invalid']; + if (!validStatuses.includes(mcpData.validationStatus)) { + errors.push({ + code: 'MCP_INVALID_STATUS', + message: `MCP validationStatus must be one of: ${validStatuses.join(', ')}`, + field: `nodes[${node.id}].data.validationStatus`, + }); + } + } + + // Output ports validation + if (mcpData.outputPorts !== VALIDATION_RULES.MCP.OUTPUT_PORTS) { + errors.push({ + code: 'MCP_INVALID_PORTS', + message: + 'MCP outputPorts must equal 1. For branching, use ifElse or switch nodes after the MCP node.', + field: `nodes[${node.id}].data.outputPorts`, + }); + } + + // Mode-specific configuration validation (T058) + const mode = mcpData.mode || 'manualParameterConfig'; + + switch (mode) { + case 'manualParameterConfig': + // Manual mode requires toolName, toolDescription, parameters, parameterValues + if (!mcpData.toolName || mcpData.toolName.trim().length === 0) { + errors.push({ + code: 'MCP_MODE_CONFIG_MISMATCH', + message: 'Manual parameter config mode requires toolName to be set', + field: `nodes[${node.id}].data.toolName`, + }); + } + if (!mcpData.toolDescription || mcpData.toolDescription.trim().length === 0) { + errors.push({ + code: 'MCP_MODE_CONFIG_MISMATCH', + message: 'Manual parameter config mode requires toolDescription to be set', + field: `nodes[${node.id}].data.toolDescription`, + }); + } + // parameters配列が定義されていない場合のみエラー(空配列はOK - パラメータなしツール用) + if (!mcpData.parameters) { + errors.push({ + code: 'MCP_MODE_CONFIG_MISMATCH', + message: 'Manual parameter config mode requires parameters array to be set', + field: `nodes[${node.id}].data.parameters`, + }); + } + // parametersが空でない場合のみ、parameterValuesの存在をチェック + if (mcpData.parameters && mcpData.parameters.length > 0) { + if (!mcpData.parameterValues || Object.keys(mcpData.parameterValues).length === 0) { + errors.push({ + code: 'MCP_MODE_CONFIG_MISMATCH', + message: 'Manual parameter config mode requires parameterValues to be configured', + field: `nodes[${node.id}].data.parameterValues`, + }); + } + } + break; + + case 'aiParameterConfig': + // AI parameter config mode requires toolName, toolDescription, parameters, aiParameterConfig + if (!mcpData.toolName || mcpData.toolName.trim().length === 0) { + errors.push({ + code: 'MCP_MODE_CONFIG_MISMATCH', + message: 'AI parameter config mode requires toolName to be set', + field: `nodes[${node.id}].data.toolName`, + }); + } + if (!mcpData.toolDescription || mcpData.toolDescription.trim().length === 0) { + errors.push({ + code: 'MCP_MODE_CONFIG_MISMATCH', + message: 'AI parameter config mode requires toolDescription to be set', + field: `nodes[${node.id}].data.toolDescription`, + }); + } + if (!mcpData.parameters || mcpData.parameters.length === 0) { + errors.push({ + code: 'MCP_MODE_CONFIG_MISMATCH', + message: 'AI parameter config mode requires parameters array to be set', + field: `nodes[${node.id}].data.parameters`, + }); + } + if (!mcpData.aiParameterConfig) { + errors.push({ + code: 'MCP_MODE_CONFIG_MISMATCH', + message: 'AI parameter config mode requires aiParameterConfig to be set', + field: `nodes[${node.id}].data.aiParameterConfig`, + }); + } else if ( + !mcpData.aiParameterConfig.description || + mcpData.aiParameterConfig.description.trim().length === 0 + ) { + errors.push({ + code: 'MCP_MODE_CONFIG_MISMATCH', + message: + 'AI parameter config mode requires aiParameterConfig.description to be non-empty', + field: `nodes[${node.id}].data.aiParameterConfig.description`, + }); + } + // parameterValues is optional (AI will set values based on description) + break; + + case 'aiToolSelection': + // AI tool selection mode requires aiToolSelectionConfig + if (!mcpData.aiToolSelectionConfig) { + errors.push({ + code: 'MCP_MODE_CONFIG_MISMATCH', + message: 'AI tool selection mode requires aiToolSelectionConfig to be set', + field: `nodes[${node.id}].data.aiToolSelectionConfig`, + }); + } else if ( + !mcpData.aiToolSelectionConfig.taskDescription || + mcpData.aiToolSelectionConfig.taskDescription.trim().length === 0 + ) { + errors.push({ + code: 'MCP_MODE_CONFIG_MISMATCH', + message: + 'AI tool selection mode requires aiToolSelectionConfig.taskDescription to be non-empty', + field: `nodes[${node.id}].data.aiToolSelectionConfig.taskDescription`, + }); + } + // toolName is optional for AI tool selection mode (AI will select the tool) + break; + + default: + // Unknown mode + errors.push({ + code: 'MCP_INVALID_MODE', + message: `Invalid MCP mode: ${mode}. Must be one of: manualParameterConfig, aiParameterConfig, aiToolSelection`, + field: `nodes[${node.id}].data.mode`, + }); + } + + return errors; +} + +/** + * Validate all connections in the workflow + */ +function validateConnections(connections: Connection[], nodes: WorkflowNode[]): ValidationError[] { + const errors: ValidationError[] = []; + const nodeIds = new Set(nodes.map((n) => n.id)); + const connectionIds = new Set(); + + for (const conn of connections) { + // Check for duplicate connection IDs + if (connectionIds.has(conn.id)) { + errors.push({ + code: 'DUPLICATE_CONNECTION_ID', + message: `Duplicate connection ID: ${conn.id}`, + field: `connections[${conn.id}]`, + }); + } + connectionIds.add(conn.id); + + // Validate from/to node IDs exist + if (!nodeIds.has(conn.from)) { + errors.push({ + code: 'INVALID_CONNECTION', + message: `Connection references non-existent from node: ${conn.from}`, + field: `connections[${conn.id}].from`, + }); + } + + if (!nodeIds.has(conn.to)) { + errors.push({ + code: 'INVALID_CONNECTION', + message: `Connection references non-existent to node: ${conn.to}`, + field: `connections[${conn.id}].to`, + }); + } + + // Validate no self-connections + if (conn.from === conn.to) { + errors.push({ + code: 'SELF_CONNECTION', + message: 'Node cannot connect to itself', + field: `connections[${conn.id}]`, + }); + } + + // Validate Start/End node connection rules + const fromNode = nodes.find((n) => n.id === conn.from); + const toNode = nodes.find((n) => n.id === conn.to); + + if (toNode?.type === NodeType.Start) { + errors.push({ + code: 'INVALID_CONNECTION', + message: 'Start node cannot have input connections', + field: `connections[${conn.id}]`, + }); + } + + if (fromNode?.type === NodeType.End) { + errors.push({ + code: 'INVALID_CONNECTION', + message: 'End node cannot have output connections', + field: `connections[${conn.id}]`, + }); + } + } + + // Check for cycles (simplified check - full cycle detection would be more complex) + // For MVP, we'll rely on the AI to generate acyclic workflows + + return errors; +} + +/** + * Validate hooks configuration + * + * Issue #413: Hooks configuration for workflow execution + * See: https://code.claude.com/docs/en/hooks + * + * @param hooks - Hooks configuration to validate + * @returns Array of validation errors + */ +function validateHooks(hooks: WorkflowHooks): ValidationError[] { + const errors: ValidationError[] = []; + + const validHookTypes: HookType[] = ['PreToolUse', 'PostToolUse', 'Stop']; + const validActionTypes = ['command', 'prompt']; + + for (const [hookType, entries] of Object.entries(hooks)) { + // Validate hook type + if (!validHookTypes.includes(hookType as HookType)) { + errors.push({ + code: 'HOOKS_INVALID_TYPE', + message: `Invalid hook type: ${hookType}. Must be one of: ${validHookTypes.join(', ')}`, + field: `hooks.${hookType}`, + }); + continue; + } + + // Validate entries array + if (!Array.isArray(entries)) { + errors.push({ + code: 'HOOKS_INVALID_ENTRIES', + message: `Hook ${hookType} entries must be an array`, + field: `hooks.${hookType}`, + }); + continue; + } + + // Validate max entries per hook + if (entries.length > VALIDATION_RULES.HOOKS.MAX_ENTRIES_PER_HOOK) { + errors.push({ + code: 'HOOKS_TOO_MANY_ENTRIES', + message: `Hook ${hookType} exceeds maximum of ${VALIDATION_RULES.HOOKS.MAX_ENTRIES_PER_HOOK} entries`, + field: `hooks.${hookType}`, + }); + } + + // Validate each entry + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + + if (!entry || typeof entry !== 'object') { + errors.push({ + code: 'HOOKS_INVALID_ENTRY', + message: `Hook ${hookType}[${i}] must be an object`, + field: `hooks.${hookType}[${i}]`, + }); + continue; + } + + // Validate matcher (optional for all hook types, but check length if provided) + if (entry.matcher) { + if (typeof entry.matcher !== 'string') { + errors.push({ + code: 'HOOKS_INVALID_MATCHER', + message: `Hook ${hookType}[${i}] matcher must be a string`, + field: `hooks.${hookType}[${i}].matcher`, + }); + } else if (entry.matcher.length > VALIDATION_RULES.HOOKS.MATCHER_MAX_LENGTH) { + errors.push({ + code: 'HOOKS_MATCHER_TOO_LONG', + message: `Hook ${hookType}[${i}] matcher exceeds ${VALIDATION_RULES.HOOKS.MATCHER_MAX_LENGTH} characters`, + field: `hooks.${hookType}[${i}].matcher`, + }); + } + } + + // Validate hooks array (actions) + if (!entry.hooks || !Array.isArray(entry.hooks)) { + errors.push({ + code: 'HOOKS_MISSING_ACTIONS', + message: `Hook ${hookType}[${i}] must have a hooks array`, + field: `hooks.${hookType}[${i}].hooks`, + }); + continue; + } + + if (entry.hooks.length === 0) { + errors.push({ + code: 'HOOKS_EMPTY_ACTIONS', + message: `Hook ${hookType}[${i}].hooks cannot be empty`, + field: `hooks.${hookType}[${i}].hooks`, + }); + } + + if (entry.hooks.length > VALIDATION_RULES.HOOKS.MAX_ACTIONS_PER_ENTRY) { + errors.push({ + code: 'HOOKS_TOO_MANY_ACTIONS', + message: `Hook ${hookType}[${i}].hooks exceeds maximum of ${VALIDATION_RULES.HOOKS.MAX_ACTIONS_PER_ENTRY} actions`, + field: `hooks.${hookType}[${i}].hooks`, + }); + } + + // Validate each action + for (let j = 0; j < entry.hooks.length; j++) { + const action = entry.hooks[j]; + + if (!action || typeof action !== 'object') { + errors.push({ + code: 'HOOKS_INVALID_ACTION', + message: `Hook ${hookType}[${i}].hooks[${j}] must be an object`, + field: `hooks.${hookType}[${i}].hooks[${j}]`, + }); + continue; + } + + // Validate action type + if (!action.type || !validActionTypes.includes(action.type)) { + errors.push({ + code: 'HOOKS_INVALID_ACTION_TYPE', + message: `Hook ${hookType}[${i}].hooks[${j}].type must be one of: ${validActionTypes.join(', ')}`, + field: `hooks.${hookType}[${i}].hooks[${j}].type`, + }); + } + + // Validate command (required for type: 'command') + if (action.type === 'command') { + if (!action.command || typeof action.command !== 'string') { + errors.push({ + code: 'HOOKS_MISSING_COMMAND', + message: `Hook ${hookType}[${i}].hooks[${j}] requires a command string`, + field: `hooks.${hookType}[${i}].hooks[${j}].command`, + }); + } else { + // Validate command length + if (action.command.length < VALIDATION_RULES.HOOKS.COMMAND_MIN_LENGTH) { + errors.push({ + code: 'HOOKS_COMMAND_EMPTY', + message: `Hook ${hookType}[${i}].hooks[${j}].command cannot be empty`, + field: `hooks.${hookType}[${i}].hooks[${j}].command`, + }); + } + + if (action.command.length > VALIDATION_RULES.HOOKS.COMMAND_MAX_LENGTH) { + errors.push({ + code: 'HOOKS_COMMAND_TOO_LONG', + message: `Hook ${hookType}[${i}].hooks[${j}].command exceeds ${VALIDATION_RULES.HOOKS.COMMAND_MAX_LENGTH} characters`, + field: `hooks.${hookType}[${i}].hooks[${j}].command`, + }); + } + } + } + + // Validate once (optional boolean) + if (action.once !== undefined && typeof action.once !== 'boolean') { + errors.push({ + code: 'HOOKS_INVALID_ONCE', + message: `Hook ${hookType}[${i}].hooks[${j}].once must be a boolean`, + field: `hooks.${hookType}[${i}].hooks[${j}].once`, + }); + } + } + } + } + + return errors; +} diff --git a/packages/core/src/utils/workflow-validator.ts b/packages/core/src/utils/workflow-validator.ts new file mode 100644 index 00000000..24da716f --- /dev/null +++ b/packages/core/src/utils/workflow-validator.ts @@ -0,0 +1,101 @@ +/** + * Workflow Validator + * + * Validates workflow JSON files downloaded from Slack. + * Ensures required fields exist and structure is valid before import. + * + * Based on specs/001-slack-workflow-sharing/contracts/extension-host-api-contracts.md + */ + +import type { Workflow } from '../types/workflow-definition.js'; + +/** + * Validation result + */ +export interface ValidationResult { + /** Whether the workflow is valid */ + valid: boolean; + /** Validation error messages (if invalid) */ + errors?: string[]; + /** Parsed workflow object (if valid) */ + workflow?: Workflow; +} + +/** + * Validates workflow JSON content + * + * Checks: + * 1. Valid JSON format + * 2. Required fields exist (id, name, version, nodes, connections) + * 3. Basic structure validation + * + * @param content - Workflow JSON string + * @returns Validation result with errors or parsed workflow + */ +export function validateWorkflowFile(content: string): ValidationResult { + const errors: string[] = []; + + // Step 1: Parse JSON + let parsedData: unknown; + try { + parsedData = JSON.parse(content); + } catch (error) { + return { + valid: false, + errors: [`Invalid JSON format: ${error instanceof Error ? error.message : String(error)}`], + }; + } + + // Step 2: Type check + if (typeof parsedData !== 'object' || parsedData === null) { + return { + valid: false, + errors: ['Workflow must be a JSON object'], + }; + } + + const workflow = parsedData as Record; + + // Step 3: Required field validation + const requiredFields: Array = ['id', 'name', 'version', 'nodes', 'connections']; + + for (const field of requiredFields) { + if (!(field in workflow)) { + errors.push(`Missing required field: ${field}`); + } + } + + // Step 4: Field type validation + if ('id' in workflow && typeof workflow.id !== 'string') { + errors.push('Field "id" must be a string'); + } + + if ('name' in workflow && typeof workflow.name !== 'string') { + errors.push('Field "name" must be a string'); + } + + if ('version' in workflow && typeof workflow.version !== 'string') { + errors.push('Field "version" must be a string'); + } + + if ('nodes' in workflow && !Array.isArray(workflow.nodes)) { + errors.push('Field "nodes" must be an array'); + } + + if ('connections' in workflow && !Array.isArray(workflow.connections)) { + errors.push('Field "connections" must be an array'); + } + + // Step 5: Return validation result + if (errors.length > 0) { + return { + valid: false, + errors, + }; + } + + return { + valid: true, + workflow: workflow as unknown as Workflow, + }; +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 00000000..4e36783e --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "lib": ["ES2020"], + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/electron/package.json b/packages/electron/package.json new file mode 100644 index 00000000..f17e013b --- /dev/null +++ b/packages/electron/package.json @@ -0,0 +1,20 @@ +{ + "name": "@cc-wf-studio/electron", + "version": "3.26.0", + "private": true, + "license": "AGPL-3.0-or-later", + "main": "dist/main/main.js", + "scripts": { + "build": "echo 'Electron build requires: npm install electron' && tsc -p tsconfig.main.json && tsc -p tsconfig.preload.json", + "start": "electron dist/main/main.js", + "clean": "rm -rf dist" + }, + "dependencies": { + "@cc-wf-studio/core": "*" + }, + "devDependencies": { + "typescript": "^5.3.0", + "@types/node": "^25.0.3", + "electron": "^33.0.0" + } +} diff --git a/packages/electron/renderer/index.html b/packages/electron/renderer/index.html new file mode 100644 index 00000000..4a0ee97e --- /dev/null +++ b/packages/electron/renderer/index.html @@ -0,0 +1,13 @@ + + + + + + + CC Workflow Studio + + +
+ + + diff --git a/packages/electron/src/main/adapters/electron-dialog-service.ts b/packages/electron/src/main/adapters/electron-dialog-service.ts new file mode 100644 index 00000000..6dcbd471 --- /dev/null +++ b/packages/electron/src/main/adapters/electron-dialog-service.ts @@ -0,0 +1,71 @@ +/** + * Electron Dialog Service + * + * IDialogService implementation using Electron's dialog API. + */ + +import type { IDialogService } from '@cc-wf-studio/core'; +import { type BrowserWindow, dialog } from 'electron'; + +export class ElectronDialogService implements IDialogService { + constructor(private readonly getWindow: () => BrowserWindow | null) {} + + showInformationMessage(message: string): void { + const win = this.getWindow(); + if (win) { + dialog.showMessageBoxSync(win, { type: 'info', message }); + } + } + + showWarningMessage(message: string): void { + const win = this.getWindow(); + if (win) { + dialog.showMessageBoxSync(win, { type: 'warning', message }); + } + } + + showErrorMessage(message: string): void { + const win = this.getWindow(); + if (win) { + dialog.showMessageBoxSync(win, { type: 'error', message }); + } + } + + async showConfirmDialog(message: string, confirmLabel: string): Promise { + const win = this.getWindow(); + if (!win) return false; + + const result = await dialog.showMessageBox(win, { + type: 'question', + buttons: [confirmLabel, 'Cancel'], + defaultId: 0, + cancelId: 1, + message, + }); + + return result.response === 0; + } + + async showOpenFileDialog(options: { + filters?: Record; + title?: string; + }): Promise { + const win = this.getWindow(); + if (!win) return null; + + const filters = options.filters + ? Object.entries(options.filters).map(([name, extensions]) => ({ + name, + extensions, + })) + : []; + + const result = await dialog.showOpenDialog(win, { + title: options.title, + filters, + properties: ['openFile'], + }); + + return result.canceled ? null : result.filePaths[0] || null; + } +} diff --git a/packages/electron/src/main/adapters/electron-message-transport.ts b/packages/electron/src/main/adapters/electron-message-transport.ts new file mode 100644 index 00000000..028f8f0d --- /dev/null +++ b/packages/electron/src/main/adapters/electron-message-transport.ts @@ -0,0 +1,40 @@ +/** + * Electron Message Transport + * + * IMessageTransport implementation using Electron IPC. + * Routes messages between the main process and renderer. + */ + +import type { IMessageTransport } from '@cc-wf-studio/core'; +import type { BrowserWindow, IpcMain } from 'electron'; + +export class ElectronMessageTransport implements IMessageTransport { + private handlers: Array< + (message: { type: string; requestId?: string; payload?: unknown }) => void + > = []; + + constructor( + private readonly ipcMain: IpcMain, + private readonly getWindow: () => BrowserWindow | null + ) { + // Listen for messages from renderer + this.ipcMain.on('webview-message', (_event, message) => { + for (const handler of this.handlers) { + handler(message); + } + }); + } + + postMessage(message: { type: string; requestId?: string; payload?: unknown }): void { + const win = this.getWindow(); + if (win && !win.isDestroyed()) { + win.webContents.send('host-message', message); + } + } + + onMessage( + handler: (message: { type: string; requestId?: string; payload?: unknown }) => void + ): void { + this.handlers.push(handler); + } +} diff --git a/packages/electron/src/main/ipc/ipc-handlers.ts b/packages/electron/src/main/ipc/ipc-handlers.ts new file mode 100644 index 00000000..6be1eee7 --- /dev/null +++ b/packages/electron/src/main/ipc/ipc-handlers.ts @@ -0,0 +1,131 @@ +/** + * Electron IPC Handlers + * + * Routes messages from the renderer process to core services. + * Mirrors the message routing in the VSCode extension's open-editor.ts, + * but delegates to core package services instead of VSCode APIs. + */ + +import * as path from 'node:path'; +import type { IDialogService, IFileSystem, ILogger } from '@cc-wf-studio/core'; +import { FileService, type McpServerManager } from '@cc-wf-studio/core'; +import type { BrowserWindow, IpcMain } from 'electron'; + +interface IpcDependencies { + getMainWindow: () => BrowserWindow | null; + fs: IFileSystem; + logger: ILogger; + dialog: IDialogService; + mcpManager: McpServerManager; +} + +export function setupIpcHandlers(ipcMain: IpcMain, deps: IpcDependencies): void { + let fileService: FileService | null = null; + + ipcMain.on('webview-message', async (_event, message) => { + const { type, requestId, payload } = message; + const win = deps.getMainWindow(); + + const reply = (responseType: string, responsePayload?: unknown): void => { + if (win && !win.isDestroyed()) { + win.webContents.send('host-message', { + type: responseType, + requestId, + payload: responsePayload, + }); + } + }; + + try { + switch (type) { + case 'WEBVIEW_READY': { + deps.logger.info('Webview ready'); + // Send initial state + reply('INITIAL_STATE', { locale: 'en' }); + break; + } + + case 'SAVE_WORKFLOW': { + if (!fileService) { + // Use current working directory as workspace root + fileService = new FileService(deps.fs, process.cwd()); + } + await fileService.ensureWorkflowsDirectory(); + const workflow = payload.workflow; + const filePath = fileService.getWorkflowFilePath(workflow.name); + await fileService.writeFile(filePath, JSON.stringify(workflow, null, 2)); + reply('SAVE_SUCCESS'); + break; + } + + case 'LOAD_WORKFLOW_LIST': { + if (!fileService) { + fileService = new FileService(deps.fs, process.cwd()); + } + const files = await fileService.listWorkflowFiles(); + const workflows = []; + for (const name of files) { + try { + const filePath = fileService.getWorkflowFilePath(name); + const content = await fileService.readFile(filePath); + workflows.push(JSON.parse(content)); + } catch { + deps.logger.warn(`Failed to load workflow: ${name}`); + } + } + reply('WORKFLOW_LIST_LOADED', { workflows }); + break; + } + + case 'EXPORT_WORKFLOW': { + deps.logger.info('Export workflow requested'); + // Basic export — write to .claude/commands/ + if (!fileService) { + fileService = new FileService(deps.fs, process.cwd()); + } + const exportWorkflow = payload.workflow; + const commandsDir = path.join(process.cwd(), '.claude', 'commands'); + await deps.fs.createDirectory(commandsDir); + const exportPath = path.join(commandsDir, `${exportWorkflow.name}.md`); + await deps.fs.writeFile(exportPath, `# ${exportWorkflow.name}\n\nExported workflow.`); + reply('EXPORT_SUCCESS', { exportedFiles: [exportPath] }); + break; + } + + case 'STATE_UPDATE': { + // State persistence — store in localStorage via preload + deps.logger.info('State update received'); + break; + } + + case 'OPEN_EXTERNAL_URL': { + const { shell } = require('electron'); + shell.openExternal(payload.url); + break; + } + + case 'GET_CURRENT_WORKFLOW_RESPONSE': { + deps.mcpManager.handleWorkflowResponse(payload); + break; + } + + case 'APPLY_WORKFLOW_FROM_MCP_RESPONSE': { + deps.mcpManager.handleApplyResponse(payload); + break; + } + + default: { + deps.logger.info(`Unhandled message type: ${type}`); + break; + } + } + } catch (error) { + deps.logger.error(`Error handling message ${type}`, { + error: error instanceof Error ? error.message : String(error), + }); + reply('ERROR', { + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); +} diff --git a/packages/electron/src/main/main.ts b/packages/electron/src/main/main.ts new file mode 100644 index 00000000..1f5d8b6e --- /dev/null +++ b/packages/electron/src/main/main.ts @@ -0,0 +1,229 @@ +/** + * CC Workflow Studio - Electron Main Process + * + * Desktop application entry point. + * Creates a BrowserWindow that loads the shared webview UI. + */ + +import * as path from 'node:path'; +import { ConsoleLogger, McpServerManager, NodeFileSystem, setLogger } from '@cc-wf-studio/core'; +import { app, BrowserWindow, ipcMain, nativeTheme, shell } from 'electron'; +import { ElectronDialogService } from './adapters/electron-dialog-service'; +import { ElectronMessageTransport } from './adapters/electron-message-transport'; +import { setupIpcHandlers } from './ipc/ipc-handlers'; + +let mainWindow: BrowserWindow | null = null; +const logger = new ConsoleLogger(); + +// Set the global logger for core services +setLogger(logger); + +function createWindow(): void { + mainWindow = new BrowserWindow({ + width: 1400, + height: 900, + minWidth: 800, + minHeight: 600, + title: 'CC Workflow Studio', + webPreferences: { + preload: path.join(__dirname, '../preload/preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + + // Load the webview build output + const webviewPath = path.join(__dirname, '../../webview/dist/index.html'); + mainWindow.loadFile(webviewPath); + + // Inject theme CSS variables + mainWindow.webContents.on('did-finish-load', () => { + const isDark = nativeTheme.shouldUseDarkColors; + injectThemeVariables(isDark); + }); + + // Listen for theme changes + nativeTheme.on('updated', () => { + const isDark = nativeTheme.shouldUseDarkColors; + injectThemeVariables(isDark); + }); + + // Open external links in default browser + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url); + return { action: 'deny' }; + }); + + mainWindow.on('closed', () => { + mainWindow = null; + }); +} + +function injectThemeVariables(isDark: boolean): void { + if (!mainWindow) return; + + const variables = isDark ? getDarkThemeVariables() : getLightThemeVariables(); + const css = `:root { ${variables} }`; + + mainWindow.webContents.insertCSS(css); +} + +function getDarkThemeVariables(): string { + return ` + --vscode-editor-background: #1e1e1e; + --vscode-editor-foreground: #d4d4d4; + --vscode-foreground: #cccccc; + --vscode-descriptionForeground: #9d9d9d; + --vscode-button-background: #0e639c; + --vscode-button-foreground: #ffffff; + --vscode-button-hoverBackground: #1177bb; + --vscode-button-secondaryBackground: #3a3d41; + --vscode-button-secondaryForeground: #ffffff; + --vscode-button-secondaryHoverBackground: #45494e; + --vscode-input-background: #3c3c3c; + --vscode-input-foreground: #cccccc; + --vscode-input-border: #3c3c3c; + --vscode-input-placeholderForeground: #a6a6a6; + --vscode-focusBorder: #007fd4; + --vscode-panel-border: #2b2b2b; + --vscode-panel-background: #1e1e1e; + --vscode-sideBar-background: #252526; + --vscode-sideBar-foreground: #cccccc; + --vscode-list-hoverBackground: #2a2d2e; + --vscode-list-activeSelectionBackground: #094771; + --vscode-list-activeSelectionForeground: #ffffff; + --vscode-list-inactiveSelectionBackground: #37373d; + --vscode-badge-background: #4d4d4d; + --vscode-badge-foreground: #ffffff; + --vscode-scrollbarSlider-background: rgba(121, 121, 121, 0.4); + --vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7); + --vscode-scrollbarSlider-activeBackground: rgba(191, 191, 191, 0.4); + --vscode-textLink-foreground: #3794ff; + --vscode-textLink-activeForeground: #3794ff; + --vscode-errorForeground: #f48771; + --vscode-icon-foreground: #c5c5c5; + --vscode-dropdown-background: #3c3c3c; + --vscode-dropdown-foreground: #cccccc; + --vscode-dropdown-border: #3c3c3c; + --vscode-checkbox-background: #3c3c3c; + --vscode-checkbox-foreground: #cccccc; + --vscode-checkbox-border: #3c3c3c; + --vscode-widget-shadow: rgba(0, 0, 0, 0.36); + --vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31); + --vscode-tab-activeBackground: #1e1e1e; + --vscode-tab-activeForeground: #ffffff; + --vscode-tab-inactiveBackground: #2d2d2d; + --vscode-tab-inactiveForeground: rgba(255, 255, 255, 0.5); + --vscode-tab-border: #252526; + --vscode-editorWidget-background: #252526; + --vscode-editorWidget-foreground: #cccccc; + --vscode-editorWidget-border: #454545; + --vscode-notifications-background: #252526; + --vscode-notifications-foreground: #cccccc; + --vscode-notificationCenterHeader-background: #303031; + --vscode-settings-textInputBackground: #3c3c3c; + --vscode-settings-textInputForeground: #cccccc; + --vscode-settings-textInputBorder: #3c3c3c; + --vscode-titleBar-activeBackground: #3c3c3c; + --vscode-titleBar-activeForeground: #cccccc; + --vscode-statusBar-background: #007acc; + --vscode-statusBar-foreground: #ffffff; + `; +} + +function getLightThemeVariables(): string { + return ` + --vscode-editor-background: #ffffff; + --vscode-editor-foreground: #333333; + --vscode-foreground: #616161; + --vscode-descriptionForeground: #717171; + --vscode-button-background: #007acc; + --vscode-button-foreground: #ffffff; + --vscode-button-hoverBackground: #0062a3; + --vscode-button-secondaryBackground: #5f6a79; + --vscode-button-secondaryForeground: #ffffff; + --vscode-button-secondaryHoverBackground: #4c5561; + --vscode-input-background: #ffffff; + --vscode-input-foreground: #616161; + --vscode-input-border: #cecece; + --vscode-input-placeholderForeground: #767676; + --vscode-focusBorder: #0078d4; + --vscode-panel-border: #e7e7e7; + --vscode-panel-background: #ffffff; + --vscode-sideBar-background: #f3f3f3; + --vscode-sideBar-foreground: #616161; + --vscode-list-hoverBackground: #e8e8e8; + --vscode-list-activeSelectionBackground: #0060c0; + --vscode-list-activeSelectionForeground: #ffffff; + --vscode-list-inactiveSelectionBackground: #e4e6f1; + --vscode-badge-background: #c4c4c4; + --vscode-badge-foreground: #333333; + --vscode-scrollbarSlider-background: rgba(100, 100, 100, 0.4); + --vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7); + --vscode-scrollbarSlider-activeBackground: rgba(0, 0, 0, 0.6); + --vscode-textLink-foreground: #006ab1; + --vscode-textLink-activeForeground: #006ab1; + --vscode-errorForeground: #a1260d; + --vscode-icon-foreground: #424242; + --vscode-dropdown-background: #ffffff; + --vscode-dropdown-foreground: #616161; + --vscode-dropdown-border: #cecece; + --vscode-checkbox-background: #ffffff; + --vscode-checkbox-foreground: #616161; + --vscode-checkbox-border: #cecece; + --vscode-widget-shadow: rgba(0, 0, 0, 0.16); + --vscode-toolbar-hoverBackground: rgba(184, 184, 184, 0.31); + --vscode-tab-activeBackground: #ffffff; + --vscode-tab-activeForeground: #333333; + --vscode-tab-inactiveBackground: #ececec; + --vscode-tab-inactiveForeground: rgba(51, 51, 51, 0.7); + --vscode-tab-border: #f3f3f3; + --vscode-editorWidget-background: #f3f3f3; + --vscode-editorWidget-foreground: #616161; + --vscode-editorWidget-border: #c8c8c8; + --vscode-notifications-background: #f3f3f3; + --vscode-notifications-foreground: #616161; + --vscode-notificationCenterHeader-background: #e7e7e7; + --vscode-settings-textInputBackground: #ffffff; + --vscode-settings-textInputForeground: #616161; + --vscode-settings-textInputBorder: #cecece; + --vscode-titleBar-activeBackground: #dddddd; + --vscode-titleBar-activeForeground: #333333; + --vscode-statusBar-background: #007acc; + --vscode-statusBar-foreground: #ffffff; + `; +} + +app.whenReady().then(() => { + createWindow(); + + const fs = new NodeFileSystem(); + const mcpManager = new McpServerManager(); + const dialogService = new ElectronDialogService(() => mainWindow); + + // Set up IPC message transport + const transport = new ElectronMessageTransport(ipcMain, () => mainWindow); + mcpManager.setTransport(transport); + + // Set up IPC handlers + setupIpcHandlers(ipcMain, { + getMainWindow: () => mainWindow, + fs, + logger, + dialog: dialogService, + mcpManager, + }); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } +}); diff --git a/packages/electron/src/main/preload.ts b/packages/electron/src/main/preload.ts new file mode 100644 index 00000000..d7150df8 --- /dev/null +++ b/packages/electron/src/main/preload.ts @@ -0,0 +1,20 @@ +/** + * Electron Preload Script + * + * Exposes a safe API to the renderer process via contextBridge. + */ + +import { contextBridge, ipcRenderer } from 'electron'; + +contextBridge.exposeInMainWorld('electronAPI', { + send: (channel: string, data: unknown): void => { + ipcRenderer.send(channel, data); + }, + on: (channel: string, callback: (data: unknown) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: unknown): void => callback(data); + ipcRenderer.on(channel, listener); + return () => { + ipcRenderer.removeListener(channel, listener); + }; + }, +}); diff --git a/packages/electron/tsconfig.main.json b/packages/electron/tsconfig.main.json new file mode 100644 index 00000000..e870de30 --- /dev/null +++ b/packages/electron/tsconfig.main.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src/main", + "outDir": "./dist/main", + "lib": ["ES2020"], + "types": ["node"], + "module": "commonjs", + "moduleResolution": "node" + }, + "include": ["src/main/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/electron/tsconfig.preload.json b/packages/electron/tsconfig.preload.json new file mode 100644 index 00000000..38a2783f --- /dev/null +++ b/packages/electron/tsconfig.preload.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src/main", + "outDir": "./dist/preload", + "lib": ["ES2020"], + "types": ["node"], + "module": "commonjs", + "moduleResolution": "node" + }, + "include": ["src/main/preload.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/vscode/src/adapters/vscode-file-system.ts b/packages/vscode/src/adapters/vscode-file-system.ts new file mode 100644 index 00000000..e02ce41c --- /dev/null +++ b/packages/vscode/src/adapters/vscode-file-system.ts @@ -0,0 +1,58 @@ +/** + * VSCode File System Adapter + * + * IFileSystem implementation using VSCode workspace.fs API. + */ + +import type { IFileSystem } from '@cc-wf-studio/core'; +import * as vscode from 'vscode'; + +export class VSCodeFileSystem implements IFileSystem { + async readFile(filePath: string): Promise { + const uri = vscode.Uri.file(filePath); + const bytes = await vscode.workspace.fs.readFile(uri); + return Buffer.from(bytes).toString('utf-8'); + } + + async writeFile(filePath: string, content: string): Promise { + const uri = vscode.Uri.file(filePath); + const bytes = Buffer.from(content, 'utf-8'); + await vscode.workspace.fs.writeFile(uri, bytes); + } + + async fileExists(filePath: string): Promise { + const uri = vscode.Uri.file(filePath); + try { + await vscode.workspace.fs.stat(uri); + return true; + } catch { + return false; + } + } + + async createDirectory(dirPath: string): Promise { + const uri = vscode.Uri.file(dirPath); + await vscode.workspace.fs.createDirectory(uri); + } + + async readDirectory( + dirPath: string + ): Promise> { + const uri = vscode.Uri.file(dirPath); + const entries = await vscode.workspace.fs.readDirectory(uri); + return entries.map(([name, type]) => ({ + name, + isFile: type === vscode.FileType.File, + isDirectory: type === vscode.FileType.Directory, + })); + } + + async stat(filePath: string): Promise<{ isFile: boolean; isDirectory: boolean }> { + const uri = vscode.Uri.file(filePath); + const stat = await vscode.workspace.fs.stat(uri); + return { + isFile: stat.type === vscode.FileType.File, + isDirectory: stat.type === vscode.FileType.Directory, + }; + } +} diff --git a/packages/vscode/src/adapters/vscode-logger.ts b/packages/vscode/src/adapters/vscode-logger.ts new file mode 100644 index 00000000..e2dc49d3 --- /dev/null +++ b/packages/vscode/src/adapters/vscode-logger.ts @@ -0,0 +1,42 @@ +/** + * VSCode Logger Adapter + * + * ILogger implementation using VSCode OutputChannel. + */ + +import type { ILogger } from '@cc-wf-studio/core'; +import type * as vscode from 'vscode'; + +export class VSCodeLogger implements ILogger { + constructor(private readonly outputChannel: vscode.OutputChannel) {} + + info(message: string, data?: unknown): void { + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] [INFO] ${message}`; + this.outputChannel.appendLine(logMessage); + if (data) { + this.outputChannel.appendLine(` Data: ${JSON.stringify(data, null, 2)}`); + } + console.log(logMessage, data ?? ''); + } + + warn(message: string, data?: unknown): void { + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] [WARN] ${message}`; + this.outputChannel.appendLine(logMessage); + if (data) { + this.outputChannel.appendLine(` Data: ${JSON.stringify(data, null, 2)}`); + } + console.warn(logMessage, data ?? ''); + } + + error(message: string, data?: unknown): void { + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] [ERROR] ${message}`; + this.outputChannel.appendLine(logMessage); + if (data) { + this.outputChannel.appendLine(` Data: ${JSON.stringify(data, null, 2)}`); + } + console.error(logMessage, data ?? ''); + } +} diff --git a/packages/vscode/src/adapters/vscode-message-transport.ts b/packages/vscode/src/adapters/vscode-message-transport.ts new file mode 100644 index 00000000..648446a9 --- /dev/null +++ b/packages/vscode/src/adapters/vscode-message-transport.ts @@ -0,0 +1,39 @@ +/** + * VSCode Message Transport Adapter + * + * IMessageTransport implementation wrapping VSCode Webview postMessage. + */ + +import type { IMessageTransport } from '@cc-wf-studio/core'; +import type * as vscode from 'vscode'; + +export class VSCodeMessageTransport implements IMessageTransport { + private webview: vscode.Webview | null = null; + private handlers: Array< + (message: { type: string; requestId?: string; payload?: unknown }) => void + > = []; + + setWebview(webview: vscode.Webview | null): void { + this.webview = webview; + } + + postMessage(message: { type: string; requestId?: string; payload?: unknown }): void { + this.webview?.postMessage(message); + } + + onMessage( + handler: (message: { type: string; requestId?: string; payload?: unknown }) => void + ): void { + this.handlers.push(handler); + } + + /** + * Called from the extension host when a webview message is received. + * Forwards to registered handlers. + */ + handleIncomingMessage(message: { type: string; requestId?: string; payload?: unknown }): void { + for (const handler of this.handlers) { + handler(message); + } + } +} diff --git a/src/webview/src/main.tsx b/src/webview/src/main.tsx index a0ccff40..dab9cdd9 100644 --- a/src/webview/src/main.tsx +++ b/src/webview/src/main.tsx @@ -1,8 +1,8 @@ /** * Claude Code Workflow Studio - Webview Entry Point * - * React 18 root initialization and VSCode API acquisition - * Based on: /specs/001-cc-wf-studio/plan.md + * React 18 root initialization with platform-agnostic bridge detection. + * Supports VSCode, Electron, and Dev mode environments. */ import React from 'react'; @@ -10,17 +10,16 @@ import ReactDOM from 'react-dom/client'; import { ReactFlowProvider } from 'reactflow'; import App from './App'; import { I18nProvider } from './i18n/i18n-context'; +import { type IHostBridge, setBridge } from './services/bridge'; +import { createElectronBridge } from './services/electron-bridge-adapter'; +import { createVSCodeBridge } from './services/vscode-bridge-adapter'; import 'reactflow/dist/style.css'; import './styles/main.css'; // ============================================================================ -// VSCode API +// VSCode API Type (for type checking only) // ============================================================================ -/** - * VSCode API type definition - * Reference: https://code.visualstudio.com/api/extension-guides/webview - */ interface VSCodeAPI { postMessage(message: unknown): void; getState(): unknown; @@ -35,22 +34,53 @@ declare global { } } -// Acquire VSCode API (only available in VSCode Webview context) -export const vscode = window.acquireVsCodeApi?.() ?? { - postMessage: (message: unknown) => { - console.log('[Dev Mode] postMessage:', message); - }, - getState: () => { - console.log('[Dev Mode] getState'); - return null; - }, - setState: (state: unknown) => { - console.log('[Dev Mode] setState:', state); - }, -}; +// ============================================================================ +// Bridge Initialization +// ============================================================================ + +function createDevBridge(): IHostBridge { + return { + postMessage: (message: unknown) => { + console.log('[Dev Mode] postMessage:', message); + }, + onMessage: (handler) => { + window.addEventListener('message', handler); + return () => window.removeEventListener('message', handler); + }, + getState: () => { + console.log('[Dev Mode] getState'); + return null; + }, + setState: (state: unknown) => { + console.log('[Dev Mode] setState:', state); + }, + }; +} + +let bridge: IHostBridge; + +if (window.acquireVsCodeApi) { + // VSCode Webview context + const api = window.acquireVsCodeApi(); + bridge = createVSCodeBridge(api); + // Make vscode API available globally for backward compatibility + window.vscode = api; +} else if (window.electronAPI) { + // Electron renderer context + bridge = createElectronBridge(); +} else { + // Dev mode (browser) + bridge = createDevBridge(); +} + +setBridge(bridge); -// Make vscode API available globally for services that can't import ES modules -window.vscode = vscode; +// Export for backward compatibility with existing code that imports `vscode` from main +export const vscode: VSCodeAPI = { + postMessage: (msg: unknown) => bridge.postMessage(msg as { type: string }), + getState: () => bridge.getState(), + setState: (s: unknown) => bridge.setState(s), +}; // ============================================================================ // React 18 Root Initialization @@ -77,7 +107,5 @@ root.render( ); -// Notify Extension Host that Webview is ready to receive messages -// This ensures INITIAL_STATE is sent only after React is fully initialized -// Fixes: Issue #396 - blank page when Webview loads slowly -vscode.postMessage({ type: 'WEBVIEW_READY' }); +// Notify host that Webview is ready to receive messages +bridge.postMessage({ type: 'WEBVIEW_READY' }); diff --git a/src/webview/src/services/bridge.ts b/src/webview/src/services/bridge.ts new file mode 100644 index 00000000..6496eaa1 --- /dev/null +++ b/src/webview/src/services/bridge.ts @@ -0,0 +1,26 @@ +/** + * Host Bridge Abstraction + * + * Platform-agnostic bridge interface for webview-to-host communication. + * Implementations: VSCode (postMessage), Electron (IPC), Dev (console) + */ + +export interface IHostBridge { + postMessage(message: { type: string; requestId?: string; payload?: unknown }): void; + onMessage(handler: (event: { data: unknown }) => void): () => void; + getState(): unknown; + setState(state: unknown): void; +} + +let bridge: IHostBridge | null = null; + +export function setBridge(b: IHostBridge): void { + bridge = b; +} + +export function getBridge(): IHostBridge { + if (!bridge) { + throw new Error('Bridge not initialized. Call setBridge() first.'); + } + return bridge; +} diff --git a/src/webview/src/services/electron-bridge-adapter.ts b/src/webview/src/services/electron-bridge-adapter.ts new file mode 100644 index 00000000..da7def85 --- /dev/null +++ b/src/webview/src/services/electron-bridge-adapter.ts @@ -0,0 +1,35 @@ +/** + * Electron Bridge Adapter + * + * IHostBridge implementation for Electron renderer context. + * Uses window.electronAPI (exposed via preload script) for communication. + */ + +import type { IHostBridge } from './bridge'; + +interface ElectronAPI { + send(channel: string, data: unknown): void; + on(channel: string, callback: (data: unknown) => void): () => void; +} + +declare global { + interface Window { + electronAPI?: ElectronAPI; + } +} + +export function createElectronBridge(): IHostBridge { + const api = window.electronAPI; + if (!api) { + throw new Error('electronAPI not available. Ensure preload script is loaded.'); + } + + return { + postMessage: (msg) => api.send('webview-message', msg), + onMessage: (handler) => { + return api.on('host-message', (data) => handler({ data })); + }, + getState: () => JSON.parse(localStorage.getItem('app-state') || 'null'), + setState: (s) => localStorage.setItem('app-state', JSON.stringify(s)), + }; +} diff --git a/src/webview/src/services/vscode-bridge-adapter.ts b/src/webview/src/services/vscode-bridge-adapter.ts new file mode 100644 index 00000000..3a254576 --- /dev/null +++ b/src/webview/src/services/vscode-bridge-adapter.ts @@ -0,0 +1,26 @@ +/** + * VSCode Bridge Adapter + * + * IHostBridge implementation for VSCode Webview context. + * Uses window.acquireVsCodeApi() for communication. + */ + +import type { IHostBridge } from './bridge'; + +interface VSCodeAPI { + postMessage(message: unknown): void; + getState(): unknown; + setState(state: unknown): void; +} + +export function createVSCodeBridge(api: VSCodeAPI): IHostBridge { + return { + postMessage: (msg) => api.postMessage(msg), + onMessage: (handler) => { + window.addEventListener('message', handler); + return () => window.removeEventListener('message', handler); + }, + getState: () => api.getState(), + setState: (s) => api.setState(s), + }; +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000..97631352 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + } +} From d08db94b83e1721ca9663b2edf24583f67db697b Mon Sep 17 00:00:00 2001 From: APPLO <1134226402@qq.com> Date: Sat, 28 Feb 2026 18:52:40 +0800 Subject: [PATCH 2/3] feat: add standalone theme for Web/Dev mode and update README - Add standalone-theme.css with 75 --vscode-* CSS variable defaults (dark/light) - Import theme in main.tsx as fallback for Web/Dev mode - Fix scrollbar track using same color as thumb, add body color transition - Add Multi-Platform Support, Web Mode, and Project Structure to README --- README.md | 36 +++ src/webview/src/main.tsx | 9 +- src/webview/src/styles/main.css | 5 +- src/webview/src/styles/standalone-theme.css | 317 ++++++++++++++++++++ 4 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 src/webview/src/styles/standalone-theme.css diff --git a/README.md b/README.md index fb316c7b..8da44f63 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,42 @@ --- +## Multi-Platform Support + +CC Workflow Studio runs in three modes: + +| Mode | Description | Use Case | +|------|-------------|----------| +| **VSCode Extension** | Install from [VS Marketplace](https://marketplace.visualstudio.com/items?itemName=breaking-brake.cc-wf-studio) or [OpenVSX](https://open-vsx.org/extension/breaking-brake/cc-wf-studio) | Primary usage — full integration with editor | +| **Electron Desktop App** | Standalone desktop application via `packages/electron/` | Offline use without VSCode | +| **Web Browser (Dev)** | Run with Vite dev server | Development and quick preview | + +### Web Mode (Development) + +Run the editor directly in a browser with hot reload: + +```bash +cd src/webview +npm run dev # http://localhost:5173 +npm run dev -- --host # Expose on LAN +``` + +The Web mode automatically applies a standalone theme that follows your OS light/dark preference. + +## Project Structure + +``` +cc-wf-studio/ +├── packages/ +│ ├── core/ # Shared backend logic (platform-agnostic) +│ ├── electron/ # Electron desktop app +│ └── vscode/ # VSCode extension adapter +├── src/ +│ ├── extension/ # VSCode Extension Host +│ └── webview/ # React UI (shared across all platforms) +└── resources/ # Icons, schemas +``` + ## Key Features 🔀 **Visual Workflow Editor** - Intuitive drag-and-drop canvas for designing AI agent orchestrations without code diff --git a/src/webview/src/main.tsx b/src/webview/src/main.tsx index dab9cdd9..5df39389 100644 --- a/src/webview/src/main.tsx +++ b/src/webview/src/main.tsx @@ -14,6 +14,7 @@ import { type IHostBridge, setBridge } from './services/bridge'; import { createElectronBridge } from './services/electron-bridge-adapter'; import { createVSCodeBridge } from './services/vscode-bridge-adapter'; import 'reactflow/dist/style.css'; +import './styles/standalone-theme.css'; import './styles/main.css'; // ============================================================================ @@ -42,13 +43,19 @@ function createDevBridge(): IHostBridge { return { postMessage: (message: unknown) => { console.log('[Dev Mode] postMessage:', message); + const msg = message as { type: string }; + // Simulate extension host: when webview sends WEBVIEW_READY, reply with INITIAL_STATE + if (msg.type === 'WEBVIEW_READY') { + setTimeout(() => { + window.postMessage({ type: 'INITIAL_STATE', payload: { locale: 'en' } }, '*'); + }, 50); + } }, onMessage: (handler) => { window.addEventListener('message', handler); return () => window.removeEventListener('message', handler); }, getState: () => { - console.log('[Dev Mode] getState'); return null; }, setState: (state: unknown) => { diff --git a/src/webview/src/styles/main.css b/src/webview/src/styles/main.css index 050a23de..fc6a4065 100644 --- a/src/webview/src/styles/main.css +++ b/src/webview/src/styles/main.css @@ -29,6 +29,9 @@ body { -moz-osx-font-smoothing: grayscale; background-color: var(--vscode-editor-background); color: var(--vscode-editor-foreground); + transition: + background-color 0.2s ease, + color 0.2s ease; } #root { @@ -84,7 +87,7 @@ button { } ::-webkit-scrollbar-track { - background: var(--vscode-scrollbarSlider-background); + background: transparent; } ::-webkit-scrollbar-thumb { diff --git a/src/webview/src/styles/standalone-theme.css b/src/webview/src/styles/standalone-theme.css new file mode 100644 index 00000000..976f805f --- /dev/null +++ b/src/webview/src/styles/standalone-theme.css @@ -0,0 +1,317 @@ +/** + * Standalone Theme - Default CSS Variables for Web/Dev Mode + * + * Provides fallback values for all --vscode-* CSS variables used across the app. + * In VSCode, the host injects these variables. In Electron, main.ts injects them. + * In Web/Dev mode (Vite dev server), this file provides the defaults. + * + * Dark theme is the default. Light theme uses @media (prefers-color-scheme: light). + * + * Variable values sourced from: + * - packages/electron/src/main/main.ts (getDarkThemeVariables / getLightThemeVariables) + * - VS Code Dark+ / Light+ default themes (for variables not in Electron) + */ + +/* ============================================================================ + * Dark Theme (Default) + * ============================================================================ */ +:root { + /* --- Font --- */ + --vscode-font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", + "Droid Sans", "Helvetica Neue", sans-serif; + --vscode-editor-font-family: "Menlo", "Monaco", "Courier New", monospace; + + /* --- Core Colors --- */ + --vscode-editor-background: #1e1e1e; + --vscode-editor-foreground: #d4d4d4; + --vscode-foreground: #cccccc; + --vscode-descriptionForeground: #9d9d9d; + --vscode-disabledForeground: #6b6b6b; + --vscode-focusBorder: #007fd4; + --vscode-icon-foreground: #c5c5c5; + --vscode-widget-shadow: rgba(0, 0, 0, 0.36); + + /* --- Buttons --- */ + --vscode-button-background: #0e639c; + --vscode-button-foreground: #ffffff; + --vscode-button-hoverBackground: #1177bb; + --vscode-button-border: transparent; + --vscode-button-secondaryBackground: #3a3d41; + --vscode-button-secondaryForeground: #ffffff; + --vscode-button-secondaryHoverBackground: #45494e; + + /* --- Inputs --- */ + --vscode-input-background: #3c3c3c; + --vscode-input-foreground: #cccccc; + --vscode-input-border: #3c3c3c; + --vscode-input-placeholderForeground: #a6a6a6; + --vscode-inputOption-activeBackground: rgba(0, 127, 212, 0.4); + --vscode-inputOption-activeForeground: #ffffff; + --vscode-inputValidation-errorBackground: #5a1d1d; + --vscode-inputValidation-errorBorder: #be1100; + --vscode-inputValidation-errorForeground: #cccccc; + --vscode-inputValidation-warningBackground: #352a05; + --vscode-inputValidation-warningBorder: #9e6a03; + --vscode-inputValidation-warningForeground: #cccccc; + + /* --- Dropdowns --- */ + --vscode-dropdown-background: #3c3c3c; + --vscode-dropdown-foreground: #cccccc; + --vscode-dropdown-border: #3c3c3c; + + /* --- Checkbox --- */ + --vscode-checkbox-background: #3c3c3c; + --vscode-checkbox-foreground: #cccccc; + --vscode-checkbox-border: #3c3c3c; + + /* --- Lists --- */ + --vscode-list-hoverBackground: #2a2d2e; + --vscode-list-activeSelectionBackground: #094771; + --vscode-list-activeSelectionForeground: #ffffff; + --vscode-list-inactiveSelectionBackground: #37373d; + + /* --- Panels & Sidebar --- */ + --vscode-panel-border: #2b2b2b; + --vscode-panel-background: #1e1e1e; + --vscode-sideBar-background: #252526; + --vscode-sideBar-foreground: #cccccc; + --vscode-sideBarSectionHeader-background: #333333; + + /* --- Tabs --- */ + --vscode-tab-activeBackground: #1e1e1e; + --vscode-tab-activeForeground: #ffffff; + --vscode-tab-inactiveBackground: #2d2d2d; + --vscode-tab-inactiveForeground: rgba(255, 255, 255, 0.5); + --vscode-tab-border: #252526; + + /* --- Title Bar --- */ + --vscode-titleBar-activeBackground: #3c3c3c; + --vscode-titleBar-activeForeground: #cccccc; + --vscode-titleBar-inactiveBackground: #333333; + + /* --- Status Bar --- */ + --vscode-statusBar-background: #007acc; + --vscode-statusBar-foreground: #ffffff; + + /* --- Badges --- */ + --vscode-badge-background: #4d4d4d; + --vscode-badge-foreground: #ffffff; + + /* --- Scrollbar --- */ + --vscode-scrollbarSlider-background: rgba(121, 121, 121, 0.4); + --vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7); + --vscode-scrollbarSlider-activeBackground: rgba(191, 191, 191, 0.4); + + /* --- Links --- */ + --vscode-textLink-foreground: #3794ff; + --vscode-textLink-activeForeground: #3794ff; + + /* --- Errors & Warnings --- */ + --vscode-errorForeground: #f48771; + --vscode-errorBackground: #5a1d1d; + --vscode-errorBorder: #be1100; + --vscode-editorWarning-foreground: #cca700; + --vscode-notificationsWarningIcon-foreground: #cca700; + + /* --- Toolbar --- */ + --vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31); + + /* --- Editor Widgets --- */ + --vscode-editorWidget-background: #252526; + --vscode-editorWidget-foreground: #cccccc; + --vscode-editorWidget-border: #454545; + --vscode-editorHoverWidget-background: #252526; + --vscode-editorHoverWidget-foreground: #cccccc; + --vscode-editorHoverWidget-border: #454545; + --vscode-editor-inactiveSelectionBackground: #3a3d41; + + /* --- Notifications --- */ + --vscode-notifications-background: #252526; + --vscode-notifications-foreground: #cccccc; + --vscode-notificationCenterHeader-background: #303031; + + /* --- Settings --- */ + --vscode-settings-textInputBackground: #3c3c3c; + --vscode-settings-textInputForeground: #cccccc; + --vscode-settings-textInputBorder: #3c3c3c; + + /* --- Progress --- */ + --vscode-progressBar-background: #0e70c0; + + /* --- Charts --- */ + --vscode-charts-blue: #3794ff; + --vscode-charts-cyan: #00bcd4; + --vscode-charts-green: #89d185; + --vscode-charts-orange: #d18616; + --vscode-charts-purple: #b180d7; + --vscode-charts-red: #f48771; + --vscode-charts-yellow: #cca700; + + /* --- Git Decorations --- */ + --vscode-gitDecoration-addedResourceForeground: #81b88b; + --vscode-gitDecoration-deletedResourceForeground: #c74e39; + --vscode-gitDecoration-modifiedResourceForeground: #e2c08d; + + /* --- Terminal --- */ + --vscode-terminal-ansiBlue: #2472c8; + --vscode-terminal-ansiYellow: #cd9731; + + /* --- Testing --- */ + --vscode-testing-iconPassed: #73c991; + + /* --- Text Blocks --- */ + --vscode-textBlockQuote-background: #2b2b2b; + --vscode-textBlockQuote-border: #616161; + --vscode-textCodeBlock-background: #2b2b2b; +} + +/* ============================================================================ + * Light Theme + * ============================================================================ */ +@media (prefers-color-scheme: light) { + :root { + /* --- Core Colors --- */ + --vscode-editor-background: #ffffff; + --vscode-editor-foreground: #333333; + --vscode-foreground: #616161; + --vscode-descriptionForeground: #717171; + --vscode-disabledForeground: #b1b1b1; + --vscode-focusBorder: #0078d4; + --vscode-icon-foreground: #424242; + --vscode-widget-shadow: rgba(0, 0, 0, 0.16); + + /* --- Buttons --- */ + --vscode-button-background: #007acc; + --vscode-button-foreground: #ffffff; + --vscode-button-hoverBackground: #0062a3; + --vscode-button-border: transparent; + --vscode-button-secondaryBackground: #5f6a79; + --vscode-button-secondaryForeground: #ffffff; + --vscode-button-secondaryHoverBackground: #4c5561; + + /* --- Inputs --- */ + --vscode-input-background: #ffffff; + --vscode-input-foreground: #616161; + --vscode-input-border: #cecece; + --vscode-input-placeholderForeground: #767676; + --vscode-inputOption-activeBackground: rgba(0, 120, 212, 0.2); + --vscode-inputOption-activeForeground: #000000; + --vscode-inputValidation-errorBackground: #f2dede; + --vscode-inputValidation-errorBorder: #be1100; + --vscode-inputValidation-errorForeground: #333333; + --vscode-inputValidation-warningBackground: #f6f5d2; + --vscode-inputValidation-warningBorder: #b89500; + --vscode-inputValidation-warningForeground: #333333; + + /* --- Dropdowns --- */ + --vscode-dropdown-background: #ffffff; + --vscode-dropdown-foreground: #616161; + --vscode-dropdown-border: #cecece; + + /* --- Checkbox --- */ + --vscode-checkbox-background: #ffffff; + --vscode-checkbox-foreground: #616161; + --vscode-checkbox-border: #cecece; + + /* --- Lists --- */ + --vscode-list-hoverBackground: #e8e8e8; + --vscode-list-activeSelectionBackground: #0060c0; + --vscode-list-activeSelectionForeground: #ffffff; + --vscode-list-inactiveSelectionBackground: #e4e6f1; + + /* --- Panels & Sidebar --- */ + --vscode-panel-border: #e7e7e7; + --vscode-panel-background: #ffffff; + --vscode-sideBar-background: #f3f3f3; + --vscode-sideBar-foreground: #616161; + --vscode-sideBarSectionHeader-background: #e7e7e7; + + /* --- Tabs --- */ + --vscode-tab-activeBackground: #ffffff; + --vscode-tab-activeForeground: #333333; + --vscode-tab-inactiveBackground: #ececec; + --vscode-tab-inactiveForeground: rgba(51, 51, 51, 0.7); + --vscode-tab-border: #f3f3f3; + + /* --- Title Bar --- */ + --vscode-titleBar-activeBackground: #dddddd; + --vscode-titleBar-activeForeground: #333333; + --vscode-titleBar-inactiveBackground: #e0e0e0; + + /* --- Status Bar --- */ + --vscode-statusBar-background: #007acc; + --vscode-statusBar-foreground: #ffffff; + + /* --- Badges --- */ + --vscode-badge-background: #c4c4c4; + --vscode-badge-foreground: #333333; + + /* --- Scrollbar --- */ + --vscode-scrollbarSlider-background: rgba(100, 100, 100, 0.4); + --vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7); + --vscode-scrollbarSlider-activeBackground: rgba(0, 0, 0, 0.6); + + /* --- Links --- */ + --vscode-textLink-foreground: #006ab1; + --vscode-textLink-activeForeground: #006ab1; + + /* --- Errors & Warnings --- */ + --vscode-errorForeground: #a1260d; + --vscode-errorBackground: #f2dede; + --vscode-errorBorder: #be1100; + --vscode-editorWarning-foreground: #bf8803; + --vscode-notificationsWarningIcon-foreground: #bf8803; + + /* --- Toolbar --- */ + --vscode-toolbar-hoverBackground: rgba(184, 184, 184, 0.31); + + /* --- Editor Widgets --- */ + --vscode-editorWidget-background: #f3f3f3; + --vscode-editorWidget-foreground: #616161; + --vscode-editorWidget-border: #c8c8c8; + --vscode-editorHoverWidget-background: #f3f3f3; + --vscode-editorHoverWidget-foreground: #616161; + --vscode-editorHoverWidget-border: #c8c8c8; + --vscode-editor-inactiveSelectionBackground: #e5ebf1; + + /* --- Notifications --- */ + --vscode-notifications-background: #f3f3f3; + --vscode-notifications-foreground: #616161; + --vscode-notificationCenterHeader-background: #e7e7e7; + + /* --- Settings --- */ + --vscode-settings-textInputBackground: #ffffff; + --vscode-settings-textInputForeground: #616161; + --vscode-settings-textInputBorder: #cecece; + + /* --- Progress --- */ + --vscode-progressBar-background: #0078d4; + + /* --- Charts --- */ + --vscode-charts-blue: #005fb8; + --vscode-charts-cyan: #0097a7; + --vscode-charts-green: #388a34; + --vscode-charts-orange: #d18616; + --vscode-charts-purple: #7e57c2; + --vscode-charts-red: #a1260d; + --vscode-charts-yellow: #bf8803; + + /* --- Git Decorations --- */ + --vscode-gitDecoration-addedResourceForeground: #587c0c; + --vscode-gitDecoration-deletedResourceForeground: #ad0707; + --vscode-gitDecoration-modifiedResourceForeground: #895503; + + /* --- Terminal --- */ + --vscode-terminal-ansiBlue: #0451a5; + --vscode-terminal-ansiYellow: #b5ba00; + + /* --- Testing --- */ + --vscode-testing-iconPassed: #388a34; + + /* --- Text Blocks --- */ + --vscode-textBlockQuote-background: #f2f2f2; + --vscode-textBlockQuote-border: #cccccc; + --vscode-textCodeBlock-background: #f2f2f2; + } +} From eb553eb9428efed0645b1d8862fda6fe200c5401 Mon Sep 17 00:00:00 2001 From: APPLO <1134226402@qq.com> Date: Sat, 28 Feb 2026 23:59:35 +0800 Subject: [PATCH 3/3] feat: migrate from VSCode extension to standalone web app - Add Hono web server with WebSocket message routing (packages/web-server/) - Add web bridge adapter for browser-to-server communication via WebSocket - Port all 65+ message handlers from VSCode extension to web server - Add encrypted file-based secret store replacing VSCode secrets API - Add shell-exec service replacing VSCode terminal API - Remove src/extension/ (21 command handlers, 48 services) - Remove packages/electron/ and packages/vscode/ - Update build scripts, tsconfig, and CLAUDE.md for web architecture --- .vscodeignore | 64 - CLAUDE.md | 102 +- package.json | 90 +- packages/electron/package.json | 20 - packages/electron/renderer/index.html | 13 - .../main/adapters/electron-dialog-service.ts | 71 - .../adapters/electron-message-transport.ts | 40 - .../electron/src/main/ipc/ipc-handlers.ts | 131 -- packages/electron/src/main/main.ts | 229 --- packages/electron/src/main/preload.ts | 20 - packages/electron/tsconfig.preload.json | 13 - .../vscode/src/adapters/vscode-file-system.ts | 58 - packages/vscode/src/adapters/vscode-logger.ts | 42 - .../src/adapters/vscode-message-transport.ts | 39 - packages/web-server/package.json | 25 + .../src/adapters/ws-message-transport.ts | 35 + .../web-server/src/handlers/ai-handlers.ts | 457 +++++ .../src/handlers/export-handlers.ts | 317 ++++ .../web-server/src/handlers/mcp-handlers.ts | 263 +++ .../web-server/src/handlers/skill-handlers.ts | 252 +++ .../web-server/src/handlers/slack-handlers.ts | 277 +++ .../src/handlers/workflow-handlers.ts | 118 ++ packages/web-server/src/routes/ws-handler.ts | 717 ++++++++ packages/web-server/src/server.ts | 73 + .../web-server/src/services/secret-store.ts | 106 ++ .../web-server/src/services/shell-exec.ts | 93 + .../src/services/web-dialog-service.ts | 82 + .../tsconfig.json} | 11 +- scripts/generate-editing-flow.ts | 2 +- .../commands/antigravity-handlers.ts | 237 --- src/extension/commands/codex-handlers.ts | 309 ---- src/extension/commands/copilot-handlers.ts | 534 ------ src/extension/commands/cursor-handlers.ts | 231 --- src/extension/commands/export-workflow.ts | 277 --- src/extension/commands/gemini-handlers.ts | 307 ---- src/extension/commands/load-workflow-list.ts | 81 - src/extension/commands/load-workflow.ts | 72 - src/extension/commands/mcp-handlers.ts | 529 ------ src/extension/commands/open-editor.ts | 1632 ----------------- src/extension/commands/roo-code-handlers.ts | 304 --- src/extension/commands/save-workflow.ts | 137 -- src/extension/commands/skill-operations.ts | 198 -- .../commands/slack-connect-manual.ts | 148 -- src/extension/commands/slack-connect-oauth.ts | 140 -- .../commands/slack-description-generation.ts | 181 -- .../commands/slack-import-workflow.ts | 274 --- .../commands/slack-share-workflow.ts | 354 ---- src/extension/commands/text-editor.ts | 193 -- .../commands/workflow-name-generation.ts | 190 -- src/extension/commands/workflow-refinement.ts | 811 -------- .../workflow-preview-editor-provider.ts | 198 -- src/extension/extension.ts | 267 --- src/extension/i18n/i18n-service.ts | 117 -- src/extension/i18n/translation-keys.ts | 15 - src/extension/i18n/translations/en.ts | 16 - src/extension/i18n/translations/ja.ts | 16 - src/extension/i18n/translations/ko.ts | 16 - src/extension/i18n/translations/zh-CN.ts | 15 - src/extension/i18n/translations/zh-TW.ts | 15 - .../services/ai-editing-skill-service.ts | 199 -- src/extension/services/ai-metrics-service.ts | 109 -- src/extension/services/ai-provider.ts | 240 --- .../services/antigravity-extension-service.ts | 67 - .../antigravity-skill-export-service.ts | 132 -- src/extension/services/claude-cli-path.ts | 111 -- src/extension/services/claude-code-service.ts | 877 --------- src/extension/services/cli-path-detector.ts | 248 --- src/extension/services/codex-cli-path.ts | 108 -- src/extension/services/codex-cli-service.ts | 1009 ---------- .../services/codex-mcp-sync-service.ts | 232 --- .../services/codex-skill-export-service.ts | 133 -- .../services/copilot-cli-mcp-sync-service.ts | 176 -- .../services/copilot-export-service.ts | 388 ---- .../services/copilot-skill-export-service.ts | 131 -- .../services/cursor-extension-service.ts | 47 - .../services/cursor-skill-export-service.ts | 199 -- src/extension/services/export-service.ts | 435 ----- src/extension/services/file-service.ts | 139 -- src/extension/services/gemini-cli-path.ts | 108 -- .../services/gemini-mcp-sync-service.ts | 233 --- .../services/gemini-skill-export-service.ts | 131 -- src/extension/services/mcp-cache-service.ts | 353 ---- src/extension/services/mcp-cli-service.ts | 1090 ----------- src/extension/services/mcp-config-reader.ts | 1273 ------------- src/extension/services/mcp-sdk-client.ts | 313 ---- .../services/mcp-server-config-writer.ts | 307 ---- src/extension/services/mcp-server-service.ts | 296 --- src/extension/services/mcp-server-tools.ts | 222 --- .../services/refinement-prompt-builder.ts | 207 --- src/extension/services/refinement-service.ts | 1585 ---------------- .../services/roo-code-extension-service.ts | 47 - .../services/roo-code-mcp-sync-service.ts | 208 --- .../services/roo-code-skill-export-service.ts | 131 -- .../services/schema-loader-service.ts | 202 -- .../services/skill-file-generator.ts | 68 - .../services/skill-normalization-service.ts | 548 ------ .../services/skill-relevance-matcher.ts | 251 --- src/extension/services/skill-service.ts | 582 ------ src/extension/services/slack-api-service.ts | 522 ------ .../slack-description-prompt-builder.ts | 40 - src/extension/services/slack-oauth-service.ts | 306 ---- .../services/terminal-execution-service.ts | 189 -- src/extension/services/vscode-lm-service.ts | 458 ----- .../services/workflow-name-prompt-builder.ts | 41 - .../services/workflow-prompt-generator.ts | 846 --------- src/extension/services/yaml-parser.ts | 72 - .../types/slack-integration-types.ts | 232 --- src/extension/types/slack-messages.ts | 384 ---- src/extension/utils/migrate-workflow.ts | 203 -- src/extension/utils/path-utils.ts | 488 ----- src/extension/utils/schema-parser.ts | 348 ---- .../utils/sensitive-data-detector.ts | 207 --- src/extension/utils/slack-error-handler.ts | 261 --- src/extension/utils/slack-message-builder.ts | 141 -- src/extension/utils/slack-token-manager.ts | 446 ----- src/extension/utils/validate-workflow.ts | 1068 ----------- src/extension/utils/workflow-validator.ts | 101 - src/extension/webview-content.ts | 79 - src/webview/src/main.tsx | 8 +- .../src/services/web-bridge-adapter.ts | 117 ++ src/webview/vite.config.ts | 17 +- tsconfig.json | 6 +- vite.extension.config.ts | 100 - 123 files changed, 3028 insertions(+), 28361 deletions(-) delete mode 100644 .vscodeignore delete mode 100644 packages/electron/package.json delete mode 100644 packages/electron/renderer/index.html delete mode 100644 packages/electron/src/main/adapters/electron-dialog-service.ts delete mode 100644 packages/electron/src/main/adapters/electron-message-transport.ts delete mode 100644 packages/electron/src/main/ipc/ipc-handlers.ts delete mode 100644 packages/electron/src/main/main.ts delete mode 100644 packages/electron/src/main/preload.ts delete mode 100644 packages/electron/tsconfig.preload.json delete mode 100644 packages/vscode/src/adapters/vscode-file-system.ts delete mode 100644 packages/vscode/src/adapters/vscode-logger.ts delete mode 100644 packages/vscode/src/adapters/vscode-message-transport.ts create mode 100644 packages/web-server/package.json create mode 100644 packages/web-server/src/adapters/ws-message-transport.ts create mode 100644 packages/web-server/src/handlers/ai-handlers.ts create mode 100644 packages/web-server/src/handlers/export-handlers.ts create mode 100644 packages/web-server/src/handlers/mcp-handlers.ts create mode 100644 packages/web-server/src/handlers/skill-handlers.ts create mode 100644 packages/web-server/src/handlers/slack-handlers.ts create mode 100644 packages/web-server/src/handlers/workflow-handlers.ts create mode 100644 packages/web-server/src/routes/ws-handler.ts create mode 100644 packages/web-server/src/server.ts create mode 100644 packages/web-server/src/services/secret-store.ts create mode 100644 packages/web-server/src/services/shell-exec.ts create mode 100644 packages/web-server/src/services/web-dialog-service.ts rename packages/{electron/tsconfig.main.json => web-server/tsconfig.json} (51%) delete mode 100644 src/extension/commands/antigravity-handlers.ts delete mode 100644 src/extension/commands/codex-handlers.ts delete mode 100644 src/extension/commands/copilot-handlers.ts delete mode 100644 src/extension/commands/cursor-handlers.ts delete mode 100644 src/extension/commands/export-workflow.ts delete mode 100644 src/extension/commands/gemini-handlers.ts delete mode 100644 src/extension/commands/load-workflow-list.ts delete mode 100644 src/extension/commands/load-workflow.ts delete mode 100644 src/extension/commands/mcp-handlers.ts delete mode 100644 src/extension/commands/open-editor.ts delete mode 100644 src/extension/commands/roo-code-handlers.ts delete mode 100644 src/extension/commands/save-workflow.ts delete mode 100644 src/extension/commands/skill-operations.ts delete mode 100644 src/extension/commands/slack-connect-manual.ts delete mode 100644 src/extension/commands/slack-connect-oauth.ts delete mode 100644 src/extension/commands/slack-description-generation.ts delete mode 100644 src/extension/commands/slack-import-workflow.ts delete mode 100644 src/extension/commands/slack-share-workflow.ts delete mode 100644 src/extension/commands/text-editor.ts delete mode 100644 src/extension/commands/workflow-name-generation.ts delete mode 100644 src/extension/commands/workflow-refinement.ts delete mode 100644 src/extension/editors/workflow-preview-editor-provider.ts delete mode 100644 src/extension/extension.ts delete mode 100644 src/extension/i18n/i18n-service.ts delete mode 100644 src/extension/i18n/translation-keys.ts delete mode 100644 src/extension/i18n/translations/en.ts delete mode 100644 src/extension/i18n/translations/ja.ts delete mode 100644 src/extension/i18n/translations/ko.ts delete mode 100644 src/extension/i18n/translations/zh-CN.ts delete mode 100644 src/extension/i18n/translations/zh-TW.ts delete mode 100644 src/extension/services/ai-editing-skill-service.ts delete mode 100644 src/extension/services/ai-metrics-service.ts delete mode 100644 src/extension/services/ai-provider.ts delete mode 100644 src/extension/services/antigravity-extension-service.ts delete mode 100644 src/extension/services/antigravity-skill-export-service.ts delete mode 100644 src/extension/services/claude-cli-path.ts delete mode 100644 src/extension/services/claude-code-service.ts delete mode 100644 src/extension/services/cli-path-detector.ts delete mode 100644 src/extension/services/codex-cli-path.ts delete mode 100644 src/extension/services/codex-cli-service.ts delete mode 100644 src/extension/services/codex-mcp-sync-service.ts delete mode 100644 src/extension/services/codex-skill-export-service.ts delete mode 100644 src/extension/services/copilot-cli-mcp-sync-service.ts delete mode 100644 src/extension/services/copilot-export-service.ts delete mode 100644 src/extension/services/copilot-skill-export-service.ts delete mode 100644 src/extension/services/cursor-extension-service.ts delete mode 100644 src/extension/services/cursor-skill-export-service.ts delete mode 100644 src/extension/services/export-service.ts delete mode 100644 src/extension/services/file-service.ts delete mode 100644 src/extension/services/gemini-cli-path.ts delete mode 100644 src/extension/services/gemini-mcp-sync-service.ts delete mode 100644 src/extension/services/gemini-skill-export-service.ts delete mode 100644 src/extension/services/mcp-cache-service.ts delete mode 100644 src/extension/services/mcp-cli-service.ts delete mode 100644 src/extension/services/mcp-config-reader.ts delete mode 100644 src/extension/services/mcp-sdk-client.ts delete mode 100644 src/extension/services/mcp-server-config-writer.ts delete mode 100644 src/extension/services/mcp-server-service.ts delete mode 100644 src/extension/services/mcp-server-tools.ts delete mode 100644 src/extension/services/refinement-prompt-builder.ts delete mode 100644 src/extension/services/refinement-service.ts delete mode 100644 src/extension/services/roo-code-extension-service.ts delete mode 100644 src/extension/services/roo-code-mcp-sync-service.ts delete mode 100644 src/extension/services/roo-code-skill-export-service.ts delete mode 100644 src/extension/services/schema-loader-service.ts delete mode 100644 src/extension/services/skill-file-generator.ts delete mode 100644 src/extension/services/skill-normalization-service.ts delete mode 100644 src/extension/services/skill-relevance-matcher.ts delete mode 100644 src/extension/services/skill-service.ts delete mode 100644 src/extension/services/slack-api-service.ts delete mode 100644 src/extension/services/slack-description-prompt-builder.ts delete mode 100644 src/extension/services/slack-oauth-service.ts delete mode 100644 src/extension/services/terminal-execution-service.ts delete mode 100644 src/extension/services/vscode-lm-service.ts delete mode 100644 src/extension/services/workflow-name-prompt-builder.ts delete mode 100644 src/extension/services/workflow-prompt-generator.ts delete mode 100644 src/extension/services/yaml-parser.ts delete mode 100644 src/extension/types/slack-integration-types.ts delete mode 100644 src/extension/types/slack-messages.ts delete mode 100644 src/extension/utils/migrate-workflow.ts delete mode 100644 src/extension/utils/path-utils.ts delete mode 100644 src/extension/utils/schema-parser.ts delete mode 100644 src/extension/utils/sensitive-data-detector.ts delete mode 100644 src/extension/utils/slack-error-handler.ts delete mode 100644 src/extension/utils/slack-message-builder.ts delete mode 100644 src/extension/utils/slack-token-manager.ts delete mode 100644 src/extension/utils/validate-workflow.ts delete mode 100644 src/extension/utils/workflow-validator.ts delete mode 100644 src/extension/webview-content.ts create mode 100644 src/webview/src/services/web-bridge-adapter.ts delete mode 100644 vite.extension.config.ts diff --git a/.vscodeignore b/.vscodeignore deleted file mode 100644 index 158343db..00000000 --- a/.vscodeignore +++ /dev/null @@ -1,64 +0,0 @@ -# Development files -.vscode/** -.vscode-test/** -.gitignore -.git/** -.env -.env.* - -# Source files (use built files in dist/ instead) -src/extension/**/*.ts -src/shared/**/*.ts -tsconfig.json -out/** - -# Webview source and build dependencies -src/webview/src/** -src/webview/node_modules/** -src/webview/package.json -src/webview/package-lock.json -src/webview/tsconfig.json -src/webview/tsconfig.node.json -src/webview/vite.config.ts -src/webview/index.html - -# Keep only webview/dist for runtime -!src/webview/dist/** - -# Development and spec files -.specify/** -.claude/** -specs/** - -# Test files -**/__tests__/** -**/*.test.ts -**/*.test.js -**/test/** -.vscode-test/** - -# Build tools -node_modules/** -*.vsix - -# Documentation -readme.md -CLAUDE.md - -# Heavy assets (for README only, not needed in VSIX) -resources/ai-refinement-demo-1.gif -resources/ai-refinement-demo-2.gif -resources/demo_edit_with_ai.gif -resources/demo_run_workflow.gif -resources/export-demo.gif -resources/hero.png -resources/icon-large.png -resources/icon-save.png -resources/icon-file-down.png -resources/icon-export.png -resources/icon-play.png -resources/icon-sparkles.png - -# Build config -biome.json -vite.extension.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index 75c2acb9..599e1d9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,25 +3,27 @@ Auto-generated from all feature plans. Last updated: 2025-11-01 ## Active Technologies -- ローカルファイルシステム (`.vscode/workflows/*.json`, `.claude/skills/*.md`, `.claude/commands/*.md`) (001-cc-wf-studio) -- TypeScript 5.3 (VSCode Extension Host), React 18.2 (Webview UI) (001-node-types-extension) -- ローカルファイルシステム (`.vscode/workflows/*.json`) (001-node-types-extension) -- TypeScript 5.3.0 (001-skill-node) -- File system (SKILL.md files in `~/.claude/skills/` and `.claude/skills/`), workflow JSON files in `.vscode/workflows/` (001-skill-node) -- TypeScript 5.3.0 (VSCode Extension Host), TypeScript/React 18.2 (Webview UI) + VSCode Extension API 1.80.0+, React 18.2, React Flow (visual canvas), Zustand (state management), child_process (Claude Code CLI execution) (001-mcp-node) -- Workflow JSON files in `.vscode/workflows/` directory, Claude Code MCP configuration (user/project/enterprise scopes) (001-mcp-node) -- TypeScript 5.3.0 (VSCode Extension Host), TypeScript/React 18.2 (Webview UI) + VSCode Extension API 1.80.0+, React 18.2, React Flow (visual canvas), Zustand (state management), existing MCP SDK client services (001-mcp-natural-language-mode) -- Workflow JSON files in `.vscode/workflows/` directory (extends existing McpNodeData structure) (001-mcp-natural-language-mode) -- TypeScript 5.3 (VSCode Extension Host), React 18.2 (Webview UI), @slack/web-api 7.x, Node.js http (OAuth callback server), VSCode Secret Storage (001-slack-workflow-sharing) -- Workflow JSON files in `.vscode/workflows/` directory, Slack message attachments (workflow storage), VSCode Secret Storage (OAuth tokens) (001-slack-workflow-sharing) - -- TypeScript 5.x (VSCode Extension Host), React 18.x (Webview UI) (001-cc-wf-studio) +- TypeScript 5.x (Hono Web Server), React 18.x (Webview UI) +- Hono (HTTP server + WebSocket), React Flow (visual canvas), Zustand (state management) +- @cc-wf-studio/core (shared services: FileService, McpServerManager, NodeFileSystem) +- @slack/web-api 7.x, @modelcontextprotocol/sdk +- File system: `.vscode/workflows/*.json`, `.claude/skills/*.md`, `.claude/commands/*.md` ## Project Structure ```text +packages/ + core/ # Shared services, interfaces, types + web-server/ # Hono backend (replaces src/extension/) + src/ + server.ts # HTTP + WebSocket server + routes/ # WebSocket message router + handlers/ # Message handlers by feature + services/ # Secret store, shell exec, dialog + adapters/ # WebSocket transport adapter src/ -tests/ + webview/ # React UI (unchanged) + shared/ # Shared types between webview and server ``` ## Development Workflow & Commands @@ -93,8 +95,8 @@ npm run build # Build extension and webview (verify compilation) ```bash npm run build ``` - - Compiles TypeScript and builds extension - - Required for testing changes in VSCode + - Compiles TypeScript and builds webview + web server + - Required for testing changes 3. **Before git commit**: ```bash @@ -107,7 +109,8 @@ npm run build # Build extension and webview (verify compilation) - **Unit/Integration tests**: Not required (manual E2E testing only) - **Manual E2E testing**: Required for all feature changes and bug fixes - Run `npm run build` first - - Test in VSCode Extension Development Host + - Run `npm run start:web` to start the server + - Open `http://localhost:3001` in browser ## Version Update Procedure @@ -127,9 +130,8 @@ This project uses **Semantic Release** with **GitHub Actions** for fully automat 3. **Changelog Generation**: Automatically updates `CHANGELOG.md` 4. **Git Commit**: Creates release commit with message `chore(release): ${version} [skip ci]` 5. **GitHub Release**: Creates GitHub release with release notes -6. **VSIX Build**: Builds and packages the extension -7. **VSIX Upload**: Uploads `.vsix` file to GitHub release -8. **Version Sync**: Merges version changes from `production` to `main` branch +6. **Build**: Builds the web application +7. **Version Sync**: Merges version changes from `production` to `main` branch #### Commit Message Convention (Conventional Commits) @@ -191,11 +193,12 @@ Manual version updates will be overwritten by the next automated release. ## Code Style -TypeScript 5.x (VSCode Extension Host), React 18.x (Webview UI): Follow standard conventions +TypeScript 5.x (Hono Web Server + React Webview): Follow standard conventions ## Recent Changes -- 001-mcp-natural-language-mode: Added TypeScript 5.3.0 (VSCode Extension Host), TypeScript/React 18.2 (Webview UI) + VSCode Extension API 1.80.0+, React 18.2, React Flow (visual canvas), Zustand (state management), existing MCP SDK client services -- 001-mcp-node: Added TypeScript 5.3.0 (VSCode Extension Host), TypeScript/React 18.2 (Webview UI) + VSCode Extension API 1.80.0+, React 18.2, React Flow (visual canvas), Zustand (state management), child_process (Claude Code CLI execution) +- Migrated from VSCode extension to standalone web app with Hono backend + WebSocket +- Removed src/extension/, packages/vscode/, packages/electron/ +- Added packages/web-server/ with full message routing for all features @@ -241,17 +244,18 @@ sequenceDiagram ```mermaid flowchart TB - subgraph VSCode["VSCode Extension"] - subgraph ExtHost["Extension Host (Node.js)"] - Commands["Commands
src/extension/commands/"] - Services["Services
src/extension/services/"] - Utils["Utilities
src/extension/utils/"] - end - subgraph Webview["Webview (React)"] - Components["Components
src/webview/src/components/"] - Stores["Zustand Stores
src/webview/src/stores/"] - WVServices["Services
src/webview/src/services/"] - end + subgraph Browser["Browser (React)"] + Components["Components
src/webview/src/components/"] + Stores["Zustand Stores
src/webview/src/stores/"] + WVServices["Services
src/webview/src/services/"] + end + subgraph Server["Hono Web Server (Node.js)"] + WSHandler["WS Handler
packages/web-server/src/routes/"] + Handlers["Handlers
packages/web-server/src/handlers/"] + Services["Services
packages/web-server/src/services/"] + end + subgraph Core["@cc-wf-studio/core"] + CoreServices["FileService, McpServerManager"] end subgraph External["External Services"] FS["File System
.vscode/workflows/"] @@ -260,11 +264,12 @@ flowchart TB MCP["MCP Servers"] end - Webview <-->|postMessage| ExtHost - ExtHost --> FS - ExtHost --> CLI - ExtHost --> Slack - ExtHost --> MCP + Browser <-->|WebSocket| Server + Handlers --> CoreServices + Server --> FS + Server --> CLI + Server --> Slack + Server --> MCP ``` ### ワークフロー保存フロー @@ -273,20 +278,21 @@ flowchart TB sequenceDiagram actor User participant Toolbar as Toolbar.tsx - participant Bridge as vscode-bridge.ts - participant Cmd as save-workflow.ts - participant FS as file-service.ts + participant Bridge as web-bridge-adapter.ts + participant WS as WebSocket + participant Handler as ws-handler.ts + participant FS as FileService (core) participant Disk as .vscode/workflows/ User->>Toolbar: Click Save Toolbar->>Bridge: saveWorkflow(workflow) - Bridge->>Cmd: postMessage(SAVE_WORKFLOW) - Cmd->>Cmd: validateWorkflow() - Cmd->>FS: ensureDirectory() - FS->>Disk: mkdir -p - Cmd->>FS: writeFile() + Bridge->>WS: send(SAVE_WORKFLOW) + WS->>Handler: handleMessage + Handler->>FS: ensureDirectory() + Handler->>FS: writeFile() FS->>Disk: write JSON - Cmd->>Bridge: postMessage(SAVE_SUCCESS) + Handler->>WS: send(SAVE_SUCCESS) + WS->>Bridge: onmessage Bridge->>Toolbar: resolve Promise Toolbar->>User: Show notification ``` diff --git a/package.json b/package.json index 97d1a6ee..72591d5e 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,6 @@ "workspaces": [ "packages/*" ], - "publisher": "breaking-brake", - "icon": "resources/icon.png", "repository": { "type": "git", "url": "https://github.com/breaking-brake/cc-wf-studio" @@ -28,91 +26,28 @@ "cursor" ], "engines": { - "vscode": "^1.80.0" + "node": ">=18.0.0" }, - "categories": [ - "AI", - "Visualization" - ], - "main": "./dist/extension.js", - "contributes": { - "commands": [ - { - "command": "cc-wf-studio.openEditor", - "title": "CC Workflow Studio: Open Editor", - "icon": { - "light": "resources/icon.png", - "dark": "resources/icon.png" - } - } - ], - "menus": { - "editor/title": [ - { - "command": "cc-wf-studio.openEditor", - "group": "navigation" - } - ] - }, - "customEditors": [ - { - "viewType": "cc-wf-studio.workflowPreview", - "displayName": "Workflow Preview", - "selector": [ - { - "filenamePattern": "**/.vscode/workflows/*.json" - } - ], - "priority": "default" - } - ], - "configuration": { - "title": "CC Workflow Studio", - "properties": { - "cc-wf-studio.ai.schemaFormat": { - "type": "string", - "enum": [ - "json", - "toon" - ], - "default": "toon", - "description": "Schema format for AI prompts. 'toon' reduces token consumption by ~23%." - }, - "cc-wf-studio.ai.collectMetrics": { - "type": "boolean", - "default": false, - "description": "Collect and log metrics for A/B comparison of schema formats. Note: Prompt format is always 'toon'." - } - } - } - }, - "activationEvents": [ - "onUri", - "onCustomEditor:cc-wf-studio.workflowPreview" - ], "scripts": { - "vscode:prepublish": "npm run build", "generate:toon": "npx tsx scripts/generate-toon-schema.ts", "generate:editing-flow": "npx tsx scripts/generate-editing-flow.ts", - "build": "npm run build:core && npm run generate:toon && npm run generate:editing-flow && npm run build:webview && npm run build:extension", + "build": "npm run build:core && npm run generate:toon && npm run generate:editing-flow && npm run build:webview && npm run build:web-server", "build:core": "npm run build -w @cc-wf-studio/core", - "build:vscode": "npm run build:core && npm run generate:toon && npm run generate:editing-flow && npm run build:webview && npm run build:extension", - "build:electron": "npm run build:core && npm run build:webview && npm run build -w @cc-wf-studio/electron", - "build:dev": "npm run build:webview:dev && npm run build:extension:dev", "build:webview": "cd src/webview && npm run build", "build:webview:dev": "cd src/webview && npm run build:dev", - "build:extension": "vite build --config vite.extension.config.ts", - "build:extension:dev": "vite build --config vite.extension.config.ts --mode development", - "compile": "tsc -p ./", - "watch": "vite build --config vite.extension.config.ts --watch", + "build:web": "npm run build:core && npm run generate:toon && npm run generate:editing-flow && npm run build:webview:web && npm run build:web-server", + "build:webview:web": "cd src/webview && npx vite build --mode web", + "build:web-server": "npm run build -w @cc-wf-studio/web-server", + "dev:web": "npx concurrently --kill-others \"npm run dev:web-server\" \"npm run dev:webview-web\"", + "dev:web-server": "npm run dev -w @cc-wf-studio/web-server", + "dev:webview-web": "cd src/webview && npx vite --mode web", + "start:web": "npm run start -w @cc-wf-studio/web-server", "watch:webview": "cd src/webview && npm run dev", "lint": "biome lint .", "format": "biome format --write .", "check": "biome check --write .", - "test": "npm run test:unit && npm run test:integration", - "test:unit": "cd src/webview && npm run test", - "test:integration": "vscode-test", - "test:e2e": "wdio run wdio.conf.ts" + "test": "npm run test:unit", + "test:unit": "cd src/webview && npm run test" }, "devDependencies": { "@biomejs/biome": "2.4.4", @@ -120,9 +55,6 @@ "@semantic-release/exec": "^7.1.0", "@semantic-release/git": "^10.0.1", "@types/node": "^25.0.3", - "@types/vscode": "^1.80.0", - "@vscode/test-cli": "^0.0.12", - "@vscode/test-electron": "^2.3.8", "conventional-changelog-conventionalcommits": "^9.1.0", "semantic-release": "^25.0.2", "typescript": "^5.3.0", diff --git a/packages/electron/package.json b/packages/electron/package.json deleted file mode 100644 index f17e013b..00000000 --- a/packages/electron/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "@cc-wf-studio/electron", - "version": "3.26.0", - "private": true, - "license": "AGPL-3.0-or-later", - "main": "dist/main/main.js", - "scripts": { - "build": "echo 'Electron build requires: npm install electron' && tsc -p tsconfig.main.json && tsc -p tsconfig.preload.json", - "start": "electron dist/main/main.js", - "clean": "rm -rf dist" - }, - "dependencies": { - "@cc-wf-studio/core": "*" - }, - "devDependencies": { - "typescript": "^5.3.0", - "@types/node": "^25.0.3", - "electron": "^33.0.0" - } -} diff --git a/packages/electron/renderer/index.html b/packages/electron/renderer/index.html deleted file mode 100644 index 4a0ee97e..00000000 --- a/packages/electron/renderer/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - CC Workflow Studio - - -
- - - diff --git a/packages/electron/src/main/adapters/electron-dialog-service.ts b/packages/electron/src/main/adapters/electron-dialog-service.ts deleted file mode 100644 index 6dcbd471..00000000 --- a/packages/electron/src/main/adapters/electron-dialog-service.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Electron Dialog Service - * - * IDialogService implementation using Electron's dialog API. - */ - -import type { IDialogService } from '@cc-wf-studio/core'; -import { type BrowserWindow, dialog } from 'electron'; - -export class ElectronDialogService implements IDialogService { - constructor(private readonly getWindow: () => BrowserWindow | null) {} - - showInformationMessage(message: string): void { - const win = this.getWindow(); - if (win) { - dialog.showMessageBoxSync(win, { type: 'info', message }); - } - } - - showWarningMessage(message: string): void { - const win = this.getWindow(); - if (win) { - dialog.showMessageBoxSync(win, { type: 'warning', message }); - } - } - - showErrorMessage(message: string): void { - const win = this.getWindow(); - if (win) { - dialog.showMessageBoxSync(win, { type: 'error', message }); - } - } - - async showConfirmDialog(message: string, confirmLabel: string): Promise { - const win = this.getWindow(); - if (!win) return false; - - const result = await dialog.showMessageBox(win, { - type: 'question', - buttons: [confirmLabel, 'Cancel'], - defaultId: 0, - cancelId: 1, - message, - }); - - return result.response === 0; - } - - async showOpenFileDialog(options: { - filters?: Record; - title?: string; - }): Promise { - const win = this.getWindow(); - if (!win) return null; - - const filters = options.filters - ? Object.entries(options.filters).map(([name, extensions]) => ({ - name, - extensions, - })) - : []; - - const result = await dialog.showOpenDialog(win, { - title: options.title, - filters, - properties: ['openFile'], - }); - - return result.canceled ? null : result.filePaths[0] || null; - } -} diff --git a/packages/electron/src/main/adapters/electron-message-transport.ts b/packages/electron/src/main/adapters/electron-message-transport.ts deleted file mode 100644 index 028f8f0d..00000000 --- a/packages/electron/src/main/adapters/electron-message-transport.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Electron Message Transport - * - * IMessageTransport implementation using Electron IPC. - * Routes messages between the main process and renderer. - */ - -import type { IMessageTransport } from '@cc-wf-studio/core'; -import type { BrowserWindow, IpcMain } from 'electron'; - -export class ElectronMessageTransport implements IMessageTransport { - private handlers: Array< - (message: { type: string; requestId?: string; payload?: unknown }) => void - > = []; - - constructor( - private readonly ipcMain: IpcMain, - private readonly getWindow: () => BrowserWindow | null - ) { - // Listen for messages from renderer - this.ipcMain.on('webview-message', (_event, message) => { - for (const handler of this.handlers) { - handler(message); - } - }); - } - - postMessage(message: { type: string; requestId?: string; payload?: unknown }): void { - const win = this.getWindow(); - if (win && !win.isDestroyed()) { - win.webContents.send('host-message', message); - } - } - - onMessage( - handler: (message: { type: string; requestId?: string; payload?: unknown }) => void - ): void { - this.handlers.push(handler); - } -} diff --git a/packages/electron/src/main/ipc/ipc-handlers.ts b/packages/electron/src/main/ipc/ipc-handlers.ts deleted file mode 100644 index 6be1eee7..00000000 --- a/packages/electron/src/main/ipc/ipc-handlers.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Electron IPC Handlers - * - * Routes messages from the renderer process to core services. - * Mirrors the message routing in the VSCode extension's open-editor.ts, - * but delegates to core package services instead of VSCode APIs. - */ - -import * as path from 'node:path'; -import type { IDialogService, IFileSystem, ILogger } from '@cc-wf-studio/core'; -import { FileService, type McpServerManager } from '@cc-wf-studio/core'; -import type { BrowserWindow, IpcMain } from 'electron'; - -interface IpcDependencies { - getMainWindow: () => BrowserWindow | null; - fs: IFileSystem; - logger: ILogger; - dialog: IDialogService; - mcpManager: McpServerManager; -} - -export function setupIpcHandlers(ipcMain: IpcMain, deps: IpcDependencies): void { - let fileService: FileService | null = null; - - ipcMain.on('webview-message', async (_event, message) => { - const { type, requestId, payload } = message; - const win = deps.getMainWindow(); - - const reply = (responseType: string, responsePayload?: unknown): void => { - if (win && !win.isDestroyed()) { - win.webContents.send('host-message', { - type: responseType, - requestId, - payload: responsePayload, - }); - } - }; - - try { - switch (type) { - case 'WEBVIEW_READY': { - deps.logger.info('Webview ready'); - // Send initial state - reply('INITIAL_STATE', { locale: 'en' }); - break; - } - - case 'SAVE_WORKFLOW': { - if (!fileService) { - // Use current working directory as workspace root - fileService = new FileService(deps.fs, process.cwd()); - } - await fileService.ensureWorkflowsDirectory(); - const workflow = payload.workflow; - const filePath = fileService.getWorkflowFilePath(workflow.name); - await fileService.writeFile(filePath, JSON.stringify(workflow, null, 2)); - reply('SAVE_SUCCESS'); - break; - } - - case 'LOAD_WORKFLOW_LIST': { - if (!fileService) { - fileService = new FileService(deps.fs, process.cwd()); - } - const files = await fileService.listWorkflowFiles(); - const workflows = []; - for (const name of files) { - try { - const filePath = fileService.getWorkflowFilePath(name); - const content = await fileService.readFile(filePath); - workflows.push(JSON.parse(content)); - } catch { - deps.logger.warn(`Failed to load workflow: ${name}`); - } - } - reply('WORKFLOW_LIST_LOADED', { workflows }); - break; - } - - case 'EXPORT_WORKFLOW': { - deps.logger.info('Export workflow requested'); - // Basic export — write to .claude/commands/ - if (!fileService) { - fileService = new FileService(deps.fs, process.cwd()); - } - const exportWorkflow = payload.workflow; - const commandsDir = path.join(process.cwd(), '.claude', 'commands'); - await deps.fs.createDirectory(commandsDir); - const exportPath = path.join(commandsDir, `${exportWorkflow.name}.md`); - await deps.fs.writeFile(exportPath, `# ${exportWorkflow.name}\n\nExported workflow.`); - reply('EXPORT_SUCCESS', { exportedFiles: [exportPath] }); - break; - } - - case 'STATE_UPDATE': { - // State persistence — store in localStorage via preload - deps.logger.info('State update received'); - break; - } - - case 'OPEN_EXTERNAL_URL': { - const { shell } = require('electron'); - shell.openExternal(payload.url); - break; - } - - case 'GET_CURRENT_WORKFLOW_RESPONSE': { - deps.mcpManager.handleWorkflowResponse(payload); - break; - } - - case 'APPLY_WORKFLOW_FROM_MCP_RESPONSE': { - deps.mcpManager.handleApplyResponse(payload); - break; - } - - default: { - deps.logger.info(`Unhandled message type: ${type}`); - break; - } - } - } catch (error) { - deps.logger.error(`Error handling message ${type}`, { - error: error instanceof Error ? error.message : String(error), - }); - reply('ERROR', { - message: error instanceof Error ? error.message : 'Unknown error', - }); - } - }); -} diff --git a/packages/electron/src/main/main.ts b/packages/electron/src/main/main.ts deleted file mode 100644 index 1f5d8b6e..00000000 --- a/packages/electron/src/main/main.ts +++ /dev/null @@ -1,229 +0,0 @@ -/** - * CC Workflow Studio - Electron Main Process - * - * Desktop application entry point. - * Creates a BrowserWindow that loads the shared webview UI. - */ - -import * as path from 'node:path'; -import { ConsoleLogger, McpServerManager, NodeFileSystem, setLogger } from '@cc-wf-studio/core'; -import { app, BrowserWindow, ipcMain, nativeTheme, shell } from 'electron'; -import { ElectronDialogService } from './adapters/electron-dialog-service'; -import { ElectronMessageTransport } from './adapters/electron-message-transport'; -import { setupIpcHandlers } from './ipc/ipc-handlers'; - -let mainWindow: BrowserWindow | null = null; -const logger = new ConsoleLogger(); - -// Set the global logger for core services -setLogger(logger); - -function createWindow(): void { - mainWindow = new BrowserWindow({ - width: 1400, - height: 900, - minWidth: 800, - minHeight: 600, - title: 'CC Workflow Studio', - webPreferences: { - preload: path.join(__dirname, '../preload/preload.js'), - contextIsolation: true, - nodeIntegration: false, - sandbox: false, - }, - }); - - // Load the webview build output - const webviewPath = path.join(__dirname, '../../webview/dist/index.html'); - mainWindow.loadFile(webviewPath); - - // Inject theme CSS variables - mainWindow.webContents.on('did-finish-load', () => { - const isDark = nativeTheme.shouldUseDarkColors; - injectThemeVariables(isDark); - }); - - // Listen for theme changes - nativeTheme.on('updated', () => { - const isDark = nativeTheme.shouldUseDarkColors; - injectThemeVariables(isDark); - }); - - // Open external links in default browser - mainWindow.webContents.setWindowOpenHandler(({ url }) => { - shell.openExternal(url); - return { action: 'deny' }; - }); - - mainWindow.on('closed', () => { - mainWindow = null; - }); -} - -function injectThemeVariables(isDark: boolean): void { - if (!mainWindow) return; - - const variables = isDark ? getDarkThemeVariables() : getLightThemeVariables(); - const css = `:root { ${variables} }`; - - mainWindow.webContents.insertCSS(css); -} - -function getDarkThemeVariables(): string { - return ` - --vscode-editor-background: #1e1e1e; - --vscode-editor-foreground: #d4d4d4; - --vscode-foreground: #cccccc; - --vscode-descriptionForeground: #9d9d9d; - --vscode-button-background: #0e639c; - --vscode-button-foreground: #ffffff; - --vscode-button-hoverBackground: #1177bb; - --vscode-button-secondaryBackground: #3a3d41; - --vscode-button-secondaryForeground: #ffffff; - --vscode-button-secondaryHoverBackground: #45494e; - --vscode-input-background: #3c3c3c; - --vscode-input-foreground: #cccccc; - --vscode-input-border: #3c3c3c; - --vscode-input-placeholderForeground: #a6a6a6; - --vscode-focusBorder: #007fd4; - --vscode-panel-border: #2b2b2b; - --vscode-panel-background: #1e1e1e; - --vscode-sideBar-background: #252526; - --vscode-sideBar-foreground: #cccccc; - --vscode-list-hoverBackground: #2a2d2e; - --vscode-list-activeSelectionBackground: #094771; - --vscode-list-activeSelectionForeground: #ffffff; - --vscode-list-inactiveSelectionBackground: #37373d; - --vscode-badge-background: #4d4d4d; - --vscode-badge-foreground: #ffffff; - --vscode-scrollbarSlider-background: rgba(121, 121, 121, 0.4); - --vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7); - --vscode-scrollbarSlider-activeBackground: rgba(191, 191, 191, 0.4); - --vscode-textLink-foreground: #3794ff; - --vscode-textLink-activeForeground: #3794ff; - --vscode-errorForeground: #f48771; - --vscode-icon-foreground: #c5c5c5; - --vscode-dropdown-background: #3c3c3c; - --vscode-dropdown-foreground: #cccccc; - --vscode-dropdown-border: #3c3c3c; - --vscode-checkbox-background: #3c3c3c; - --vscode-checkbox-foreground: #cccccc; - --vscode-checkbox-border: #3c3c3c; - --vscode-widget-shadow: rgba(0, 0, 0, 0.36); - --vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31); - --vscode-tab-activeBackground: #1e1e1e; - --vscode-tab-activeForeground: #ffffff; - --vscode-tab-inactiveBackground: #2d2d2d; - --vscode-tab-inactiveForeground: rgba(255, 255, 255, 0.5); - --vscode-tab-border: #252526; - --vscode-editorWidget-background: #252526; - --vscode-editorWidget-foreground: #cccccc; - --vscode-editorWidget-border: #454545; - --vscode-notifications-background: #252526; - --vscode-notifications-foreground: #cccccc; - --vscode-notificationCenterHeader-background: #303031; - --vscode-settings-textInputBackground: #3c3c3c; - --vscode-settings-textInputForeground: #cccccc; - --vscode-settings-textInputBorder: #3c3c3c; - --vscode-titleBar-activeBackground: #3c3c3c; - --vscode-titleBar-activeForeground: #cccccc; - --vscode-statusBar-background: #007acc; - --vscode-statusBar-foreground: #ffffff; - `; -} - -function getLightThemeVariables(): string { - return ` - --vscode-editor-background: #ffffff; - --vscode-editor-foreground: #333333; - --vscode-foreground: #616161; - --vscode-descriptionForeground: #717171; - --vscode-button-background: #007acc; - --vscode-button-foreground: #ffffff; - --vscode-button-hoverBackground: #0062a3; - --vscode-button-secondaryBackground: #5f6a79; - --vscode-button-secondaryForeground: #ffffff; - --vscode-button-secondaryHoverBackground: #4c5561; - --vscode-input-background: #ffffff; - --vscode-input-foreground: #616161; - --vscode-input-border: #cecece; - --vscode-input-placeholderForeground: #767676; - --vscode-focusBorder: #0078d4; - --vscode-panel-border: #e7e7e7; - --vscode-panel-background: #ffffff; - --vscode-sideBar-background: #f3f3f3; - --vscode-sideBar-foreground: #616161; - --vscode-list-hoverBackground: #e8e8e8; - --vscode-list-activeSelectionBackground: #0060c0; - --vscode-list-activeSelectionForeground: #ffffff; - --vscode-list-inactiveSelectionBackground: #e4e6f1; - --vscode-badge-background: #c4c4c4; - --vscode-badge-foreground: #333333; - --vscode-scrollbarSlider-background: rgba(100, 100, 100, 0.4); - --vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7); - --vscode-scrollbarSlider-activeBackground: rgba(0, 0, 0, 0.6); - --vscode-textLink-foreground: #006ab1; - --vscode-textLink-activeForeground: #006ab1; - --vscode-errorForeground: #a1260d; - --vscode-icon-foreground: #424242; - --vscode-dropdown-background: #ffffff; - --vscode-dropdown-foreground: #616161; - --vscode-dropdown-border: #cecece; - --vscode-checkbox-background: #ffffff; - --vscode-checkbox-foreground: #616161; - --vscode-checkbox-border: #cecece; - --vscode-widget-shadow: rgba(0, 0, 0, 0.16); - --vscode-toolbar-hoverBackground: rgba(184, 184, 184, 0.31); - --vscode-tab-activeBackground: #ffffff; - --vscode-tab-activeForeground: #333333; - --vscode-tab-inactiveBackground: #ececec; - --vscode-tab-inactiveForeground: rgba(51, 51, 51, 0.7); - --vscode-tab-border: #f3f3f3; - --vscode-editorWidget-background: #f3f3f3; - --vscode-editorWidget-foreground: #616161; - --vscode-editorWidget-border: #c8c8c8; - --vscode-notifications-background: #f3f3f3; - --vscode-notifications-foreground: #616161; - --vscode-notificationCenterHeader-background: #e7e7e7; - --vscode-settings-textInputBackground: #ffffff; - --vscode-settings-textInputForeground: #616161; - --vscode-settings-textInputBorder: #cecece; - --vscode-titleBar-activeBackground: #dddddd; - --vscode-titleBar-activeForeground: #333333; - --vscode-statusBar-background: #007acc; - --vscode-statusBar-foreground: #ffffff; - `; -} - -app.whenReady().then(() => { - createWindow(); - - const fs = new NodeFileSystem(); - const mcpManager = new McpServerManager(); - const dialogService = new ElectronDialogService(() => mainWindow); - - // Set up IPC message transport - const transport = new ElectronMessageTransport(ipcMain, () => mainWindow); - mcpManager.setTransport(transport); - - // Set up IPC handlers - setupIpcHandlers(ipcMain, { - getMainWindow: () => mainWindow, - fs, - logger, - dialog: dialogService, - mcpManager, - }); - - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); - } - }); -}); - -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } -}); diff --git a/packages/electron/src/main/preload.ts b/packages/electron/src/main/preload.ts deleted file mode 100644 index d7150df8..00000000 --- a/packages/electron/src/main/preload.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Electron Preload Script - * - * Exposes a safe API to the renderer process via contextBridge. - */ - -import { contextBridge, ipcRenderer } from 'electron'; - -contextBridge.exposeInMainWorld('electronAPI', { - send: (channel: string, data: unknown): void => { - ipcRenderer.send(channel, data); - }, - on: (channel: string, callback: (data: unknown) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: unknown): void => callback(data); - ipcRenderer.on(channel, listener); - return () => { - ipcRenderer.removeListener(channel, listener); - }; - }, -}); diff --git a/packages/electron/tsconfig.preload.json b/packages/electron/tsconfig.preload.json deleted file mode 100644 index 38a2783f..00000000 --- a/packages/electron/tsconfig.preload.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "./src/main", - "outDir": "./dist/preload", - "lib": ["ES2020"], - "types": ["node"], - "module": "commonjs", - "moduleResolution": "node" - }, - "include": ["src/main/preload.ts"], - "exclude": ["node_modules", "dist"] -} diff --git a/packages/vscode/src/adapters/vscode-file-system.ts b/packages/vscode/src/adapters/vscode-file-system.ts deleted file mode 100644 index e02ce41c..00000000 --- a/packages/vscode/src/adapters/vscode-file-system.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * VSCode File System Adapter - * - * IFileSystem implementation using VSCode workspace.fs API. - */ - -import type { IFileSystem } from '@cc-wf-studio/core'; -import * as vscode from 'vscode'; - -export class VSCodeFileSystem implements IFileSystem { - async readFile(filePath: string): Promise { - const uri = vscode.Uri.file(filePath); - const bytes = await vscode.workspace.fs.readFile(uri); - return Buffer.from(bytes).toString('utf-8'); - } - - async writeFile(filePath: string, content: string): Promise { - const uri = vscode.Uri.file(filePath); - const bytes = Buffer.from(content, 'utf-8'); - await vscode.workspace.fs.writeFile(uri, bytes); - } - - async fileExists(filePath: string): Promise { - const uri = vscode.Uri.file(filePath); - try { - await vscode.workspace.fs.stat(uri); - return true; - } catch { - return false; - } - } - - async createDirectory(dirPath: string): Promise { - const uri = vscode.Uri.file(dirPath); - await vscode.workspace.fs.createDirectory(uri); - } - - async readDirectory( - dirPath: string - ): Promise> { - const uri = vscode.Uri.file(dirPath); - const entries = await vscode.workspace.fs.readDirectory(uri); - return entries.map(([name, type]) => ({ - name, - isFile: type === vscode.FileType.File, - isDirectory: type === vscode.FileType.Directory, - })); - } - - async stat(filePath: string): Promise<{ isFile: boolean; isDirectory: boolean }> { - const uri = vscode.Uri.file(filePath); - const stat = await vscode.workspace.fs.stat(uri); - return { - isFile: stat.type === vscode.FileType.File, - isDirectory: stat.type === vscode.FileType.Directory, - }; - } -} diff --git a/packages/vscode/src/adapters/vscode-logger.ts b/packages/vscode/src/adapters/vscode-logger.ts deleted file mode 100644 index e2dc49d3..00000000 --- a/packages/vscode/src/adapters/vscode-logger.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * VSCode Logger Adapter - * - * ILogger implementation using VSCode OutputChannel. - */ - -import type { ILogger } from '@cc-wf-studio/core'; -import type * as vscode from 'vscode'; - -export class VSCodeLogger implements ILogger { - constructor(private readonly outputChannel: vscode.OutputChannel) {} - - info(message: string, data?: unknown): void { - const timestamp = new Date().toISOString(); - const logMessage = `[${timestamp}] [INFO] ${message}`; - this.outputChannel.appendLine(logMessage); - if (data) { - this.outputChannel.appendLine(` Data: ${JSON.stringify(data, null, 2)}`); - } - console.log(logMessage, data ?? ''); - } - - warn(message: string, data?: unknown): void { - const timestamp = new Date().toISOString(); - const logMessage = `[${timestamp}] [WARN] ${message}`; - this.outputChannel.appendLine(logMessage); - if (data) { - this.outputChannel.appendLine(` Data: ${JSON.stringify(data, null, 2)}`); - } - console.warn(logMessage, data ?? ''); - } - - error(message: string, data?: unknown): void { - const timestamp = new Date().toISOString(); - const logMessage = `[${timestamp}] [ERROR] ${message}`; - this.outputChannel.appendLine(logMessage); - if (data) { - this.outputChannel.appendLine(` Data: ${JSON.stringify(data, null, 2)}`); - } - console.error(logMessage, data ?? ''); - } -} diff --git a/packages/vscode/src/adapters/vscode-message-transport.ts b/packages/vscode/src/adapters/vscode-message-transport.ts deleted file mode 100644 index 648446a9..00000000 --- a/packages/vscode/src/adapters/vscode-message-transport.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * VSCode Message Transport Adapter - * - * IMessageTransport implementation wrapping VSCode Webview postMessage. - */ - -import type { IMessageTransport } from '@cc-wf-studio/core'; -import type * as vscode from 'vscode'; - -export class VSCodeMessageTransport implements IMessageTransport { - private webview: vscode.Webview | null = null; - private handlers: Array< - (message: { type: string; requestId?: string; payload?: unknown }) => void - > = []; - - setWebview(webview: vscode.Webview | null): void { - this.webview = webview; - } - - postMessage(message: { type: string; requestId?: string; payload?: unknown }): void { - this.webview?.postMessage(message); - } - - onMessage( - handler: (message: { type: string; requestId?: string; payload?: unknown }) => void - ): void { - this.handlers.push(handler); - } - - /** - * Called from the extension host when a webview message is received. - * Forwards to registered handlers. - */ - handleIncomingMessage(message: { type: string; requestId?: string; payload?: unknown }): void { - for (const handler of this.handlers) { - handler(message); - } - } -} diff --git a/packages/web-server/package.json b/packages/web-server/package.json new file mode 100644 index 00000000..4fb6354a --- /dev/null +++ b/packages/web-server/package.json @@ -0,0 +1,25 @@ +{ + "name": "@cc-wf-studio/web-server", + "version": "3.26.0", + "private": true, + "license": "AGPL-3.0-or-later", + "type": "module", + "main": "./dist/server.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsx watch src/server.ts", + "start": "node dist/server.js", + "clean": "rm -rf dist" + }, + "dependencies": { + "@cc-wf-studio/core": "*", + "@hono/node-server": "^1.13.8", + "@hono/node-ws": "^1.1.1", + "hono": "^4.7.6" + }, + "devDependencies": { + "@types/node": "^25.0.3", + "tsx": "^4.19.0", + "typescript": "^5.3.0" + } +} diff --git a/packages/web-server/src/adapters/ws-message-transport.ts b/packages/web-server/src/adapters/ws-message-transport.ts new file mode 100644 index 00000000..d6120e96 --- /dev/null +++ b/packages/web-server/src/adapters/ws-message-transport.ts @@ -0,0 +1,35 @@ +/** + * WebSocket Message Transport + * + * IMessageTransport implementation using WebSocket. + * Used by McpServerManager to communicate with the browser client. + */ + +import type { IMessageTransport } from '@cc-wf-studio/core'; +import type { WebSocketMessageSender } from '../routes/ws-handler.js'; + +export class WebSocketMessageTransport implements IMessageTransport { + private sender: WebSocketMessageSender | null = null; + private handler: + | ((message: { type: string; requestId?: string; payload?: unknown }) => void) + | null = null; + + setSender(sender: WebSocketMessageSender | null): void { + this.sender = sender; + } + + postMessage(message: { type: string; requestId?: string; payload?: unknown }): void { + this.sender?.(message); + } + + onMessage( + handler: (message: { type: string; requestId?: string; payload?: unknown }) => void + ): void { + this.handler = handler; + } + + /** Called by the WS handler when a message arrives from the client */ + handleIncomingMessage(message: { type: string; requestId?: string; payload?: unknown }): void { + this.handler?.(message); + } +} diff --git a/packages/web-server/src/handlers/ai-handlers.ts b/packages/web-server/src/handlers/ai-handlers.ts new file mode 100644 index 00000000..6c732095 --- /dev/null +++ b/packages/web-server/src/handlers/ai-handlers.ts @@ -0,0 +1,457 @@ +/** + * AI Handlers - Web Server + * + * Handles AI refinement, name/description generation, AI editing skill service. + * Ported from src/extension/commands/workflow-refinement.ts, + * workflow-name-generation.ts, and ai-editing-skill-service.ts + */ + +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { type AiEditingProvider, log, type McpServerManager } from '@cc-wf-studio/core'; +import type { WebSocketMessageTransport } from '../adapters/ws-message-transport.js'; + +type Reply = (type: string, payload?: unknown) => void; +type Send = (message: { type: string; requestId?: string; payload?: unknown }) => void; + +// Track active refinement processes for cancellation +const activeProcesses = new Map void }>(); + +/** + * Handle REFINE_WORKFLOW + */ +export async function handleRefineWorkflowWeb( + payload: Record, + workspacePath: string, + requestId: string | undefined, + reply: Reply, + send: Send +): Promise { + try { + const instruction = payload.instruction as string; + const workflow = payload.workflow as Record; + const provider = (payload.provider as string) || 'claude-code'; + + // Build refinement prompt + const prompt = buildRefinementPrompt(instruction, workflow); + + // Execute via CLI with streaming + const result = await executeClaudeWithStreaming( + prompt, + workspacePath, + requestId, + send, + provider + ); + + if (result.cancelled) { + reply('REFINEMENT_CANCELLED', { + reason: 'user_cancelled', + timestamp: new Date().toISOString(), + }); + return; + } + + // Try to parse the AI output as a workflow + try { + const refinedWorkflow = parseWorkflowFromOutput(result.output, workflow); + reply('REFINEMENT_SUCCESS', { + workflow: refinedWorkflow, + conversationHistory: [], + executionTimeMs: result.executionTimeMs, + timestamp: new Date().toISOString(), + }); + } catch { + // AI might be asking for clarification + reply('REFINEMENT_CLARIFICATION', { + message: result.output, + timestamp: new Date().toISOString(), + }); + } + } catch (error) { + reply('REFINEMENT_FAILED', { + error: { + code: 'REFINEMENT_ERROR', + message: error instanceof Error ? error.message : 'Refinement failed', + }, + executionTimeMs: 0, + timestamp: new Date().toISOString(), + }); + } +} + +/** + * Handle CANCEL_REFINEMENT + */ +export async function handleCancelRefinementWeb( + payload: Record, + requestId: string | undefined, + reply: Reply +): Promise { + const targetId = (payload.targetRequestId as string) || requestId; + if (targetId) { + const process = activeProcesses.get(targetId); + if (process) { + process.kill(); + activeProcesses.delete(targetId); + } + } + reply('REFINEMENT_CANCELLED', { + reason: 'user_cancelled', + timestamp: new Date().toISOString(), + }); +} + +/** + * Handle CLEAR_CONVERSATION + */ +export async function handleClearConversationWeb( + _payload: Record, + _requestId: string | undefined, + reply: Reply +): Promise { + reply('CONVERSATION_CLEARED', { + timestamp: new Date().toISOString(), + }); +} + +/** + * Handle GENERATE_WORKFLOW_NAME + */ +export async function handleGenerateWorkflowNameWeb( + payload: Record, + workspacePath: string, + requestId: string | undefined, + reply: Reply +): Promise { + try { + const workflow = payload.workflow as Record; + const prompt = buildNamePrompt(workflow); + + const result = await executeClaudeCommand(prompt, workspacePath, requestId); + + if (result.cancelled) { + reply('GENERATE_WORKFLOW_NAME_CANCELLED', {}); + return; + } + + // Parse name from output + const name = parseName(result.output); + + reply('GENERATE_WORKFLOW_NAME_SUCCESS', { + name, + timestamp: new Date().toISOString(), + }); + } catch (error) { + reply('GENERATE_WORKFLOW_NAME_FAILED', { + errorMessage: error instanceof Error ? error.message : 'Failed to generate name', + timestamp: new Date().toISOString(), + }); + } +} + +/** + * Cancel a generation process + */ +export async function handleCancelGenerationWeb(targetRequestId: string): Promise { + const process = activeProcesses.get(targetRequestId); + if (process) { + process.kill(); + activeProcesses.delete(targetRequestId); + } +} + +/** + * Handle RUN_AI_EDITING_SKILL + */ +export async function handleRunAiEditingSkillWeb( + payload: { provider: string }, + workspacePath: string, + _requestId: string | undefined, + reply: Reply +): Promise { + try { + // Generate the AI editing skill and run it + const { provider } = payload; + + // Find the skill file path + const skillPath = path.join(workspacePath, '.claude', 'skills', 'cc-workflow-ai-editor.md'); + + // Execute the skill + const cliCommand = getCliForProvider(provider); + if (!cliCommand) { + throw new Error(`Unsupported provider: ${provider}`); + } + + spawn(cliCommand, ['--skill', skillPath], { + cwd: workspacePath, + shell: true, + stdio: 'ignore', + detached: true, + }).unref(); + + reply('RUN_AI_EDITING_SKILL_SUCCESS', { + provider, + timestamp: new Date().toISOString(), + }); + } catch (error) { + reply('RUN_AI_EDITING_SKILL_FAILED', { + errorMessage: error instanceof Error ? error.message : 'Failed to run AI editing skill', + timestamp: new Date().toISOString(), + }); + } +} + +/** + * Handle LAUNCH_AI_AGENT — one-click: start server → write config → launch skill + */ +export async function handleLaunchAiAgentWeb( + payload: { provider: string }, + existingManager: McpServerManager | null, + transport: WebSocketMessageTransport, + workspacePath: string, + requestId: string | undefined, + reply: Reply, + send: Send +): Promise { + const { provider } = payload; + + try { + // 1. Start MCP server if needed + let manager = existingManager; + if (!manager || !manager.isRunning()) { + const { handleStartMcpServerWeb } = await import('./mcp-handlers.js'); + manager = await handleStartMcpServerWeb( + existingManager, + transport, + workspacePath, + { configTargets: [provider] }, + requestId, + // Suppress the MCP_SERVER_STATUS reply — we'll send our own + () => {} + ); + } + + // 2. Send MCP_SERVER_STATUS + if (manager) { + send({ + type: 'MCP_SERVER_STATUS', + payload: { + running: true, + port: manager.getPort(), + configsWritten: [], + reviewBeforeApply: manager.getReviewBeforeApply(), + }, + }); + + manager.setCurrentProvider(provider as AiEditingProvider); + } + + // 3. Run AI editing skill + await handleRunAiEditingSkillWeb({ provider }, workspacePath, requestId, () => {}); + + reply('LAUNCH_AI_AGENT_SUCCESS', { + provider, + timestamp: new Date().toISOString(), + }); + + return manager; + } catch (error) { + log('ERROR', 'Failed to launch AI agent', { + error: error instanceof Error ? error.message : String(error), + }); + reply('LAUNCH_AI_AGENT_FAILED', { + errorMessage: error instanceof Error ? error.message : 'Failed to launch AI agent', + timestamp: new Date().toISOString(), + }); + return existingManager; + } +} + +// ============================================================================ +// Internal helpers +// ============================================================================ + +function buildRefinementPrompt(instruction: string, workflow: Record): string { + return [ + 'You are a workflow refinement assistant.', + 'Given the following workflow JSON and user instruction, output a refined workflow JSON.', + '', + '## Current Workflow', + '```json', + JSON.stringify(workflow, null, 2), + '```', + '', + '## User Instruction', + instruction, + '', + '## Output', + 'Output ONLY the refined workflow JSON, nothing else.', + ].join('\n'); +} + +function buildNamePrompt(workflow: Record): string { + const nodes = (workflow.nodes as Array>) || []; + const nodeLabels = nodes + .map((n) => (n.data as Record)?.label) + .filter(Boolean) + .join(', '); + + return [ + 'Generate a concise kebab-case workflow name (e.g., "deploy-api", "lint-and-test").', + `Nodes: ${nodeLabels || 'none'}`, + `Description: ${workflow.description || 'none'}`, + 'Output ONLY the name, nothing else.', + ].join('\n'); +} + +function parseName(output: string): string { + // Clean and format as kebab-case + const cleaned = output + .trim() + .replace(/['"]/g, '') + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/gi, '') + .toLowerCase() + .slice(0, 50); + + return cleaned || 'untitled-workflow'; +} + +function parseWorkflowFromOutput( + output: string, + originalWorkflow: Record +): Record { + // Try to extract JSON from the output + const jsonMatch = output.match(/```json\s*([\s\S]*?)```/) || output.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error('No JSON found in output'); + } + + const jsonStr = jsonMatch[1] || jsonMatch[0]; + const parsed = JSON.parse(jsonStr); + + // Merge with original to preserve any missing fields + return { + ...originalWorkflow, + ...parsed, + id: originalWorkflow.id, // Preserve original ID + version: originalWorkflow.version, // Preserve version + }; +} + +async function executeClaudeWithStreaming( + prompt: string, + cwd: string, + requestId: string | undefined, + send: Send, + provider: string +): Promise<{ output: string; cancelled: boolean; executionTimeMs: number }> { + const startTime = Date.now(); + const command = getCliForProvider(provider) || 'claude'; + + return new Promise((resolve) => { + let output = ''; + let cancelled = false; + + const child = spawn(command, ['--print', prompt], { + cwd, + shell: true, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + if (requestId) { + activeProcesses.set(requestId, { + kill: () => { + cancelled = true; + child.kill('SIGTERM'); + }, + }); + } + + child.stdout?.on('data', (data) => { + const chunk = data.toString(); + output += chunk; + send({ + type: 'REFINEMENT_PROGRESS', + requestId, + payload: { chunk }, + }); + }); + + child.stderr?.on('data', (data) => { + log('WARN', `CLI stderr: ${data.toString()}`); + }); + + child.on('close', () => { + if (requestId) activeProcesses.delete(requestId); + resolve({ + output, + cancelled, + executionTimeMs: Date.now() - startTime, + }); + }); + + child.on('error', (_error) => { + if (requestId) activeProcesses.delete(requestId); + resolve({ + output: '', + cancelled: false, + executionTimeMs: Date.now() - startTime, + }); + }); + }); +} + +async function executeClaudeCommand( + prompt: string, + cwd: string, + requestId: string | undefined +): Promise<{ output: string; cancelled: boolean }> { + return new Promise((resolve) => { + let output = ''; + let cancelled = false; + + const child = spawn('claude', ['--print', prompt], { + cwd, + shell: true, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + if (requestId) { + activeProcesses.set(requestId, { + kill: () => { + cancelled = true; + child.kill('SIGTERM'); + }, + }); + } + + child.stdout?.on('data', (data) => { + output += data.toString(); + }); + + child.on('close', () => { + if (requestId) activeProcesses.delete(requestId); + resolve({ output, cancelled }); + }); + + child.on('error', () => { + if (requestId) activeProcesses.delete(requestId); + resolve({ output: '', cancelled: false }); + }); + }); +} + +function getCliForProvider(provider: string): string | null { + const cliMap: Record = { + 'claude-code': 'claude', + copilot: 'copilot', + codex: 'codex', + gemini: 'gemini', + cursor: 'cursor', + antigravity: 'antigravity', + 'roo-code': 'roo', + }; + return cliMap[provider] || null; +} diff --git a/packages/web-server/src/handlers/export-handlers.ts b/packages/web-server/src/handlers/export-handlers.ts new file mode 100644 index 00000000..3279b66a --- /dev/null +++ b/packages/web-server/src/handlers/export-handlers.ts @@ -0,0 +1,317 @@ +/** + * Export & Run Handlers - Web Server + * + * Handles multi-agent export and run operations for all supported AI platforms: + * Claude Code, Copilot, Codex, Roo Code, Gemini, Antigravity, Cursor. + * + * Ported from src/extension/commands/*-handlers.ts + */ + +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { type FileService, log } from '@cc-wf-studio/core'; + +type Reply = (type: string, payload?: unknown) => void; +type Send = (message: { type: string; requestId?: string; payload?: unknown }) => void; + +/** + * Handle RUN_AS_SLASH_COMMAND — export + execute via CLI + */ +export async function handleRunAsSlashCommandWeb( + fileService: FileService, + workflow: Record, + workspacePath: string, + requestId: string | undefined, + reply: Reply, + send: Send +): Promise { + try { + // First, export the workflow + const { handleExportWorkflowWeb } = await import('./workflow-handlers.js'); + let exportSuccess = false; + const exportReply = (type: string, _payload?: unknown) => { + if (type === 'EXPORT_SUCCESS') exportSuccess = true; + }; + await handleExportWorkflowWeb(fileService, { workflow }, requestId, exportReply); + + if (!exportSuccess) { + reply('ERROR', { + code: 'EXPORT_FAILED', + message: 'Failed to export workflow before execution', + }); + return; + } + + // Execute the slash command via CLI + const workflowName = workflow.name as string; + executeCliCommand('claude', [workflowName], workspacePath, send, requestId); + + reply('RUN_AS_SLASH_COMMAND_SUCCESS', { + workflowName, + terminalName: `Claude: ${workflowName}`, + timestamp: new Date().toISOString(), + }); + } catch (error) { + reply('ERROR', { + code: 'RUN_FAILED', + message: error instanceof Error ? error.message : 'Failed to run workflow', + }); + } +} + +/** + * Handle all agent export operations (EXPORT_FOR_*) + */ +export async function handleAgentExportWeb( + messageType: string, + fileService: FileService, + payload: { workflow: Record }, + workspacePath: string, + _requestId: string | undefined, + reply: Reply +): Promise { + const { workflow } = payload; + const workflowName = workflow.name as string; + + // Map message type to agent config + const agentConfig = getAgentConfig(messageType); + if (!agentConfig) { + reply('ERROR', { code: 'UNKNOWN_AGENT', message: `Unknown export type: ${messageType}` }); + return; + } + + try { + // Create target skills directory + const skillsDir = path.join(workspacePath, agentConfig.skillsDir); + await fileService.createDirectory(skillsDir); + + // Generate skill file + const skillContent = generateSkillContent(workflow); + const skillPath = path.join(skillsDir, `${workflowName}.md`); + await fileService.writeFile(skillPath, skillContent); + + const successType = agentConfig.successType; + reply(successType, { + exportedFiles: [skillPath], + timestamp: new Date().toISOString(), + }); + } catch (error) { + const failType = agentConfig.failType; + reply(failType, { + errorCode: 'EXPORT_FAILED', + errorMessage: error instanceof Error ? error.message : 'Failed to export', + timestamp: new Date().toISOString(), + }); + } +} + +/** + * Handle all agent run operations (RUN_FOR_*) + */ +export async function handleAgentRunWeb( + messageType: string, + fileService: FileService, + payload: { workflow: Record }, + workspacePath: string, + requestId: string | undefined, + reply: Reply, + send: Send +): Promise { + const { workflow } = payload; + const workflowName = workflow.name as string; + + const agentConfig = getAgentConfig(messageType.replace('RUN_FOR_', 'EXPORT_FOR_')); + if (!agentConfig) { + reply('ERROR', { code: 'UNKNOWN_AGENT', message: `Unknown run type: ${messageType}` }); + return; + } + + try { + // First export + const skillsDir = path.join(workspacePath, agentConfig.skillsDir); + await fileService.createDirectory(skillsDir); + const skillContent = generateSkillContent(workflow); + const skillPath = path.join(skillsDir, `${workflowName}.md`); + await fileService.writeFile(skillPath, skillContent); + + // Then run via CLI if command is available + if (agentConfig.cliCommand) { + executeCliCommand( + agentConfig.cliCommand, + agentConfig.cliArgs ? agentConfig.cliArgs(workflowName) : [workflowName], + workspacePath, + send, + requestId + ); + } + + const successType = agentConfig.runSuccessType || agentConfig.successType; + reply(successType, { + workflowName, + timestamp: new Date().toISOString(), + }); + } catch (error) { + const failType = agentConfig.runFailType || agentConfig.failType; + reply(failType, { + errorCode: 'RUN_FAILED', + errorMessage: error instanceof Error ? error.message : 'Failed to run', + timestamp: new Date().toISOString(), + }); + } +} + +// ============================================================================ +// Agent configuration +// ============================================================================ + +interface AgentConfig { + skillsDir: string; + successType: string; + failType: string; + runSuccessType?: string; + runFailType?: string; + cliCommand?: string; + cliArgs?: (workflowName: string) => string[]; +} + +function getAgentConfig(messageType: string): AgentConfig | null { + const configs: Record = { + EXPORT_FOR_COPILOT: { + skillsDir: '.github/prompts', + successType: 'EXPORT_FOR_COPILOT_SUCCESS', + failType: 'EXPORT_FOR_COPILOT_FAILED', + runSuccessType: 'RUN_FOR_COPILOT_SUCCESS', + runFailType: 'RUN_FOR_COPILOT_FAILED', + }, + EXPORT_FOR_COPILOT_CLI: { + skillsDir: '.github/skills', + successType: 'EXPORT_FOR_COPILOT_CLI_SUCCESS', + failType: 'EXPORT_FOR_COPILOT_CLI_FAILED', + runSuccessType: 'RUN_FOR_COPILOT_CLI_SUCCESS', + runFailType: 'RUN_FOR_COPILOT_CLI_FAILED', + cliCommand: 'copilot', + cliArgs: (name) => [':task', name], + }, + EXPORT_FOR_CODEX_CLI: { + skillsDir: '.codex/skills', + successType: 'EXPORT_FOR_CODEX_CLI_SUCCESS', + failType: 'EXPORT_FOR_CODEX_CLI_FAILED', + runSuccessType: 'RUN_FOR_CODEX_CLI_SUCCESS', + runFailType: 'RUN_FOR_CODEX_CLI_FAILED', + cliCommand: 'codex', + cliArgs: (name) => [':task', name], + }, + EXPORT_FOR_ROO_CODE: { + skillsDir: '.roo/skills', + successType: 'EXPORT_FOR_ROO_CODE_SUCCESS', + failType: 'EXPORT_FOR_ROO_CODE_FAILED', + runSuccessType: 'RUN_FOR_ROO_CODE_SUCCESS', + runFailType: 'RUN_FOR_ROO_CODE_FAILED', + }, + EXPORT_FOR_GEMINI_CLI: { + skillsDir: '.gemini/skills', + successType: 'EXPORT_FOR_GEMINI_CLI_SUCCESS', + failType: 'EXPORT_FOR_GEMINI_CLI_FAILED', + runSuccessType: 'RUN_FOR_GEMINI_CLI_SUCCESS', + runFailType: 'RUN_FOR_GEMINI_CLI_FAILED', + cliCommand: 'gemini', + cliArgs: (name) => [':task', name], + }, + EXPORT_FOR_ANTIGRAVITY: { + skillsDir: '.agent/skills', + successType: 'EXPORT_FOR_ANTIGRAVITY_SUCCESS', + failType: 'EXPORT_FOR_ANTIGRAVITY_FAILED', + runSuccessType: 'RUN_FOR_ANTIGRAVITY_SUCCESS', + runFailType: 'RUN_FOR_ANTIGRAVITY_FAILED', + }, + EXPORT_FOR_CURSOR: { + skillsDir: '.cursor/skills', + successType: 'EXPORT_FOR_CURSOR_SUCCESS', + failType: 'EXPORT_FOR_CURSOR_FAILED', + runSuccessType: 'RUN_FOR_CURSOR_SUCCESS', + runFailType: 'RUN_FOR_CURSOR_FAILED', + }, + }; + + return configs[messageType] || null; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function generateSkillContent(workflow: Record): string { + const name = workflow.name as string; + const description = (workflow.description as string) || ''; + const nodes = (workflow.nodes as Array>) || []; + + const lines: string[] = [`# ${name}`]; + if (description) { + lines.push('', description); + } + lines.push('', '## Instructions', ''); + + for (const node of nodes) { + const data = node.data as Record; + if (!data) continue; + const label = (data.label as string) || ''; + const content = (data.content as string) || (data.prompt as string) || ''; + + if (label && label !== 'Start' && label !== 'End') { + lines.push(`### ${label}`); + if (content) { + lines.push('', content, ''); + } + } + } + + return lines.join('\n'); +} + +function executeCliCommand( + command: string, + args: string[], + cwd: string, + send: Send, + requestId?: string +): void { + try { + const child = spawn(command, args, { + cwd, + shell: true, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + child.stdout?.on('data', (data) => { + send({ + type: 'CLI_OUTPUT', + requestId, + payload: { stream: 'stdout', data: data.toString() }, + }); + }); + + child.stderr?.on('data', (data) => { + send({ + type: 'CLI_OUTPUT', + requestId, + payload: { stream: 'stderr', data: data.toString() }, + }); + }); + + child.on('close', (code) => { + send({ + type: 'CLI_EXIT', + requestId, + payload: { code }, + }); + }); + + child.on('error', (error) => { + log('ERROR', `CLI command failed: ${command}`, { error: error.message }); + }); + } catch (error) { + log('ERROR', `Failed to spawn CLI: ${command}`, { + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/packages/web-server/src/handlers/mcp-handlers.ts b/packages/web-server/src/handlers/mcp-handlers.ts new file mode 100644 index 00000000..d13fb80b --- /dev/null +++ b/packages/web-server/src/handlers/mcp-handlers.ts @@ -0,0 +1,263 @@ +/** + * MCP Handlers - Web Server + * + * Handles MCP server management, tool discovery, and AI agent launch. + * Ported from src/extension/commands/mcp-handlers.ts + */ + +import path from 'node:path'; +import { log, type McpServerManager } from '@cc-wf-studio/core'; +import type { WebSocketMessageTransport } from '../adapters/ws-message-transport.js'; + +type Reply = (type: string, payload?: unknown) => void; + +// In-memory MCP cache +const mcpCache = { + servers: null as unknown[] | null, + serversCachedAt: 0, + tools: new Map(), + schemas: new Map(), + TTL_SERVERS: 30_000, + TTL_TOOLS: 30_000, + TTL_SCHEMAS: 60_000, +}; + +/** + * Handle LIST_MCP_SERVERS + */ +export async function handleListMcpServersWeb( + _payload: Record, + _requestId: string | undefined, + reply: Reply +): Promise { + try { + // Check cache + if (mcpCache.servers && Date.now() - mcpCache.serversCachedAt < mcpCache.TTL_SERVERS) { + reply('MCP_SERVERS_LIST', { servers: mcpCache.servers }); + return; + } + + // Read MCP config files to discover servers + const servers = await discoverMcpServers(); + mcpCache.servers = servers; + mcpCache.serversCachedAt = Date.now(); + + reply('MCP_SERVERS_LIST', { servers }); + } catch (error) { + log('ERROR', 'Failed to list MCP servers', { + error: error instanceof Error ? error.message : String(error), + }); + reply('MCP_SERVERS_LIST', { servers: [] }); + } +} + +/** + * Handle GET_MCP_TOOLS + */ +export async function handleGetMcpToolsWeb( + payload: { serverId: string }, + _requestId: string | undefined, + reply: Reply +): Promise { + try { + const { serverId } = payload; + + // Check cache + const cached = mcpCache.tools.get(serverId); + if (cached && Date.now() - cached.cachedAt < mcpCache.TTL_TOOLS) { + reply('MCP_TOOLS_LIST', { serverId, tools: cached.data }); + return; + } + + // TODO: Connect to MCP server and list tools using SDK + const tools: unknown[] = []; + mcpCache.tools.set(serverId, { data: tools, cachedAt: Date.now() }); + + reply('MCP_TOOLS_LIST', { serverId, tools }); + } catch (error) { + reply('MCP_TOOLS_FAILED', { + serverId: payload.serverId, + errorCode: 'CONNECTION_FAILED', + errorMessage: error instanceof Error ? error.message : 'Failed to get tools', + }); + } +} + +/** + * Handle GET_MCP_TOOL_SCHEMA + */ +export async function handleGetMcpToolSchemaWeb( + payload: { serverId: string; toolName: string }, + _requestId: string | undefined, + reply: Reply +): Promise { + try { + const { serverId, toolName } = payload; + const cacheKey = `${serverId}:${toolName}`; + + // Check cache + const cached = mcpCache.schemas.get(cacheKey); + if (cached && Date.now() - cached.cachedAt < mcpCache.TTL_SCHEMAS) { + reply('MCP_TOOL_SCHEMA', { serverId, toolName, schema: cached.data }); + return; + } + + // TODO: Connect to MCP server and get tool schema + const schema = {}; + mcpCache.schemas.set(cacheKey, { data: schema, cachedAt: Date.now() }); + + reply('MCP_TOOL_SCHEMA', { serverId, toolName, schema }); + } catch (error) { + reply('MCP_TOOL_SCHEMA_FAILED', { + serverId: payload.serverId, + toolName: payload.toolName, + errorCode: 'SCHEMA_FETCH_FAILED', + errorMessage: error instanceof Error ? error.message : 'Failed to get schema', + }); + } +} + +/** + * Handle REFRESH_MCP_CACHE + */ +export async function handleRefreshMcpCacheWeb( + payload: Record, + requestId: string | undefined, + reply: Reply +): Promise { + mcpCache.servers = null; + mcpCache.serversCachedAt = 0; + mcpCache.tools.clear(); + mcpCache.schemas.clear(); + + // Re-fetch servers + await handleListMcpServersWeb(payload, requestId, reply); +} + +/** + * Handle START_MCP_SERVER + */ +export async function handleStartMcpServerWeb( + existingManager: McpServerManager | null, + transport: WebSocketMessageTransport, + _workspacePath: string, + _payload: { configTargets?: string[] } | undefined, + _requestId: string | undefined, + reply: Reply +): Promise { + try { + // Use existing or create new McpServerManager + let manager = existingManager; + if (!manager) { + // Import McpServerManager from core + const { McpServerManager: Mgr } = await import('@cc-wf-studio/core'); + manager = new Mgr(); + manager.setTransport(transport); + } + + // Find the extension/core path that has the MCP server CLI + const corePath = path.resolve( + import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname), + '../../../core' + ); + + const port = await manager.start(corePath); + + log('INFO', 'MCP Server started via web UI', { port }); + + reply('MCP_SERVER_STATUS', { + running: true, + port, + configsWritten: [], + reviewBeforeApply: manager.getReviewBeforeApply(), + }); + + return manager; + } catch (error) { + log('ERROR', 'Failed to start MCP server', { + error: error instanceof Error ? error.message : String(error), + }); + reply('MCP_SERVER_STATUS', { + running: false, + port: null, + configsWritten: [], + reviewBeforeApply: true, + }); + return existingManager; + } +} + +/** + * Handle STOP_MCP_SERVER + */ +export async function handleStopMcpServerWeb( + manager: McpServerManager | null, + _workspacePath: string, + _requestId: string | undefined, + reply: Reply +): Promise { + try { + if (manager) { + await manager.stop(); + } + + reply('MCP_SERVER_STATUS', { + running: false, + port: null, + configsWritten: [], + reviewBeforeApply: manager?.getReviewBeforeApply() ?? true, + }); + } catch (error) { + log('ERROR', 'Failed to stop MCP server', { + error: error instanceof Error ? error.message : String(error), + }); + reply('MCP_SERVER_STATUS', { + running: false, + port: null, + configsWritten: [], + reviewBeforeApply: true, + }); + } +} + +// ============================================================================ +// Internal helpers +// ============================================================================ + +async function discoverMcpServers(): Promise { + // Discover MCP servers from known config locations + const servers: unknown[] = []; + const fs = await import('node:fs/promises'); + const os = await import('node:os'); + + const configPaths = [ + // Claude Code + path.join(os.default.homedir(), '.claude', 'mcp_servers.json'), + path.join(process.cwd(), '.claude', 'mcp_servers.json'), + // VSCode Copilot + path.join(os.default.homedir(), '.vscode', 'mcp.json'), + ]; + + for (const configPath of configPaths) { + try { + const content = await fs.readFile(configPath, 'utf-8'); + const config = JSON.parse(content); + + if (config && typeof config === 'object') { + const mcpServers = config.mcpServers || config; + for (const [name, serverConfig] of Object.entries(mcpServers)) { + servers.push({ + id: name, + name, + config: serverConfig, + source: configPath, + }); + } + } + } catch { + // Config file doesn't exist — skip + } + } + + return servers; +} diff --git a/packages/web-server/src/handlers/skill-handlers.ts b/packages/web-server/src/handlers/skill-handlers.ts new file mode 100644 index 00000000..9131bc82 --- /dev/null +++ b/packages/web-server/src/handlers/skill-handlers.ts @@ -0,0 +1,252 @@ +/** + * Skill Handlers - Web Server + * + * Handles skill browsing, creation, and validation. + * Ported from src/extension/commands/skill-operations.ts + * and src/extension/services/skill-service.ts + */ + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { log } from '@cc-wf-studio/core'; + +type Reply = (type: string, payload?: unknown) => void; + +interface SkillReference { + skillPath: string; + name: string; + description: string; + scope: 'user' | 'project' | 'local'; + validationStatus: 'valid' | 'invalid'; + allowedTools?: string[]; +} + +/** + * Handle BROWSE_SKILLS request + * Scans user (~/.claude/skills/) and project (.claude/skills/) directories + */ +export async function handleBrowseSkillsWeb( + workspacePath: string, + _requestId: string | undefined, + reply: Reply +): Promise { + const startTime = Date.now(); + log('INFO', `[Skill Browse] Starting scan`); + + try { + const userSkills = await scanSkillDirectory( + path.join(os.homedir(), '.claude', 'skills'), + 'user' + ); + const projectSkills = await scanSkillDirectory( + path.join(workspacePath, '.claude', 'skills'), + 'project' + ); + + // Also scan other provider directories + const localSkills: SkillReference[] = []; + const otherDirs = [ + { dir: '.github/skills', scope: 'local' as const }, + { dir: '.codex/skills', scope: 'local' as const }, + { dir: '.roo/skills', scope: 'local' as const }, + { dir: '.gemini/skills', scope: 'local' as const }, + { dir: '.cursor/skills', scope: 'local' as const }, + { dir: '.agent/skills', scope: 'local' as const }, + ]; + + for (const { dir, scope } of otherDirs) { + const skills = await scanSkillDirectory(path.join(workspacePath, dir), scope); + localSkills.push(...skills); + } + + const allSkills = [...userSkills, ...projectSkills, ...localSkills]; + const executionTime = Date.now() - startTime; + log( + 'INFO', + `[Skill Browse] Scan completed in ${executionTime}ms - Found ${userSkills.length} user, ${projectSkills.length} project, ${localSkills.length} local Skills` + ); + + reply('SKILL_LIST_LOADED', { + skills: allSkills, + timestamp: new Date().toISOString(), + userCount: userSkills.length, + projectCount: projectSkills.length, + localCount: localSkills.length, + }); + } catch (error) { + log('ERROR', `[Skill Browse] Error: ${error}`); + reply('SKILL_VALIDATION_FAILED', { + errorCode: 'UNKNOWN_ERROR', + errorMessage: String(error), + details: error instanceof Error ? error.stack : undefined, + }); + } +} + +/** + * Handle CREATE_SKILL request + */ +export async function handleCreateSkillWeb( + workspacePath: string, + payload: { name: string; description: string; scope: 'user' | 'project'; content?: string }, + _requestId: string | undefined, + reply: Reply +): Promise { + try { + const { name, description, scope, content } = payload; + const baseDir = + scope === 'user' + ? path.join(os.homedir(), '.claude', 'skills') + : path.join(workspacePath, '.claude', 'skills'); + + await fs.mkdir(baseDir, { recursive: true }); + + const fileName = `${name.replace(/[^a-zA-Z0-9_-]/g, '-')}.md`; + const skillPath = path.join(baseDir, fileName); + + // Generate skill file content + const skillContent = [ + '---', + `name: ${name}`, + `description: ${description}`, + '---', + '', + content || `# ${name}`, + '', + description || '', + ].join('\n'); + + await fs.writeFile(skillPath, skillContent, 'utf-8'); + + reply('SKILL_CREATION_SUCCESS', { + skillPath, + name, + description, + scope, + timestamp: new Date().toISOString(), + }); + } catch (error) { + reply('SKILL_CREATION_FAILED', { + errorCode: 'UNKNOWN_ERROR', + errorMessage: String(error), + details: error instanceof Error ? error.stack : undefined, + }); + } +} + +/** + * Handle VALIDATE_SKILL_FILE request + */ +export async function handleValidateSkillFileWeb( + payload: { skillPath: string }, + _requestId: string | undefined, + reply: Reply +): Promise { + try { + const { skillPath } = payload; + const content = await fs.readFile(skillPath, 'utf-8'); + const metadata = parseSkillFrontmatter(content); + + if (!metadata.name) { + throw new Error('Invalid SKILL.md frontmatter: missing name'); + } + + const normalizedPath = skillPath.replace(/\\/g, '/'); + const scope: 'user' | 'project' | 'local' = normalizedPath.includes('/.claude/skills') + ? 'project' + : 'user'; + + reply('SKILL_VALIDATION_SUCCESS', { + skill: { + skillPath, + name: metadata.name, + description: metadata.description || '', + scope, + validationStatus: 'valid', + allowedTools: metadata.allowedTools, + }, + }); + } catch (error) { + const errorMessage = String(error); + let errorCode: string = 'UNKNOWN_ERROR'; + if (errorMessage.includes('ENOENT')) { + errorCode = 'SKILL_NOT_FOUND'; + } else if (errorMessage.includes('frontmatter')) { + errorCode = 'INVALID_FRONTMATTER'; + } + + reply('SKILL_VALIDATION_FAILED', { + errorCode, + errorMessage, + filePath: payload.skillPath, + }); + } +} + +// ============================================================================ +// Internal helpers +// ============================================================================ + +async function scanSkillDirectory( + dirPath: string, + scope: 'user' | 'project' | 'local' +): Promise { + const skills: SkillReference[] = []; + try { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) { + const skillPath = path.join(dirPath, entry.name); + try { + const content = await fs.readFile(skillPath, 'utf-8'); + const metadata = parseSkillFrontmatter(content); + skills.push({ + skillPath, + name: metadata.name || entry.name.replace(/\.md$/i, ''), + description: metadata.description || '', + scope, + validationStatus: metadata.name ? 'valid' : 'invalid', + allowedTools: metadata.allowedTools, + }); + } catch { + // Skip files that can't be read + } + } + } + } catch { + // Directory doesn't exist — that's fine + } + return skills; +} + +function parseSkillFrontmatter(content: string): { + name: string; + description: string; + allowedTools?: string[]; +} { + const match = content.match(/^---\s*\n([\s\S]*?)\n---/); + if (!match) { + return { name: '', description: '' }; + } + + const frontmatter = match[1]; + const nameMatch = frontmatter.match(/^name:\s*(.+)$/m); + const descMatch = frontmatter.match(/^description:\s*(.+)$/m); + + // Parse allowed_tools as YAML array + const toolsMatch = frontmatter.match(/^allowed_tools:\s*\n((?:\s+-\s+.+\n?)*)/m); + let allowedTools: string[] | undefined; + if (toolsMatch) { + allowedTools = toolsMatch[1] + .split('\n') + .map((line) => line.replace(/^\s+-\s+/, '').trim()) + .filter(Boolean); + } + + return { + name: nameMatch?.[1]?.trim() || '', + description: descMatch?.[1]?.trim() || '', + allowedTools, + }; +} diff --git a/packages/web-server/src/handlers/slack-handlers.ts b/packages/web-server/src/handlers/slack-handlers.ts new file mode 100644 index 00000000..3e75188d --- /dev/null +++ b/packages/web-server/src/handlers/slack-handlers.ts @@ -0,0 +1,277 @@ +/** + * Slack Handlers - Web Server + * + * Handles Slack OAuth, share/import workflows, description generation. + * Ported from src/extension/commands/slack-*.ts + */ + +import { type FileService, log } from '@cc-wf-studio/core'; +import { SecretStore } from '../services/secret-store.js'; + +type Reply = (type: string, payload?: unknown) => void; +type Send = (message: { type: string; requestId?: string; payload?: unknown }) => void; + +// Module-level Slack state +const secretStore = new SecretStore(); + +/** + * Handle LIST_SLACK_WORKSPACES + */ +export async function handleListSlackWorkspacesWeb( + _requestId: string | undefined, + reply: Reply +): Promise { + try { + const token = await secretStore.get('slack-user-token'); + if (!token) { + reply('SLACK_WORKSPACES_LIST', { workspaces: [] }); + return; + } + + // Use @slack/web-api to list workspaces + const { WebClient } = await import('@slack/web-api'); + const client = new WebClient(token); + const result = await client.auth.test(); + + reply('SLACK_WORKSPACES_LIST', { + workspaces: [ + { + id: result.team_id, + name: result.team, + connected: true, + }, + ], + }); + } catch (error) { + log('ERROR', 'Failed to list Slack workspaces', { + error: error instanceof Error ? error.message : String(error), + }); + reply('SLACK_WORKSPACES_LIST', { workspaces: [] }); + } +} + +/** + * Handle GET_SLACK_CHANNELS + */ +export async function handleGetSlackChannelsWeb( + payload: { workspaceId: string; types?: string }, + _requestId: string | undefined, + reply: Reply +): Promise { + try { + const token = await secretStore.get('slack-user-token'); + if (!token) { + reply('ERROR', { code: 'SLACK_NOT_CONNECTED', message: 'Not connected to Slack' }); + return; + } + + const { WebClient } = await import('@slack/web-api'); + const client = new WebClient(token); + const result = await client.conversations.list({ + types: payload.types || 'public_channel,private_channel', + limit: 200, + }); + + const channels = (result.channels || []).map((ch) => ({ + id: ch.id, + name: ch.name, + isPrivate: ch.is_private, + memberCount: ch.num_members, + })); + + reply('SLACK_CHANNELS_LIST', { channels }); + } catch (error) { + reply('SLACK_CHANNELS_FAILED', { + errorMessage: error instanceof Error ? error.message : 'Failed to get channels', + }); + } +} + +/** + * Handle SHARE_WORKFLOW_TO_SLACK + */ +export async function handleShareWorkflowToSlackWeb( + payload: Record, + _fileService: FileService, + _requestId: string | undefined, + reply: Reply +): Promise { + try { + const token = await secretStore.get('slack-user-token'); + if (!token) { + reply('SHARE_WORKFLOW_TO_SLACK_FAILED', { + errorCode: 'SLACK_NOT_CONNECTED', + errorMessage: 'Not connected to Slack', + }); + return; + } + + const { WebClient } = await import('@slack/web-api'); + const client = new WebClient(token); + + const workflow = payload.workflow as Record; + const channelId = payload.channelId as string; + const description = (payload.description as string) || ''; + + // Upload workflow JSON as file + const workflowJson = JSON.stringify(workflow, null, 2); + await client.filesUploadV2({ + channel_id: channelId, + content: workflowJson, + filename: `${workflow.name}.json`, + title: `Workflow: ${workflow.name}`, + initial_comment: description || `Shared workflow: ${workflow.name}`, + }); + + reply('SHARE_WORKFLOW_TO_SLACK_SUCCESS', { + channelId, + timestamp: new Date().toISOString(), + }); + } catch (error) { + reply('SHARE_WORKFLOW_TO_SLACK_FAILED', { + errorCode: 'SHARE_FAILED', + errorMessage: error instanceof Error ? error.message : 'Failed to share to Slack', + }); + } +} + +/** + * Handle GENERATE_SLACK_DESCRIPTION + */ +export async function handleGenerateSlackDescriptionWeb( + payload: Record, + _workspacePath: string, + requestId: string | undefined, + reply: Reply +): Promise { + // TODO: Port AI description generation from claude-code-service + reply('GENERATE_SLACK_DESCRIPTION_SUCCESS', { + description: `Workflow: ${(payload.workflow as Record)?.name || 'unknown'}`, + requestId, + }); +} + +/** + * Handle IMPORT_WORKFLOW_FROM_SLACK + */ +export async function handleImportWorkflowFromSlackWeb( + payload: Record, + fileService: FileService, + _requestId: string | undefined, + reply: Reply +): Promise { + try { + const token = await secretStore.get('slack-user-token'); + if (!token) { + reply('IMPORT_WORKFLOW_FROM_SLACK_FAILED', { + errorCode: 'SLACK_NOT_CONNECTED', + errorMessage: 'Not connected to Slack', + }); + return; + } + + const { WebClient } = await import('@slack/web-api'); + const client = new WebClient(token); + + const fileId = payload.fileId as string; + const fileInfo = await client.files.info({ file: fileId }); + const file = fileInfo.file; + + if (!file?.url_private) { + throw new Error('File URL not available'); + } + + // Download file content + const response = await fetch(file.url_private, { + headers: { Authorization: `Bearer ${token}` }, + }); + const content = await response.text(); + const workflow = JSON.parse(content); + + // Save to local workflows directory + await fileService.ensureWorkflowsDirectory(); + const filePath = fileService.getWorkflowFilePath(workflow.name); + await fileService.writeFile(filePath, JSON.stringify(workflow, null, 2)); + + reply('IMPORT_WORKFLOW_FROM_SLACK_SUCCESS', { + workflow, + timestamp: new Date().toISOString(), + }); + } catch (error) { + reply('IMPORT_WORKFLOW_FROM_SLACK_FAILED', { + errorCode: 'IMPORT_FAILED', + errorMessage: error instanceof Error ? error.message : 'Failed to import from Slack', + }); + } +} + +/** + * Handle CONNECT_SLACK_MANUAL + */ +export async function handleConnectSlackManualWeb( + payload: { userToken: string }, + _requestId: string | undefined, + reply: Reply +): Promise { + try { + const { WebClient } = await import('@slack/web-api'); + const client = new WebClient(payload.userToken); + const result = await client.auth.test(); + + if (!result.ok) { + throw new Error('Invalid Slack token'); + } + + // Store token + await secretStore.set('slack-user-token', payload.userToken); + + reply('CONNECT_SLACK_MANUAL_SUCCESS', { + workspaceId: result.team_id, + workspaceName: result.team, + }); + } catch (error) { + reply('CONNECT_SLACK_MANUAL_FAILED', { + code: 'SLACK_CONNECTION_FAILED', + message: error instanceof Error ? error.message : 'Failed to connect to Slack', + }); + } +} + +/** + * Handle SLACK_CONNECT_OAUTH + */ +export async function handleSlackConnectOAuthWeb( + _requestId: string | undefined, + reply: Reply, + _send: Send +): Promise { + // OAuth in web mode: redirect to Slack OAuth URL + // For now, return a message indicating manual token is preferred + reply('SLACK_OAUTH_FAILED', { + message: 'OAuth flow is not yet supported in web mode. Please use manual token connection.', + }); +} + +/** + * Handle SLACK_CANCEL_OAUTH + */ +export function handleSlackCancelOAuthWeb(): void { + // No-op in web mode +} + +/** + * Handle SLACK_DISCONNECT + */ +export async function handleSlackDisconnectWeb( + _requestId: string | undefined, + reply: Reply +): Promise { + try { + await secretStore.delete('slack-user-token'); + reply('SLACK_DISCONNECT_SUCCESS', {}); + } catch (error) { + reply('SLACK_DISCONNECT_FAILED', { + message: error instanceof Error ? error.message : 'Failed to disconnect from Slack', + }); + } +} diff --git a/packages/web-server/src/handlers/workflow-handlers.ts b/packages/web-server/src/handlers/workflow-handlers.ts new file mode 100644 index 00000000..3ce60a3f --- /dev/null +++ b/packages/web-server/src/handlers/workflow-handlers.ts @@ -0,0 +1,118 @@ +/** + * Workflow Handlers - Web Server + * + * Handles workflow CRUD operations (save, load, list, export) + * Ported from src/extension/commands/save-workflow.ts, load-workflow.ts, etc. + */ + +import path from 'node:path'; +import type { FileService } from '@cc-wf-studio/core'; + +type Reply = (type: string, payload?: unknown) => void; + +/** + * Handle EXPORT_WORKFLOW message + * Exports workflow to .claude format (agents/*.md and commands/*.md) + */ +export async function handleExportWorkflowWeb( + fileService: FileService, + payload: { workflow: Record; overwriteExisting?: boolean }, + _requestId: string | undefined, + reply: Reply +): Promise { + try { + const { workflow } = payload; + const workspacePath = fileService.getWorkspacePath(); + + // Get workflow nodes for export + const nodes = (workflow.nodes as Array>) || []; + const exportedFiles: string[] = []; + + // Export SlashCommand (main workflow) to .claude/commands/ + const commandsDir = path.join(workspacePath, '.claude', 'commands'); + await fileService.createDirectory(commandsDir); + + // Generate command file content + const workflowName = workflow.name as string; + // Simple export: create a command file with workflow instructions + const commandContent = generateCommandContent(workflow, nodes); + const commandPath = path.join(commandsDir, `${workflowName}.md`); + await fileService.writeFile(commandPath, commandContent); + exportedFiles.push(commandPath); + + // Export Sub-Agent nodes as .md files to .claude/agents/ + const subAgentNodes = nodes.filter((n) => n.type === 'subAgentFlow' || n.type === 'branch'); + if (subAgentNodes.length > 0) { + const agentsDir = path.join(workspacePath, '.claude', 'agents'); + await fileService.createDirectory(agentsDir); + + for (const node of subAgentNodes) { + const data = node.data as Record; + const agentName = (data.label as string) || `agent-${node.id}`; + const agentContent = generateAgentContent(data); + const agentPath = path.join(agentsDir, `${agentName}.md`); + await fileService.writeFile(agentPath, agentContent); + exportedFiles.push(agentPath); + } + } + + reply('EXPORT_SUCCESS', { + exportedFiles, + timestamp: new Date().toISOString(), + }); + } catch (error) { + reply('ERROR', { + code: 'EXPORT_FAILED', + message: error instanceof Error ? error.message : 'Failed to export workflow', + }); + } +} + +function generateCommandContent( + workflow: Record, + nodes: Array> +): string { + const lines: string[] = []; + const description = (workflow.description as string) || ''; + + lines.push(`# ${workflow.name}`); + if (description) { + lines.push(''); + lines.push(description); + } + lines.push(''); + lines.push('## Instructions'); + lines.push(''); + + for (const node of nodes) { + const data = node.data as Record; + const label = (data.label as string) || ''; + const content = (data.content as string) || (data.prompt as string) || ''; + + if (label) { + lines.push(`### ${label}`); + } + if (content) { + lines.push(''); + lines.push(content); + lines.push(''); + } + } + + return lines.join('\n'); +} + +function generateAgentContent(data: Record): string { + const label = (data.label as string) || 'Agent'; + const description = (data.description as string) || ''; + const content = (data.content as string) || (data.prompt as string) || ''; + + const lines = [`# ${label}`]; + if (description) { + lines.push('', description); + } + if (content) { + lines.push('', '## Instructions', '', content); + } + return lines.join('\n'); +} diff --git a/packages/web-server/src/routes/ws-handler.ts b/packages/web-server/src/routes/ws-handler.ts new file mode 100644 index 00000000..97d24384 --- /dev/null +++ b/packages/web-server/src/routes/ws-handler.ts @@ -0,0 +1,717 @@ +/** + * CC Workflow Studio - WebSocket Message Router + * + * Routes messages from the browser client to the appropriate handlers, + * mirroring the message routing in the VSCode extension's open-editor.ts. + * Uses the same message protocol as the VSCode postMessage API. + */ + +import { + FileService, + log, + type McpServerManager, + migrateWorkflow, + NodeFileSystem, + validateAIGeneratedWorkflow, +} from '@cc-wf-studio/core'; +import type { WSContext, WSEvents } from 'hono/ws'; +import { WebSocketMessageTransport } from '../adapters/ws-message-transport.js'; +import { WebDialogService } from '../services/web-dialog-service.js'; + +export type WebSocketMessageSender = (message: { + type: string; + requestId?: string; + payload?: unknown; +}) => void; + +/** + * Server-side state shared across WebSocket connections + */ +interface ServerState { + fileService: FileService | null; + dialogService: WebDialogService; + transport: WebSocketMessageTransport; + mcpManager: McpServerManager | null; + workspacePath: string; + globalState: Map; +} + +function getWorkspacePath(): string { + return process.env.WORKSPACE_PATH ?? process.cwd(); +} + +function createState(): ServerState { + const workspacePath = getWorkspacePath(); + const fs = new NodeFileSystem(); + const dialogService = new WebDialogService(); + const transport = new WebSocketMessageTransport(); + + return { + fileService: new FileService(fs, workspacePath), + dialogService, + transport, + mcpManager: null, + workspacePath, + globalState: new Map(), + }; +} + +export function setupWebSocketHandler(): (c: unknown) => WSEvents { + const state = createState(); + + return (_c: unknown) => { + let ws: WSContext | null = null; + + const send: WebSocketMessageSender = (message) => { + if (ws) { + ws.send(JSON.stringify(message)); + } + }; + + return { + onOpen(_evt, wsCtx) { + ws = wsCtx; + state.dialogService.setSender(send); + state.transport.setSender(send); + log('INFO', 'WebSocket client connected'); + }, + + async onMessage(evt, _wsCtx) { + let message: { type: string; requestId?: string; payload?: Record }; + try { + const data = typeof evt.data === 'string' ? evt.data : evt.data.toString(); + message = JSON.parse(data); + } catch { + log('ERROR', 'Failed to parse WebSocket message'); + return; + } + + const { type, requestId, payload } = message; + + const reply = (responseType: string, responsePayload?: unknown): void => { + send({ type: responseType, requestId, payload: responsePayload }); + }; + + try { + await handleMessage(type, payload, requestId, reply, state, send); + } catch (error) { + log('ERROR', `Error handling message ${type}`, { + error: error instanceof Error ? error.message : String(error), + }); + reply('ERROR', { + code: 'INTERNAL_ERROR', + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + }, + + onClose() { + ws = null; + state.dialogService.setSender(null); + state.transport.setSender(null); + log('INFO', 'WebSocket client disconnected'); + }, + + onError(evt) { + log('ERROR', 'WebSocket error', { error: String(evt) }); + }, + }; + }; +} + +async function handleMessage( + type: string, + payload: Record | undefined, + requestId: string | undefined, + reply: (type: string, payload?: unknown) => void, + state: ServerState, + send: WebSocketMessageSender +): Promise { + const { fileService } = state; + + switch (type) { + // ======================================================================== + // Lifecycle + // ======================================================================== + case 'WEBVIEW_READY': { + const hasAcceptedTerms = state.globalState.get('hasAcceptedTerms') ?? false; + reply('INITIAL_STATE', { hasAcceptedTerms }); + break; + } + + case 'ACCEPT_TERMS': { + state.globalState.set('hasAcceptedTerms', true); + reply('INITIAL_STATE', { hasAcceptedTerms: true }); + break; + } + + case 'CANCEL_TERMS': { + // In web mode, just acknowledge — can't close browser tab + break; + } + + // ======================================================================== + // Workflow CRUD + // ======================================================================== + case 'SAVE_WORKFLOW': { + if (!fileService || !payload?.workflow) { + reply('ERROR', { code: 'VALIDATION_ERROR', message: 'Workflow is required' }); + break; + } + const workflow = payload.workflow as Record; + await fileService.ensureWorkflowsDirectory(); + const filePath = fileService.getWorkflowFilePath(workflow.name as string); + + // Check if file exists + if (await fileService.fileExists(filePath)) { + // In web mode, always overwrite (dialog confirmation done client-side) + // The client should handle overwrite confirmation via UI + } + + const content = JSON.stringify(workflow, null, 2); + await fileService.writeFile(filePath, content); + + // Update MCP manager cache if active + if (state.mcpManager) { + state.mcpManager.updateWorkflowCache(workflow as never); + } + + reply('SAVE_SUCCESS', { + filePath, + timestamp: new Date().toISOString(), + }); + break; + } + + case 'LOAD_WORKFLOW_LIST': { + if (!fileService) { + reply('ERROR', { code: 'LOAD_FAILED', message: 'File service not initialized' }); + break; + } + await fileService.ensureWorkflowsDirectory(); + const names = await fileService.listWorkflowFiles(); + const workflows = []; + for (const name of names) { + try { + const fp = fileService.getWorkflowFilePath(name); + const content = await fileService.readFile(fp); + const parsed = JSON.parse(content); + workflows.push({ + id: name, + name: parsed.name || name, + description: parsed.description, + updatedAt: parsed.updatedAt || new Date().toISOString(), + }); + } catch { + log('WARN', `Failed to parse workflow: ${name}`); + } + } + reply('WORKFLOW_LIST_LOADED', { workflows }); + break; + } + + case 'LOAD_WORKFLOW': { + if (!fileService || !payload?.workflowId) { + reply('ERROR', { code: 'VALIDATION_ERROR', message: 'Workflow ID is required' }); + break; + } + const workflowId = payload.workflowId as string; + const fp = fileService.getWorkflowFilePath(workflowId); + if (!(await fileService.fileExists(fp))) { + reply('ERROR', { code: 'LOAD_FAILED', message: `Workflow "${workflowId}" not found` }); + break; + } + const raw = await fileService.readFile(fp); + const parsed = JSON.parse(raw); + const migrated = migrateWorkflow(parsed); + reply('LOAD_WORKFLOW', { workflow: migrated }); + break; + } + + case 'EXPORT_WORKFLOW': { + if (!fileService || !payload?.workflow) { + reply('ERROR', { code: 'VALIDATION_ERROR', message: 'Export payload is required' }); + break; + } + const workflow = payload.workflow as Record; + const validation = validateAIGeneratedWorkflow(workflow as never); + if (!validation.valid) { + const errorMessages = validation.errors + .map((e: { message: string }) => e.message) + .join('\n'); + reply('ERROR', { code: 'EXPORT_FAILED', message: `Validation failed:\n${errorMessages}` }); + break; + } + // Use dynamic import for export service to avoid circular dependencies + const { handleExportWorkflowWeb } = await import('../handlers/workflow-handlers.js'); + await handleExportWorkflowWeb(fileService, payload as never, requestId, reply); + break; + } + + case 'OPEN_FILE_PICKER': { + // File picker not available in web mode — send cancel + reply('FILE_PICKER_CANCELLED'); + break; + } + + // ======================================================================== + // State + // ======================================================================== + case 'STATE_UPDATE': { + // State persistence — store in memory + log('INFO', 'State update received'); + break; + } + + case 'CONFIRM_OVERWRITE': { + // Handled client-side in web mode + break; + } + + // ======================================================================== + // Skill Operations + // ======================================================================== + case 'BROWSE_SKILLS': { + const { handleBrowseSkillsWeb } = await import('../handlers/skill-handlers.js'); + await handleBrowseSkillsWeb(state.workspacePath, requestId, reply); + break; + } + + case 'CREATE_SKILL': { + if (!payload) { + reply('ERROR', { code: 'VALIDATION_ERROR', message: 'Skill creation payload is required' }); + break; + } + const { handleCreateSkillWeb } = await import('../handlers/skill-handlers.js'); + await handleCreateSkillWeb(state.workspacePath, payload as never, requestId, reply); + break; + } + + case 'VALIDATE_SKILL_FILE': { + if (!payload) { + reply('ERROR', { code: 'VALIDATION_ERROR', message: 'Skill file path is required' }); + break; + } + const { handleValidateSkillFileWeb } = await import('../handlers/skill-handlers.js'); + await handleValidateSkillFileWeb(payload as never, requestId, reply); + break; + } + + // ======================================================================== + // AI Refinement + // ======================================================================== + case 'REFINE_WORKFLOW': { + if (!payload) { + reply('REFINEMENT_FAILED', { + error: { code: 'VALIDATION_ERROR', message: 'Refinement payload is required' }, + executionTimeMs: 0, + timestamp: new Date().toISOString(), + }); + break; + } + const { handleRefineWorkflowWeb } = await import('../handlers/ai-handlers.js'); + await handleRefineWorkflowWeb(payload as never, state.workspacePath, requestId, reply, send); + break; + } + + case 'CANCEL_REFINEMENT': { + if (payload) { + const { handleCancelRefinementWeb } = await import('../handlers/ai-handlers.js'); + await handleCancelRefinementWeb(payload as never, requestId, reply); + } + break; + } + + case 'CLEAR_CONVERSATION': { + if (payload) { + const { handleClearConversationWeb } = await import('../handlers/ai-handlers.js'); + await handleClearConversationWeb(payload as never, requestId, reply); + } + break; + } + + case 'GENERATE_WORKFLOW_NAME': { + if (!payload) { + reply('ERROR', { + code: 'VALIDATION_ERROR', + message: 'Generate workflow name payload is required', + }); + break; + } + const { handleGenerateWorkflowNameWeb } = await import('../handlers/ai-handlers.js'); + await handleGenerateWorkflowNameWeb(payload as never, state.workspacePath, requestId, reply); + break; + } + + case 'CANCEL_WORKFLOW_NAME': { + if (payload?.targetRequestId) { + const { handleCancelGenerationWeb } = await import('../handlers/ai-handlers.js'); + await handleCancelGenerationWeb(payload.targetRequestId as string); + } + break; + } + + // ======================================================================== + // Multi-Agent Export & Run (Claude Code / Copilot / Codex / Roo / Gemini / Antigravity / Cursor) + // ======================================================================== + case 'RUN_AS_SLASH_COMMAND': { + if (!fileService || !payload?.workflow) { + reply('ERROR', { code: 'VALIDATION_ERROR', message: 'Workflow is required' }); + break; + } + const { handleRunAsSlashCommandWeb } = await import('../handlers/export-handlers.js'); + await handleRunAsSlashCommandWeb( + fileService, + payload.workflow as never, + state.workspacePath, + requestId, + reply, + send + ); + break; + } + + case 'EXPORT_FOR_COPILOT': + case 'EXPORT_FOR_COPILOT_CLI': + case 'EXPORT_FOR_CODEX_CLI': + case 'EXPORT_FOR_ROO_CODE': + case 'EXPORT_FOR_GEMINI_CLI': + case 'EXPORT_FOR_ANTIGRAVITY': + case 'EXPORT_FOR_CURSOR': { + if (!fileService || !payload?.workflow) { + const failType = `${type}_FAILED`; + reply(failType, { + errorCode: 'UNKNOWN_ERROR', + errorMessage: 'Workflow is required', + timestamp: new Date().toISOString(), + }); + break; + } + const { handleAgentExportWeb } = await import('../handlers/export-handlers.js'); + await handleAgentExportWeb( + type, + fileService, + payload as never, + state.workspacePath, + requestId, + reply + ); + break; + } + + case 'RUN_FOR_COPILOT': + case 'RUN_FOR_COPILOT_CLI': + case 'RUN_FOR_CODEX_CLI': + case 'RUN_FOR_ROO_CODE': + case 'RUN_FOR_GEMINI_CLI': + case 'RUN_FOR_ANTIGRAVITY': + case 'RUN_FOR_CURSOR': { + if (!fileService || !payload?.workflow) { + const failType = `${type}_FAILED`; + reply(failType, { + errorCode: 'UNKNOWN_ERROR', + errorMessage: 'Workflow is required', + timestamp: new Date().toISOString(), + }); + break; + } + const { handleAgentRunWeb } = await import('../handlers/export-handlers.js'); + await handleAgentRunWeb( + type, + fileService, + payload as never, + state.workspacePath, + requestId, + reply, + send + ); + break; + } + + case 'LIST_COPILOT_MODELS': { + // VSCode LM API not available in web mode — return empty list + reply('COPILOT_MODELS_LIST', { models: [] }); + break; + } + + // ======================================================================== + // MCP Integration + // ======================================================================== + case 'LIST_MCP_SERVERS': { + const { handleListMcpServersWeb } = await import('../handlers/mcp-handlers.js'); + await handleListMcpServersWeb(payload ?? {}, requestId, reply); + break; + } + + case 'GET_MCP_TOOLS': { + if (!payload?.serverId) { + reply('ERROR', { code: 'VALIDATION_ERROR', message: 'Server ID is required' }); + break; + } + const { handleGetMcpToolsWeb } = await import('../handlers/mcp-handlers.js'); + await handleGetMcpToolsWeb(payload as never, requestId, reply); + break; + } + + case 'GET_MCP_TOOL_SCHEMA': { + if (!payload?.serverId || !payload?.toolName) { + reply('ERROR', { + code: 'VALIDATION_ERROR', + message: 'Server ID and Tool Name are required', + }); + break; + } + const { handleGetMcpToolSchemaWeb } = await import('../handlers/mcp-handlers.js'); + await handleGetMcpToolSchemaWeb(payload as never, requestId, reply); + break; + } + + case 'REFRESH_MCP_CACHE': { + const { handleRefreshMcpCacheWeb } = await import('../handlers/mcp-handlers.js'); + await handleRefreshMcpCacheWeb(payload ?? {}, requestId, reply); + break; + } + + case 'START_MCP_SERVER': { + const { handleStartMcpServerWeb } = await import('../handlers/mcp-handlers.js'); + state.mcpManager = await handleStartMcpServerWeb( + state.mcpManager, + state.transport, + state.workspacePath, + payload as never, + requestId, + reply + ); + break; + } + + case 'STOP_MCP_SERVER': { + const { handleStopMcpServerWeb } = await import('../handlers/mcp-handlers.js'); + await handleStopMcpServerWeb(state.mcpManager, state.workspacePath, requestId, reply); + break; + } + + case 'GET_MCP_SERVER_STATUS': { + const running = state.mcpManager?.isRunning() ?? false; + const port = running ? (state.mcpManager?.getPort() ?? null) : null; + reply('MCP_SERVER_STATUS', { + running, + port, + configsWritten: [], + reviewBeforeApply: state.mcpManager?.getReviewBeforeApply() ?? true, + }); + break; + } + + case 'SET_REVIEW_BEFORE_APPLY': { + if (payload != null && state.mcpManager) { + state.mcpManager.setReviewBeforeApply(payload.value as boolean); + } + break; + } + + case 'GET_CURRENT_WORKFLOW_RESPONSE': { + if (state.mcpManager && payload) { + state.mcpManager.handleWorkflowResponse(payload as never); + } + break; + } + + case 'APPLY_WORKFLOW_FROM_MCP_RESPONSE': { + if (state.mcpManager && payload) { + state.mcpManager.handleApplyResponse(payload as never); + } + break; + } + + // ======================================================================== + // AI Editing / Agent Launch + // ======================================================================== + case 'RUN_AI_EDITING_SKILL': { + if (!payload?.provider) break; + const { handleRunAiEditingSkillWeb } = await import('../handlers/ai-handlers.js'); + await handleRunAiEditingSkillWeb(payload as never, state.workspacePath, requestId, reply); + break; + } + + case 'LAUNCH_AI_AGENT': { + if (!payload?.provider) break; + const { handleLaunchAiAgentWeb } = await import('../handlers/ai-handlers.js'); + state.mcpManager = await handleLaunchAiAgentWeb( + payload as never, + state.mcpManager, + state.transport, + state.workspacePath, + requestId, + reply, + send + ); + break; + } + + case 'OPEN_ANTIGRAVITY_MCP_SETTINGS': { + // Not applicable in web mode + break; + } + + case 'CONFIRM_ANTIGRAVITY_CASCADE_LAUNCH': { + // Antigravity Cascade not available in web mode + reply('LAUNCH_AI_AGENT_FAILED', { + errorMessage: 'Antigravity Cascade is not available in web mode', + timestamp: new Date().toISOString(), + }); + break; + } + + // ======================================================================== + // Slack Integration + // ======================================================================== + case 'LIST_SLACK_WORKSPACES': { + const { handleListSlackWorkspacesWeb } = await import('../handlers/slack-handlers.js'); + await handleListSlackWorkspacesWeb(requestId, reply); + break; + } + + case 'GET_SLACK_CHANNELS': { + if (!payload?.workspaceId) { + reply('ERROR', { code: 'VALIDATION_ERROR', message: 'Workspace ID is required' }); + break; + } + const { handleGetSlackChannelsWeb } = await import('../handlers/slack-handlers.js'); + await handleGetSlackChannelsWeb(payload as never, requestId, reply); + break; + } + + case 'SHARE_WORKFLOW_TO_SLACK': { + if (!payload || !fileService) { + reply('ERROR', { code: 'VALIDATION_ERROR', message: 'Share workflow payload is required' }); + break; + } + const { handleShareWorkflowToSlackWeb } = await import('../handlers/slack-handlers.js'); + await handleShareWorkflowToSlackWeb(payload as never, fileService, requestId, reply); + break; + } + + case 'GENERATE_SLACK_DESCRIPTION': { + if (!payload) { + reply('ERROR', { + code: 'VALIDATION_ERROR', + message: 'Generate Slack description payload is required', + }); + break; + } + const { handleGenerateSlackDescriptionWeb } = await import('../handlers/slack-handlers.js'); + await handleGenerateSlackDescriptionWeb( + payload as never, + state.workspacePath, + requestId, + reply + ); + break; + } + + case 'CANCEL_SLACK_DESCRIPTION': { + if (payload?.targetRequestId) { + const { handleCancelGenerationWeb } = await import('../handlers/ai-handlers.js'); + await handleCancelGenerationWeb(payload.targetRequestId as string); + } + break; + } + + case 'IMPORT_WORKFLOW_FROM_SLACK': { + if (!payload || !fileService) { + reply('ERROR', { + code: 'VALIDATION_ERROR', + message: 'Import workflow payload is required', + }); + break; + } + const { handleImportWorkflowFromSlackWeb } = await import('../handlers/slack-handlers.js'); + await handleImportWorkflowFromSlackWeb(payload as never, fileService, requestId, reply); + break; + } + + case 'CONNECT_SLACK_MANUAL': { + if (!payload?.userToken) { + reply('CONNECT_SLACK_MANUAL_FAILED', { + code: 'SLACK_CONNECTION_FAILED', + message: 'User Token is required', + }); + break; + } + const { handleConnectSlackManualWeb } = await import('../handlers/slack-handlers.js'); + await handleConnectSlackManualWeb(payload as never, requestId, reply); + break; + } + + case 'SLACK_CONNECT_OAUTH': { + const { handleSlackConnectOAuthWeb } = await import('../handlers/slack-handlers.js'); + await handleSlackConnectOAuthWeb(requestId, reply, send); + break; + } + + case 'SLACK_CANCEL_OAUTH': { + const { handleSlackCancelOAuthWeb } = await import('../handlers/slack-handlers.js'); + handleSlackCancelOAuthWeb(); + break; + } + + case 'SLACK_DISCONNECT': { + const { handleSlackDisconnectWeb } = await import('../handlers/slack-handlers.js'); + await handleSlackDisconnectWeb(requestId, reply); + break; + } + + case 'GET_LAST_SHARED_CHANNEL': { + const channelId = state.globalState.get('slack-last-shared-channel') ?? null; + reply('GET_LAST_SHARED_CHANNEL_SUCCESS', { channelId }); + break; + } + + case 'SET_LAST_SHARED_CHANNEL': { + if (payload?.channelId) { + state.globalState.set('slack-last-shared-channel', payload.channelId); + } + break; + } + + // ======================================================================== + // Utility + // ======================================================================== + case 'OPEN_EXTERNAL_URL': { + if (payload?.url) { + // In web mode, tell the client to open the URL + send({ + type: 'OPEN_URL', + payload: { url: payload.url }, + }); + } + break; + } + + case 'OPEN_IN_EDITOR': { + // In web mode, send content back to client for display + if (payload) { + send({ + type: 'SHOW_EDITOR_CONTENT', + requestId, + payload, + }); + } + break; + } + + case 'DIALOG_RESPONSE': { + // Handle client dialog responses + if (requestId && payload?.confirmed !== undefined) { + state.dialogService.handleDialogResponse(requestId, payload.confirmed as boolean); + } + break; + } + + default: { + log('WARN', `Unhandled message type: ${type}`); + break; + } + } +} diff --git a/packages/web-server/src/server.ts b/packages/web-server/src/server.ts new file mode 100644 index 00000000..039f9f72 --- /dev/null +++ b/packages/web-server/src/server.ts @@ -0,0 +1,73 @@ +/** + * CC Workflow Studio - Web Server + * + * Hono-based HTTP server with WebSocket support. + * Serves the webview static files and provides a WebSocket endpoint + * for bidirectional communication using the same message protocol + * as the VSCode extension's postMessage API. + */ + +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { ConsoleLogger, setLogger } from '@cc-wf-studio/core'; +import { serve } from '@hono/node-server'; +import { serveStatic } from '@hono/node-server/serve-static'; +import { createNodeWebSocket } from '@hono/node-ws'; +import { Hono } from 'hono'; +import { setupWebSocketHandler } from './routes/ws-handler.js'; + +// Initialize logger +const logger = new ConsoleLogger(); +setLogger(logger); + +const app = new Hono(); + +// WebSocket setup +const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }); + +// WebSocket endpoint +const wsHandler = setupWebSocketHandler(); +app.get('/ws', upgradeWebSocket(wsHandler)); + +// Determine paths +const webviewDistPath = path.resolve( + import.meta.dirname ?? path.dirname(new URL(import.meta.url).pathname), + '../../../src/webview/dist' +); + +// Serve static files from webview dist +app.use( + '/*', + serveStatic({ + root: '', + rewriteRequestPath: (p) => { + return path.join(webviewDistPath, p); + }, + onNotFound: (_path, _c) => { + // For SPA routing, serve index.html for non-asset paths + }, + }) +); + +// SPA fallback: serve index.html for any route that doesn't match a static file +app.get('*', (c) => { + try { + const indexPath = path.join(webviewDistPath, 'index.html'); + const html = readFileSync(indexPath, 'utf-8'); + return c.html(html); + } catch { + return c.text('Webview not built. Run: npm run build:webview', 500); + } +}); + +// Start server +const port = Number(process.env.PORT ?? 3001); +const server = serve({ fetch: app.fetch, port }, (info) => { + logger.info(`CC Workflow Studio web server running at http://localhost:${info.port}`); + logger.info(`WebSocket endpoint: ws://localhost:${info.port}/ws`); +}); + +// Inject WebSocket support into the HTTP server +injectWebSocket(server); + +export { app }; diff --git a/packages/web-server/src/services/secret-store.ts b/packages/web-server/src/services/secret-store.ts new file mode 100644 index 00000000..f230835b --- /dev/null +++ b/packages/web-server/src/services/secret-store.ts @@ -0,0 +1,106 @@ +/** + * Secret Store + * + * Encrypted file-based token storage for web mode. + * Replaces vscode.ExtensionContext.secrets for standalone operation. + * + * Stores encrypted secrets in ~/.cc-wf-studio/secrets.json + * Uses Node.js crypto for AES-256-GCM encryption with a machine-derived key. + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +const STORE_DIR = path.join(os.homedir(), '.cc-wf-studio'); +const STORE_FILE = path.join(STORE_DIR, 'secrets.json'); +const ALGORITHM = 'aes-256-gcm'; + +export class SecretStore { + private cache: Map | null = null; + + /** + * Get the encryption key derived from machine-specific data + */ + private getKey(): Buffer { + // Derive key from hostname + username for machine-specific encryption + const material = `cc-wf-studio-${os.hostname()}-${os.userInfo().username}`; + return crypto.createHash('sha256').update(material).digest(); + } + + private encrypt(plaintext: string): string { + const key = this.getKey(); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + let encrypted = cipher.update(plaintext, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + const tag = cipher.getAuthTag(); + return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted}`; + } + + private decrypt(ciphertext: string): string { + const [ivHex, tagHex, encrypted] = ciphertext.split(':'); + const key = this.getKey(); + const iv = Buffer.from(ivHex, 'hex'); + const tag = Buffer.from(tagHex, 'hex'); + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } + + private async loadStore(): Promise> { + if (this.cache) return this.cache; + + try { + const content = await fs.readFile(STORE_FILE, 'utf-8'); + const data = JSON.parse(content); + this.cache = new Map(Object.entries(data)); + } catch { + this.cache = new Map(); + } + + return this.cache; + } + + private async saveStore(): Promise { + if (!this.cache) return; + await fs.mkdir(STORE_DIR, { recursive: true }); + const data = Object.fromEntries(this.cache); + await fs.writeFile(STORE_FILE, JSON.stringify(data, null, 2), { mode: 0o600 }); + } + + async get(key: string): Promise { + const store = await this.loadStore(); + const encrypted = store.get(key); + if (!encrypted) return null; + + try { + return this.decrypt(encrypted); + } catch { + // Decryption failed (key changed, corrupted data) + store.delete(key); + await this.saveStore(); + return null; + } + } + + async set(key: string, value: string): Promise { + const store = await this.loadStore(); + store.set(key, this.encrypt(value)); + await this.saveStore(); + } + + async delete(key: string): Promise { + const store = await this.loadStore(); + store.delete(key); + await this.saveStore(); + } + + async has(key: string): Promise { + const store = await this.loadStore(); + return store.has(key); + } +} diff --git a/packages/web-server/src/services/shell-exec.ts b/packages/web-server/src/services/shell-exec.ts new file mode 100644 index 00000000..0ffd66bd --- /dev/null +++ b/packages/web-server/src/services/shell-exec.ts @@ -0,0 +1,93 @@ +/** + * Shell Execution Service + * + * Replaces vscode.window.createTerminal() with child_process.spawn() + * and WebSocket stdout/stderr streaming. + */ + +import { spawn } from 'node:child_process'; +import { log } from '@cc-wf-studio/core'; + +export interface ShellExecOptions { + command: string; + args?: string[]; + cwd?: string; + env?: Record; + shell?: boolean; +} + +export interface ShellExecResult { + pid: number | undefined; + kill: () => void; +} + +type StreamCallback = (stream: 'stdout' | 'stderr', data: string) => void; +type ExitCallback = (code: number | null) => void; + +/** + * Execute a command with streaming output + */ +export function executeWithStreaming( + options: ShellExecOptions, + onStream: StreamCallback, + onExit?: ExitCallback +): ShellExecResult { + const { command, args = [], cwd, env, shell = true } = options; + + const child = spawn(command, args, { + cwd, + shell, + env: env ? { ...process.env, ...env } : process.env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + child.stdout?.on('data', (data) => { + onStream('stdout', data.toString()); + }); + + child.stderr?.on('data', (data) => { + onStream('stderr', data.toString()); + }); + + child.on('close', (code) => { + onExit?.(code); + }); + + child.on('error', (error) => { + log('ERROR', `Shell exec error: ${command}`, { error: error.message }); + onStream('stderr', `Error: ${error.message}\n`); + onExit?.(1); + }); + + return { + pid: child.pid, + kill: () => { + if (!child.killed) { + child.kill('SIGTERM'); + } + }, + }; +} + +/** + * Execute a command and collect output + */ +export async function execute( + options: ShellExecOptions +): Promise<{ stdout: string; stderr: string; code: number | null }> { + return new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + + executeWithStreaming( + options, + (stream, data) => { + if (stream === 'stdout') stdout += data; + else stderr += data; + }, + (code) => { + resolve({ stdout, stderr, code }); + } + ); + }); +} diff --git a/packages/web-server/src/services/web-dialog-service.ts b/packages/web-server/src/services/web-dialog-service.ts new file mode 100644 index 00000000..e5c4cd50 --- /dev/null +++ b/packages/web-server/src/services/web-dialog-service.ts @@ -0,0 +1,82 @@ +/** + * Web Dialog Service + * + * IDialogService implementation that sends dialog requests via WebSocket + * to the browser client for rendering as web UI dialogs. + */ + +import type { IDialogService } from '@cc-wf-studio/core'; +import type { WebSocketMessageSender } from '../routes/ws-handler.js'; + +export class WebDialogService implements IDialogService { + private sender: WebSocketMessageSender | null = null; + private pendingDialogs = new Map< + string, + { resolve: (value: boolean) => void; reject: (error: Error) => void } + >(); + + setSender(sender: WebSocketMessageSender | null): void { + this.sender = sender; + } + + showInformationMessage(message: string): void { + this.sender?.({ + type: 'SHOW_NOTIFICATION', + payload: { level: 'info', message }, + }); + } + + showWarningMessage(message: string): void { + this.sender?.({ + type: 'SHOW_NOTIFICATION', + payload: { level: 'warning', message }, + }); + } + + showErrorMessage(message: string): void { + this.sender?.({ + type: 'SHOW_NOTIFICATION', + payload: { level: 'error', message }, + }); + } + + async showConfirmDialog(message: string, confirmLabel: string): Promise { + if (!this.sender) return false; + + const requestId = `dialog-${Date.now()}-${Math.random().toString(36).slice(2)}`; + + return new Promise((resolve) => { + this.pendingDialogs.set(requestId, { resolve, reject: () => resolve(false) }); + + this.sender?.({ + type: 'SHOW_CONFIRM_DIALOG', + requestId, + payload: { message, confirmLabel }, + }); + + // Auto-resolve after 60 seconds if no response + setTimeout(() => { + if (this.pendingDialogs.has(requestId)) { + this.pendingDialogs.delete(requestId); + resolve(false); + } + }, 60000); + }); + } + + handleDialogResponse(requestId: string, confirmed: boolean): void { + const pending = this.pendingDialogs.get(requestId); + if (pending) { + this.pendingDialogs.delete(requestId); + pending.resolve(confirmed); + } + } + + async showOpenFileDialog(_options: { + filters?: Record; + title?: string; + }): Promise { + // File picker not supported in web mode — return null + return null; + } +} diff --git a/packages/electron/tsconfig.main.json b/packages/web-server/tsconfig.json similarity index 51% rename from packages/electron/tsconfig.main.json rename to packages/web-server/tsconfig.json index e870de30..801f4580 100644 --- a/packages/electron/tsconfig.main.json +++ b/packages/web-server/tsconfig.json @@ -1,13 +1,14 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "rootDir": "./src/main", - "outDir": "./dist/main", + "rootDir": "./src", + "outDir": "./dist", "lib": ["ES2020"], "types": ["node"], - "module": "commonjs", - "moduleResolution": "node" + "paths": { + "@shared/*": ["../../src/shared/*"] + } }, - "include": ["src/main/**/*"], + "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } diff --git a/scripts/generate-editing-flow.ts b/scripts/generate-editing-flow.ts index e84e6c2b..474e92a6 100644 --- a/scripts/generate-editing-flow.ts +++ b/scripts/generate-editing-flow.ts @@ -15,7 +15,7 @@ const RESOURCES_DIR = path.resolve(__dirname, '../resources'); const MD_PATH = path.join(RESOURCES_DIR, 'ai-editing-process-flow.md'); const OUTPUT_PATH = path.resolve( __dirname, - '../src/extension/services/editing-flow-constants.generated.ts' + '../packages/core/src/generated/editing-flow-constants.generated.ts' ); interface EditingFlowData { diff --git a/src/extension/commands/antigravity-handlers.ts b/src/extension/commands/antigravity-handlers.ts deleted file mode 100644 index 6863581b..00000000 --- a/src/extension/commands/antigravity-handlers.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * Claude Code Workflow Studio - Antigravity Integration Handlers - * - * Handles Export/Run for Google Antigravity (Cascade) integration - */ - -import * as vscode from 'vscode'; -import type { - AntigravityOperationFailedPayload, - ExportForAntigravityPayload, - ExportForAntigravitySuccessPayload, - RunForAntigravityPayload, - RunForAntigravitySuccessPayload, -} from '../../shared/types/messages'; -import { - isAntigravityInstalled, - startAntigravityTask, -} from '../services/antigravity-extension-service'; -import { - checkExistingAntigravitySkill, - exportWorkflowAsAntigravitySkill, -} from '../services/antigravity-skill-export-service'; -import type { FileService } from '../services/file-service'; -import { - hasNonStandardSkills, - promptAndNormalizeSkills, -} from '../services/skill-normalization-service'; - -/** - * Handle Export for Antigravity request - * - * Exports workflow to Skills format (.agent/skills/name/SKILL.md) - * - * @param fileService - File service instance - * @param webview - Webview for sending responses - * @param payload - Export payload - * @param requestId - Optional request ID for response correlation - */ -export async function handleExportForAntigravity( - fileService: FileService, - webview: vscode.Webview, - payload: ExportForAntigravityPayload, - requestId?: string -): Promise { - try { - const { workflow } = payload; - - // Check for existing skill and ask for confirmation - const existingSkillPath = await checkExistingAntigravitySkill(workflow, fileService); - if (existingSkillPath) { - const result = await vscode.window.showWarningMessage( - `Skill already exists: ${existingSkillPath}\n\nOverwrite?`, - { modal: true }, - 'Overwrite', - 'Cancel' - ); - if (result !== 'Overwrite') { - webview.postMessage({ - type: 'EXPORT_FOR_ANTIGRAVITY_CANCELLED', - requestId, - }); - return; - } - } - - // Export workflow as skill to .agent/skills/{name}/SKILL.md - const exportResult = await exportWorkflowAsAntigravitySkill(workflow, fileService); - - if (!exportResult.success) { - const failedPayload: AntigravityOperationFailedPayload = { - errorCode: 'EXPORT_FAILED', - errorMessage: exportResult.errors?.join(', ') || 'Failed to export workflow as skill', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'EXPORT_FOR_ANTIGRAVITY_FAILED', - requestId, - payload: failedPayload, - }); - return; - } - - // Send success response - const successPayload: ExportForAntigravitySuccessPayload = { - skillName: exportResult.skillName, - skillPath: exportResult.skillPath, - timestamp: new Date().toISOString(), - }; - - webview.postMessage({ - type: 'EXPORT_FOR_ANTIGRAVITY_SUCCESS', - requestId, - payload: successPayload, - }); - - vscode.window.showInformationMessage( - `Exported workflow as Antigravity skill: ${exportResult.skillPath}` - ); - } catch (error) { - const failedPayload: AntigravityOperationFailedPayload = { - errorCode: 'UNKNOWN_ERROR', - errorMessage: error instanceof Error ? error.message : 'Unknown error', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'EXPORT_FOR_ANTIGRAVITY_FAILED', - requestId, - payload: failedPayload, - }); - } -} - -/** - * Handle Run for Antigravity request - * - * Exports workflow to Skills format and runs it via Antigravity (Cascade) - * - * @param fileService - File service instance - * @param webview - Webview for sending responses - * @param payload - Run payload - * @param requestId - Optional request ID for response correlation - */ -export async function handleRunForAntigravity( - fileService: FileService, - webview: vscode.Webview, - payload: RunForAntigravityPayload, - requestId?: string -): Promise { - try { - const { workflow } = payload; - - // Step 0.5: Normalize skills (copy non-standard skills to .claude/skills/) - // For Antigravity, .agent/skills/ is the native directory - if (hasNonStandardSkills(workflow, 'antigravity')) { - const normalizeResult = await promptAndNormalizeSkills(workflow, 'antigravity'); - - if (!normalizeResult.success) { - if (normalizeResult.cancelled) { - webview.postMessage({ - type: 'RUN_FOR_ANTIGRAVITY_CANCELLED', - requestId, - }); - return; - } - throw new Error(normalizeResult.error || 'Failed to copy skills to .claude/skills/'); - } - - // Log normalized skills - if (normalizeResult.normalizedSkills && normalizeResult.normalizedSkills.length > 0) { - console.log( - `[Antigravity] Copied ${normalizeResult.normalizedSkills.length} skill(s) to .claude/skills/` - ); - } - } - - // Step 1: Check for existing skill and ask for confirmation - const existingSkillPath = await checkExistingAntigravitySkill(workflow, fileService); - if (existingSkillPath) { - const result = await vscode.window.showWarningMessage( - `Skill already exists: ${existingSkillPath}\n\nOverwrite?`, - { modal: true }, - 'Overwrite', - 'Cancel' - ); - if (result !== 'Overwrite') { - webview.postMessage({ - type: 'RUN_FOR_ANTIGRAVITY_CANCELLED', - requestId, - }); - return; - } - } - - // Step 2: Export workflow as skill to .claude/skills/{name}/SKILL.md - const exportResult = await exportWorkflowAsAntigravitySkill(workflow, fileService); - - if (!exportResult.success) { - const failedPayload: AntigravityOperationFailedPayload = { - errorCode: 'EXPORT_FAILED', - errorMessage: exportResult.errors?.join(', ') || 'Failed to export workflow as skill', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'RUN_FOR_ANTIGRAVITY_FAILED', - requestId, - payload: failedPayload, - }); - return; - } - - // Step 3: Check if Antigravity is installed - if (!isAntigravityInstalled()) { - const failedPayload: AntigravityOperationFailedPayload = { - errorCode: 'ANTIGRAVITY_NOT_INSTALLED', - errorMessage: 'Antigravity extension is not installed.', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'RUN_FOR_ANTIGRAVITY_FAILED', - requestId, - payload: failedPayload, - }); - return; - } - - // Step 4: Launch Cascade with the skill - await startAntigravityTask(exportResult.skillName); - - // Send success response - const successPayload: RunForAntigravitySuccessPayload = { - workflowName: workflow.name, - antigravityOpened: true, - timestamp: new Date().toISOString(), - }; - - webview.postMessage({ - type: 'RUN_FOR_ANTIGRAVITY_SUCCESS', - requestId, - payload: successPayload, - }); - - vscode.window.showInformationMessage( - `Running workflow via Antigravity (Cascade): ${workflow.name}` - ); - } catch (error) { - const failedPayload: AntigravityOperationFailedPayload = { - errorCode: 'UNKNOWN_ERROR', - errorMessage: error instanceof Error ? error.message : 'Unknown error', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'RUN_FOR_ANTIGRAVITY_FAILED', - requestId, - payload: failedPayload, - }); - } -} diff --git a/src/extension/commands/codex-handlers.ts b/src/extension/commands/codex-handlers.ts deleted file mode 100644 index ce697dfb..00000000 --- a/src/extension/commands/codex-handlers.ts +++ /dev/null @@ -1,309 +0,0 @@ -/** - * Claude Code Workflow Studio - Codex CLI Integration Handlers - * - * Handles Export/Run for OpenAI Codex CLI integration - */ - -import * as vscode from 'vscode'; -import type { - CodexOperationFailedPayload, - ExportForCodexCliPayload, - ExportForCodexCliSuccessPayload, - RunForCodexCliPayload, - RunForCodexCliSuccessPayload, -} from '../../shared/types/messages'; -import { NodeType } from '../../shared/types/workflow-definition'; -import { - checkCodexMultiAgentEnabled, - enableCodexMultiAgent, - previewMcpSyncForCodexCli, - syncMcpConfigForCodexCli, -} from '../services/codex-mcp-sync-service'; -import { - checkExistingCodexSkill, - exportWorkflowAsCodexSkill, -} from '../services/codex-skill-export-service'; -import { extractMcpServerIdsFromWorkflow } from '../services/copilot-export-service'; -import type { FileService } from '../services/file-service'; -import { - hasNonStandardSkills, - promptAndNormalizeSkills, -} from '../services/skill-normalization-service'; -import { executeCodexCliInTerminal } from '../services/terminal-execution-service'; - -/** - * Handle Export for Codex CLI request - * - * Exports workflow to Skills format (.codex/skills/name/SKILL.md) - * - * @param fileService - File service instance - * @param webview - Webview for sending responses - * @param payload - Export payload - * @param requestId - Optional request ID for response correlation - */ -export async function handleExportForCodexCli( - fileService: FileService, - webview: vscode.Webview, - payload: ExportForCodexCliPayload, - requestId?: string -): Promise { - try { - const { workflow } = payload; - - // Check if workflow uses SubAgent/SubAgentFlow nodes and ensure multi_agent is enabled - const hasSubAgentNodes = workflow.nodes.some( - (node) => node.type === NodeType.SubAgent || node.type === NodeType.SubAgentFlow - ); - if (hasSubAgentNodes) { - const multiAgentEnabled = await checkCodexMultiAgentEnabled(); - if (!multiAgentEnabled) { - const result = await vscode.window.showInformationMessage( - 'This workflow uses Sub-Agent nodes which require the multi_agent feature in Codex CLI.\n\nAdd the following setting to ~/.codex/config.toml?\n\n[features]\nmulti_agent = true', - { modal: true }, - 'Yes' - ); - if (result !== 'Yes') { - webview.postMessage({ - type: 'EXPORT_FOR_CODEX_CLI_CANCELLED', - requestId, - }); - return; - } - await enableCodexMultiAgent(); - } - } - - // Check for existing skill and ask for confirmation - const existingSkillPath = await checkExistingCodexSkill(workflow, fileService); - if (existingSkillPath) { - const result = await vscode.window.showWarningMessage( - `Skill already exists: ${existingSkillPath}\n\nOverwrite?`, - { modal: true }, - 'Overwrite', - 'Cancel' - ); - if (result !== 'Overwrite') { - webview.postMessage({ - type: 'EXPORT_FOR_CODEX_CLI_CANCELLED', - requestId, - }); - return; - } - } - - // Export workflow as skill to .codex/skills/{name}/SKILL.md - const exportResult = await exportWorkflowAsCodexSkill(workflow, fileService); - - if (!exportResult.success) { - const failedPayload: CodexOperationFailedPayload = { - errorCode: 'EXPORT_FAILED', - errorMessage: exportResult.errors?.join(', ') || 'Failed to export workflow as skill', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'EXPORT_FOR_CODEX_CLI_FAILED', - requestId, - payload: failedPayload, - }); - return; - } - - // Send success response - const successPayload: ExportForCodexCliSuccessPayload = { - skillName: exportResult.skillName, - skillPath: exportResult.skillPath, - timestamp: new Date().toISOString(), - }; - - webview.postMessage({ - type: 'EXPORT_FOR_CODEX_CLI_SUCCESS', - requestId, - payload: successPayload, - }); - - vscode.window.showInformationMessage( - `Exported workflow as Codex skill: ${exportResult.skillPath}` - ); - } catch (error) { - const failedPayload: CodexOperationFailedPayload = { - errorCode: 'UNKNOWN_ERROR', - errorMessage: error instanceof Error ? error.message : 'Unknown error', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'EXPORT_FOR_CODEX_CLI_FAILED', - requestId, - payload: failedPayload, - }); - } -} - -/** - * Handle Run for Codex CLI request - * - * Exports workflow to Skills format and runs it via Codex CLI - * using the $skill-name command format - * - * @param fileService - File service instance - * @param webview - Webview for sending responses - * @param payload - Run payload - * @param requestId - Optional request ID for response correlation - */ -export async function handleRunForCodexCli( - fileService: FileService, - webview: vscode.Webview, - payload: RunForCodexCliPayload, - requestId?: string -): Promise { - try { - const { workflow } = payload; - const workspacePath = fileService.getWorkspacePath(); - - // Step 0.5: Normalize skills (copy non-standard skills to .claude/skills/) - // For Codex CLI, .codex/skills/ is considered "native" (no copy needed) - // Only skills from other directories (e.g., .github/skills/, .copilot/skills/) need to be copied - if (hasNonStandardSkills(workflow, 'codex')) { - const normalizeResult = await promptAndNormalizeSkills(workflow, 'codex'); - - if (!normalizeResult.success) { - if (normalizeResult.cancelled) { - webview.postMessage({ - type: 'RUN_FOR_CODEX_CLI_CANCELLED', - requestId, - }); - return; - } - throw new Error(normalizeResult.error || 'Failed to copy skills to .claude/skills/'); - } - - // Log normalized skills - if (normalizeResult.normalizedSkills && normalizeResult.normalizedSkills.length > 0) { - console.log( - `[Codex CLI] Copied ${normalizeResult.normalizedSkills.length} skill(s) to .claude/skills/` - ); - } - } - - // Step 0.75: Check if workflow uses SubAgent/SubAgentFlow nodes and ensure multi_agent is enabled - const hasSubAgentNodes = workflow.nodes.some( - (node) => node.type === NodeType.SubAgent || node.type === NodeType.SubAgentFlow - ); - if (hasSubAgentNodes) { - const multiAgentEnabled = await checkCodexMultiAgentEnabled(); - if (!multiAgentEnabled) { - const result = await vscode.window.showInformationMessage( - 'This workflow uses Sub-Agent nodes which require the multi_agent feature in Codex CLI.\n\nAdd the following setting to ~/.codex/config.toml?\n\n[features]\nmulti_agent = true', - { modal: true }, - 'Yes' - ); - if (result !== 'Yes') { - webview.postMessage({ - type: 'RUN_FOR_CODEX_CLI_CANCELLED', - requestId, - }); - return; - } - await enableCodexMultiAgent(); - } - } - - // Step 1: Check if MCP servers need to be synced to $HOME/.codex/config.toml - const mcpServerIds = extractMcpServerIdsFromWorkflow(workflow); - let mcpSyncConfirmed = false; - - if (mcpServerIds.length > 0) { - const mcpSyncPreview = await previewMcpSyncForCodexCli(mcpServerIds, workspacePath); - - if (mcpSyncPreview.serversToAdd.length > 0) { - const serverList = mcpSyncPreview.serversToAdd.map((s) => ` • ${s}`).join('\n'); - const result = await vscode.window.showInformationMessage( - `The following MCP servers will be added to $HOME/.codex/config.toml for Codex CLI:\n\n${serverList}\n\nProceed?`, - { modal: true }, - 'Yes', - 'No' - ); - mcpSyncConfirmed = result === 'Yes'; - } - } - - // Step 2: Check for existing skill and ask for confirmation - const existingSkillPath = await checkExistingCodexSkill(workflow, fileService); - if (existingSkillPath) { - const result = await vscode.window.showWarningMessage( - `Skill already exists: ${existingSkillPath}\n\nOverwrite?`, - { modal: true }, - 'Overwrite', - 'Cancel' - ); - if (result !== 'Overwrite') { - webview.postMessage({ - type: 'RUN_FOR_CODEX_CLI_CANCELLED', - requestId, - }); - return; - } - } - - // Step 3: Export workflow as skill to .codex/skills/{name}/SKILL.md - const exportResult = await exportWorkflowAsCodexSkill(workflow, fileService); - - if (!exportResult.success) { - const failedPayload: CodexOperationFailedPayload = { - errorCode: 'EXPORT_FAILED', - errorMessage: exportResult.errors?.join(', ') || 'Failed to export workflow as skill', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'RUN_FOR_CODEX_CLI_FAILED', - requestId, - payload: failedPayload, - }); - return; - } - - // Step 4: Sync MCP servers to $HOME/.codex/config.toml if confirmed - let syncedMcpServers: string[] = []; - if (mcpSyncConfirmed) { - syncedMcpServers = await syncMcpConfigForCodexCli(mcpServerIds, workspacePath); - } - - // Step 5: Execute in terminal - const terminalResult = executeCodexCliInTerminal({ - skillName: exportResult.skillName, - workingDirectory: workspacePath, - }); - - // Send success response - const successPayload: RunForCodexCliSuccessPayload = { - workflowName: workflow.name, - terminalName: terminalResult.terminalName, - timestamp: new Date().toISOString(), - }; - - webview.postMessage({ - type: 'RUN_FOR_CODEX_CLI_SUCCESS', - requestId, - payload: successPayload, - }); - - // Show notification with config sync info - const configInfo = - syncedMcpServers.length > 0 - ? ` (MCP servers: ${syncedMcpServers.join(', ')} added to ~/.codex/config.toml)` - : ''; - vscode.window.showInformationMessage( - `Running workflow via Codex CLI: ${workflow.name}${configInfo}` - ); - } catch (error) { - const failedPayload: CodexOperationFailedPayload = { - errorCode: 'UNKNOWN_ERROR', - errorMessage: error instanceof Error ? error.message : 'Unknown error', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'RUN_FOR_CODEX_CLI_FAILED', - requestId, - payload: failedPayload, - }); - } -} diff --git a/src/extension/commands/copilot-handlers.ts b/src/extension/commands/copilot-handlers.ts deleted file mode 100644 index 41c2f783..00000000 --- a/src/extension/commands/copilot-handlers.ts +++ /dev/null @@ -1,534 +0,0 @@ -/** - * Claude Code Workflow Studio - Copilot Integration Handlers - * - * Handles Export/Run for GitHub Copilot integration - */ - -import * as vscode from 'vscode'; -import type { - CopilotOperationFailedPayload, - ExportForCopilotCliPayload, - ExportForCopilotCliSuccessPayload, - ExportForCopilotPayload, - ExportForCopilotSuccessPayload, - RunForCopilotCliPayload, - RunForCopilotCliSuccessPayload, - RunForCopilotPayload, - RunForCopilotSuccessPayload, -} from '../../shared/types/messages'; -import { - previewMcpSyncForCopilotCli, - syncMcpConfigForCopilotCli, -} from '../services/copilot-cli-mcp-sync-service'; -import { - type CopilotExportOptions, - checkExistingCopilotFiles, - executeMcpSyncForCopilot, - exportWorkflowForCopilot, - extractMcpServerIdsFromWorkflow, - previewMcpSyncForCopilot, -} from '../services/copilot-export-service'; -import { - checkExistingSkill, - exportWorkflowAsSkill, -} from '../services/copilot-skill-export-service'; -import { nodeNameToFileName } from '../services/export-service'; -import type { FileService } from '../services/file-service'; -import { - hasNonStandardSkills, - promptAndNormalizeSkills, -} from '../services/skill-normalization-service'; -import { executeCopilotCliInTerminal } from '../services/terminal-execution-service'; - -/** - * Handle Export for Copilot request - * - * @param fileService - File service instance - * @param webview - Webview for sending responses - * @param payload - Export payload - * @param requestId - Optional request ID for response correlation - */ -export async function handleExportForCopilot( - fileService: FileService, - webview: vscode.Webview, - payload: ExportForCopilotPayload, - requestId?: string -): Promise { - try { - const { workflow } = payload; - - // Check for existing files and ask for confirmation - const existingFiles = await checkExistingCopilotFiles(workflow, fileService); - - if (existingFiles.length > 0) { - const result = await vscode.window.showWarningMessage( - `The following files already exist:\n${existingFiles.join('\n')}\n\nOverwrite?`, - { modal: true }, - 'Overwrite', - 'Cancel' - ); - - if (result !== 'Overwrite') { - webview.postMessage({ - type: 'EXPORT_FOR_COPILOT_CANCELLED', - requestId, - }); - return; - } - } - - // Check if MCP servers need to be synced - const mcpSyncPreview = await previewMcpSyncForCopilot(workflow, fileService); - let mcpSyncConfirmed = false; - - if (mcpSyncPreview.serversToAdd.length > 0) { - const serverList = mcpSyncPreview.serversToAdd.map((s) => ` • ${s}`).join('\n'); - const result = await vscode.window.showInformationMessage( - `The following MCP servers will be added to .vscode/mcp.json for GitHub Copilot:\n\n${serverList}\n\nProceed?`, - { modal: true }, - 'Yes', - 'No' - ); - mcpSyncConfirmed = result === 'Yes'; - } - - // Export to Copilot format (skip MCP sync here, we'll do it separately if confirmed) - const copilotOptions: CopilotExportOptions = { - destination: 'copilot', - agent: 'agent', - skipMcpSync: true, - }; - - const copilotResult = await exportWorkflowForCopilot(workflow, fileService, copilotOptions); - - if (!copilotResult.success) { - const failedPayload: CopilotOperationFailedPayload = { - errorCode: 'EXPORT_FAILED', - errorMessage: copilotResult.errors?.join(', ') || 'Unknown error', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'EXPORT_FOR_COPILOT_FAILED', - requestId, - payload: failedPayload, - }); - return; - } - - // Execute MCP sync if user confirmed - let syncedMcpServers: string[] = []; - if (mcpSyncConfirmed) { - syncedMcpServers = await executeMcpSyncForCopilot(workflow, fileService); - } - - // Send success response - const successPayload: ExportForCopilotSuccessPayload = { - exportedFiles: copilotResult.exportedFiles, - timestamp: new Date().toISOString(), - }; - - webview.postMessage({ - type: 'EXPORT_FOR_COPILOT_SUCCESS', - requestId, - payload: successPayload, - }); - - // Show notification with MCP sync info - const syncInfo = - syncedMcpServers.length > 0 ? ` (MCP servers synced: ${syncedMcpServers.join(', ')})` : ''; - vscode.window.showInformationMessage( - `Exported workflow for Copilot (${copilotResult.exportedFiles.length} files)${syncInfo}` - ); - } catch (error) { - const failedPayload: CopilotOperationFailedPayload = { - errorCode: 'UNKNOWN_ERROR', - errorMessage: error instanceof Error ? error.message : 'Unknown error', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'EXPORT_FOR_COPILOT_FAILED', - requestId, - payload: failedPayload, - }); - } -} - -/** - * Handle Run for Copilot request - * - * Exports workflow to Copilot format and opens Copilot Chat with the prompt - * - * @param fileService - File service instance - * @param webview - Webview for sending responses - * @param payload - Run payload - * @param requestId - Optional request ID for response correlation - */ -export async function handleRunForCopilot( - fileService: FileService, - webview: vscode.Webview, - payload: RunForCopilotPayload, - requestId?: string -): Promise { - try { - const { workflow } = payload; - - // Check for existing files and ask for confirmation - const existingFiles = await checkExistingCopilotFiles(workflow, fileService); - - if (existingFiles.length > 0) { - const result = await vscode.window.showWarningMessage( - `The following files already exist:\n${existingFiles.join('\n')}\n\nOverwrite?`, - { modal: true }, - 'Overwrite', - 'Cancel' - ); - - if (result !== 'Overwrite') { - webview.postMessage({ - type: 'RUN_FOR_COPILOT_CANCELLED', - requestId, - }); - return; - } - } - - // Check if MCP servers need to be synced - const mcpSyncPreview = await previewMcpSyncForCopilot(workflow, fileService); - let mcpSyncConfirmed = false; - - if (mcpSyncPreview.serversToAdd.length > 0) { - const serverList = mcpSyncPreview.serversToAdd.map((s) => ` • ${s}`).join('\n'); - const result = await vscode.window.showInformationMessage( - `The following MCP servers will be added to .vscode/mcp.json for GitHub Copilot:\n\n${serverList}\n\nProceed?`, - { modal: true }, - 'Yes', - 'No' - ); - mcpSyncConfirmed = result === 'Yes'; - } - - // First, export the workflow to Copilot format (skip MCP sync, we'll do it separately) - const copilotOptions: CopilotExportOptions = { - destination: 'copilot', - agent: 'agent', - skipMcpSync: true, - }; - - const exportResult = await exportWorkflowForCopilot(workflow, fileService, copilotOptions); - - if (!exportResult.success) { - const failedPayload: CopilotOperationFailedPayload = { - errorCode: 'EXPORT_FAILED', - errorMessage: exportResult.errors?.join(', ') || 'Failed to export workflow', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'RUN_FOR_COPILOT_FAILED', - requestId, - payload: failedPayload, - }); - return; - } - - // Execute MCP sync if user confirmed - if (mcpSyncConfirmed) { - await executeMcpSyncForCopilot(workflow, fileService); - } - - // Try to open Copilot Chat with the prompt - const workflowName = nodeNameToFileName(workflow.name); - let copilotChatOpened = false; - - try { - // Step 1: Create a new chat session - await vscode.commands.executeCommand('workbench.action.chat.newChat'); - // Step 2: Send the query to the new session - await vscode.commands.executeCommand('workbench.action.chat.open', { - query: `/${workflowName}`, - isPartialQuery: false, // Auto-send - }); - copilotChatOpened = true; - } catch (chatError) { - // Copilot Chat might not be installed or command failed - // We still exported the file, so it's a partial success - console.warn('Failed to open Copilot Chat:', chatError); - - // Try alternative approach: just open the chat panel - try { - await vscode.commands.executeCommand('workbench.action.chat.open'); - copilotChatOpened = true; - // Show message that user needs to type the command manually - vscode.window.showInformationMessage( - `Workflow exported. Type "/${workflowName}" in Copilot Chat to run.` - ); - } catch { - // Copilot is likely not installed - const failedPayload: CopilotOperationFailedPayload = { - errorCode: 'COPILOT_NOT_INSTALLED', - errorMessage: - 'GitHub Copilot Chat is not installed or not available. The workflow was exported but could not be run.', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'RUN_FOR_COPILOT_FAILED', - requestId, - payload: failedPayload, - }); - return; - } - } - - // Send success response - const successPayload: RunForCopilotSuccessPayload = { - workflowName: workflow.name, - copilotChatOpened, - timestamp: new Date().toISOString(), - }; - - webview.postMessage({ - type: 'RUN_FOR_COPILOT_SUCCESS', - requestId, - payload: successPayload, - }); - } catch (error) { - const failedPayload: CopilotOperationFailedPayload = { - errorCode: 'UNKNOWN_ERROR', - errorMessage: error instanceof Error ? error.message : 'Unknown error', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'RUN_FOR_COPILOT_FAILED', - requestId, - payload: failedPayload, - }); - } -} - -/** - * Handle Run for Copilot CLI request - * - * Exports workflow to Copilot format and runs it via Copilot CLI - * using the :task command - * - * @param fileService - File service instance - * @param webview - Webview for sending responses - * @param payload - Run payload - * @param requestId - Optional request ID for response correlation - */ -export async function handleRunForCopilotCli( - fileService: FileService, - webview: vscode.Webview, - payload: RunForCopilotCliPayload, - requestId?: string -): Promise { - try { - const { workflow } = payload; - const workspacePath = fileService.getWorkspacePath(); - - // Step 0.5: Normalize skills (copy non-standard skills to .claude/skills/) - // For Copilot CLI, .github/skills/ and .copilot/skills/ are considered "native" (no copy needed) - // Only skills from other directories (e.g., .codex/skills/) need to be copied - if (hasNonStandardSkills(workflow, 'copilot')) { - const normalizeResult = await promptAndNormalizeSkills(workflow, 'copilot'); - - if (!normalizeResult.success) { - if (normalizeResult.cancelled) { - webview.postMessage({ - type: 'RUN_FOR_COPILOT_CLI_CANCELLED', - requestId, - }); - return; - } - throw new Error(normalizeResult.error || 'Failed to copy skills to .claude/skills/'); - } - - // Log normalized skills - if (normalizeResult.normalizedSkills && normalizeResult.normalizedSkills.length > 0) { - console.log( - `[Copilot CLI] Copied ${normalizeResult.normalizedSkills.length} skill(s) to .claude/skills/` - ); - } - } - - // Step 1: Check if MCP servers need to be synced to $HOME/.copilot/mcp-config.json - const mcpServerIds = extractMcpServerIdsFromWorkflow(workflow); - let mcpSyncConfirmed = false; - - if (mcpServerIds.length > 0) { - const mcpSyncPreview = await previewMcpSyncForCopilotCli(mcpServerIds, workspacePath); - - if (mcpSyncPreview.serversToAdd.length > 0) { - const serverList = mcpSyncPreview.serversToAdd.map((s) => ` • ${s}`).join('\n'); - const result = await vscode.window.showInformationMessage( - `The following MCP servers will be added to $HOME/.copilot/mcp-config.json for Copilot CLI:\n\n${serverList}\n\nProceed?`, - { modal: true }, - 'Yes', - 'No' - ); - mcpSyncConfirmed = result === 'Yes'; - } - } - - // Step 2: Check for existing skill and ask for confirmation - const existingSkillPath = await checkExistingSkill(workflow, fileService); - if (existingSkillPath) { - const result = await vscode.window.showWarningMessage( - `Skill already exists: ${existingSkillPath}\n\nOverwrite?`, - { modal: true }, - 'Overwrite', - 'Cancel' - ); - if (result !== 'Overwrite') { - webview.postMessage({ - type: 'RUN_FOR_COPILOT_CLI_CANCELLED', - requestId, - }); - return; - } - } - - // Step 3: Export workflow as skill to .github/skills/{name}/SKILL.md - const exportResult = await exportWorkflowAsSkill(workflow, fileService); - - if (!exportResult.success) { - const failedPayload: CopilotOperationFailedPayload = { - errorCode: 'EXPORT_FAILED', - errorMessage: exportResult.errors?.join(', ') || 'Failed to export workflow as skill', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'RUN_FOR_COPILOT_CLI_FAILED', - requestId, - payload: failedPayload, - }); - return; - } - - // Step 4: Sync MCP servers to $HOME/.copilot/mcp-config.json if confirmed - let syncedMcpServers: string[] = []; - if (mcpSyncConfirmed) { - syncedMcpServers = await syncMcpConfigForCopilotCli(mcpServerIds, workspacePath); - } - - // Step 5: Execute in terminal - const terminalResult = executeCopilotCliInTerminal({ - skillName: exportResult.skillName, - workingDirectory: workspacePath, - }); - - // Send success response - const successPayload: RunForCopilotCliSuccessPayload = { - workflowName: workflow.name, - terminalName: terminalResult.terminalName, - timestamp: new Date().toISOString(), - }; - - webview.postMessage({ - type: 'RUN_FOR_COPILOT_CLI_SUCCESS', - requestId, - payload: successPayload, - }); - - // Show notification with MCP sync info - const syncInfo = - syncedMcpServers.length > 0 - ? ` (MCP servers synced to ~/.copilot/mcp-config.json: ${syncedMcpServers.join(', ')})` - : ''; - vscode.window.showInformationMessage( - `Running workflow via Copilot CLI: ${workflow.name}${syncInfo}` - ); - } catch (error) { - const failedPayload: CopilotOperationFailedPayload = { - errorCode: 'UNKNOWN_ERROR', - errorMessage: error instanceof Error ? error.message : 'Unknown error', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'RUN_FOR_COPILOT_CLI_FAILED', - requestId, - payload: failedPayload, - }); - } -} - -/** - * Handle Export for Copilot CLI request - * - * Exports workflow to Skills format (.github/skills/name/SKILL.md) - * - * @param fileService - File service instance - * @param webview - Webview for sending responses - * @param payload - Export payload - * @param requestId - Optional request ID for response correlation - */ -export async function handleExportForCopilotCli( - fileService: FileService, - webview: vscode.Webview, - payload: ExportForCopilotCliPayload, - requestId?: string -): Promise { - try { - const { workflow } = payload; - - // Check for existing skill and ask for confirmation - const existingSkillPath = await checkExistingSkill(workflow, fileService); - if (existingSkillPath) { - const result = await vscode.window.showWarningMessage( - `Skill already exists: ${existingSkillPath}\n\nOverwrite?`, - { modal: true }, - 'Overwrite', - 'Cancel' - ); - if (result !== 'Overwrite') { - webview.postMessage({ - type: 'EXPORT_FOR_COPILOT_CLI_CANCELLED', - requestId, - }); - return; - } - } - - // Export workflow as skill to .github/skills/{name}/SKILL.md - const exportResult = await exportWorkflowAsSkill(workflow, fileService); - - if (!exportResult.success) { - const failedPayload: CopilotOperationFailedPayload = { - errorCode: 'EXPORT_FAILED', - errorMessage: exportResult.errors?.join(', ') || 'Failed to export workflow as skill', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'EXPORT_FOR_COPILOT_CLI_FAILED', - requestId, - payload: failedPayload, - }); - return; - } - - // Send success response - const successPayload: ExportForCopilotCliSuccessPayload = { - skillName: exportResult.skillName, - skillPath: exportResult.skillPath, - timestamp: new Date().toISOString(), - }; - - webview.postMessage({ - type: 'EXPORT_FOR_COPILOT_CLI_SUCCESS', - requestId, - payload: successPayload, - }); - - vscode.window.showInformationMessage(`Exported workflow as skill: ${exportResult.skillPath}`); - } catch (error) { - const failedPayload: CopilotOperationFailedPayload = { - errorCode: 'UNKNOWN_ERROR', - errorMessage: error instanceof Error ? error.message : 'Unknown error', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'EXPORT_FOR_COPILOT_CLI_FAILED', - requestId, - payload: failedPayload, - }); - } -} diff --git a/src/extension/commands/cursor-handlers.ts b/src/extension/commands/cursor-handlers.ts deleted file mode 100644 index f2676663..00000000 --- a/src/extension/commands/cursor-handlers.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * Claude Code Workflow Studio - Cursor Integration Handlers - * - * Handles Export/Run for Cursor (Anysphere VSCode fork) integration - */ - -import * as vscode from 'vscode'; -import type { - CursorOperationFailedPayload, - ExportForCursorPayload, - ExportForCursorSuccessPayload, - RunForCursorPayload, - RunForCursorSuccessPayload, -} from '../../shared/types/messages'; -import { isCursorInstalled, startCursorTask } from '../services/cursor-extension-service'; -import { - checkExistingCursorSkill, - exportWorkflowAsCursorSkill, -} from '../services/cursor-skill-export-service'; -import type { FileService } from '../services/file-service'; -import { - hasNonStandardSkills, - promptAndNormalizeSkills, -} from '../services/skill-normalization-service'; - -/** - * Handle Export for Cursor request - * - * Exports workflow to Skills format (.cursor/skills/name/SKILL.md) - * - * @param fileService - File service instance - * @param webview - Webview for sending responses - * @param payload - Export payload - * @param requestId - Optional request ID for response correlation - */ -export async function handleExportForCursor( - fileService: FileService, - webview: vscode.Webview, - payload: ExportForCursorPayload, - requestId?: string -): Promise { - try { - const { workflow } = payload; - - // Check for existing skill and ask for confirmation - const existingSkillPath = await checkExistingCursorSkill(workflow, fileService); - if (existingSkillPath) { - const result = await vscode.window.showWarningMessage( - `Skill already exists: ${existingSkillPath}\n\nOverwrite?`, - { modal: true }, - 'Overwrite', - 'Cancel' - ); - if (result !== 'Overwrite') { - webview.postMessage({ - type: 'EXPORT_FOR_CURSOR_CANCELLED', - requestId, - }); - return; - } - } - - // Export workflow as skill to .cursor/skills/{name}/SKILL.md - const exportResult = await exportWorkflowAsCursorSkill(workflow, fileService); - - if (!exportResult.success) { - const failedPayload: CursorOperationFailedPayload = { - errorCode: 'EXPORT_FAILED', - errorMessage: exportResult.errors?.join(', ') || 'Failed to export workflow as skill', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'EXPORT_FOR_CURSOR_FAILED', - requestId, - payload: failedPayload, - }); - return; - } - - // Send success response - const successPayload: ExportForCursorSuccessPayload = { - skillName: exportResult.skillName, - skillPath: exportResult.skillPath, - timestamp: new Date().toISOString(), - }; - - webview.postMessage({ - type: 'EXPORT_FOR_CURSOR_SUCCESS', - requestId, - payload: successPayload, - }); - - vscode.window.showInformationMessage( - `Exported workflow as Cursor skill: ${exportResult.skillPath}` - ); - } catch (error) { - const failedPayload: CursorOperationFailedPayload = { - errorCode: 'UNKNOWN_ERROR', - errorMessage: error instanceof Error ? error.message : 'Unknown error', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'EXPORT_FOR_CURSOR_FAILED', - requestId, - payload: failedPayload, - }); - } -} - -/** - * Handle Run for Cursor request - * - * Exports workflow to Skills format and runs it via Cursor - * - * @param fileService - File service instance - * @param webview - Webview for sending responses - * @param payload - Run payload - * @param requestId - Optional request ID for response correlation - */ -export async function handleRunForCursor( - fileService: FileService, - webview: vscode.Webview, - payload: RunForCursorPayload, - requestId?: string -): Promise { - try { - const { workflow } = payload; - - // Step 0.5: Normalize skills (copy non-standard skills to .claude/skills/) - if (hasNonStandardSkills(workflow, 'cursor')) { - const normalizeResult = await promptAndNormalizeSkills(workflow, 'cursor'); - - if (!normalizeResult.success) { - if (normalizeResult.cancelled) { - webview.postMessage({ - type: 'RUN_FOR_CURSOR_CANCELLED', - requestId, - }); - return; - } - throw new Error(normalizeResult.error || 'Failed to copy skills to .claude/skills/'); - } - - // Log normalized skills - if (normalizeResult.normalizedSkills && normalizeResult.normalizedSkills.length > 0) { - console.log( - `[Cursor] Copied ${normalizeResult.normalizedSkills.length} skill(s) to .claude/skills/` - ); - } - } - - // Step 1: Check for existing skill and ask for confirmation - const existingSkillPath = await checkExistingCursorSkill(workflow, fileService); - if (existingSkillPath) { - const result = await vscode.window.showWarningMessage( - `Skill already exists: ${existingSkillPath}\n\nOverwrite?`, - { modal: true }, - 'Overwrite', - 'Cancel' - ); - if (result !== 'Overwrite') { - webview.postMessage({ - type: 'RUN_FOR_CURSOR_CANCELLED', - requestId, - }); - return; - } - } - - // Step 2: Export workflow as skill to .cursor/skills/{name}/SKILL.md - const exportResult = await exportWorkflowAsCursorSkill(workflow, fileService); - - if (!exportResult.success) { - const failedPayload: CursorOperationFailedPayload = { - errorCode: 'EXPORT_FAILED', - errorMessage: exportResult.errors?.join(', ') || 'Failed to export workflow as skill', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'RUN_FOR_CURSOR_FAILED', - requestId, - payload: failedPayload, - }); - return; - } - - // Step 3: Check if Cursor is installed - if (!isCursorInstalled()) { - const failedPayload: CursorOperationFailedPayload = { - errorCode: 'CURSOR_NOT_INSTALLED', - errorMessage: 'Cursor extension is not installed.', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'RUN_FOR_CURSOR_FAILED', - requestId, - payload: failedPayload, - }); - return; - } - - // Step 4: Launch Cursor with the skill - await startCursorTask(exportResult.skillName); - - // Send success response - const successPayload: RunForCursorSuccessPayload = { - workflowName: workflow.name, - cursorOpened: true, - timestamp: new Date().toISOString(), - }; - - webview.postMessage({ - type: 'RUN_FOR_CURSOR_SUCCESS', - requestId, - payload: successPayload, - }); - - vscode.window.showInformationMessage(`Running workflow via Cursor: ${workflow.name}`); - } catch (error) { - const failedPayload: CursorOperationFailedPayload = { - errorCode: 'UNKNOWN_ERROR', - errorMessage: error instanceof Error ? error.message : 'Unknown error', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'RUN_FOR_CURSOR_FAILED', - requestId, - payload: failedPayload, - }); - } -} diff --git a/src/extension/commands/export-workflow.ts b/src/extension/commands/export-workflow.ts deleted file mode 100644 index 96e1e97a..00000000 --- a/src/extension/commands/export-workflow.ts +++ /dev/null @@ -1,277 +0,0 @@ -/** - * Claude Code Workflow Studio - Export Workflow Command - * - * Exports workflow to .claude format (agents/*.md and commands/*.md) - */ - -import * as path from 'node:path'; -import type { Webview } from 'vscode'; -import * as vscode from 'vscode'; -import type { - ExportSuccessPayload, - ExportWorkflowPayload, - Workflow, -} from '../../shared/types/messages'; -import { - checkExistingFiles, - exportWorkflow, - validateClaudeFileFormat, -} from '../services/export-service'; -import type { FileService } from '../services/file-service'; -import { - hasNonStandardSkills, - promptAndNormalizeSkills, -} from '../services/skill-normalization-service'; -import { validateAIGeneratedWorkflow } from '../utils/validate-workflow'; - -/** - * Export workflow to .claude format - * - * @param fileService - File service instance - * @param webview - Webview to send response to - * @param payload - Export workflow payload - * @param requestId - Request ID for response matching - */ -export async function handleExportWorkflow( - fileService: FileService, - webview: Webview, - payload: ExportWorkflowPayload, - requestId?: string -): Promise { - try { - // Validate workflow structure before export - const validationResult = validateAIGeneratedWorkflow(payload.workflow); - if (!validationResult.valid) { - const errorMessages = validationResult.errors.map((err) => err.message).join('\n'); - throw new Error(`Workflow validation failed:\n${errorMessages}`); - } - - // Check if workflow uses skills from non-standard directories (e.g., .github/skills/, .codex/skills/) - // For Claude Code execution (export), only .claude/skills/ is considered standard - // All other skill directories need to be copied to .claude/skills/ - if (hasNonStandardSkills(payload.workflow, 'claude')) { - const normalizeResult = await promptAndNormalizeSkills(payload.workflow, 'claude'); - - if (!normalizeResult.success) { - if (normalizeResult.cancelled) { - webview.postMessage({ - type: 'EXPORT_CANCELLED', - requestId, - }); - return; - } - throw new Error(normalizeResult.error || 'Failed to copy skills to .claude/skills/'); - } - - // Log copied skills - if (normalizeResult.normalizedSkills && normalizeResult.normalizedSkills.length > 0) { - console.log( - `[Export] Copied ${normalizeResult.normalizedSkills.length} skill(s) to .claude/skills/` - ); - } - } - - // Check if files already exist (unless overwrite is confirmed) - if (!payload.overwriteExisting) { - const existingFiles = await checkExistingFiles(payload.workflow, fileService); - - if (existingFiles.length > 0) { - // Show warning dialog for overwrite confirmation - const fileList = existingFiles.map((f) => ` - ${f}`).join('\n'); - const answer = await vscode.window.showWarningMessage( - `The following files already exist:\n${fileList}\n\nDo you want to overwrite them?`, - { modal: true }, - 'Overwrite' - ); - - if (answer !== 'Overwrite') { - // User cancelled - send cancellation message (not an error) - webview.postMessage({ - type: 'EXPORT_CANCELLED', - requestId, - }); - return; - } - } - } - - // Export workflow - const exportedFiles = await exportWorkflow(payload.workflow, fileService); - - // Validate exported files - const validationErrors: string[] = []; - for (const filePath of exportedFiles) { - try { - const content = await fileService.readFile(filePath); - const fileType = /[/\\]agents[/\\]/.test(filePath) ? 'subAgent' : 'slashCommand'; - validateClaudeFileFormat(content, fileType); - } catch (error) { - const fileName = path.basename(filePath); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - validationErrors.push(`${fileName}: ${errorMessage}`); - } - } - - // If validation errors occurred, report them - if (validationErrors.length > 0) { - throw new Error(`Exported files have validation errors:\n${validationErrors.join('\n')}`); - } - - // Send success response - const successPayload: ExportSuccessPayload = { - exportedFiles, - timestamp: new Date().toISOString(), - }; - - webview.postMessage({ - type: 'EXPORT_SUCCESS', - requestId, - payload: successPayload, - }); - - // Show success notification - vscode.window.showInformationMessage( - `Workflow "${payload.workflow.name}" exported successfully! ${exportedFiles.length} files created and validated.` - ); - } catch (error) { - // Send error response - webview.postMessage({ - type: 'ERROR', - requestId, - payload: { - code: 'EXPORT_FAILED', - message: error instanceof Error ? error.message : 'Failed to export workflow', - details: error, - }, - }); - - // Show error notification - vscode.window.showErrorMessage( - `Failed to export workflow: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Result of export for execution - */ -export interface ExportForExecutionResult { - success: boolean; - cancelled?: boolean; - exportedFiles?: string[]; - error?: string; -} - -/** - * Export workflow for terminal execution (without UI notifications) - * - * This function exports the workflow to .claude format for use with - * the "Execute as Slash Command" feature. Unlike handleExportWorkflow, - * it does not show UI notifications and returns the result directly. - * - * If the workflow uses skills from .github/skills/, prompts the user - * to copy them to .claude/skills/ first (Issue #493 Part 2). - * - * @param workflow - Workflow to export - * @param fileService - File service instance - * @returns Export result with success status and exported files - */ -export async function handleExportWorkflowForExecution( - workflow: Workflow, - fileService: FileService -): Promise { - try { - // Validate workflow structure before export - const validationResult = validateAIGeneratedWorkflow(workflow); - if (!validationResult.valid) { - const errorMessages = validationResult.errors.map((err) => err.message).join('\n'); - return { - success: false, - error: `Workflow validation failed:\n${errorMessages}`, - }; - } - - // Check if workflow uses skills from non-standard directories (e.g., .github/skills/, .codex/skills/) - // For Claude Code execution, only .claude/skills/ is considered standard - // All other skill directories need to be copied to .claude/skills/ - if (hasNonStandardSkills(workflow, 'claude')) { - const normalizeResult = await promptAndNormalizeSkills(workflow, 'claude'); - - if (!normalizeResult.success) { - if (normalizeResult.cancelled) { - return { - success: false, - cancelled: true, - }; - } - return { - success: false, - error: normalizeResult.error || 'Failed to copy skills to .claude/skills/', - }; - } - - // Log copied skills - if (normalizeResult.normalizedSkills && normalizeResult.normalizedSkills.length > 0) { - console.log( - `[Export] Copied ${normalizeResult.normalizedSkills.length} skill(s) to .claude/skills/` - ); - } - } - - // Check if files already exist - const existingFiles = await checkExistingFiles(workflow, fileService); - - if (existingFiles.length > 0) { - // Show warning dialog for overwrite confirmation - const fileList = existingFiles.map((f) => ` - ${f}`).join('\n'); - const answer = await vscode.window.showWarningMessage( - `The following files already exist:\n${fileList}\n\nDo you want to overwrite them?`, - { modal: true }, - 'Overwrite' - ); - - if (answer !== 'Overwrite') { - // User cancelled - return { - success: false, - cancelled: true, - }; - } - } - - // Export workflow - const exportedFiles = await exportWorkflow(workflow, fileService); - - // Validate exported files - const validationErrors: string[] = []; - for (const filePath of exportedFiles) { - try { - const content = await fileService.readFile(filePath); - const fileType = /[/\\]agents[/\\]/.test(filePath) ? 'subAgent' : 'slashCommand'; - validateClaudeFileFormat(content, fileType); - } catch (error) { - const fileName = path.basename(filePath); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - validationErrors.push(`${fileName}: ${errorMessage}`); - } - } - - // If validation errors occurred, report them - if (validationErrors.length > 0) { - return { - success: false, - error: `Exported files have validation errors:\n${validationErrors.join('\n')}`, - }; - } - - return { - success: true, - exportedFiles, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to export workflow', - }; - } -} diff --git a/src/extension/commands/gemini-handlers.ts b/src/extension/commands/gemini-handlers.ts deleted file mode 100644 index 6fe2ba77..00000000 --- a/src/extension/commands/gemini-handlers.ts +++ /dev/null @@ -1,307 +0,0 @@ -/** - * Claude Code Workflow Studio - Gemini CLI Integration Handlers - * - * Handles Export/Run for Google Gemini CLI integration - */ - -import * as vscode from 'vscode'; -import type { - ExportForGeminiCliPayload, - ExportForGeminiCliSuccessPayload, - GeminiOperationFailedPayload, - RunForGeminiCliPayload, - RunForGeminiCliSuccessPayload, -} from '../../shared/types/messages'; -import { NodeType } from '../../shared/types/workflow-definition'; -import { extractMcpServerIdsFromWorkflow } from '../services/copilot-export-service'; -import type { FileService } from '../services/file-service'; -import { - checkGeminiAgentsEnabled, - enableGeminiAgents, - previewMcpSyncForGeminiCli, - syncMcpConfigForGeminiCli, -} from '../services/gemini-mcp-sync-service'; -import { - checkExistingGeminiSkill, - exportWorkflowAsGeminiSkill, -} from '../services/gemini-skill-export-service'; -import { - hasNonStandardSkills, - promptAndNormalizeSkills, -} from '../services/skill-normalization-service'; -import { executeGeminiCliInTerminal } from '../services/terminal-execution-service'; - -/** - * Handle Export for Gemini CLI request - * - * Exports workflow to Skills format (.gemini/skills/name/SKILL.md) - * - * @param fileService - File service instance - * @param webview - Webview for sending responses - * @param payload - Export payload - * @param requestId - Optional request ID for response correlation - */ -export async function handleExportForGeminiCli( - fileService: FileService, - webview: vscode.Webview, - payload: ExportForGeminiCliPayload, - requestId?: string -): Promise { - try { - const { workflow } = payload; - - // Check if workflow uses SubAgent/SubAgentFlow nodes and ensure enableAgents is enabled - const hasSubAgentNodes = workflow.nodes.some( - (node) => node.type === NodeType.SubAgent || node.type === NodeType.SubAgentFlow - ); - if (hasSubAgentNodes) { - const agentsEnabled = await checkGeminiAgentsEnabled(); - if (!agentsEnabled) { - const result = await vscode.window.showInformationMessage( - 'This workflow uses Sub-Agent nodes which require the enableAgents feature in Gemini CLI.\n\nAdd the following setting to ~/.gemini/settings.json?\n\n{ "experimental": { "enableAgents": true } }', - { modal: true }, - 'Yes' - ); - if (result !== 'Yes') { - webview.postMessage({ - type: 'EXPORT_FOR_GEMINI_CLI_CANCELLED', - requestId, - }); - return; - } - await enableGeminiAgents(); - } - } - - // Check for existing skill and ask for confirmation - const existingSkillPath = await checkExistingGeminiSkill(workflow, fileService); - if (existingSkillPath) { - const result = await vscode.window.showWarningMessage( - `Skill already exists: ${existingSkillPath}\n\nOverwrite?`, - { modal: true }, - 'Overwrite', - 'Cancel' - ); - if (result !== 'Overwrite') { - webview.postMessage({ - type: 'EXPORT_FOR_GEMINI_CLI_CANCELLED', - requestId, - }); - return; - } - } - - // Export workflow as skill to .gemini/skills/{name}/SKILL.md - const exportResult = await exportWorkflowAsGeminiSkill(workflow, fileService); - - if (!exportResult.success) { - const failedPayload: GeminiOperationFailedPayload = { - errorCode: 'EXPORT_FAILED', - errorMessage: exportResult.errors?.join(', ') || 'Failed to export workflow as skill', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'EXPORT_FOR_GEMINI_CLI_FAILED', - requestId, - payload: failedPayload, - }); - return; - } - - // Send success response - const successPayload: ExportForGeminiCliSuccessPayload = { - skillName: exportResult.skillName, - skillPath: exportResult.skillPath, - timestamp: new Date().toISOString(), - }; - - webview.postMessage({ - type: 'EXPORT_FOR_GEMINI_CLI_SUCCESS', - requestId, - payload: successPayload, - }); - - vscode.window.showInformationMessage( - `Exported workflow as Gemini skill: ${exportResult.skillPath}` - ); - } catch (error) { - const failedPayload: GeminiOperationFailedPayload = { - errorCode: 'UNKNOWN_ERROR', - errorMessage: error instanceof Error ? error.message : 'Unknown error', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'EXPORT_FOR_GEMINI_CLI_FAILED', - requestId, - payload: failedPayload, - }); - } -} - -/** - * Handle Run for Gemini CLI request - * - * Exports workflow to Skills format and runs it via Gemini CLI - * - * @param fileService - File service instance - * @param webview - Webview for sending responses - * @param payload - Run payload - * @param requestId - Optional request ID for response correlation - */ -export async function handleRunForGeminiCli( - fileService: FileService, - webview: vscode.Webview, - payload: RunForGeminiCliPayload, - requestId?: string -): Promise { - try { - const { workflow } = payload; - const workspacePath = fileService.getWorkspacePath(); - - // Step 0.5: Normalize skills (copy non-standard skills to .claude/skills/) - // For Gemini CLI, .gemini/skills/ is considered "native" (no copy needed) - if (hasNonStandardSkills(workflow, 'gemini')) { - const normalizeResult = await promptAndNormalizeSkills(workflow, 'gemini'); - - if (!normalizeResult.success) { - if (normalizeResult.cancelled) { - webview.postMessage({ - type: 'RUN_FOR_GEMINI_CLI_CANCELLED', - requestId, - }); - return; - } - throw new Error(normalizeResult.error || 'Failed to copy skills to .claude/skills/'); - } - - // Log normalized skills - if (normalizeResult.normalizedSkills && normalizeResult.normalizedSkills.length > 0) { - console.log( - `[Gemini CLI] Copied ${normalizeResult.normalizedSkills.length} skill(s) to .claude/skills/` - ); - } - } - - // Step 0.75: Check if workflow uses SubAgent/SubAgentFlow nodes and ensure enableAgents is enabled - const hasSubAgentNodes = workflow.nodes.some( - (node) => node.type === NodeType.SubAgent || node.type === NodeType.SubAgentFlow - ); - if (hasSubAgentNodes) { - const agentsEnabled = await checkGeminiAgentsEnabled(); - if (!agentsEnabled) { - const result = await vscode.window.showInformationMessage( - 'This workflow uses Sub-Agent nodes which require the enableAgents feature in Gemini CLI.\n\nAdd the following setting to ~/.gemini/settings.json?\n\n{ "experimental": { "enableAgents": true } }', - { modal: true }, - 'Yes' - ); - if (result !== 'Yes') { - webview.postMessage({ - type: 'RUN_FOR_GEMINI_CLI_CANCELLED', - requestId, - }); - return; - } - await enableGeminiAgents(); - } - } - - // Step 1: Check if MCP servers need to be synced to ~/.gemini/settings.json - const mcpServerIds = extractMcpServerIdsFromWorkflow(workflow); - let mcpSyncConfirmed = false; - - if (mcpServerIds.length > 0) { - const mcpSyncPreview = await previewMcpSyncForGeminiCli(mcpServerIds, workspacePath); - - if (mcpSyncPreview.serversToAdd.length > 0) { - const serverList = mcpSyncPreview.serversToAdd.map((s) => ` • ${s}`).join('\n'); - const result = await vscode.window.showInformationMessage( - `The following MCP servers will be added to ~/.gemini/settings.json for Gemini CLI:\n\n${serverList}\n\nProceed?`, - { modal: true }, - 'Yes', - 'No' - ); - mcpSyncConfirmed = result === 'Yes'; - } - } - - // Step 2: Check for existing skill and ask for confirmation - const existingSkillPath = await checkExistingGeminiSkill(workflow, fileService); - if (existingSkillPath) { - const result = await vscode.window.showWarningMessage( - `Skill already exists: ${existingSkillPath}\n\nOverwrite?`, - { modal: true }, - 'Overwrite', - 'Cancel' - ); - if (result !== 'Overwrite') { - webview.postMessage({ - type: 'RUN_FOR_GEMINI_CLI_CANCELLED', - requestId, - }); - return; - } - } - - // Step 3: Export workflow as skill to .gemini/skills/{name}/SKILL.md - const exportResult = await exportWorkflowAsGeminiSkill(workflow, fileService); - - if (!exportResult.success) { - const failedPayload: GeminiOperationFailedPayload = { - errorCode: 'EXPORT_FAILED', - errorMessage: exportResult.errors?.join(', ') || 'Failed to export workflow as skill', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'RUN_FOR_GEMINI_CLI_FAILED', - requestId, - payload: failedPayload, - }); - return; - } - - // Step 4: Sync MCP servers to ~/.gemini/settings.json if confirmed - let syncedMcpServers: string[] = []; - if (mcpSyncConfirmed) { - syncedMcpServers = await syncMcpConfigForGeminiCli(mcpServerIds, workspacePath); - } - - // Step 5: Execute in terminal - const terminalResult = executeGeminiCliInTerminal({ - skillName: exportResult.skillName, - workingDirectory: workspacePath, - }); - - // Send success response - const successPayload: RunForGeminiCliSuccessPayload = { - workflowName: workflow.name, - terminalName: terminalResult.terminalName, - timestamp: new Date().toISOString(), - }; - - webview.postMessage({ - type: 'RUN_FOR_GEMINI_CLI_SUCCESS', - requestId, - payload: successPayload, - }); - - // Show notification with config sync info - const configInfo = - syncedMcpServers.length > 0 - ? ` (MCP servers: ${syncedMcpServers.join(', ')} added to ~/.gemini/settings.json)` - : ''; - vscode.window.showInformationMessage( - `Running workflow via Gemini CLI: ${workflow.name}${configInfo}` - ); - } catch (error) { - const failedPayload: GeminiOperationFailedPayload = { - errorCode: 'UNKNOWN_ERROR', - errorMessage: error instanceof Error ? error.message : 'Unknown error', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'RUN_FOR_GEMINI_CLI_FAILED', - requestId, - payload: failedPayload, - }); - } -} diff --git a/src/extension/commands/load-workflow-list.ts b/src/extension/commands/load-workflow-list.ts deleted file mode 100644 index e79be3d0..00000000 --- a/src/extension/commands/load-workflow-list.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Claude Code Workflow Studio - Load Workflow List Command - * - * Loads list of available workflows from .vscode/workflows/ directory - */ - -import type { Webview } from 'vscode'; -import * as vscode from 'vscode'; -import type { WorkflowListPayload } from '../../shared/types/messages'; -import type { FileService } from '../services/file-service'; - -/** - * Load workflow list and send to webview - * - * @param fileService - File service instance - * @param webview - Webview to send response to - * @param requestId - Request ID for response matching - */ -export async function loadWorkflowList( - fileService: FileService, - webview: Webview, - requestId?: string -): Promise { - try { - // Ensure workflows directory exists - await fileService.ensureWorkflowsDirectory(); - - // Read all workflow files - const workflowsPath = fileService.getWorkflowsDirectory(); - const uri = vscode.Uri.file(workflowsPath); - - let files: [string, vscode.FileType][] = []; - try { - files = await vscode.workspace.fs.readDirectory(uri); - } catch (error) { - // Directory doesn't exist or is empty - console.log('No workflows directory or empty:', error); - files = []; - } - - // Filter JSON files and load metadata - const workflows = []; - for (const [filename, fileType] of files) { - if (fileType === vscode.FileType.File && filename.endsWith('.json')) { - try { - const filePath = fileService.getWorkflowFilePath(filename.replace('.json', '')); - const content = await fileService.readFile(filePath); - const workflow = JSON.parse(content); - - workflows.push({ - id: filename.replace('.json', ''), // Always use filename as ID - name: workflow.name || filename.replace('.json', ''), - description: workflow.description, - updatedAt: workflow.updatedAt || new Date().toISOString(), - }); - } catch (error) { - console.error(`Failed to parse workflow file ${filename}:`, error); - } - } - } - - // Send success response - const payload: WorkflowListPayload = { workflows }; - webview.postMessage({ - type: 'WORKFLOW_LIST_LOADED', - requestId, - payload, - }); - } catch (error) { - // Send error response - webview.postMessage({ - type: 'ERROR', - requestId, - payload: { - code: 'LOAD_FAILED', - message: error instanceof Error ? error.message : 'Failed to load workflow list', - details: error, - }, - }); - } -} diff --git a/src/extension/commands/load-workflow.ts b/src/extension/commands/load-workflow.ts deleted file mode 100644 index beeaa8b2..00000000 --- a/src/extension/commands/load-workflow.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Claude Code Workflow Studio - Load Workflow Command - * - * Loads a specific workflow file and sends it to the Webview - */ - -import type { Webview } from 'vscode'; -import type { LoadWorkflowPayload } from '../../shared/types/messages'; -import type { FileService } from '../services/file-service'; -import { migrateWorkflow } from '../utils/migrate-workflow'; - -/** - * Load a specific workflow and send to webview - * - * @param fileService - File service instance - * @param webview - Webview to send response to - * @param workflowId - Workflow ID (filename without .json extension) - * @param requestId - Request ID for response matching - */ -export async function loadWorkflow( - fileService: FileService, - webview: Webview, - workflowId: string, - requestId?: string -): Promise { - try { - // Get workflow file path - const filePath = fileService.getWorkflowFilePath(workflowId); - - // Check if file exists - const exists = await fileService.fileExists(filePath); - if (!exists) { - webview.postMessage({ - type: 'ERROR', - requestId, - payload: { - code: 'LOAD_FAILED', - message: `Workflow "${workflowId}" not found`, - }, - }); - return; - } - - // Read and parse workflow file - const content = await fileService.readFile(filePath); - const parsedWorkflow = JSON.parse(content); - - // Apply migrations for backward compatibility - const workflow = migrateWorkflow(parsedWorkflow); - - // Send success response - const payload: LoadWorkflowPayload = { workflow }; - webview.postMessage({ - type: 'LOAD_WORKFLOW', - requestId, - payload, - }); - - console.log(`Workflow loaded: ${workflowId}`); - } catch (error) { - // Send error response - webview.postMessage({ - type: 'ERROR', - requestId, - payload: { - code: 'LOAD_FAILED', - message: error instanceof Error ? error.message : 'Failed to load workflow', - details: error, - }, - }); - } -} diff --git a/src/extension/commands/mcp-handlers.ts b/src/extension/commands/mcp-handlers.ts deleted file mode 100644 index fe3c05aa..00000000 --- a/src/extension/commands/mcp-handlers.ts +++ /dev/null @@ -1,529 +0,0 @@ -/** - * MCP Operations - Extension Host Message Handlers - * - * Feature: 001-mcp-node - * Purpose: Handle Webview requests for MCP server and tool operations - * - * Based on: specs/001-mcp-node/contracts/extension-webview-messages.schema.json - * - * Feature: 001-mcp-natural-language-mode - * Enhancement: T046 - Updated handleGetMcpTools to use getTools() with built-in caching - */ - -import * as vscode from 'vscode'; -import type { - GetMcpToolSchemaPayload, - GetMcpToolsPayload, - ListMcpServersPayload, - McpCacheRefreshedPayload, - McpServersResultPayload, - McpToolSchemaResultPayload, - McpToolsResultPayload, - RefreshMcpCachePayload, -} from '../../shared/types/messages'; -import { log } from '../extension'; -import { - getCachedServerList, - invalidateAllCache, - setCachedServerList, -} from '../services/mcp-cache-service'; -import { getToolSchema, getTools, listServers } from '../services/mcp-cli-service'; -import { - getAllMcpServersWithSource, - type McpServerWithSource, -} from '../services/mcp-config-reader'; - -/** - * Handle LIST_MCP_SERVERS request from Webview (T018) - * - * Executes 'claude mcp list' CLI command to retrieve all configured MCP servers. - * Supports optional scope filtering and cache optimization. - * - * @param payload - Server list request payload - * @param webview - VSCode Webview instance - * @param requestId - Request ID for response matching - */ -export async function handleListMcpServers( - payload: ListMcpServersPayload, - webview: vscode.Webview, - requestId: string -): Promise { - const startTime = Date.now(); - - log('INFO', 'LIST_MCP_SERVERS request started', { - requestId, - filterByScope: payload.options?.filterByScope, - }); - - try { - // Get workspace folder for project-scoped MCP servers - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - - // Check cache first - const cached = getCachedServerList(); - if (cached) { - const executionTimeMs = Date.now() - startTime; - log('INFO', 'LIST_MCP_SERVERS cache hit', { - requestId, - serverCount: cached.length, - executionTimeMs, - }); - - // Apply scope filter if specified - const filteredServers = payload.options?.filterByScope - ? cached.filter((server) => payload.options?.filterByScope?.includes(server.scope)) - : cached; - - const resultPayload: McpServersResultPayload = { - success: true, - servers: filteredServers, - timestamp: new Date().toISOString(), - executionTimeMs, - }; - - webview.postMessage({ - type: 'MCP_SERVERS_RESULT', - requestId, - payload: resultPayload, - }); - return; - } - - // Cache miss - execute CLI command with workspace folder - const result = await listServers(workspaceFolder); - - // Get servers from all config sources (Claude Code, Copilot CLI, Codex CLI) - const configServers = getAllMcpServersWithSource(workspaceFolder); - - // Build config server lookup map for supplementing CLI results with accurate type/url - const configServerMap = new Map(); - for (const configServer of configServers) { - const key = `${configServer.source || 'claude'}:${configServer.id}`; - if (!configServerMap.has(key)) { - configServerMap.set(key, configServer); - } - } - - // Convert McpServerWithSource to McpServerReference - // Note: status is omitted because config readers can't determine connection status - const convertToServerReference = ( - server: McpServerWithSource - ): import('../../shared/types/mcp-node').McpServerReference => ({ - id: server.id, - name: server.id, // Use ID as name since config files don't have separate name - scope: 'user', // Config file servers are always user scope - // status is intentionally omitted - only Claude Code CLI can determine connection status - command: server.command || '', - args: server.args || [], - type: server.type || 'stdio', - url: server.url, - environment: server.env, - source: server.source, - }); - - // Combine CLI results and config file servers - // Use id + source combination as unique key to allow same server ID from different sources - const mergedServers: import('../../shared/types/mcp-node').McpServerReference[] = []; - const seenServerKeys = new Set(); - - // Helper to create unique key from id and source - const getServerKey = (id: string, source: string | undefined) => `${source || 'claude'}:${id}`; - - if (result.success && result.data) { - // Add CLI results first (they have accurate status info) - // Supplement type/url from config files (CLI parser may hardcode type to 'stdio') - for (const server of result.data) { - const key = getServerKey(server.id, server.source); - if (!seenServerKeys.has(key)) { - const configMatch = configServerMap.get(key); - if (configMatch) { - // Use config file's type and url (more accurate than CLI parser) - server.type = configMatch.type || server.type; - server.url = configMatch.url || server.url; - } - mergedServers.push(server); - seenServerKeys.add(key); - } - } - } - - // Add servers from config files (Copilot CLI, Codex CLI, etc.) - // Same ID from different sources will be included - for (const configServer of configServers) { - const key = getServerKey(configServer.id, configServer.source); - if (!seenServerKeys.has(key)) { - mergedServers.push(convertToServerReference(configServer)); - seenServerKeys.add(key); - } - } - - const executionTimeMs = Date.now() - startTime; - - // If no servers found at all, report error - if (mergedServers.length === 0 && !result.success) { - log('ERROR', 'LIST_MCP_SERVERS failed', { - requestId, - errorCode: result.error?.code, - errorMessage: result.error?.message, - errorDetails: result.error?.details, - executionTimeMs, - }); - - const errorPayload: McpServersResultPayload = { - success: false, - error: result.error, - timestamp: new Date().toISOString(), - executionTimeMs, - }; - - webview.postMessage({ - type: 'MCP_SERVERS_RESULT', - requestId, - payload: errorPayload, - }); - return; - } - - // Success - cache and return - setCachedServerList(mergedServers); - - log('INFO', 'LIST_MCP_SERVERS completed successfully', { - requestId, - serverCount: mergedServers.length, - cliServerCount: result.data?.length ?? 0, - configServerCount: configServers.length, - executionTimeMs, - }); - - // Apply scope filter if specified - const filteredServers = payload.options?.filterByScope - ? mergedServers.filter((server) => payload.options?.filterByScope?.includes(server.scope)) - : mergedServers; - - const successPayload: McpServersResultPayload = { - success: true, - servers: filteredServers, - timestamp: new Date().toISOString(), - executionTimeMs, - }; - - webview.postMessage({ - type: 'MCP_SERVERS_RESULT', - requestId, - payload: successPayload, - }); - } catch (error) { - const executionTimeMs = Date.now() - startTime; - - log('ERROR', 'LIST_MCP_SERVERS unexpected error', { - requestId, - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - executionTimeMs, - }); - - const errorPayload: McpServersResultPayload = { - success: false, - error: { - code: 'MCP_UNKNOWN_ERROR', - message: error instanceof Error ? error.message : 'Unknown error occurred', - details: error instanceof Error ? error.stack : undefined, - }, - timestamp: new Date().toISOString(), - executionTimeMs, - }; - - webview.postMessage({ - type: 'MCP_SERVERS_RESULT', - requestId, - payload: errorPayload, - }); - } -} - -/** - * Handle GET_MCP_TOOLS request from Webview (T019, T046) - * - * Retrieves tools from a specific MCP server using getTools() with built-in caching. - * - * @param payload - Tool list request payload - * @param webview - VSCode Webview instance - * @param requestId - Request ID for response matching - */ -export async function handleGetMcpTools( - payload: GetMcpToolsPayload, - webview: vscode.Webview, - requestId: string -): Promise { - const startTime = Date.now(); - - log('INFO', 'GET_MCP_TOOLS request started', { - requestId, - serverId: payload.serverId, - }); - - try { - // Get workspace folder for project-scoped MCP servers - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - - // Use getTools() with built-in caching (T045, T046) - const result = await getTools(payload.serverId, workspaceFolder); - const executionTimeMs = Date.now() - startTime; - - if (!result.success || !result.data) { - log('ERROR', 'GET_MCP_TOOLS failed', { - requestId, - serverId: payload.serverId, - errorCode: result.error?.code, - errorMessage: result.error?.message, - errorDetails: result.error?.details, - executionTimeMs, - }); - - const errorPayload: McpToolsResultPayload = { - success: false, - serverId: payload.serverId, - error: result.error, - timestamp: new Date().toISOString(), - executionTimeMs, - }; - - webview.postMessage({ - type: 'MCP_TOOLS_RESULT', - requestId, - payload: errorPayload, - }); - return; - } - - // Success - return tools - log('INFO', 'GET_MCP_TOOLS completed successfully', { - requestId, - serverId: payload.serverId, - toolCount: result.data.length, - executionTimeMs, - }); - - const successPayload: McpToolsResultPayload = { - success: true, - serverId: payload.serverId, - tools: result.data, - timestamp: new Date().toISOString(), - executionTimeMs, - }; - - webview.postMessage({ - type: 'MCP_TOOLS_RESULT', - requestId, - payload: successPayload, - }); - } catch (error) { - const executionTimeMs = Date.now() - startTime; - - log('ERROR', 'GET_MCP_TOOLS unexpected error', { - requestId, - serverId: payload.serverId, - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - executionTimeMs, - }); - - const errorPayload: McpToolsResultPayload = { - success: false, - serverId: payload.serverId, - error: { - code: 'MCP_UNKNOWN_ERROR', - message: error instanceof Error ? error.message : 'Unknown error occurred', - details: error instanceof Error ? error.stack : undefined, - }, - timestamp: new Date().toISOString(), - executionTimeMs, - }; - - webview.postMessage({ - type: 'MCP_TOOLS_RESULT', - requestId, - payload: errorPayload, - }); - } -} - -/** - * Handle GET_MCP_TOOL_SCHEMA request from Webview (T028) - * - * Retrieves detailed schema for a specific MCP tool's parameters. - * Useful for dynamic form generation with validation. - * - * @param payload - Tool schema request payload - * @param webview - VSCode Webview instance - * @param requestId - Request ID for response matching - */ -export async function handleGetMcpToolSchema( - payload: GetMcpToolSchemaPayload, - webview: vscode.Webview, - requestId: string -): Promise { - const startTime = Date.now(); - - log('INFO', 'GET_MCP_TOOL_SCHEMA request started', { - requestId, - serverId: payload.serverId, - toolName: payload.toolName, - }); - - try { - // Get workspace folder for project-scoped MCP servers - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - - // Execute tool schema retrieval - const result = await getToolSchema(payload.serverId, payload.toolName, workspaceFolder); - const executionTimeMs = Date.now() - startTime; - - if (!result.success || !result.data) { - log('ERROR', 'GET_MCP_TOOL_SCHEMA failed', { - requestId, - serverId: payload.serverId, - toolName: payload.toolName, - errorCode: result.error?.code, - errorMessage: result.error?.message, - errorDetails: result.error?.details, - executionTimeMs, - }); - - const errorPayload: McpToolSchemaResultPayload = { - success: false, - serverId: payload.serverId, - toolName: payload.toolName, - error: result.error, - timestamp: new Date().toISOString(), - executionTimeMs, - }; - - webview.postMessage({ - type: 'MCP_TOOL_SCHEMA_RESULT', - requestId, - payload: errorPayload, - }); - return; - } - - // Success - return schema - log('INFO', 'GET_MCP_TOOL_SCHEMA completed successfully', { - requestId, - serverId: payload.serverId, - toolName: payload.toolName, - parameterCount: result.data.parameters?.length || 0, - executionTimeMs, - }); - - const successPayload: McpToolSchemaResultPayload = { - success: true, - serverId: payload.serverId, - toolName: payload.toolName, - schema: result.data, - timestamp: new Date().toISOString(), - executionTimeMs, - }; - - webview.postMessage({ - type: 'MCP_TOOL_SCHEMA_RESULT', - requestId, - payload: successPayload, - }); - } catch (error) { - const executionTimeMs = Date.now() - startTime; - - log('ERROR', 'GET_MCP_TOOL_SCHEMA unexpected error', { - requestId, - serverId: payload.serverId, - toolName: payload.toolName, - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - executionTimeMs, - }); - - const errorPayload: McpToolSchemaResultPayload = { - success: false, - serverId: payload.serverId, - toolName: payload.toolName, - error: { - code: 'MCP_UNKNOWN_ERROR', - message: error instanceof Error ? error.message : 'Unknown error occurred', - details: error instanceof Error ? error.stack : undefined, - }, - timestamp: new Date().toISOString(), - executionTimeMs, - }; - - webview.postMessage({ - type: 'MCP_TOOL_SCHEMA_RESULT', - requestId, - payload: errorPayload, - }); - } -} - -/** - * Handle REFRESH_MCP_CACHE request from Webview - * - * Invalidates all in-memory MCP cache (server list, tools, schemas). - * Useful when MCP servers are added/removed after initial load. - * - * @param payload - Cache refresh request payload - * @param webview - VSCode Webview instance - * @param requestId - Request ID for response matching - */ -export async function handleRefreshMcpCache( - _payload: RefreshMcpCachePayload, - webview: vscode.Webview, - requestId: string -): Promise { - const startTime = Date.now(); - - log('INFO', 'REFRESH_MCP_CACHE request started', { - requestId, - }); - - try { - // Invalidate all MCP cache - invalidateAllCache(); - - const executionTimeMs = Date.now() - startTime; - - log('INFO', 'REFRESH_MCP_CACHE completed successfully', { - requestId, - executionTimeMs, - }); - - const successPayload: McpCacheRefreshedPayload = { - success: true, - timestamp: new Date().toISOString(), - }; - - webview.postMessage({ - type: 'MCP_CACHE_REFRESHED', - requestId, - payload: successPayload, - }); - } catch (error) { - const executionTimeMs = Date.now() - startTime; - - log('ERROR', 'REFRESH_MCP_CACHE unexpected error', { - requestId, - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - executionTimeMs, - }); - - const errorPayload: McpCacheRefreshedPayload = { - success: false, - timestamp: new Date().toISOString(), - }; - - webview.postMessage({ - type: 'MCP_CACHE_REFRESHED', - requestId, - payload: errorPayload, - }); - } -} diff --git a/src/extension/commands/open-editor.ts b/src/extension/commands/open-editor.ts deleted file mode 100644 index 5a0c620f..00000000 --- a/src/extension/commands/open-editor.ts +++ /dev/null @@ -1,1632 +0,0 @@ -/** - * CC Workflow Studio - Open Editor Command - * - * Creates and manages the Webview panel for the workflow editor - * Based on: /specs/001-cc-wf-studio/contracts/vscode-extension-api.md section 1.1 - */ - -import * as vscode from 'vscode'; -import type { - AiEditingProvider, - ApplyWorkflowFromMcpResponsePayload, - GetCurrentWorkflowResponsePayload, - LaunchAiAgentPayload, - McpConfigTarget, - RunAiEditingSkillPayload, - SetReviewBeforeApplyPayload, - StartMcpServerPayload, - WebviewMessage, -} from '../../shared/types/messages'; -import { getMcpServerManager, log } from '../extension'; -import { translate } from '../i18n/i18n-service'; -import { generateAndRunAiEditingSkill } from '../services/ai-editing-skill-service'; -import { - openAntigravityMcpSettings, - startAntigravityTask, -} from '../services/antigravity-extension-service'; -import { cancelGeneration } from '../services/claude-code-service'; -import { FileService } from '../services/file-service'; -import { - getConfigTargetsForProvider, - removeAllAgentConfigs, - writeAllAgentConfigs, -} from '../services/mcp-server-config-writer'; -import { SlackApiService } from '../services/slack-api-service'; -import { executeSlashCommandInTerminal } from '../services/terminal-execution-service'; -import { listCopilotModels } from '../services/vscode-lm-service'; -import { migrateWorkflow } from '../utils/migrate-workflow'; -import { SlackTokenManager } from '../utils/slack-token-manager'; -import { validateWorkflowFile } from '../utils/workflow-validator'; -import { getWebviewContent } from '../webview-content'; -import { handleExportForAntigravity, handleRunForAntigravity } from './antigravity-handlers'; -import { handleExportForCodexCli, handleRunForCodexCli } from './codex-handlers'; -import { - handleExportForCopilot, - handleExportForCopilotCli, - handleRunForCopilot, - handleRunForCopilotCli, -} from './copilot-handlers'; -import { handleExportForCursor, handleRunForCursor } from './cursor-handlers'; -import { handleExportWorkflow, handleExportWorkflowForExecution } from './export-workflow'; -import { handleExportForGeminiCli, handleRunForGeminiCli } from './gemini-handlers'; -import { loadWorkflow } from './load-workflow'; -import { loadWorkflowList } from './load-workflow-list'; -import { - handleGetMcpToolSchema, - handleGetMcpTools, - handleListMcpServers, - handleRefreshMcpCache, -} from './mcp-handlers'; -import { handleExportForRooCode, handleRunForRooCode } from './roo-code-handlers'; -import { saveWorkflow } from './save-workflow'; -import { handleBrowseSkills, handleCreateSkill, handleValidateSkillFile } from './skill-operations'; -import { handleConnectSlackManual } from './slack-connect-manual'; -import { createOAuthService, handleConnectSlackOAuth } from './slack-connect-oauth'; -import { handleGenerateSlackDescription } from './slack-description-generation'; -import { handleImportWorkflowFromSlack } from './slack-import-workflow'; -import { - handleGetSlackChannels, - handleListSlackWorkspaces, - handleShareWorkflowToSlack, -} from './slack-share-workflow'; -import { handleOpenInEditor } from './text-editor'; -import { handleGenerateWorkflowName } from './workflow-name-generation'; -import { - handleCancelRefinement, - handleClearConversation, - handleRefineWorkflow, -} from './workflow-refinement'; - -// Module-level variables to share state between commands -let currentPanel: vscode.WebviewPanel | undefined; -let fileService: FileService; -let slackTokenManager: SlackTokenManager; -let slackApiService: SlackApiService; -let activeOAuthService: ReturnType | null = null; - -/** - * Import parameters for workflow import from Slack - */ -export interface ImportParameters { - fileId: string; - channelId: string; - messageTs: string; - workspaceId: string; - workflowId: string; - /** Workspace name for display in error dialogs (decoded from Base64) */ - workspaceName?: string; -} - -/** - * Register the open editor command - * - * @param context - VSCode extension context - */ -export function registerOpenEditorCommand( - context: vscode.ExtensionContext -): vscode.WebviewPanel | null { - const openEditorCommand = vscode.commands.registerCommand( - 'cc-wf-studio.openEditor', - (importParams?: ImportParameters | vscode.Uri) => { - // Filter out vscode.Uri objects (file paths) - only process ImportParameters - // This prevents unintended import when .json files are opened in VSCode - let actualImportParams: ImportParameters | undefined; - if (importParams !== undefined) { - if (importParams instanceof vscode.Uri) { - // Ignore Uri objects - this is just a file being opened - actualImportParams = undefined; - } else { - // This is a proper ImportParameters object - actualImportParams = importParams as ImportParameters; - } - } - - // Initialize file service - try { - fileService = new FileService(); - } catch (error) { - // Check if this is a "no workspace" error - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - if (errorMessage === 'No workspace folder is open') { - vscode.window.showErrorMessage(translate('error.noWorkspaceOpen')); - } else { - vscode.window.showErrorMessage(`Failed to initialize File Service: ${errorMessage}`); - } - return; - } - - // Initialize Slack services - slackTokenManager = new SlackTokenManager(context); - slackApiService = new SlackApiService(slackTokenManager); - - const columnToShowIn = vscode.window.activeTextEditor - ? vscode.window.activeTextEditor.viewColumn - : undefined; - - // If panel already exists, reveal it - if (currentPanel) { - currentPanel.reveal(columnToShowIn); - - // If import parameters are provided, trigger import - if (actualImportParams) { - setTimeout(() => { - if (currentPanel) { - currentPanel.webview.postMessage({ - type: 'IMPORT_WORKFLOW_FROM_SLACK', - payload: actualImportParams, - }); - } - }, 500); - } - - return; - } - - // Create new webview panel - currentPanel = vscode.window.createWebviewPanel( - 'ccWorkflowStudio', - 'CC Workflow Studio', - columnToShowIn || vscode.ViewColumn.One, - { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'src', 'webview', 'dist')], - } - ); - - // Set custom icon for the tab - currentPanel.iconPath = vscode.Uri.joinPath(context.extensionUri, 'resources', 'icon.png'); - - // Set webview HTML content - currentPanel.webview.html = getWebviewContent(currentPanel.webview, context.extensionUri); - - // Connect MCP server manager to webview - const mcpManager = getMcpServerManager(); - if (mcpManager) { - mcpManager.setWebview(currentPanel.webview); - } - - // Check if user has accepted terms of use - const hasAcceptedTerms = context.globalState.get('hasAcceptedTerms', false); - - // Store import params for use when WEBVIEW_READY is received - // This replaces the unreliable setTimeout-based approach (fixes Issue #396) - let pendingImportParams = actualImportParams; - - // Handle messages from webview - currentPanel.webview.onDidReceiveMessage( - async (message: WebviewMessage) => { - // Ensure panel still exists - if (!currentPanel) { - return; - } - const webview = currentPanel.webview; - - switch (message.type) { - case 'WEBVIEW_READY': - // Webview is fully initialized and ready to receive messages - // This is more reliable than setTimeout (fixes Issue #396) - webview.postMessage({ - type: 'INITIAL_STATE', - payload: { - hasAcceptedTerms, - }, - }); - - // If import parameters were provided, trigger import after initial state - if (pendingImportParams) { - // Small delay to ensure INITIAL_STATE is processed first - setTimeout(() => { - if (currentPanel && pendingImportParams) { - currentPanel.webview.postMessage({ - type: 'IMPORT_WORKFLOW_FROM_SLACK', - payload: pendingImportParams, - }); - pendingImportParams = undefined; - } - }, 100); - } - break; - - case 'SAVE_WORKFLOW': - // Save workflow - if (message.payload?.workflow) { - await saveWorkflow( - fileService, - webview, - message.payload.workflow, - message.requestId - ); - // Update MCP server workflow cache - const saveManager = getMcpServerManager(); - if (saveManager) { - saveManager.updateWorkflowCache(message.payload.workflow); - } - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Workflow is required', - }, - }); - } - break; - - case 'EXPORT_WORKFLOW': - // Export workflow to .claude format - if (message.payload) { - await handleExportWorkflow( - fileService, - webview, - message.payload, - message.requestId - ); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Export payload is required', - }, - }); - } - break; - - case 'RUN_AS_SLASH_COMMAND': - // Run workflow as slash command in terminal - if (message.payload?.workflow) { - try { - // First, export the workflow to .claude format - const exportResult = await handleExportWorkflowForExecution( - message.payload.workflow, - fileService - ); - - if (!exportResult.success) { - if (exportResult.cancelled) { - // User cancelled - send cancellation message (not an error) - webview.postMessage({ - type: 'RUN_AS_SLASH_COMMAND_CANCELLED', - requestId: message.requestId, - }); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'EXPORT_FAILED', - message: exportResult.error || 'Failed to export workflow', - }, - }); - } - break; - } - - // Run the slash command in terminal - const workspacePath = fileService.getWorkspacePath(); - const result = executeSlashCommandInTerminal({ - workflowName: message.payload.workflow.name, - workingDirectory: workspacePath, - }); - - // Send success response - webview.postMessage({ - type: 'RUN_AS_SLASH_COMMAND_SUCCESS', - requestId: message.requestId, - payload: { - workflowName: message.payload.workflow.name, - terminalName: result.terminalName, - timestamp: new Date().toISOString(), - }, - }); - } catch (error) { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'RUN_FAILED', - message: error instanceof Error ? error.message : 'Failed to run workflow', - details: error, - }, - }); - } - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Workflow is required', - }, - }); - } - break; - - case 'EXPORT_FOR_COPILOT': - // Export workflow for Copilot - if (message.payload?.workflow) { - await handleExportForCopilot( - fileService, - webview, - message.payload, - message.requestId - ); - } else { - webview.postMessage({ - type: 'EXPORT_FOR_COPILOT_FAILED', - requestId: message.requestId, - payload: { - errorCode: 'UNKNOWN_ERROR', - errorMessage: 'Workflow is required', - timestamp: new Date().toISOString(), - }, - }); - } - break; - - case 'LIST_COPILOT_MODELS': - // List available Copilot models from VS Code LM API - { - const result = await listCopilotModels(); - webview.postMessage({ - type: 'COPILOT_MODELS_LIST', - requestId: message.requestId, - payload: result, - }); - } - break; - - case 'RUN_FOR_COPILOT': - // Run workflow for Copilot - VSCode Copilot Chat mode - if (message.payload?.workflow) { - await handleRunForCopilot(fileService, webview, message.payload, message.requestId); - } else { - webview.postMessage({ - type: 'RUN_FOR_COPILOT_FAILED', - requestId: message.requestId, - payload: { - errorCode: 'UNKNOWN_ERROR', - errorMessage: 'Workflow is required', - timestamp: new Date().toISOString(), - }, - }); - } - break; - - case 'RUN_FOR_COPILOT_CLI': - // Run workflow for Copilot CLI mode (via Claude Code terminal) - if (message.payload?.workflow) { - await handleRunForCopilotCli( - fileService, - webview, - message.payload, - message.requestId - ); - } else { - webview.postMessage({ - type: 'RUN_FOR_COPILOT_CLI_FAILED', - requestId: message.requestId, - payload: { - errorCode: 'UNKNOWN_ERROR', - errorMessage: 'Workflow is required', - timestamp: new Date().toISOString(), - }, - }); - } - break; - - case 'EXPORT_FOR_COPILOT_CLI': - // Export workflow for Copilot CLI (Skills format) - if (message.payload?.workflow) { - await handleExportForCopilotCli( - fileService, - webview, - message.payload, - message.requestId - ); - } else { - webview.postMessage({ - type: 'EXPORT_FOR_COPILOT_CLI_FAILED', - requestId: message.requestId, - payload: { - errorCode: 'UNKNOWN_ERROR', - errorMessage: 'Workflow is required', - timestamp: new Date().toISOString(), - }, - }); - } - break; - - case 'EXPORT_FOR_CODEX_CLI': - // Export workflow for Codex CLI (Skills format) - if (message.payload?.workflow) { - await handleExportForCodexCli( - fileService, - webview, - message.payload, - message.requestId - ); - } else { - webview.postMessage({ - type: 'EXPORT_FOR_CODEX_CLI_FAILED', - requestId: message.requestId, - payload: { - errorCode: 'UNKNOWN_ERROR', - errorMessage: 'Workflow is required', - timestamp: new Date().toISOString(), - }, - }); - } - break; - - case 'RUN_FOR_CODEX_CLI': - // Run workflow for Codex CLI mode (via Codex CLI terminal) - if (message.payload?.workflow) { - await handleRunForCodexCli( - fileService, - webview, - message.payload, - message.requestId - ); - } else { - webview.postMessage({ - type: 'RUN_FOR_CODEX_CLI_FAILED', - requestId: message.requestId, - payload: { - errorCode: 'UNKNOWN_ERROR', - errorMessage: 'Workflow is required', - timestamp: new Date().toISOString(), - }, - }); - } - break; - - case 'EXPORT_FOR_ROO_CODE': - // Export workflow for Roo Code (Skills format) - if (message.payload?.workflow) { - await handleExportForRooCode( - fileService, - webview, - message.payload, - message.requestId - ); - } else { - webview.postMessage({ - type: 'EXPORT_FOR_ROO_CODE_FAILED', - requestId: message.requestId, - payload: { - errorCode: 'UNKNOWN_ERROR', - errorMessage: 'Workflow is required', - timestamp: new Date().toISOString(), - }, - }); - } - break; - - case 'RUN_FOR_ROO_CODE': - // Run workflow for Roo Code (via Extension API) - if (message.payload?.workflow) { - await handleRunForRooCode(fileService, webview, message.payload, message.requestId); - } else { - webview.postMessage({ - type: 'RUN_FOR_ROO_CODE_FAILED', - requestId: message.requestId, - payload: { - errorCode: 'UNKNOWN_ERROR', - errorMessage: 'Workflow is required', - timestamp: new Date().toISOString(), - }, - }); - } - break; - - case 'EXPORT_FOR_GEMINI_CLI': - // Export workflow for Gemini CLI (Skills format) - if (message.payload?.workflow) { - await handleExportForGeminiCli( - fileService, - webview, - message.payload, - message.requestId - ); - } else { - webview.postMessage({ - type: 'EXPORT_FOR_GEMINI_CLI_FAILED', - requestId: message.requestId, - payload: { - errorCode: 'UNKNOWN_ERROR', - errorMessage: 'Workflow is required', - timestamp: new Date().toISOString(), - }, - }); - } - break; - - case 'RUN_FOR_GEMINI_CLI': - // Run workflow for Gemini CLI (via Gemini CLI terminal) - if (message.payload?.workflow) { - await handleRunForGeminiCli( - fileService, - webview, - message.payload, - message.requestId - ); - } else { - webview.postMessage({ - type: 'RUN_FOR_GEMINI_CLI_FAILED', - requestId: message.requestId, - payload: { - errorCode: 'UNKNOWN_ERROR', - errorMessage: 'Workflow is required', - timestamp: new Date().toISOString(), - }, - }); - } - break; - - case 'EXPORT_FOR_ANTIGRAVITY': - // Export workflow for Antigravity (Skills format) - if (message.payload?.workflow) { - await handleExportForAntigravity( - fileService, - webview, - message.payload, - message.requestId - ); - } else { - webview.postMessage({ - type: 'EXPORT_FOR_ANTIGRAVITY_FAILED', - requestId: message.requestId, - payload: { - errorCode: 'UNKNOWN_ERROR', - errorMessage: 'Workflow is required', - timestamp: new Date().toISOString(), - }, - }); - } - break; - - case 'RUN_FOR_ANTIGRAVITY': - // Run workflow for Antigravity (via Cascade) - if (message.payload?.workflow) { - await handleRunForAntigravity( - fileService, - webview, - message.payload, - message.requestId - ); - } else { - webview.postMessage({ - type: 'RUN_FOR_ANTIGRAVITY_FAILED', - requestId: message.requestId, - payload: { - errorCode: 'UNKNOWN_ERROR', - errorMessage: 'Workflow is required', - timestamp: new Date().toISOString(), - }, - }); - } - break; - - case 'EXPORT_FOR_CURSOR': - // Export workflow for Cursor (Skills format) - if (message.payload?.workflow) { - await handleExportForCursor( - fileService, - webview, - message.payload, - message.requestId - ); - } else { - webview.postMessage({ - type: 'EXPORT_FOR_CURSOR_FAILED', - requestId: message.requestId, - payload: { - errorCode: 'UNKNOWN_ERROR', - errorMessage: 'Workflow is required', - timestamp: new Date().toISOString(), - }, - }); - } - break; - - case 'RUN_FOR_CURSOR': - // Run workflow for Cursor - if (message.payload?.workflow) { - await handleRunForCursor(fileService, webview, message.payload, message.requestId); - } else { - webview.postMessage({ - type: 'RUN_FOR_CURSOR_FAILED', - requestId: message.requestId, - payload: { - errorCode: 'UNKNOWN_ERROR', - errorMessage: 'Workflow is required', - timestamp: new Date().toISOString(), - }, - }); - } - break; - - case 'LOAD_WORKFLOW_LIST': - // Load workflow list - await loadWorkflowList(fileService, webview, message.requestId); - break; - - case 'LOAD_WORKFLOW': - // Load specific workflow - if (message.payload?.workflowId) { - await loadWorkflow( - fileService, - webview, - message.payload.workflowId, - message.requestId - ); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Workflow ID is required', - }, - }); - } - break; - - case 'OPEN_FILE_PICKER': - // Open OS file picker to load workflow from any location - try { - const defaultUri = vscode.Uri.file(fileService.getWorkflowsDirectory()); - - const result = await vscode.window.showOpenDialog({ - canSelectFiles: true, - canSelectFolders: false, - canSelectMany: false, - filters: { - 'Workflow Files': ['json'], - }, - defaultUri, - title: translate('filePicker.title'), - }); - - // User cancelled - if (!result || result.length === 0) { - webview.postMessage({ - type: 'FILE_PICKER_CANCELLED', - requestId: message.requestId, - }); - break; - } - - const selectedFile = result[0]; - const filePath = selectedFile.fsPath; - - // Read file content - const content = await fileService.readFile(filePath); - - // Validate workflow - const validationResult = validateWorkflowFile(content); - - if (!validationResult.valid) { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: translate('filePicker.error.invalidWorkflow'), - details: validationResult.errors, - }, - }); - break; - } - - // Apply migrations for backward compatibility - // validationResult.workflow is guaranteed to exist when validationResult.valid is true - const workflow = migrateWorkflow( - validationResult.workflow as NonNullable - ); - - // Send success response - webview.postMessage({ - type: 'LOAD_WORKFLOW', - requestId: message.requestId, - payload: { workflow }, - }); - - console.log(`Workflow loaded from file picker: ${filePath}`); - } catch (error) { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'LOAD_FAILED', - message: - error instanceof Error - ? error.message - : translate('filePicker.error.loadFailed'), - details: error, - }, - }); - } - break; - - case 'STATE_UPDATE': - // State update from webview (for persistence) - console.log('STATE_UPDATE:', message.payload); - break; - - case 'ACCEPT_TERMS': - // User accepted terms of use - await context.globalState.update('hasAcceptedTerms', true); - // Update webview with new state - webview.postMessage({ - type: 'INITIAL_STATE', - payload: { - hasAcceptedTerms: true, - }, - }); - break; - - case 'CANCEL_TERMS': - // User cancelled terms of use - close the panel - currentPanel?.dispose(); - break; - - case 'CONFIRM_OVERWRITE': - // TODO: Will be implemented in Phase 4 - console.log('CONFIRM_OVERWRITE:', message.payload); - break; - - case 'BROWSE_SKILLS': - // Browse available Claude Code Skills - await handleBrowseSkills(webview, message.requestId || ''); - break; - - case 'CREATE_SKILL': - // Create new Skill (Phase 5) - if (message.payload) { - await handleCreateSkill(message.payload, webview, message.requestId || ''); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Skill creation payload is required', - }, - }); - } - break; - - case 'VALIDATE_SKILL_FILE': - // Validate Skill file - if (message.payload) { - await handleValidateSkillFile(message.payload, webview, message.requestId || ''); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Skill file path is required', - }, - }); - } - break; - - case 'REFINE_WORKFLOW': - // AI-assisted workflow refinement - if (message.payload) { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - await handleRefineWorkflow( - message.payload, - webview, - message.requestId || '', - context.extensionPath, - workspaceRoot - ); - } else { - webview.postMessage({ - type: 'REFINEMENT_FAILED', - requestId: message.requestId, - payload: { - error: { - code: 'VALIDATION_ERROR', - message: 'Refinement payload is required', - }, - executionTimeMs: 0, - timestamp: new Date().toISOString(), - }, - }); - } - break; - - case 'CANCEL_REFINEMENT': - // Cancel workflow refinement - if (message.payload) { - await handleCancelRefinement(message.payload, webview, message.requestId || ''); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Cancel refinement payload is required', - }, - }); - } - break; - - case 'CLEAR_CONVERSATION': - // Clear conversation history - if (message.payload) { - await handleClearConversation(message.payload, webview, message.requestId || ''); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Clear conversation payload is required', - }, - }); - } - break; - - case 'LIST_MCP_SERVERS': - // List all configured MCP servers (T018) - await handleListMcpServers(message.payload || {}, webview, message.requestId || ''); - break; - - case 'GET_MCP_TOOLS': - // Get tools from a specific MCP server (T019) - if (message.payload?.serverId) { - await handleGetMcpTools(message.payload, webview, message.requestId || ''); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Server ID is required', - }, - }); - } - break; - - case 'GET_MCP_TOOL_SCHEMA': - // Get detailed schema for a specific tool (T028) - if (message.payload?.serverId && message.payload?.toolName) { - await handleGetMcpToolSchema(message.payload, webview, message.requestId || ''); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Server ID and Tool Name are required', - }, - }); - } - break; - - case 'REFRESH_MCP_CACHE': - // Refresh MCP cache (invalidate all cached data) - await handleRefreshMcpCache(message.payload || {}, webview, message.requestId || ''); - break; - - case 'LIST_SLACK_WORKSPACES': - // List connected Slack workspaces - await handleListSlackWorkspaces(webview, message.requestId || '', slackApiService); - break; - - case 'GET_SLACK_CHANNELS': - // Get Slack channels for specific workspace - if (message.payload?.workspaceId) { - await handleGetSlackChannels( - message.payload, - webview, - message.requestId || '', - slackApiService - ); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Workspace ID is required', - }, - }); - } - break; - - case 'SHARE_WORKFLOW_TO_SLACK': - // Share workflow to Slack channel (T021) - if (message.payload) { - await handleShareWorkflowToSlack( - message.payload, - webview, - message.requestId || '', - fileService, - slackApiService - ); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Share workflow payload is required', - }, - }); - } - break; - - case 'GENERATE_SLACK_DESCRIPTION': - // Generate workflow description with AI for Slack sharing - if (message.payload) { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - await handleGenerateSlackDescription( - message.payload, - webview, - message.requestId || '', - workspaceRoot - ); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Generate Slack description payload is required', - }, - }); - } - break; - - case 'CANCEL_SLACK_DESCRIPTION': - // Cancel Slack description generation - if (message.payload?.targetRequestId) { - await cancelGeneration(message.payload.targetRequestId); - } - break; - - case 'GENERATE_WORKFLOW_NAME': - // Generate workflow name with AI - if (message.payload) { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - await handleGenerateWorkflowName( - message.payload, - webview, - message.requestId || '', - workspaceRoot - ); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Generate workflow name payload is required', - }, - }); - } - break; - - case 'CANCEL_WORKFLOW_NAME': - // Cancel workflow name generation - if (message.payload?.targetRequestId) { - await cancelGeneration(message.payload.targetRequestId); - } - break; - - case 'IMPORT_WORKFLOW_FROM_SLACK': - // Import workflow from Slack (T026) - if (message.payload) { - await handleImportWorkflowFromSlack( - message.payload, - webview, - message.requestId || '', - fileService, - slackApiService - ); - } else { - webview.postMessage({ - type: 'ERROR', - requestId: message.requestId, - payload: { - code: 'VALIDATION_ERROR', - message: 'Import workflow payload is required', - }, - }); - } - break; - - case 'CONNECT_SLACK_MANUAL': - // Manual Slack connection (User Token only) - try { - if (!message.payload?.userToken) { - throw new Error('User Token is required'); - } - - const result = await handleConnectSlackManual( - slackTokenManager, - slackApiService, - '', // Bot Token is no longer used - message.payload.userToken - ); - - if (result) { - webview.postMessage({ - type: 'CONNECT_SLACK_MANUAL_SUCCESS', - requestId: message.requestId, - payload: { - workspaceId: result.workspaceId, - workspaceName: result.workspaceName, - }, - }); - } else { - throw new Error('Failed to connect to Slack'); - } - } catch (error) { - webview.postMessage({ - type: 'CONNECT_SLACK_MANUAL_FAILED', - requestId: message.requestId, - payload: { - code: 'SLACK_CONNECTION_FAILED', - message: error instanceof Error ? error.message : 'Failed to connect to Slack', - }, - }); - } - break; - - case 'SLACK_CONNECT_OAUTH': - // OAuth Slack connection flow - try { - // Create new OAuth service for this flow - activeOAuthService = createOAuthService(); - - const oauthResult = await handleConnectSlackOAuth( - slackTokenManager, - slackApiService, - activeOAuthService, - (status) => { - // Send progress updates to webview - if (status === 'initiated') { - const initiation = activeOAuthService?.initiateOAuthFlow(); - if (initiation) { - webview.postMessage({ - type: 'SLACK_OAUTH_INITIATED', - requestId: message.requestId, - payload: { - sessionId: initiation.sessionId, - authorizationUrl: initiation.authorizationUrl, - }, - }); - } - } - } - ); - - activeOAuthService = null; - - if (oauthResult) { - webview.postMessage({ - type: 'SLACK_OAUTH_SUCCESS', - requestId: message.requestId, - payload: { - workspaceId: oauthResult.workspaceId, - workspaceName: oauthResult.workspaceName, - }, - }); - } else { - webview.postMessage({ - type: 'SLACK_OAUTH_CANCELLED', - requestId: message.requestId, - }); - } - } catch (error) { - activeOAuthService = null; - webview.postMessage({ - type: 'SLACK_OAUTH_FAILED', - requestId: message.requestId, - payload: { - message: error instanceof Error ? error.message : 'OAuth authentication failed', - }, - }); - } - break; - - case 'SLACK_CANCEL_OAUTH': - // Cancel ongoing OAuth flow - if (activeOAuthService) { - activeOAuthService.cancelPolling(); - activeOAuthService = null; - } - break; - - case 'SLACK_DISCONNECT': - // Disconnect from Slack workspace - try { - await slackTokenManager.clearConnection(); - slackApiService.invalidateClient(); - vscode.window.showInformationMessage('Slack token deleted successfully'); - webview.postMessage({ - type: 'SLACK_DISCONNECT_SUCCESS', - requestId: message.requestId, - payload: {}, - }); - } catch (error) { - webview.postMessage({ - type: 'SLACK_DISCONNECT_FAILED', - requestId: message.requestId, - payload: { - message: - error instanceof Error ? error.message : 'Failed to disconnect from Slack', - }, - }); - } - break; - - case 'OPEN_EXTERNAL_URL': - // Open external URL in browser - if (message.payload?.url) { - await vscode.env.openExternal(vscode.Uri.parse(message.payload.url)); - } - break; - - case 'GET_LAST_SHARED_CHANNEL': - // Get last shared channel ID from global state - { - const lastChannelId = context.globalState.get('slack-last-shared-channel'); - webview.postMessage({ - type: 'GET_LAST_SHARED_CHANNEL_SUCCESS', - requestId: message.requestId, - payload: { - channelId: lastChannelId || null, - }, - }); - } - break; - - case 'SET_LAST_SHARED_CHANNEL': - // Save last shared channel ID to global state - if (message.payload?.channelId) { - await context.globalState.update( - 'slack-last-shared-channel', - message.payload.channelId - ); - } - break; - - case 'OPEN_IN_EDITOR': - // Open text content in VSCode native editor - if (message.payload) { - await handleOpenInEditor(message.payload, webview); - } - break; - - case 'GET_CURRENT_WORKFLOW_RESPONSE': { - // Forward workflow response to MCP server manager - const manager = getMcpServerManager(); - if (manager && message.payload) { - manager.handleWorkflowResponse( - message.payload as GetCurrentWorkflowResponsePayload - ); - } - break; - } - - case 'APPLY_WORKFLOW_FROM_MCP_RESPONSE': { - // Forward apply response to MCP server manager - const applyManager = getMcpServerManager(); - if (applyManager && message.payload) { - applyManager.handleApplyResponse( - message.payload as ApplyWorkflowFromMcpResponsePayload - ); - } - break; - } - - case 'START_MCP_SERVER': { - // Start built-in MCP server - const startManager = getMcpServerManager(); - if (!startManager) { - webview.postMessage({ - type: 'MCP_SERVER_STATUS', - payload: { - running: false, - port: null, - configsWritten: [], - reviewBeforeApply: true, - }, - }); - break; - } - - try { - const payload = message.payload as StartMcpServerPayload | undefined; - const configTargets: McpConfigTarget[] = payload?.configTargets || [ - 'claude-code', - 'roo-code', - 'copilot', - ]; - - const port = await startManager.start(context.extensionPath); - const serverUrl = `http://127.0.0.1:${port}/mcp`; - - // Write config to selected targets - const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - let configsWritten: McpConfigTarget[] = []; - if (workspacePath) { - configsWritten = await writeAllAgentConfigs( - configTargets, - serverUrl, - workspacePath - ); - } - - webview.postMessage({ - type: 'MCP_SERVER_STATUS', - requestId: message.requestId, - payload: { - running: true, - port, - configsWritten, - reviewBeforeApply: startManager.getReviewBeforeApply(), - }, - }); - - log('INFO', 'MCP Server started via UI', { port, configsWritten }); - } catch (error) { - log('ERROR', 'Failed to start MCP server', { - error: error instanceof Error ? error.message : String(error), - }); - webview.postMessage({ - type: 'MCP_SERVER_STATUS', - requestId: message.requestId, - payload: { - running: false, - port: null, - configsWritten: [], - reviewBeforeApply: startManager.getReviewBeforeApply(), - }, - }); - } - break; - } - - case 'STOP_MCP_SERVER': { - // Stop built-in MCP server - const stopManager = getMcpServerManager(); - if (!stopManager) { - webview.postMessage({ - type: 'MCP_SERVER_STATUS', - payload: { - running: false, - port: null, - configsWritten: [], - reviewBeforeApply: true, - }, - }); - break; - } - - try { - await stopManager.stop(); - - // Remove configs (best-effort) - const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (workspacePath) { - await removeAllAgentConfigs(workspacePath); - } - - webview.postMessage({ - type: 'MCP_SERVER_STATUS', - requestId: message.requestId, - payload: { - running: false, - port: null, - configsWritten: [], - reviewBeforeApply: stopManager.getReviewBeforeApply(), - }, - }); - - log('INFO', 'MCP Server stopped via UI'); - } catch (error) { - log('ERROR', 'Failed to stop MCP server', { - error: error instanceof Error ? error.message : String(error), - }); - webview.postMessage({ - type: 'MCP_SERVER_STATUS', - payload: { - running: false, - port: null, - configsWritten: [], - reviewBeforeApply: stopManager.getReviewBeforeApply(), - }, - }); - } - break; - } - - case 'GET_MCP_SERVER_STATUS': { - // Return current MCP server status - const statusManager = getMcpServerManager(); - const running = statusManager?.isRunning() ?? false; - const statusPort = running ? (statusManager?.getPort() ?? null) : null; - webview.postMessage({ - type: 'MCP_SERVER_STATUS', - payload: { - running, - port: statusPort, - configsWritten: [], - reviewBeforeApply: statusManager?.getReviewBeforeApply() ?? true, - }, - }); - break; - } - - case 'SET_REVIEW_BEFORE_APPLY': { - const reviewPayload = message.payload as SetReviewBeforeApplyPayload | undefined; - if (reviewPayload != null) { - const reviewManager = getMcpServerManager(); - if (reviewManager) { - reviewManager.setReviewBeforeApply(reviewPayload.value); - } - } - break; - } - - case 'RUN_AI_EDITING_SKILL': { - // Run AI editing skill with specified provider - const aiEditPayload = message.payload as RunAiEditingSkillPayload | undefined; - if (aiEditPayload?.provider) { - try { - const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (!workspacePath) { - throw new Error('No workspace folder is open'); - } - await generateAndRunAiEditingSkill( - aiEditPayload.provider as AiEditingProvider, - context.extensionPath, - workspacePath - ); - webview.postMessage({ - type: 'RUN_AI_EDITING_SKILL_SUCCESS', - requestId: message.requestId, - payload: { - provider: aiEditPayload.provider, - timestamp: new Date().toISOString(), - }, - }); - } catch (error) { - webview.postMessage({ - type: 'RUN_AI_EDITING_SKILL_FAILED', - requestId: message.requestId, - payload: { - errorMessage: - error instanceof Error ? error.message : 'Failed to run AI editing skill', - timestamp: new Date().toISOString(), - }, - }); - } - } - break; - } - - case 'LAUNCH_AI_AGENT': { - // One-click AI agent launch: start server → write config → launch skill - const launchPayload = message.payload as LaunchAiAgentPayload | undefined; - if (!launchPayload?.provider) break; - - try { - const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (!workspacePath) { - throw new Error('No workspace folder is open'); - } - - const launchManager = getMcpServerManager(); - if (!launchManager) { - throw new Error('MCP server manager is not available'); - } - - // 1. Start server if not running - let serverPort = launchManager.getPort(); - if (!launchManager.isRunning()) { - serverPort = await launchManager.start(context.extensionPath); - } - const serverUrl = `http://127.0.0.1:${serverPort}/mcp`; - - // 1.5. Track provider for schema variant selection - launchManager.setCurrentProvider(launchPayload.provider); - - // 2. Write config for this provider if not yet written - const requiredTargets = getConfigTargetsForProvider(launchPayload.provider); - const alreadyWritten = launchManager.getWrittenConfigs(); - const newTargets = requiredTargets.filter((t) => !alreadyWritten.has(t)); - if (newTargets.length > 0) { - const written = await writeAllAgentConfigs(newTargets, serverUrl, workspacePath); - launchManager.addWrittenConfigs(written); - } - - // 3. Send MCP_SERVER_STATUS update - webview.postMessage({ - type: 'MCP_SERVER_STATUS', - payload: { - running: true, - port: serverPort, - configsWritten: [...launchManager.getWrittenConfigs()], - reviewBeforeApply: launchManager.getReviewBeforeApply(), - }, - }); - - // 4. Generate and run AI editing skill - await generateAndRunAiEditingSkill( - launchPayload.provider as AiEditingProvider, - context.extensionPath, - workspacePath - ); - - // For Antigravity, pause and let the user manually refresh MCP - if (launchPayload.provider === 'antigravity') { - webview.postMessage({ - type: 'ANTIGRAVITY_MCP_REFRESH_NEEDED', - requestId: message.requestId, - }); - log('INFO', 'Antigravity MCP refresh needed, waiting for user', { - port: serverPort, - }); - break; - } - - webview.postMessage({ - type: 'LAUNCH_AI_AGENT_SUCCESS', - requestId: message.requestId, - payload: { - provider: launchPayload.provider, - timestamp: new Date().toISOString(), - }, - }); - - log('INFO', 'AI agent launched via one-click', { - provider: launchPayload.provider, - port: serverPort, - }); - } catch (error) { - log('ERROR', 'Failed to launch AI agent', { - error: error instanceof Error ? error.message : String(error), - }); - webview.postMessage({ - type: 'LAUNCH_AI_AGENT_FAILED', - requestId: message.requestId, - payload: { - errorMessage: - error instanceof Error ? error.message : 'Failed to launch AI agent', - timestamp: new Date().toISOString(), - }, - }); - } - break; - } - - case 'OPEN_ANTIGRAVITY_MCP_SETTINGS': { - await openAntigravityMcpSettings(); - break; - } - - case 'CONFIRM_ANTIGRAVITY_CASCADE_LAUNCH': { - try { - await startAntigravityTask('cc-workflow-ai-editor'); - webview.postMessage({ - type: 'LAUNCH_AI_AGENT_SUCCESS', - requestId: message.requestId, - payload: { - provider: 'antigravity', - timestamp: new Date().toISOString(), - }, - }); - } catch (error) { - webview.postMessage({ - type: 'LAUNCH_AI_AGENT_FAILED', - requestId: message.requestId, - payload: { - errorMessage: - error instanceof Error ? error.message : 'Failed to launch Antigravity', - timestamp: new Date().toISOString(), - }, - }); - } - break; - } - - default: - console.warn('Unknown message type:', message); - } - }, - undefined, - context.subscriptions - ); - - // Handle panel disposal - currentPanel.onDidDispose( - () => { - // Cancel any ongoing OAuth polling when panel is closed - if (activeOAuthService) { - activeOAuthService.cancelPolling(); - activeOAuthService = null; - } - // Disconnect MCP server manager from webview - const disposeManager = getMcpServerManager(); - if (disposeManager) { - disposeManager.setWebview(null); - } - currentPanel = undefined; - }, - undefined, - context.subscriptions - ); - - // Show information message - vscode.window.showInformationMessage('CC Workflow Studio: Editor opened!'); - } - ); - - context.subscriptions.push(openEditorCommand); - - return currentPanel || null; -} - -/** - * Prepare the editor for loading a new workflow - * Sends a message to show loading state - * - * @param workflowId - The workflow ID being loaded - */ -export function prepareEditorForLoad(workflowId: string): boolean { - if (!currentPanel) { - return false; - } - - currentPanel.webview.postMessage({ - type: 'PREPARE_WORKFLOW_LOAD', - payload: { workflowId }, - }); - return true; -} - -/** - * Load a workflow into the main editor panel - * Used by preview panel to open workflow in editor mode - * - * @param workflowId - The workflow ID (filename without extension) - */ -export async function loadWorkflowIntoEditor(workflowId: string): Promise { - if (!currentPanel) { - return false; - } - - if (!fileService) { - return false; - } - - try { - // Load the workflow using the existing loadWorkflow function - await loadWorkflow(fileService, currentPanel.webview, workflowId, `preview-load-${Date.now()}`); - return true; - } catch (error) { - console.error('Failed to load workflow into editor:', error); - return false; - } -} - -/** - * Check if the main editor panel exists - */ -export function hasEditorPanel(): boolean { - return currentPanel !== undefined; -} diff --git a/src/extension/commands/roo-code-handlers.ts b/src/extension/commands/roo-code-handlers.ts deleted file mode 100644 index 25e74d8c..00000000 --- a/src/extension/commands/roo-code-handlers.ts +++ /dev/null @@ -1,304 +0,0 @@ -/** - * Claude Code Workflow Studio - Roo Code Integration Handlers - * - * Handles Export/Run for Roo Code integration - */ - -import * as vscode from 'vscode'; -import type { - ExportForRooCodePayload, - ExportForRooCodeSuccessPayload, - RooCodeOperationFailedPayload, - RunForRooCodePayload, - RunForRooCodeSuccessPayload, -} from '../../shared/types/messages'; -import { NodeType } from '../../shared/types/workflow-definition'; -import { extractMcpServerIdsFromWorkflow } from '../services/copilot-export-service'; -import { nodeNameToFileName } from '../services/export-service'; -import type { FileService } from '../services/file-service'; -import { isRooCodeInstalled, startRooCodeTask } from '../services/roo-code-extension-service'; -import { - previewMcpSyncForRooCode, - syncMcpConfigForRooCode, -} from '../services/roo-code-mcp-sync-service'; -import { - checkExistingRooCodeSkill, - exportWorkflowAsRooCodeSkill, -} from '../services/roo-code-skill-export-service'; -import { - hasNonStandardSkills, - promptAndNormalizeSkills, -} from '../services/skill-normalization-service'; - -/** - * Handle Export for Roo Code request - * - * Exports workflow to Skills format (.roo/skills/name/SKILL.md) - * - * @param fileService - File service instance - * @param webview - Webview for sending responses - * @param payload - Export payload - * @param requestId - Optional request ID for response correlation - */ -export async function handleExportForRooCode( - fileService: FileService, - webview: vscode.Webview, - payload: ExportForRooCodePayload, - requestId?: string -): Promise { - try { - const { workflow } = payload; - - // Warn about SubAgent limitations in Roo Code - const hasSubAgentNodes = workflow.nodes.some( - (node) => node.type === NodeType.SubAgent || node.type === NodeType.SubAgentFlow - ); - if (hasSubAgentNodes) { - const result = await vscode.window.showWarningMessage( - 'This workflow contains Sub-Agent nodes.\n\nRoo Code does not have a Sub-Agent feature. Sub-Agents will be substituted with child tasks (new_task), which cannot run in parallel.', - { modal: true }, - 'Continue', - 'Cancel' - ); - if (result !== 'Continue') { - webview.postMessage({ - type: 'EXPORT_FOR_ROO_CODE_CANCELLED', - requestId, - }); - return; - } - } - - // Check for existing skill and ask for confirmation - const existingSkillPath = await checkExistingRooCodeSkill(workflow, fileService); - if (existingSkillPath) { - const result = await vscode.window.showWarningMessage( - `Skill already exists: ${existingSkillPath}\n\nOverwrite?`, - { modal: true }, - 'Overwrite', - 'Cancel' - ); - if (result !== 'Overwrite') { - webview.postMessage({ - type: 'EXPORT_FOR_ROO_CODE_CANCELLED', - requestId, - }); - return; - } - } - - // Export workflow as skill to .roo/skills/{name}/SKILL.md - const exportResult = await exportWorkflowAsRooCodeSkill(workflow, fileService); - - if (!exportResult.success) { - const failedPayload: RooCodeOperationFailedPayload = { - errorCode: 'EXPORT_FAILED', - errorMessage: exportResult.errors?.join(', ') || 'Failed to export workflow as skill', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'EXPORT_FOR_ROO_CODE_FAILED', - requestId, - payload: failedPayload, - }); - return; - } - - // Send success response - const successPayload: ExportForRooCodeSuccessPayload = { - skillName: exportResult.skillName, - skillPath: exportResult.skillPath, - timestamp: new Date().toISOString(), - }; - - webview.postMessage({ - type: 'EXPORT_FOR_ROO_CODE_SUCCESS', - requestId, - payload: successPayload, - }); - - vscode.window.showInformationMessage( - `Exported workflow as Roo Code skill: ${exportResult.skillPath}` - ); - } catch (error) { - const failedPayload: RooCodeOperationFailedPayload = { - errorCode: 'UNKNOWN_ERROR', - errorMessage: error instanceof Error ? error.message : 'Unknown error', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'EXPORT_FOR_ROO_CODE_FAILED', - requestId, - payload: failedPayload, - }); - } -} - -/** - * Handle Run for Roo Code request - * - * Exports workflow to Skills format, syncs MCP config, - * and starts Roo Code with :skill command via Extension API - * - * @param fileService - File service instance - * @param webview - Webview for sending responses - * @param payload - Run payload - * @param requestId - Optional request ID for response correlation - */ -export async function handleRunForRooCode( - fileService: FileService, - webview: vscode.Webview, - payload: RunForRooCodePayload, - requestId?: string -): Promise { - try { - const { workflow } = payload; - const workspacePath = fileService.getWorkspacePath(); - - // Step 0.5: Normalize skills (copy non-standard skills to .claude/skills/) - if (hasNonStandardSkills(workflow, 'roo-code')) { - const normalizeResult = await promptAndNormalizeSkills(workflow, 'roo-code'); - - if (!normalizeResult.success) { - if (normalizeResult.cancelled) { - webview.postMessage({ - type: 'RUN_FOR_ROO_CODE_CANCELLED', - requestId, - }); - return; - } - throw new Error(normalizeResult.error || 'Failed to copy skills to .claude/skills/'); - } - - if (normalizeResult.normalizedSkills && normalizeResult.normalizedSkills.length > 0) { - console.log( - `[Roo Code] Copied ${normalizeResult.normalizedSkills.length} skill(s) to .claude/skills/` - ); - } - } - - // Step 0.75: Warn about SubAgent limitations in Roo Code - const hasSubAgentNodes = workflow.nodes.some( - (node) => node.type === NodeType.SubAgent || node.type === NodeType.SubAgentFlow - ); - if (hasSubAgentNodes) { - const result = await vscode.window.showWarningMessage( - 'This workflow contains Sub-Agent nodes.\n\nRoo Code does not have a Sub-Agent feature. Sub-Agents will be substituted with child tasks (new_task), which cannot run in parallel.', - { modal: true }, - 'Continue', - 'Cancel' - ); - if (result !== 'Continue') { - webview.postMessage({ - type: 'RUN_FOR_ROO_CODE_CANCELLED', - requestId, - }); - return; - } - } - - // Step 1: Check if MCP servers need to be synced to .roo/mcp.json - const mcpServerIds = extractMcpServerIdsFromWorkflow(workflow); - let mcpSyncConfirmed = false; - - if (mcpServerIds.length > 0) { - const mcpSyncPreview = await previewMcpSyncForRooCode(mcpServerIds, workspacePath); - - if (mcpSyncPreview.serversToAdd.length > 0) { - const serverList = mcpSyncPreview.serversToAdd.map((s) => ` • ${s}`).join('\n'); - const result = await vscode.window.showInformationMessage( - `The following MCP servers will be added to .roo/mcp.json for Roo Code:\n\n${serverList}\n\nProceed?`, - { modal: true }, - 'Yes', - 'No' - ); - mcpSyncConfirmed = result === 'Yes'; - } - } - - // Step 2: Check for existing skill and ask for confirmation - const existingSkillPath = await checkExistingRooCodeSkill(workflow, fileService); - if (existingSkillPath) { - const result = await vscode.window.showWarningMessage( - `Skill already exists: ${existingSkillPath}\n\nOverwrite?`, - { modal: true }, - 'Overwrite', - 'Cancel' - ); - if (result !== 'Overwrite') { - webview.postMessage({ - type: 'RUN_FOR_ROO_CODE_CANCELLED', - requestId, - }); - return; - } - } - - // Step 3: Export workflow as skill to .roo/skills/{name}/SKILL.md - const exportResult = await exportWorkflowAsRooCodeSkill(workflow, fileService); - - if (!exportResult.success) { - const failedPayload: RooCodeOperationFailedPayload = { - errorCode: 'EXPORT_FAILED', - errorMessage: exportResult.errors?.join(', ') || 'Failed to export workflow as skill', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'RUN_FOR_ROO_CODE_FAILED', - requestId, - payload: failedPayload, - }); - return; - } - - // Step 4: Sync MCP servers to .roo/mcp.json if confirmed - let syncedMcpServers: string[] = []; - if (mcpSyncConfirmed) { - syncedMcpServers = await syncMcpConfigForRooCode(mcpServerIds, workspacePath); - } - - // Step 5: Start Roo Code with :skill command via Extension API - const skillName = nodeNameToFileName(workflow.name); - let rooCodeOpened = false; - - if (isRooCodeInstalled()) { - rooCodeOpened = await startRooCodeTask(`:skill ${skillName}`); - } - - // Send success response - const successPayload: RunForRooCodeSuccessPayload = { - workflowName: workflow.name, - rooCodeOpened, - timestamp: new Date().toISOString(), - }; - - webview.postMessage({ - type: 'RUN_FOR_ROO_CODE_SUCCESS', - requestId, - payload: successPayload, - }); - - // Show notification - const configInfo = - syncedMcpServers.length > 0 - ? ` (MCP servers: ${syncedMcpServers.join(', ')} added to .roo/mcp.json)` - : ''; - const rooCodeInfo = rooCodeOpened - ? '' - : ' (Roo Code extension not found - skill exported only)'; - vscode.window.showInformationMessage( - `Running workflow via Roo Code: ${workflow.name}${configInfo}${rooCodeInfo}` - ); - } catch (error) { - const failedPayload: RooCodeOperationFailedPayload = { - errorCode: 'UNKNOWN_ERROR', - errorMessage: error instanceof Error ? error.message : 'Unknown error', - timestamp: new Date().toISOString(), - }; - webview.postMessage({ - type: 'RUN_FOR_ROO_CODE_FAILED', - requestId, - payload: failedPayload, - }); - } -} diff --git a/src/extension/commands/save-workflow.ts b/src/extension/commands/save-workflow.ts deleted file mode 100644 index 36cc8d2d..00000000 --- a/src/extension/commands/save-workflow.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Claude Code Workflow Studio - Save Workflow Command - * - * Handles saving workflow definitions to .vscode/workflows/ - * Based on: /specs/001-cc-wf-studio/contracts/extension-webview-api.md - */ - -import type { Webview } from 'vscode'; -import * as vscode from 'vscode'; -import type { SaveSuccessPayload } from '../../shared/types/messages'; -import type { Workflow } from '../../shared/types/workflow-definition'; -import type { FileService } from '../services/file-service'; - -/** - * Save workflow to file - * - * @param fileService - File service instance - * @param webview - Webview to send response to - * @param workflow - Workflow to save - * @param requestId - Request ID for response matching - */ -export async function saveWorkflow( - fileService: FileService, - webview: Webview, - workflow: Workflow, - requestId?: string -): Promise { - try { - // Ensure workflows directory exists - await fileService.ensureWorkflowsDirectory(); - - // Validate workflow (basic checks) - validateWorkflow(workflow); - - // Get file path - const filePath = fileService.getWorkflowFilePath(workflow.name); - - // Check if file already exists - if (await fileService.fileExists(filePath)) { - // Show warning dialog for overwrite confirmation - const answer = await vscode.window.showWarningMessage( - `Workflow "${workflow.name}" already exists.\n\nDo you want to overwrite it?`, - { modal: true }, - 'Overwrite' - ); - - if (answer !== 'Overwrite') { - // User cancelled - send cancellation message (not an error) - webview.postMessage({ - type: 'SAVE_CANCELLED', - requestId, - }); - return; - } - } - - // Serialize workflow to JSON with 2-space indentation - const content = JSON.stringify(workflow, null, 2); - - // Write to file - await fileService.writeFile(filePath, content); - - // Send success message back to webview - const payload: SaveSuccessPayload = { - filePath, - timestamp: new Date().toISOString(), - }; - - webview.postMessage({ - type: 'SAVE_SUCCESS', - requestId, - payload, - }); - - // Show success notification - vscode.window.showInformationMessage(`Workflow "${workflow.name}" saved successfully!`); - - console.log(`Workflow saved: ${workflow.name}`); - } catch (error) { - // Send error message back to webview - webview.postMessage({ - type: 'ERROR', - requestId, - payload: { - code: 'SAVE_FAILED', - message: error instanceof Error ? error.message : 'Failed to save workflow', - details: error, - }, - }); - - // Show error notification - vscode.window.showErrorMessage( - `Failed to save workflow: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Validate workflow before saving - * - * @param workflow - Workflow to validate - * @throws Error if validation fails - */ -function validateWorkflow(workflow: Workflow): void { - // Check required fields - if (!workflow.id) { - throw new Error('Workflow ID is required'); - } - - if (!workflow.name) { - throw new Error('Workflow name is required'); - } - - // Validate name format (lowercase, numbers, hyphen, underscore only) - const namePattern = /^[a-z0-9_-]+$/; - if (!namePattern.test(workflow.name)) { - throw new Error( - 'Workflow name must contain only lowercase letters, numbers, hyphens, and underscores' - ); - } - - // Check name length (1-100 characters) - if (workflow.name.length < 1 || workflow.name.length > 100) { - throw new Error('Workflow name must be between 1 and 100 characters'); - } - - // Validate version format (semantic versioning) - const versionPattern = /^\d+\.\d+\.\d+$/; - if (!workflow.version || !versionPattern.test(workflow.version)) { - throw new Error('Workflow version must follow semantic versioning (e.g., 1.0.0)'); - } - - // Check max nodes (50) - if (workflow.nodes.length > 50) { - throw new Error('Workflow cannot have more than 50 nodes'); - } -} diff --git a/src/extension/commands/skill-operations.ts b/src/extension/commands/skill-operations.ts deleted file mode 100644 index ca33b8a4..00000000 --- a/src/extension/commands/skill-operations.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Skill Operations - Extension Host Message Handlers - * - * Feature: 001-skill-node - * Purpose: Handle Webview requests for Skill browsing, creation, and validation - * - * Based on: specs/001-skill-node/contracts/skill-messages.ts - */ - -import * as vscode from 'vscode'; -import type { CreateSkillPayload, ValidateSkillFilePayload } from '../../shared/types/messages'; -import { createSkill, scanAllSkills, validateSkillFile } from '../services/skill-service'; - -/** - * Output channel for logging Skill operations - */ -const outputChannel = vscode.window.createOutputChannel('CC Workflow Studio'); - -/** - * Handle BROWSE_SKILLS request from Webview - * - * Scans user (~/.claude/skills/), project (.claude/skills/), - * and plugin (via installed_plugins.json) directories - * and returns all available Skills. - * - * Plugin skills are loaded from enabled plugins only (checked via settings.json). - * - * @param webview - VSCode Webview instance - * @param requestId - Request ID for response matching - */ -export async function handleBrowseSkills( - webview: vscode.Webview, - requestId: string -): Promise { - const startTime = Date.now(); - outputChannel.appendLine(`[Skill Browse] Starting scan (requestId: ${requestId})`); - - try { - const { user, project, local } = await scanAllSkills(); - const allSkills = [...user, ...project, ...local]; - - const executionTime = Date.now() - startTime; - outputChannel.appendLine( - `[Skill Browse] Scan completed in ${executionTime}ms - Found ${user.length} user, ${project.length} project, ${local.length} local Skills` - ); - - webview.postMessage({ - type: 'SKILL_LIST_LOADED', - requestId, - payload: { - skills: allSkills, - timestamp: new Date().toISOString(), - userCount: user.length, - projectCount: project.length, - localCount: local.length, - }, - }); - } catch (error) { - const executionTime = Date.now() - startTime; - outputChannel.appendLine(`[Skill Browse] Error after ${executionTime}ms: ${error}`); - - webview.postMessage({ - type: 'SKILL_VALIDATION_FAILED', - requestId, - payload: { - errorCode: 'UNKNOWN_ERROR', - errorMessage: String(error), - details: error instanceof Error ? error.stack : undefined, - }, - }); - } -} - -/** - * Handle CREATE_SKILL request from Webview - * - * Creates a new SKILL.md file in the specified directory (user or project). - * - * @param payload - Skill creation payload - * @param webview - VSCode Webview instance - * @param requestId - Request ID for response matching - */ -export async function handleCreateSkill( - payload: CreateSkillPayload, - webview: vscode.Webview, - requestId: string -): Promise { - const startTime = Date.now(); - outputChannel.appendLine( - `[Skill Create] Creating Skill "${payload.name}" (scope: ${payload.scope}, requestId: ${requestId})` - ); - - try { - const skillPath = await createSkill(payload); - const executionTime = Date.now() - startTime; - - outputChannel.appendLine(`[Skill Create] Skill created in ${executionTime}ms at ${skillPath}`); - - webview.postMessage({ - type: 'SKILL_CREATION_SUCCESS', - requestId, - payload: { - skillPath, - name: payload.name, - description: payload.description, - scope: payload.scope, - timestamp: new Date().toISOString(), - }, - }); - } catch (error) { - const executionTime = Date.now() - startTime; - outputChannel.appendLine(`[Skill Create] Error after ${executionTime}ms: ${error}`); - - webview.postMessage({ - type: 'SKILL_CREATION_FAILED', - requestId, - payload: { - errorCode: 'UNKNOWN_ERROR', - errorMessage: String(error), - details: error instanceof Error ? error.stack : undefined, - }, - }); - } -} - -/** - * Handle VALIDATE_SKILL_FILE request from Webview - * - * Validates a SKILL.md file and returns metadata if valid. - * - * @param payload - Validation request payload - * @param webview - VSCode Webview instance - * @param requestId - Request ID for response matching - */ -export async function handleValidateSkillFile( - payload: ValidateSkillFilePayload, - webview: vscode.Webview, - requestId: string -): Promise { - const startTime = Date.now(); - outputChannel.appendLine( - `[Skill Validate] Validating ${payload.skillPath} (requestId: ${requestId})` - ); - - try { - const metadata = await validateSkillFile(payload.skillPath); - const executionTime = Date.now() - startTime; - - outputChannel.appendLine( - `[Skill Validate] Validation completed in ${executionTime}ms - Skill "${metadata.name}" is valid` - ); - - // Determine scope based on path (normalize for Windows compatibility) - const normalizedPath = payload.skillPath.replace(/\\/g, '/'); - const scope: 'user' | 'project' | 'local' = normalizedPath.includes('/.claude/skills') - ? 'project' - : 'user'; - - webview.postMessage({ - type: 'SKILL_VALIDATION_SUCCESS', - requestId, - payload: { - skill: { - skillPath: payload.skillPath, - name: metadata.name, - description: metadata.description, - scope, - validationStatus: 'valid' as const, - allowedTools: metadata.allowedTools, - }, - }, - }); - } catch (error) { - const executionTime = Date.now() - startTime; - outputChannel.appendLine(`[Skill Validate] Error after ${executionTime}ms: ${error}`); - - // Determine error code - const errorMessage = String(error); - let errorCode: 'SKILL_NOT_FOUND' | 'INVALID_FRONTMATTER' | 'UNKNOWN_ERROR' = 'UNKNOWN_ERROR'; - - if (errorMessage.includes('file not found')) { - errorCode = 'SKILL_NOT_FOUND'; - } else if (errorMessage.includes('Invalid SKILL.md frontmatter')) { - errorCode = 'INVALID_FRONTMATTER'; - } - - webview.postMessage({ - type: 'SKILL_VALIDATION_FAILED', - requestId, - payload: { - errorCode, - errorMessage, - filePath: payload.skillPath, - details: error instanceof Error ? error.stack : undefined, - }, - }); - } -} diff --git a/src/extension/commands/slack-connect-manual.ts b/src/extension/commands/slack-connect-manual.ts deleted file mode 100644 index 9bce0736..00000000 --- a/src/extension/commands/slack-connect-manual.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Slack Manual Token Input Command Handler - * - * Handles manual Slack User Token input from users. - * Users manually create Slack App and provide User Token only. - * Workspace ID and Workspace Name are automatically retrieved via auth.test API. - * Author name comes from git config (not Slack user). - * - * Based on specs/001-slack-workflow-sharing/tasks.md Phase 8 - */ - -import { WebClient } from '@slack/web-api'; -import * as vscode from 'vscode'; -import { log } from '../extension'; -import type { SlackApiService } from '../services/slack-api-service'; -import { handleSlackError } from '../utils/slack-error-handler'; -import type { SlackTokenManager } from '../utils/slack-token-manager'; - -/** - * Handle manual Slack connection command - * - * Prompts user for User Token, validates the token, and stores it in VSCode Secret Storage. - * All Slack API operations use User Token to ensure user can only access channels - * they are a member of. - * - * @param tokenManager - Token manager instance - * @param slackApiService - Slack API service instance - * @param userToken - Optional User Token (if provided, skip Input Box prompt) - * @returns Workspace info if successful - */ -export async function handleConnectSlackManual( - tokenManager: SlackTokenManager, - slackApiService: SlackApiService, - _botToken?: string, // @deprecated - kept for backward compatibility, ignored - userToken?: string -): Promise<{ workspaceId: string; workspaceName: string } | undefined> { - try { - log('INFO', 'Manual Slack connection started'); - - // Step 1: Get User Token (from parameter or Input Box) - let userAccessToken = userToken; - - if (!userAccessToken) { - // Prompt for User Token via Input Box (VSCode command path) - userAccessToken = await vscode.window.showInputBox({ - prompt: 'Enter User OAuth Token (starts with "xoxp-")', - placeHolder: 'xoxp-...', - password: true, // Hide input - validateInput: (value) => { - if (!value || value.trim().length === 0) { - return 'User Token is required'; - } - if (!value.startsWith('xoxp-')) { - return 'User Token must start with "xoxp-"'; - } - return null; - }, - }); - - if (!userAccessToken) { - log('INFO', 'Manual connection cancelled: No User Token provided'); - return; // User cancelled - } - } - - // Validate User token format (for Webview path) - if (!userAccessToken.startsWith('xoxp-')) { - throw new Error('User Token must start with "xoxp-"'); - } - - // Step 2: Validate User token and retrieve workspace info from Slack API (auth.test) - log('INFO', 'Validating User token with Slack API'); - - const client = new WebClient(userAccessToken); - const authResponse = await client.auth.test(); - - if (!authResponse.ok) { - throw new Error('User Token validation failed: Invalid token'); - } - - // Extract workspace information from auth.test response - const workspaceId = authResponse.team_id as string; - const workspaceName = authResponse.team as string; - - log('INFO', 'User Token validation successful', { - workspaceId, - workspaceName, - }); - - // Step 3: Clear existing connections before storing new one (same as delete → create flow) - await tokenManager.clearConnection(); - - // Step 4: Store connection in VSCode Secret Storage (User Token only, no Bot Token) - await tokenManager.storeManualConnection( - workspaceId, - workspaceName, - workspaceId, // teamId is same as workspaceId - '', // Bot Token is no longer used - '', // userId is no longer used (author name comes from git config) - userAccessToken - ); - - log('INFO', 'Manual Slack connection stored successfully', { - workspaceId, - workspaceName, - }); - - // Step 5: Show success message (only when called from VSCode command) - if (!userToken) { - const viewDocumentation = 'View Documentation'; - const result = await vscode.window.showInformationMessage( - `Successfully connected to Slack workspace "${workspaceName}"!`, - viewDocumentation - ); - - if (result === viewDocumentation) { - await vscode.env.openExternal( - vscode.Uri.parse('https://github.com/your-repo/docs/slack-manual-token-setup.md') - ); - } - } - - // Invalidate SlackApiService client cache to force re-initialization - slackApiService.invalidateClient(); - - log('INFO', 'Manual Slack connection completed successfully'); - - // Return workspace info for Webview callers - return { - workspaceId, - workspaceName, - }; - } catch (error) { - const errorInfo = handleSlackError(error); - - log('ERROR', 'Manual Slack connection failed', { - errorCode: errorInfo.code, - messageKey: errorInfo.messageKey, - }); - - // Note: This error message is shown via VSCode native dialog, not Webview i18n - // The messageKey is logged for debugging, but we show a generic English message - await vscode.window.showErrorMessage( - `Failed to connect to Slack. Please check your token and try again.`, - 'OK' - ); - } -} diff --git a/src/extension/commands/slack-connect-oauth.ts b/src/extension/commands/slack-connect-oauth.ts deleted file mode 100644 index cfdacf73..00000000 --- a/src/extension/commands/slack-connect-oauth.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Slack OAuth Connection Command Handler - * - * Handles OAuth 2.0 authentication flow for Slack. - * Works with the external OAuth server (cc-wf-studio-connectors) to securely - * exchange authorization codes for access tokens. - * - * Based on specs/001-slack-workflow-sharing OAuth implementation plan - */ - -import * as vscode from 'vscode'; -import { log } from '../extension'; -import type { SlackApiService } from '../services/slack-api-service'; -import { SlackOAuthService } from '../services/slack-oauth-service'; -import type { SlackTokenManager } from '../utils/slack-token-manager'; - -/** - * OAuth connection result - */ -export interface OAuthConnectionResult { - workspaceId: string; - workspaceName: string; -} - -/** - * Handle Slack OAuth connection command - * - * Initiates OAuth flow, polls for authorization code, and exchanges it for tokens. - * - * @param tokenManager - Token manager instance for storing credentials - * @param slackApiService - Slack API service instance - * @param onProgress - Optional callback for progress updates - * @returns Workspace info if successful, undefined otherwise - */ -export async function handleConnectSlackOAuth( - tokenManager: SlackTokenManager, - slackApiService: SlackApiService, - oauthService: SlackOAuthService, - onProgress?: ( - status: 'initiated' | 'polling' | 'exchanging' | 'success' | 'cancelled' | 'failed' - ) => void -): Promise { - try { - log('INFO', 'Slack OAuth connection started'); - onProgress?.('initiated'); - - // Step 1: Initialize OAuth flow (registers session with OAuth server) - const { sessionId, authorizationUrl } = await oauthService.initiateOAuthFlow(); - - // Step 2: Open browser for user authentication - await oauthService.openAuthorizationUrl(authorizationUrl); - - // Step 3: Show progress message - const progressMessage = vscode.window.setStatusBarMessage( - '$(loading~spin) Waiting for Slack authentication...' - ); - - try { - onProgress?.('polling'); - - // Step 4: Poll for authorization code - const codeResult = await oauthService.pollForCode(sessionId); - - if (!codeResult) { - log('INFO', 'OAuth flow cancelled or timed out'); - onProgress?.('cancelled'); - vscode.window.showWarningMessage('Slack authentication was cancelled or timed out.'); - return undefined; - } - - onProgress?.('exchanging'); - - // Step 5: Exchange code for token - const tokenResponse = await oauthService.exchangeCodeForToken(codeResult.code); - - // User Token is required (Bot Token is no longer used) - if (!tokenResponse.authed_user?.access_token || !tokenResponse.team) { - throw new Error('Invalid token response from OAuth server: User Token is required'); - } - - // Step 6: Clear existing connections and store new one - await tokenManager.clearConnection(); - - // Store connection with User Token only (Bot Token is deprecated) - const connectionToStore = { - workspaceId: tokenResponse.team.id, - workspaceName: tokenResponse.team.name, - teamId: tokenResponse.team.id, - accessToken: '', // Bot Token is no longer used - userAccessToken: tokenResponse.authed_user.access_token, - tokenScope: tokenResponse.scope?.split(','), - userId: tokenResponse.authed_user.id || '', - botUserId: tokenResponse.bot_user_id, - authorizedAt: new Date(), - }; - - await tokenManager.storeConnection(connectionToStore); - - // Step 7: Invalidate Slack API client cache - slackApiService.invalidateClient(); - - log('INFO', 'Slack OAuth connection completed successfully', { - workspaceId: tokenResponse.team.id, - workspaceName: tokenResponse.team.name, - }); - - onProgress?.('success'); - - vscode.window.showInformationMessage( - `Successfully connected to Slack workspace "${tokenResponse.team.name}"!` - ); - - return { - workspaceId: tokenResponse.team.id, - workspaceName: tokenResponse.team.name, - }; - } finally { - progressMessage.dispose(); - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - - log('ERROR', 'Slack OAuth connection failed', { error: errorMessage }); - onProgress?.('failed'); - - vscode.window.showErrorMessage(`Failed to connect to Slack: ${errorMessage}`); - return undefined; - } -} - -/** - * Creates an OAuth service instance for cancellation support - * - * Use this when you need to handle cancellation from UI. - * - * @returns SlackOAuthService instance - */ -export function createOAuthService(): SlackOAuthService { - return new SlackOAuthService(); -} diff --git a/src/extension/commands/slack-description-generation.ts b/src/extension/commands/slack-description-generation.ts deleted file mode 100644 index 99d510ec..00000000 --- a/src/extension/commands/slack-description-generation.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Slack Description Generation Command Handler - * - * Handles GENERATE_SLACK_DESCRIPTION messages from Webview. - * Uses Claude Code CLI to generate concise workflow descriptions for Slack sharing. - */ - -import type * as vscode from 'vscode'; -import type { - GenerateSlackDescriptionPayload, - SlackDescriptionFailedPayload, - SlackDescriptionSuccessPayload, -} from '../../shared/types/messages'; -import { log } from '../extension'; -import { executeClaudeCodeCLI } from '../services/claude-code-service'; -import { SlackDescriptionPromptBuilder } from '../services/slack-description-prompt-builder'; - -/** Default timeout for description generation (30 seconds) */ -const DEFAULT_TIMEOUT_MS = 30000; - -/** Maximum description length (matches Slack share dialog limit) */ -const MAX_DESCRIPTION_LENGTH = 500; - -/** - * Handle Slack description generation request - * - * @param payload - Generation request payload - * @param webview - Webview to send response to - * @param requestId - Request ID for correlation - * @param workspaceRoot - Optional workspace root directory for CLI execution - */ -export async function handleGenerateSlackDescription( - payload: GenerateSlackDescriptionPayload, - webview: vscode.Webview, - requestId: string, - workspaceRoot?: string -): Promise { - const startTime = Date.now(); - - log('INFO', 'Slack description generation started', { - requestId, - promptFormat: 'toon', - targetLanguage: payload.targetLanguage, - workflowJsonLength: payload.workflowJson.length, - }); - - try { - // Construct the prompt - const prompt = constructDescriptionPrompt(payload.workflowJson, payload.targetLanguage); - - // Execute Claude Code CLI - const timeoutMs = payload.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const result = await executeClaudeCodeCLI(prompt, timeoutMs, requestId, workspaceRoot, 'haiku'); - - const executionTimeMs = Date.now() - startTime; - - if (!result.success || !result.output) { - log('ERROR', 'Slack description generation failed', { - requestId, - errorCode: result.error?.code, - errorMessage: result.error?.message, - executionTimeMs, - }); - - sendDescriptionFailed(webview, requestId, { - error: { - code: result.error?.code ?? 'UNKNOWN_ERROR', - message: result.error?.message ?? 'Failed to generate description', - details: result.error?.details, - }, - executionTimeMs, - timestamp: new Date().toISOString(), - }); - return; - } - - // Parse and clean the description - const description = parseDescription(result.output); - - log('INFO', 'Slack description generation succeeded', { - requestId, - descriptionLength: description.length, - executionTimeMs, - }); - - sendDescriptionSuccess(webview, requestId, { - description, - executionTimeMs, - timestamp: new Date().toISOString(), - }); - } catch (error) { - const executionTimeMs = Date.now() - startTime; - - log('ERROR', 'Unexpected error during Slack description generation', { - requestId, - error: error instanceof Error ? error.message : String(error), - executionTimeMs, - }); - - sendDescriptionFailed(webview, requestId, { - error: { - code: 'UNKNOWN_ERROR', - message: 'An unexpected error occurred', - details: error instanceof Error ? error.message : String(error), - }, - executionTimeMs, - timestamp: new Date().toISOString(), - }); - } -} - -/** - * Construct the prompt for description generation - * - * @param workflowJson - Serialized workflow JSON - * @param targetLanguage - Target language for the description - * @returns Constructed prompt string - */ -function constructDescriptionPrompt(workflowJson: string, targetLanguage: string): string { - const builder = new SlackDescriptionPromptBuilder(workflowJson, targetLanguage); - return builder.buildPrompt(); -} - -/** - * Parse and clean the AI output to extract the description - * - * @param output - Raw output from Claude Code CLI - * @returns Cleaned description string (truncated to max length) - */ -function parseDescription(output: string): string { - // Remove any markdown code blocks if present - let description = output.replace(/```[\s\S]*?```/g, '').trim(); - - // Remove any leading/trailing quotes - description = description.replace(/^["']|["']$/g, '').trim(); - - // Remove any markdown formatting - description = description - .replace(/\*\*([^*]+)\*\*/g, '$1') // Bold - .replace(/\*([^*]+)\*/g, '$1') // Italic - .replace(/_([^_]+)_/g, '$1') // Underscore italic - .replace(/`([^`]+)`/g, '$1') // Inline code - .trim(); - - // Truncate to max length if needed - if (description.length > MAX_DESCRIPTION_LENGTH) { - description = `${description.substring(0, MAX_DESCRIPTION_LENGTH - 3)}...`; - } - - return description; -} - -/** - * Send success response to webview - */ -function sendDescriptionSuccess( - webview: vscode.Webview, - requestId: string, - payload: SlackDescriptionSuccessPayload -): void { - webview.postMessage({ - type: 'SLACK_DESCRIPTION_SUCCESS', - requestId, - payload, - }); -} - -/** - * Send failure response to webview - */ -function sendDescriptionFailed( - webview: vscode.Webview, - requestId: string, - payload: SlackDescriptionFailedPayload -): void { - webview.postMessage({ - type: 'SLACK_DESCRIPTION_FAILED', - requestId, - payload, - }); -} diff --git a/src/extension/commands/slack-import-workflow.ts b/src/extension/commands/slack-import-workflow.ts deleted file mode 100644 index f995b98a..00000000 --- a/src/extension/commands/slack-import-workflow.ts +++ /dev/null @@ -1,274 +0,0 @@ -/** - * Slack Import Workflow Command Handler - * - * Handles IMPORT_WORKFLOW_FROM_SLACK messages from Webview. - * Downloads workflow file from Slack, validates, and saves to local filesystem. - * - * Based on specs/001-slack-workflow-sharing/contracts/extension-host-api-contracts.md - */ - -import * as path from 'node:path'; -import * as vscode from 'vscode'; -import type { ImportWorkflowFromSlackPayload } from '../../shared/types/messages'; -import { log } from '../extension'; -import type { FileService } from '../services/file-service'; -import type { SlackApiService } from '../services/slack-api-service'; -import type { - ImportWorkflowFailedEvent, - ImportWorkflowSuccessEvent, -} from '../types/slack-messages'; -import { migrateWorkflow } from '../utils/migrate-workflow'; -import { handleSlackError, type SlackErrorInfo } from '../utils/slack-error-handler'; -import { validateWorkflowFile } from '../utils/workflow-validator'; - -/** - * Handle workflow import from Slack - * - * @param payload - Import workflow request - * @param webview - Webview to send response to - * @param requestId - Request ID for correlation - * @param fileService - File service instance - * @param slackApiService - Slack API service instance - */ -export async function handleImportWorkflowFromSlack( - payload: ImportWorkflowFromSlackPayload, - webview: vscode.Webview, - requestId: string, - fileService: FileService, - slackApiService: SlackApiService -): Promise { - const startTime = Date.now(); - - log('INFO', 'Slack workflow import started', { - requestId, - workflowId: payload.workflowId, - fileId: payload.fileId, - workspaceId: payload.workspaceId, - }); - - try { - // Step 1: Download workflow file from Slack - log('INFO', 'Downloading workflow file from Slack', { requestId }); - const content = await slackApiService.downloadWorkflowFile(payload.workspaceId, payload.fileId); - - log('INFO', 'Workflow file downloaded successfully', { - requestId, - contentLength: content.length, - }); - - // Step 2: Validate workflow file - log('INFO', 'Validating workflow file', { requestId }); - const validationResult = validateWorkflowFile(content); - - if (!validationResult.valid) { - log('ERROR', 'Workflow validation failed', { - requestId, - errors: validationResult.errors, - }); - - sendImportFailed( - webview, - requestId, - payload.workflowId, - 'INVALID_WORKFLOW_FILE', - `Invalid workflow file: ${validationResult.errors?.join(', ')}` - ); - return; - } - - const parsedWorkflow = validationResult.workflow; - if (!parsedWorkflow) { - throw new Error('Workflow validation succeeded but workflow object is missing'); - } - - // Apply migrations for backward compatibility - const workflow = migrateWorkflow(parsedWorkflow); - - log('INFO', 'Workflow validation passed', { - requestId, - workflowName: workflow.name, - }); - - // Step 3: Select workspace for saving - const workspaceFolders = vscode.workspace.workspaceFolders; - - if (!workspaceFolders || workspaceFolders.length === 0) { - log('ERROR', 'No workspace folder is open', { requestId }); - sendImportFailed( - webview, - requestId, - payload.workflowId, - 'FILE_WRITE_ERROR', - 'No workspace folder is open. Please open a folder or workspace first.' - ); - return; - } - - let selectedWorkspace: vscode.WorkspaceFolder; - - if (workspaceFolders.length === 1) { - // Only one workspace, use it directly - selectedWorkspace = workspaceFolders[0]; - log('INFO', 'Single workspace detected, using it automatically', { - requestId, - workspaceName: selectedWorkspace.name, - }); - } else { - // Multiple workspaces, ask user to select - const workspaceItems = workspaceFolders.map((folder) => ({ - label: folder.name, - description: folder.uri.fsPath, - folder, - })); - - const selected = await vscode.window.showQuickPick(workspaceItems, { - placeHolder: `Select workspace to save workflow "${workflow.name}"`, - ignoreFocusOut: true, - }); - - if (!selected) { - log('INFO', 'User cancelled workspace selection', { requestId }); - // User cancelled, don't send error just stop - return; - } - - selectedWorkspace = selected.folder; - log('INFO', 'User selected workspace', { - requestId, - workspaceName: selectedWorkspace.name, - }); - } - - // Step 4: Determine file path - const workflowsDir = path.join(selectedWorkspace.uri.fsPath, '.vscode', 'workflows'); - const fileName = `${workflow.name.replace(/[^a-zA-Z0-9-_]/g, '_')}.json`; - const filePath = path.join(workflowsDir, fileName); - - // Ensure .vscode/workflows directory exists - const workflowsDirUri = vscode.Uri.file(workflowsDir); - try { - await vscode.workspace.fs.stat(workflowsDirUri); - } catch { - await vscode.workspace.fs.createDirectory(workflowsDirUri); - log('INFO', 'Created workflows directory', { requestId, workflowsDir }); - } - - // Step 4: Check if file exists (unless overwriting) - if (!payload.overwriteExisting) { - const fileExists = await fileService.fileExists(filePath); - - if (fileExists) { - log('WARN', 'Workflow file already exists', { - requestId, - filePath, - }); - - // Show VSCode native confirmation dialog - const userChoice = await vscode.window.showWarningMessage( - `Workflow "${workflow.name}" already exists. Do you want to overwrite it?`, - { modal: true }, - 'Overwrite', - 'Cancel' - ); - - if (userChoice !== 'Overwrite') { - log('INFO', 'User cancelled overwrite', { requestId }); - // User cancelled - hide loading overlay in Webview - webview.postMessage({ - type: 'IMPORT_WORKFLOW_CANCELLED', - requestId, - }); - return; - } - - log('INFO', 'User confirmed overwrite', { requestId }); - // Continue to save (fall through to Step 5) - } - } - - log('INFO', 'Saving workflow file to disk', { requestId, filePath }); - - // Step 5: Save workflow file - await fileService.writeFile(filePath, content); - - log('INFO', 'Workflow file saved successfully', { requestId }); - - // Step 6: Send success response with workflow data - const successEvent: ImportWorkflowSuccessEvent = { - type: 'IMPORT_WORKFLOW_SUCCESS', - payload: { - workflowId: payload.workflowId, - filePath, - workflowName: workflow.name, - workflow, - }, - }; - - webview.postMessage({ - ...successEvent, - requestId, - }); - - // Show native notification with workspace name - vscode.window.showInformationMessage( - `Workflow "${workflow.name}" imported to ${selectedWorkspace.name}/.vscode/workflows/` - ); - - log('INFO', 'Workflow import completed successfully', { - requestId, - executionTimeMs: Date.now() - startTime, - }); - } catch (error) { - const errorInfo = handleSlackError(error); - - // Log detailed error for debugging - log('ERROR', 'Workflow import failed - detailed error', { - requestId, - errorCode: errorInfo.code, - messageKey: errorInfo.messageKey, - workspaceId: errorInfo.workspaceId || payload.workspaceId, - originalError: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - executionTimeMs: Date.now() - startTime, - }); - - sendImportFailed( - webview, - requestId, - payload.workflowId, - errorInfo, - errorInfo.workspaceId || payload.workspaceId, - payload.workspaceName - ); - } -} - -/** - * Send import workflow failed event to Webview - */ -function sendImportFailed( - webview: vscode.Webview, - requestId: string, - workflowId: string, - errorInfo: SlackErrorInfo, - workspaceId?: string, - workspaceName?: string -): void { - const failedEvent: ImportWorkflowFailedEvent = { - type: 'IMPORT_WORKFLOW_FAILED', - payload: { - workflowId, - errorCode: errorInfo.code as ImportWorkflowFailedEvent['payload']['errorCode'], - messageKey: errorInfo.messageKey, - suggestedActionKey: errorInfo.suggestedActionKey, - messageParams: errorInfo.retryAfter ? { seconds: errorInfo.retryAfter } : undefined, - workspaceId, - workspaceName, - }, - }; - - webview.postMessage({ - ...failedEvent, - requestId, - }); -} diff --git a/src/extension/commands/slack-share-workflow.ts b/src/extension/commands/slack-share-workflow.ts deleted file mode 100644 index be844791..00000000 --- a/src/extension/commands/slack-share-workflow.ts +++ /dev/null @@ -1,354 +0,0 @@ -/** - * Slack Share Workflow Command Handler - * - * Handles SHARE_WORKFLOW_TO_SLACK messages from Webview. - * Implements workflow sharing with sensitive data detection and warning flow. - * - * Based on specs/001-slack-workflow-sharing/contracts/extension-host-api-contracts.md - */ - -import * as vscode from 'vscode'; -import type { ShareWorkflowToSlackPayload } from '../../shared/types/messages'; -import { log } from '../extension'; -import type { FileService } from '../services/file-service'; -import type { SlackApiService } from '../services/slack-api-service'; -import type { - SensitiveDataWarningEvent, - ShareWorkflowFailedEvent, - ShareWorkflowSuccessEvent, -} from '../types/slack-messages'; -import { detectSensitiveData } from '../utils/sensitive-data-detector'; -import { handleSlackError, type SlackErrorInfo } from '../utils/slack-error-handler'; -import type { WorkflowMessageBlock } from '../utils/slack-message-builder'; - -/** - * Handle workflow sharing to Slack - * - * @param payload - Share workflow request - * @param webview - Webview to send response to - * @param requestId - Request ID for correlation - * @param fileService - File service instance - * @param slackApiService - Slack API service instance - */ -export async function handleShareWorkflowToSlack( - payload: ShareWorkflowToSlackPayload, - webview: vscode.Webview, - requestId: string, - _fileService: FileService, - slackApiService: SlackApiService -): Promise { - const startTime = Date.now(); - - log('INFO', 'Slack workflow sharing started', { - requestId, - workflowId: payload.workflowId, - channelId: payload.channelId, - }); - - try { - // Use workflow object directly from payload (current canvas state) - const workflow = payload.workflow; - const workflowContent = JSON.stringify(workflow, null, 2); - - // Step 1: Detect sensitive data (if not overriding warning) - if (!payload.overrideSensitiveWarning) { - const findings = detectSensitiveData(workflowContent); - - if (findings.length > 0) { - log('WARN', 'Sensitive data detected in workflow', { - requestId, - findingsCount: findings.length, - types: findings.map((f) => f.type), - }); - - // Send warning to user - const warningEvent: SensitiveDataWarningEvent = { - type: 'SENSITIVE_DATA_WARNING', - payload: { - workflowId: payload.workflowId, - findings, - }, - }; - - webview.postMessage({ - ...warningEvent, - requestId, - }); - - log('INFO', 'Sensitive data warning sent to user', { requestId }); - return; // Stop here, wait for user confirmation - } - } - - log('INFO', 'No sensitive data detected or warning overridden', { requestId }); - - // Step 2: Extract workflow metadata - const nodeCount = workflow.nodes.length; - - // Step 3: Get workspace name for deep link - log('INFO', 'Getting workspace name for deep link', { requestId }); - let workspaceName: string | undefined; - try { - const workspaces = await slackApiService.getWorkspaces(); - const workspace = workspaces.find((ws) => ws.workspaceId === payload.workspaceId); - workspaceName = workspace?.workspaceName; - } catch (_e) { - log('WARN', 'Failed to get workspace name, continuing without it', { requestId }); - } - - // Step 4: Post rich message card to channel (main message) - log('INFO', 'Posting workflow message card to Slack', { requestId }); - - const messageBlock: WorkflowMessageBlock = { - workflowId: workflow.id, - name: workflow.name, - description: payload.description || workflow.description, - version: workflow.version, - nodeCount, - fileId: '', // Will be updated after file upload - workspaceId: payload.workspaceId, - workspaceName, - channelId: payload.channelId, - }; - - const messageResult = await slackApiService.postWorkflowMessage( - payload.workspaceId, - payload.channelId, - messageBlock - ); - - log('INFO', 'Workflow message card posted successfully', { - requestId, - messageTs: messageResult.messageTs, - permalink: messageResult.permalink, - }); - - // Step 5: Upload workflow file to thread as reply - log('INFO', 'Uploading workflow file to thread', { requestId }); - - const filename = `${payload.workflowName.replace(/[^a-zA-Z0-9-_]/g, '_')}.json`; - const uploadResult = await slackApiService.uploadWorkflowFile({ - workspaceId: payload.workspaceId, - content: workflowContent, - filename, - title: payload.workflowName, - channelId: payload.channelId, - threadTs: messageResult.messageTs, - }); - - log('INFO', 'Workflow file uploaded to thread successfully', { - requestId, - fileId: uploadResult.fileId, - }); - - // Step 6: Update message with complete deep links - log('INFO', 'Updating message with complete deep links', { requestId }); - - const updatedMessageBlock: WorkflowMessageBlock = { - ...messageBlock, - fileId: uploadResult.fileId, - messageTs: messageResult.messageTs, - }; - - await slackApiService.updateWorkflowMessage( - payload.workspaceId, - payload.channelId, - messageResult.messageTs, - updatedMessageBlock - ); - - log('INFO', 'Message updated with complete deep links', { requestId }); - - // Step 7: Send success response - const successEvent: ShareWorkflowSuccessEvent = { - type: 'SHARE_WORKFLOW_SUCCESS', - payload: { - workflowId: payload.workflowId, - channelId: payload.channelId, - channelName: '', // TODO: Resolve channel name from channelId - messageTs: messageResult.messageTs, - fileId: uploadResult.fileId, - permalink: messageResult.permalink, - }, - }; - - log('INFO', 'Sending success message to webview', { requestId }); - - webview.postMessage({ - ...successEvent, - requestId, - }); - - // Show native notification - const viewInSlackButton = 'View in Slack'; - const result = await vscode.window.showInformationMessage( - `Workflow "${payload.workflowName}" shared to Slack successfully!`, - viewInSlackButton - ); - - // Open Slack permalink if user clicks the button - if (result === viewInSlackButton) { - await vscode.env.openExternal(vscode.Uri.parse(messageResult.permalink)); - } - - log('INFO', 'Workflow sharing completed successfully', { - requestId, - executionTimeMs: Date.now() - startTime, - }); - } catch (error) { - const errorInfo = handleSlackError(error); - - log('ERROR', 'Workflow sharing failed', { - requestId, - errorCode: errorInfo.code, - messageKey: errorInfo.messageKey, - executionTimeMs: Date.now() - startTime, - }); - - sendShareFailed(webview, requestId, payload.workflowId, errorInfo); - } -} - -/** - * Handle list Slack workspaces request - * - * @param webview - Webview to send response to - * @param requestId - Request ID for correlation - * @param slackApiService - Slack API service instance - */ -export async function handleListSlackWorkspaces( - webview: vscode.Webview, - requestId: string, - slackApiService: SlackApiService -): Promise { - try { - log('INFO', 'Listing Slack workspaces', { requestId }); - - const workspaces = await slackApiService.getWorkspaces(); - - // Convert to message payload format - const workspaceList = workspaces.map((ws) => ({ - workspaceId: ws.workspaceId, - workspaceName: ws.workspaceName, - teamId: ws.teamId, - authorizedAt: ws.authorizedAt.toISOString(), - lastValidatedAt: ws.lastValidatedAt?.toISOString(), - })); - - webview.postMessage({ - type: 'LIST_SLACK_WORKSPACES_SUCCESS', - requestId, - payload: { - workspaces: workspaceList, - }, - }); - - log('INFO', 'Workspace list retrieved successfully', { - requestId, - count: workspaceList.length, - }); - } catch (error) { - const errorInfo = handleSlackError(error); - - log('ERROR', 'Failed to list workspaces', { - requestId, - errorCode: errorInfo.code, - messageKey: errorInfo.messageKey, - }); - - webview.postMessage({ - type: 'LIST_SLACK_WORKSPACES_FAILED', - requestId, - payload: { - errorCode: errorInfo.code, - messageKey: errorInfo.messageKey, - suggestedActionKey: errorInfo.suggestedActionKey, - }, - }); - } -} - -/** - * Handle get Slack channels request - * - * @param payload - Get channels request payload - * @param webview - Webview to send response to - * @param requestId - Request ID for correlation - * @param slackApiService - Slack API service instance - */ -export async function handleGetSlackChannels( - payload: { workspaceId: string; includePrivate?: boolean; onlyMember?: boolean }, - webview: vscode.Webview, - requestId: string, - slackApiService: SlackApiService -): Promise { - try { - log('INFO', 'Getting Slack channels', { - requestId, - workspaceId: payload.workspaceId, - }); - - const channels = await slackApiService.getChannels( - payload.workspaceId, - payload.includePrivate ?? true, - payload.onlyMember ?? true - ); - - webview.postMessage({ - type: 'GET_SLACK_CHANNELS_SUCCESS', - requestId, - payload: { - channels, - }, - }); - - log('INFO', 'Channel list retrieved successfully', { - requestId, - count: channels.length, - }); - } catch (error) { - const errorInfo = handleSlackError(error); - - log('ERROR', 'Failed to get channels', { - requestId, - errorCode: errorInfo.code, - messageKey: errorInfo.messageKey, - }); - - webview.postMessage({ - type: 'GET_SLACK_CHANNELS_FAILED', - requestId, - payload: { - errorCode: errorInfo.code, - messageKey: errorInfo.messageKey, - suggestedActionKey: errorInfo.suggestedActionKey, - }, - }); - } -} - -/** - * Send share workflow failed event to Webview - */ -function sendShareFailed( - webview: vscode.Webview, - requestId: string, - workflowId: string, - errorInfo: SlackErrorInfo -): void { - const failedEvent: ShareWorkflowFailedEvent = { - type: 'SHARE_WORKFLOW_FAILED', - payload: { - workflowId, - errorCode: errorInfo.code as ShareWorkflowFailedEvent['payload']['errorCode'], - messageKey: errorInfo.messageKey, - suggestedActionKey: errorInfo.suggestedActionKey, - messageParams: errorInfo.retryAfter ? { seconds: errorInfo.retryAfter } : undefined, - }, - }; - - webview.postMessage({ - ...failedEvent, - requestId, - }); -} diff --git a/src/extension/commands/text-editor.ts b/src/extension/commands/text-editor.ts deleted file mode 100644 index 8613d352..00000000 --- a/src/extension/commands/text-editor.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Claude Code Workflow Studio - Text Editor Command - * - * Opens text content in VSCode's native editor for enhanced editing experience. - * Feature: Edit in VSCode Editor functionality - */ - -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import * as vscode from 'vscode'; -import type { OpenInEditorPayload } from '../../shared/types/messages'; - -/** - * Active editor sessions tracking - * Maps sessionId to session data for cleanup and response handling - */ -const activeSessions = new Map< - string, - { - filePath: string; - webview: vscode.Webview; - disposables: vscode.Disposable[]; - } ->(); - -/** - * Get file extension based on language - */ -function getExtension(language: string): string { - switch (language) { - case 'markdown': - return '.md'; - case 'plaintext': - return '.txt'; - default: - return '.txt'; - } -} - -/** - * Get temporary directory for editor files. - * Prefers .vscode/ in workspace for cross-platform path consistency, - * falls back to OS temp directory if no workspace is open. - */ -function getTempDirectory(): string { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (workspaceFolders && workspaceFolders.length > 0) { - const vscodeDir = path.join(workspaceFolders[0].uri.fsPath, '.vscode'); - // Ensure .vscode directory exists - if (!fs.existsSync(vscodeDir)) { - fs.mkdirSync(vscodeDir, { recursive: true }); - } - return vscodeDir; - } - return os.tmpdir(); -} - -/** - * Handle OPEN_IN_EDITOR message from webview - * - * Opens the provided content in a new VSCode text editor using a temporary file, - * allowing users to edit with their full editor customizations. - */ -export async function handleOpenInEditor( - payload: OpenInEditorPayload, - webview: vscode.Webview -): Promise { - const { sessionId, content, language = 'markdown' } = payload; - - try { - // Create a temporary file with the content - // Use .vscode/ in workspace for cross-platform path consistency (Windows path normalization) - const tmpDir = getTempDirectory(); - const fileName = `tmp-cc-wf-studio-${sessionId}${getExtension(language)}`; - const filePath = path.join(tmpDir, fileName); - - // Write content to temporary file - fs.writeFileSync(filePath, content, 'utf-8'); - - // Open the file in editor - // Use URI's fsPath for cross-platform path normalization (Windows case sensitivity, drive letter, etc.) - const uri = vscode.Uri.file(filePath); - const normalizedFilePath = uri.fsPath; - const doc = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(doc, { - preview: false, - viewColumn: vscode.ViewColumn.Beside, - }); - - const disposables: vscode.Disposable[] = []; - - // Set up save listener - this works for :w, Ctrl+S, menu save, etc. - const saveDisposable = vscode.workspace.onDidSaveTextDocument((savedDoc) => { - if (savedDoc.uri.fsPath !== normalizedFilePath) return; - - // Send content to webview - webview.postMessage({ - type: 'EDITOR_CONTENT_UPDATED', - payload: { - sessionId, - content: savedDoc.getText(), - saved: true, - }, - }); - - // Cleanup and close editor - cleanupSession(sessionId); - vscode.commands.executeCommand('workbench.action.closeActiveEditor'); - vscode.window.showInformationMessage('Content applied successfully'); - }); - disposables.push(saveDisposable); - - // Set up listener to detect when our editor is no longer visible (closed) - const editorChangeDisposable = vscode.window.onDidChangeVisibleTextEditors((editors) => { - const session = activeSessions.get(sessionId); - if (!session) return; // Already cleaned up by save handler - - // Check if our file is still open in any visible editor - // Use normalizedFilePath for cross-platform path comparison - const isStillOpen = editors.some( - (editor) => editor.document.uri.fsPath === normalizedFilePath - ); - - if (!isStillOpen) { - // Editor was closed without saving - // Read final content from file - let finalContent = content; - try { - if (fs.existsSync(filePath)) { - finalContent = fs.readFileSync(filePath, 'utf-8'); - } - } catch { - // Use original content if file read fails - } - - webview.postMessage({ - type: 'EDITOR_CONTENT_UPDATED', - payload: { - sessionId, - content: finalContent, - saved: false, - }, - }); - - cleanupSession(sessionId); - } - }); - disposables.push(editorChangeDisposable); - - // Store session with normalized file path for cross-platform consistency - activeSessions.set(sessionId, { filePath: normalizedFilePath, webview, disposables }); - } catch (error) { - // Send error back to webview - webview.postMessage({ - type: 'EDITOR_CONTENT_UPDATED', - payload: { - sessionId, - content, - saved: false, - }, - }); - - vscode.window.showErrorMessage( - `Failed to open editor: ${error instanceof Error ? error.message : 'Unknown error'}` - ); - } -} - -/** - * Cleanup a session: dispose listeners, delete temp file, clear context - */ -function cleanupSession(sessionId: string): void { - const session = activeSessions.get(sessionId); - if (!session) return; - - // Dispose all listeners - for (const disposable of session.disposables) { - disposable.dispose(); - } - - // Delete temporary file - try { - if (fs.existsSync(session.filePath)) { - fs.unlinkSync(session.filePath); - } - } catch { - // Ignore file deletion errors - } - - // Remove from active sessions - activeSessions.delete(sessionId); -} diff --git a/src/extension/commands/workflow-name-generation.ts b/src/extension/commands/workflow-name-generation.ts deleted file mode 100644 index 124e736e..00000000 --- a/src/extension/commands/workflow-name-generation.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Workflow Name Generation Command Handler - * - * Handles GENERATE_WORKFLOW_NAME messages from Webview. - * Uses Claude Code CLI to generate concise workflow names in kebab-case format. - */ - -import type * as vscode from 'vscode'; -import type { - GenerateWorkflowNamePayload, - WorkflowNameFailedPayload, - WorkflowNameSuccessPayload, -} from '../../shared/types/messages'; -import { log } from '../extension'; -import { executeClaudeCodeCLI } from '../services/claude-code-service'; -import { WorkflowNamePromptBuilder } from '../services/workflow-name-prompt-builder'; - -/** Default timeout for name generation (30 seconds) */ -const DEFAULT_TIMEOUT_MS = 30000; - -/** Maximum name length */ -const MAX_NAME_LENGTH = 64; - -/** - * Handle workflow name generation request - * - * @param payload - Generation request payload - * @param webview - Webview to send response to - * @param requestId - Request ID for correlation - * @param workspaceRoot - Optional workspace root directory for CLI execution - */ -export async function handleGenerateWorkflowName( - payload: GenerateWorkflowNamePayload, - webview: vscode.Webview, - requestId: string, - workspaceRoot?: string -): Promise { - const startTime = Date.now(); - - log('INFO', 'Workflow name generation started', { - requestId, - promptFormat: 'toon', - targetLanguage: payload.targetLanguage, - workflowJsonLength: payload.workflowJson.length, - }); - - try { - // Construct the prompt - const prompt = constructNamePrompt(payload.workflowJson, payload.targetLanguage); - - // Execute Claude Code CLI - const timeoutMs = payload.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const result = await executeClaudeCodeCLI(prompt, timeoutMs, requestId, workspaceRoot, 'haiku'); - - const executionTimeMs = Date.now() - startTime; - - if (!result.success || !result.output) { - log('ERROR', 'Workflow name generation failed', { - requestId, - errorCode: result.error?.code, - errorMessage: result.error?.message, - executionTimeMs, - }); - - sendNameFailed(webview, requestId, { - error: { - code: result.error?.code ?? 'UNKNOWN_ERROR', - message: result.error?.message ?? 'Failed to generate name', - details: result.error?.details, - }, - executionTimeMs, - timestamp: new Date().toISOString(), - }); - return; - } - - // Parse and clean the name - const name = parseName(result.output); - - log('INFO', 'Workflow name generation succeeded', { - requestId, - nameLength: name.length, - generatedName: name, - executionTimeMs, - }); - - sendNameSuccess(webview, requestId, { - name, - executionTimeMs, - timestamp: new Date().toISOString(), - }); - } catch (error) { - const executionTimeMs = Date.now() - startTime; - - log('ERROR', 'Unexpected error during workflow name generation', { - requestId, - error: error instanceof Error ? error.message : String(error), - executionTimeMs, - }); - - sendNameFailed(webview, requestId, { - error: { - code: 'UNKNOWN_ERROR', - message: 'An unexpected error occurred', - details: error instanceof Error ? error.message : String(error), - }, - executionTimeMs, - timestamp: new Date().toISOString(), - }); - } -} - -/** - * Construct the prompt for name generation - * - * @param workflowJson - Serialized workflow JSON - * @param targetLanguage - Target language for the name - * @returns Constructed prompt string - */ -function constructNamePrompt(workflowJson: string, targetLanguage: string): string { - const builder = new WorkflowNamePromptBuilder(workflowJson, targetLanguage); - return builder.buildPrompt(); -} - -/** - * Parse and clean the AI output to extract the name - * - * @param output - Raw output from Claude Code CLI - * @returns Cleaned name string (kebab-case, truncated to max length) - */ -function parseName(output: string): string { - // Remove any markdown code blocks if present - let name = output.replace(/```[\s\S]*?```/g, '').trim(); - - // Remove any leading/trailing quotes - name = name.replace(/^["']|["']$/g, '').trim(); - - // Remove any markdown formatting - name = name - .replace(/\*\*([^*]+)\*\*/g, '$1') // Bold - .replace(/\*([^*]+)\*/g, '$1') // Italic - .replace(/_([^_]+)_/g, '$1') // Underscore italic - .replace(/`([^`]+)`/g, '$1') // Inline code - .trim(); - - // Normalize to kebab-case - name = name - .toLowerCase() - .replace(/[^a-z0-9-]/g, '-') // Replace non-alphanumeric with hyphen - .replace(/-+/g, '-') // Collapse multiple hyphens - .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens - - // Truncate to max length if needed - if (name.length > MAX_NAME_LENGTH) { - name = name.substring(0, MAX_NAME_LENGTH).replace(/-$/, ''); - } - - // Fallback if empty - return name || 'untitled-workflow'; -} - -/** - * Send success response to webview - */ -function sendNameSuccess( - webview: vscode.Webview, - requestId: string, - payload: WorkflowNameSuccessPayload -): void { - webview.postMessage({ - type: 'WORKFLOW_NAME_SUCCESS', - requestId, - payload, - }); -} - -/** - * Send failure response to webview - */ -function sendNameFailed( - webview: vscode.Webview, - requestId: string, - payload: WorkflowNameFailedPayload -): void { - webview.postMessage({ - type: 'WORKFLOW_NAME_FAILED', - requestId, - payload, - }); -} diff --git a/src/extension/commands/workflow-refinement.ts b/src/extension/commands/workflow-refinement.ts deleted file mode 100644 index 5512a9e5..00000000 --- a/src/extension/commands/workflow-refinement.ts +++ /dev/null @@ -1,811 +0,0 @@ -/** - * Workflow Refinement Command Handler - * - * Handles REFINE_WORKFLOW and CLEAR_CONVERSATION messages from Webview. - * Based on: /specs/001-ai-workflow-refinement/quickstart.md Section 2.2 - */ - -import type * as vscode from 'vscode'; -import type { - CancelRefinementPayload, - ClearConversationPayload, - ConversationClearedPayload, - RefinementCancelledPayload, - RefinementClarificationPayload, - RefinementFailedPayload, - RefinementProgressPayload, - RefinementSuccessPayload, - RefineWorkflowPayload, - SubAgentFlowRefinementSuccessPayload, -} from '../../shared/types/messages'; -import type { ConversationMessage } from '../../shared/types/workflow-definition'; -import { log } from '../extension'; -import { cancelAiRequest } from '../services/ai-provider'; -import { - DEFAULT_REFINEMENT_TIMEOUT_MS, - refineSubAgentFlow, - refineWorkflow, -} from '../services/refinement-service'; - -/** - * Handle workflow refinement request - * - * @param payload - Refinement request from Webview - * @param webview - Webview to send response messages to - * @param requestId - Request ID for correlation - * @param extensionPath - VSCode extension path for schema loading - * @param workspaceRoot - The workspace root path for CLI execution - */ -export async function handleRefineWorkflow( - payload: RefineWorkflowPayload, - webview: vscode.Webview, - requestId: string, - extensionPath: string, - workspaceRoot?: string -): Promise { - const { - workflowId, - userMessage, - currentWorkflow, - conversationHistory, - useSkills = true, - timeoutMs, - targetType = 'workflow', - subAgentFlowId, - model = 'sonnet', - allowedTools, - previousValidationErrors, - provider = 'claude-code', - copilotModel = 'gpt-4o', - codexModel = '', - codexReasoningEffort = 'low', - useCodex = false, - } = payload; - const startTime = Date.now(); - - // Use provided timeout or default - const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REFINEMENT_TIMEOUT_MS; - - log('INFO', 'Workflow refinement request received', { - requestId, - workflowId, - messageLength: userMessage.length, - currentIteration: conversationHistory.currentIteration, - maxIterations: conversationHistory.maxIterations, - useSkills, - useCodex, - timeoutMs: effectiveTimeoutMs, - targetType, - subAgentFlowId, - model, - allowedTools, - hasPreviousErrors: !!previousValidationErrors && previousValidationErrors.length > 0, - previousErrorCount: previousValidationErrors?.length ?? 0, - provider, - copilotModel, - codexModel, - codexReasoningEffort, - }); - - // Route to SubAgentFlow refinement if targetType is 'subAgentFlow' - if (targetType === 'subAgentFlow') { - await handleRefineSubAgentFlow(payload, webview, requestId, extensionPath, workspaceRoot); - return; - } - - try { - // Check iteration limit - if (conversationHistory.currentIteration >= conversationHistory.maxIterations) { - log('WARN', 'Iteration limit reached', { - requestId, - workflowId, - currentIteration: conversationHistory.currentIteration, - maxIterations: conversationHistory.maxIterations, - }); - - sendRefinementFailed(webview, requestId, { - error: { - code: 'ITERATION_LIMIT_REACHED', - message: `Maximum iteration limit (${conversationHistory.maxIterations}) reached. Please clear conversation history to continue.`, - }, - executionTimeMs: Date.now() - startTime, - timestamp: new Date().toISOString(), - }); - return; - } - - // Create streaming progress callback - const onProgress = ( - chunk: string, - displayText: string, - explanatoryText: string, - contentType?: 'tool_use' | 'text' - ) => { - log('INFO', 'onProgress callback invoked', { - requestId, - chunkLength: chunk.length, - displayTextLength: displayText.length, - explanatoryTextLength: explanatoryText.length, - contentType, - }); - - sendRefinementProgress(webview, requestId, { - chunk, - accumulatedText: displayText, - explanatoryText, - contentType, - timestamp: new Date().toISOString(), - }); - }; - - // Execute refinement with streaming - const result = await refineWorkflow( - currentWorkflow, - conversationHistory, - userMessage, - extensionPath, - useSkills, - effectiveTimeoutMs, - requestId, - workspaceRoot, - onProgress, - model, - allowedTools, - previousValidationErrors, - provider, - copilotModel, - codexModel, - codexReasoningEffort, - useCodex - ); - - // Check if AI is asking for clarification - if (result.success && result.clarificationMessage && !result.refinedWorkflow) { - // AI is requesting clarification - log('INFO', 'AI requested clarification', { - requestId, - workflowId, - messagePreview: result.clarificationMessage.substring(0, 100), - executionTimeMs: result.executionTimeMs, - }); - - // Create AI clarification message - const aiMessage: ConversationMessage = { - id: crypto.randomUUID(), - sender: 'ai', - content: result.clarificationMessage, - timestamp: new Date().toISOString(), - }; - - // Create user message - const userMessageObj: ConversationMessage = { - id: crypto.randomUUID(), - sender: 'user', - content: userMessage, - timestamp: new Date().toISOString(), - }; - - // Update conversation history with session ID for continuity - const updatedHistory = { - ...conversationHistory, - messages: [...conversationHistory.messages, userMessageObj, aiMessage], - currentIteration: conversationHistory.currentIteration + 1, - updatedAt: new Date().toISOString(), - sessionId: result.newSessionId || conversationHistory.sessionId, - }; - - log('INFO', 'Sending clarification request to webview', { - requestId, - workflowId, - newIteration: updatedHistory.currentIteration, - }); - - // Send clarification message - sendRefinementClarification(webview, requestId, { - aiMessage, - updatedConversationHistory: updatedHistory, - executionTimeMs: result.executionTimeMs, - timestamp: new Date().toISOString(), - sessionReconnected: result.sessionReconnected, - }); - return; - } - - if (!result.success || !result.refinedWorkflow) { - // Refinement failed - log('ERROR', 'Workflow refinement failed', { - requestId, - workflowId, - errorCode: result.error?.code, - errorMessage: result.error?.message, - validationErrors: result.validationErrors, - executionTimeMs: result.executionTimeMs, - }); - - sendRefinementFailed(webview, requestId, { - error: result.error ?? { - code: 'UNKNOWN_ERROR', - message: 'Unknown error occurred during refinement', - }, - executionTimeMs: result.executionTimeMs, - timestamp: new Date().toISOString(), - validationErrors: result.validationErrors, - }); - return; - } - - // Create AI message - use AI's message if available, otherwise use translation key - const aiMessage: ConversationMessage = { - id: crypto.randomUUID(), - sender: 'ai', - content: result.aiMessage || 'Workflow has been updated.', // AI message or fallback - translationKey: result.aiMessage ? undefined : 'refinement.success.defaultMessage', - timestamp: new Date().toISOString(), - }; - - // Create user message - const userMessageObj: ConversationMessage = { - id: crypto.randomUUID(), - sender: 'user', - content: userMessage, - timestamp: new Date().toISOString(), - }; - - // Update conversation history with session ID for continuity - const updatedHistory = { - ...conversationHistory, - messages: [...conversationHistory.messages, userMessageObj, aiMessage], - currentIteration: conversationHistory.currentIteration + 1, - updatedAt: new Date().toISOString(), - sessionId: result.newSessionId || conversationHistory.sessionId, - }; - - // Attach updated conversation history to refined workflow - result.refinedWorkflow.conversationHistory = updatedHistory; - - log('INFO', 'Workflow refinement successful', { - requestId, - workflowId, - executionTimeMs: result.executionTimeMs, - newIteration: updatedHistory.currentIteration, - totalMessages: updatedHistory.messages.length, - }); - - sendRefinementSuccess(webview, requestId, { - refinedWorkflow: result.refinedWorkflow, - aiMessage, - updatedConversationHistory: updatedHistory, - executionTimeMs: result.executionTimeMs, - timestamp: new Date().toISOString(), - sessionReconnected: result.sessionReconnected, - }); - } catch (error) { - const executionTimeMs = Date.now() - startTime; - - log('ERROR', 'Unexpected error in handleRefineWorkflow', { - requestId, - workflowId, - errorMessage: error instanceof Error ? error.message : String(error), - executionTimeMs, - }); - - sendRefinementFailed(webview, requestId, { - error: { - code: 'UNKNOWN_ERROR', - message: error instanceof Error ? error.message : 'Unknown error occurred', - }, - executionTimeMs, - timestamp: new Date().toISOString(), - }); - } -} - -/** - * Handle SubAgentFlow refinement request - * - * @param payload - Refinement request from Webview - * @param webview - Webview to send response messages to - * @param requestId - Request ID for correlation - * @param extensionPath - VSCode extension path for schema loading - * @param workspaceRoot - The workspace root path for CLI execution - */ -async function handleRefineSubAgentFlow( - payload: RefineWorkflowPayload, - webview: vscode.Webview, - requestId: string, - extensionPath: string, - workspaceRoot?: string -): Promise { - const { - workflowId, - userMessage, - currentWorkflow, - conversationHistory, - useSkills = true, - timeoutMs, - subAgentFlowId, - model = 'sonnet', - allowedTools, - provider = 'claude-code', - copilotModel = 'gpt-4o', - codexModel = '', - codexReasoningEffort = 'low', - useCodex = false, - } = payload; - const startTime = Date.now(); - - // Use provided timeout or default - const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REFINEMENT_TIMEOUT_MS; - - log('INFO', 'SubAgentFlow refinement request received', { - requestId, - workflowId, - subAgentFlowId, - messageLength: userMessage.length, - currentIteration: conversationHistory.currentIteration, - maxIterations: conversationHistory.maxIterations, - useSkills, - useCodex, - timeoutMs: effectiveTimeoutMs, - model, - allowedTools, - provider, - copilotModel, - codexModel, - codexReasoningEffort, - }); - - // Validate subAgentFlowId - if (!subAgentFlowId) { - log('ERROR', 'SubAgentFlow ID is required for SubAgentFlow refinement', { - requestId, - workflowId, - }); - - sendRefinementFailed(webview, requestId, { - error: { - code: 'VALIDATION_ERROR', - message: 'SubAgentFlow ID is required for SubAgentFlow refinement', - }, - executionTimeMs: Date.now() - startTime, - timestamp: new Date().toISOString(), - }); - return; - } - - // Find the SubAgentFlow - const subAgentFlow = currentWorkflow.subAgentFlows?.find((saf) => saf.id === subAgentFlowId); - - if (!subAgentFlow) { - log('ERROR', 'SubAgentFlow not found', { - requestId, - workflowId, - subAgentFlowId, - }); - - sendRefinementFailed(webview, requestId, { - error: { - code: 'VALIDATION_ERROR', - message: `SubAgentFlow with ID "${subAgentFlowId}" not found`, - }, - executionTimeMs: Date.now() - startTime, - timestamp: new Date().toISOString(), - }); - return; - } - - try { - // Check iteration limit - if (conversationHistory.currentIteration >= conversationHistory.maxIterations) { - log('WARN', 'Iteration limit reached for SubAgentFlow', { - requestId, - workflowId, - subAgentFlowId, - currentIteration: conversationHistory.currentIteration, - maxIterations: conversationHistory.maxIterations, - }); - - sendRefinementFailed(webview, requestId, { - error: { - code: 'ITERATION_LIMIT_REACHED', - message: `Maximum iteration limit (${conversationHistory.maxIterations}) reached. Please clear conversation history to continue.`, - }, - executionTimeMs: Date.now() - startTime, - timestamp: new Date().toISOString(), - }); - return; - } - - // Execute SubAgentFlow refinement - const result = await refineSubAgentFlow( - { nodes: subAgentFlow.nodes, connections: subAgentFlow.connections }, - conversationHistory, - userMessage, - extensionPath, - useSkills, - effectiveTimeoutMs, - requestId, - workspaceRoot, - model, - allowedTools, - provider, - copilotModel, - codexModel, - codexReasoningEffort, - useCodex - ); - - // Check if AI is asking for clarification - if (result.success && result.clarificationMessage && !result.refinedInnerWorkflow) { - log('INFO', 'AI requested clarification for SubAgentFlow', { - requestId, - workflowId, - subAgentFlowId, - messagePreview: result.clarificationMessage.substring(0, 100), - executionTimeMs: result.executionTimeMs, - }); - - // Create AI clarification message - const aiMessage: ConversationMessage = { - id: crypto.randomUUID(), - sender: 'ai', - content: result.clarificationMessage, - timestamp: new Date().toISOString(), - }; - - // Create user message - const userMessageObj: ConversationMessage = { - id: crypto.randomUUID(), - sender: 'user', - content: userMessage, - timestamp: new Date().toISOString(), - }; - - // Update conversation history with session ID for continuity - const updatedHistory = { - ...conversationHistory, - messages: [...conversationHistory.messages, userMessageObj, aiMessage], - currentIteration: conversationHistory.currentIteration + 1, - updatedAt: new Date().toISOString(), - sessionId: result.newSessionId || conversationHistory.sessionId, - }; - - log('INFO', 'Sending clarification request for SubAgentFlow to webview', { - requestId, - workflowId, - subAgentFlowId, - newIteration: updatedHistory.currentIteration, - }); - - // Send clarification message (reuse existing message type) - sendRefinementClarification(webview, requestId, { - aiMessage, - updatedConversationHistory: updatedHistory, - executionTimeMs: result.executionTimeMs, - timestamp: new Date().toISOString(), - sessionReconnected: result.sessionReconnected, - }); - return; - } - - if (!result.success || !result.refinedInnerWorkflow) { - // Refinement failed - log('ERROR', 'SubAgentFlow refinement failed', { - requestId, - workflowId, - subAgentFlowId, - errorCode: result.error?.code, - errorMessage: result.error?.message, - executionTimeMs: result.executionTimeMs, - }); - - sendRefinementFailed(webview, requestId, { - error: result.error ?? { - code: 'UNKNOWN_ERROR', - message: 'Unknown error occurred during SubAgentFlow refinement', - }, - executionTimeMs: result.executionTimeMs, - timestamp: new Date().toISOString(), - }); - return; - } - - // Create AI message - use AI's message if available, otherwise use translation key - const aiMessage: ConversationMessage = { - id: crypto.randomUUID(), - sender: 'ai', - content: result.aiMessage || 'Sub-Agent Flow has been updated.', // AI message or fallback - translationKey: result.aiMessage - ? undefined - : 'subAgentFlow.refinement.success.defaultMessage', - timestamp: new Date().toISOString(), - }; - - // Create user message - const userMessageObj: ConversationMessage = { - id: crypto.randomUUID(), - sender: 'user', - content: userMessage, - timestamp: new Date().toISOString(), - }; - - // Update conversation history with session ID for continuity - const updatedHistory = { - ...conversationHistory, - messages: [...conversationHistory.messages, userMessageObj, aiMessage], - currentIteration: conversationHistory.currentIteration + 1, - updatedAt: new Date().toISOString(), - sessionId: result.newSessionId || conversationHistory.sessionId, - }; - - log('INFO', 'SubAgentFlow refinement successful', { - requestId, - workflowId, - subAgentFlowId, - executionTimeMs: result.executionTimeMs, - newIteration: updatedHistory.currentIteration, - totalMessages: updatedHistory.messages.length, - }); - - // Send SubAgentFlow-specific success response - sendSubAgentFlowRefinementSuccess(webview, requestId, { - subAgentFlowId, - refinedInnerWorkflow: result.refinedInnerWorkflow, - aiMessage, - updatedConversationHistory: updatedHistory, - executionTimeMs: result.executionTimeMs, - timestamp: new Date().toISOString(), - sessionReconnected: result.sessionReconnected, - }); - } catch (error) { - const executionTimeMs = Date.now() - startTime; - - log('ERROR', 'Unexpected error in handleRefineSubAgentFlow', { - requestId, - workflowId, - subAgentFlowId, - errorMessage: error instanceof Error ? error.message : String(error), - executionTimeMs, - }); - - sendRefinementFailed(webview, requestId, { - error: { - code: 'UNKNOWN_ERROR', - message: error instanceof Error ? error.message : 'Unknown error occurred', - }, - executionTimeMs, - timestamp: new Date().toISOString(), - }); - } -} - -/** - * Handle conversation history clear request - * - * @param payload - Clear conversation request from Webview - * @param webview - Webview to send response messages to - * @param requestId - Request ID for correlation - */ -export async function handleClearConversation( - payload: ClearConversationPayload, - webview: vscode.Webview, - requestId: string -): Promise { - const { workflowId } = payload; - - log('INFO', 'Clear conversation request received', { - requestId, - workflowId, - }); - - try { - // Send success response - // Note: The actual conversation history clearing is handled in the Webview store - // The Extension Host just acknowledges the request - sendConversationCleared(webview, requestId, { - workflowId, - }); - - log('INFO', 'Conversation cleared successfully', { - requestId, - workflowId, - }); - } catch (error) { - log('ERROR', 'Unexpected error in handleClearConversation', { - requestId, - workflowId, - errorMessage: error instanceof Error ? error.message : String(error), - }); - - // For simplicity, we still send success even if there's an error - // since clearing is a local operation - sendConversationCleared(webview, requestId, { - workflowId, - }); - } -} - -/** - * Send refinement progress message to Webview (streaming) - */ -function sendRefinementProgress( - webview: vscode.Webview, - requestId: string, - payload: RefinementProgressPayload -): void { - webview.postMessage({ - type: 'REFINEMENT_PROGRESS', - requestId, - payload, - }); -} - -/** - * Send refinement success message to Webview - */ -function sendRefinementSuccess( - webview: vscode.Webview, - requestId: string, - payload: RefinementSuccessPayload -): void { - webview.postMessage({ - type: 'REFINEMENT_SUCCESS', - requestId, - payload, - }); -} - -/** - * Send refinement failed message to Webview - */ -function sendRefinementFailed( - webview: vscode.Webview, - requestId: string, - payload: RefinementFailedPayload -): void { - webview.postMessage({ - type: 'REFINEMENT_FAILED', - requestId, - payload, - }); -} - -/** - * Send refinement clarification message to Webview - */ -function sendRefinementClarification( - webview: vscode.Webview, - requestId: string, - payload: RefinementClarificationPayload -): void { - webview.postMessage({ - type: 'REFINEMENT_CLARIFICATION', - requestId, - payload, - }); -} - -/** - * Send conversation cleared message to Webview - */ -function sendConversationCleared( - webview: vscode.Webview, - requestId: string, - payload: ConversationClearedPayload -): void { - webview.postMessage({ - type: 'CONVERSATION_CLEARED', - requestId, - payload, - }); -} - -/** - * Send SubAgentFlow refinement success message to Webview - */ -function sendSubAgentFlowRefinementSuccess( - webview: vscode.Webview, - requestId: string, - payload: SubAgentFlowRefinementSuccessPayload -): void { - webview.postMessage({ - type: 'SUBAGENTFLOW_REFINEMENT_SUCCESS', - requestId, - payload, - }); -} - -/** - * Handle workflow refinement cancellation request - * - * @param payload - Cancellation request from Webview - * @param webview - Webview to send response messages to - * @param requestId - Request ID for correlation - */ -export async function handleCancelRefinement( - payload: CancelRefinementPayload, - webview: vscode.Webview, - requestId: string -): Promise { - const { requestId: targetRequestId } = payload; - - log('INFO', 'Refinement cancellation request received', { - requestId, - targetRequestId, - }); - - try { - // Cancel the active refinement process - // Try all providers since we don't know which one is active - const [claudeResult, copilotResult, codexResult] = await Promise.all([ - cancelAiRequest('claude-code', targetRequestId), - cancelAiRequest('copilot', targetRequestId), - cancelAiRequest('codex', targetRequestId), - ]); - - const cancelled = claudeResult.cancelled || copilotResult.cancelled || codexResult.cancelled; - const executionTimeMs = - claudeResult.executionTimeMs ?? - copilotResult.executionTimeMs ?? - codexResult.executionTimeMs ?? - 0; - - if (cancelled) { - const cancelledBy = claudeResult.cancelled - ? 'claude-code' - : copilotResult.cancelled - ? 'copilot' - : 'codex'; - log('INFO', 'Refinement cancelled successfully', { - requestId, - targetRequestId, - executionTimeMs, - cancelledBy, - }); - - // Send cancellation confirmation - sendRefinementCancelled(webview, targetRequestId, { - executionTimeMs, - timestamp: new Date().toISOString(), - }); - } else { - log('WARN', 'Refinement process not found or already completed', { - requestId, - targetRequestId, - }); - - // Still send cancellation message (process may have already completed) - sendRefinementCancelled(webview, targetRequestId, { - executionTimeMs: 0, - timestamp: new Date().toISOString(), - }); - } - } catch (error) { - log('ERROR', 'Unexpected error in handleCancelRefinement', { - requestId, - targetRequestId, - errorMessage: error instanceof Error ? error.message : String(error), - }); - - // Send cancellation message anyway - sendRefinementCancelled(webview, targetRequestId, { - executionTimeMs: 0, - timestamp: new Date().toISOString(), - }); - } -} - -/** - * Send refinement cancelled message to Webview - */ -function sendRefinementCancelled( - webview: vscode.Webview, - requestId: string, - payload: RefinementCancelledPayload -): void { - webview.postMessage({ - type: 'REFINEMENT_CANCELLED', - requestId, - payload, - }); -} diff --git a/src/extension/editors/workflow-preview-editor-provider.ts b/src/extension/editors/workflow-preview-editor-provider.ts deleted file mode 100644 index b7578997..00000000 --- a/src/extension/editors/workflow-preview-editor-provider.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Claude Code Workflow Studio - Workflow Preview Editor Provider - * - * Custom editor provider that shows a visual preview of workflow JSON files - * instead of the default JSON text editor. - */ - -import { execFileSync } from 'node:child_process'; -import * as path from 'node:path'; -import * as vscode from 'vscode'; -import type { Workflow } from '../../shared/types/workflow-definition'; -import { loadWorkflowIntoEditor, prepareEditorForLoad } from '../commands/open-editor'; -import { log } from '../extension'; -import { migrateWorkflow } from '../utils/migrate-workflow'; -import { validateWorkflowFile } from '../utils/workflow-validator'; -import { getWebviewContent } from '../webview-content'; - -/** - * Check if a file has uncommitted git changes - */ -function hasGitChanges(filePath: string): boolean { - try { - const dir = path.dirname(filePath); - const fileName = path.basename(filePath); - // Check for both staged and unstaged changes - // Use execFileSync with array args to avoid shell escaping issues on Windows - const result = execFileSync('git', ['diff', '--name-only', 'HEAD', '--', fileName], { - cwd: dir, - encoding: 'utf-8', - timeout: 5000, - }); - return result.trim().length > 0; - } catch { - // If git command fails (not a git repo, etc.), return false - return false; - } -} - -/** - * Custom editor provider for workflow JSON files - * Opens a visual preview instead of the default text editor - */ -export class WorkflowPreviewEditorProvider implements vscode.CustomTextEditorProvider { - public static readonly viewType = 'cc-wf-studio.workflowPreview'; - - constructor(private readonly context: vscode.ExtensionContext) {} - - /** - * Register the custom editor provider - */ - public static register(context: vscode.ExtensionContext): vscode.Disposable { - const provider = new WorkflowPreviewEditorProvider(context); - const registration = vscode.window.registerCustomEditorProvider( - WorkflowPreviewEditorProvider.viewType, - provider, - { - webviewOptions: { - retainContextWhenHidden: true, - }, - supportsMultipleEditorsPerDocument: false, - } - ); - return registration; - } - - /** - * Called when a custom editor is opened - */ - public async resolveCustomTextEditor( - document: vscode.TextDocument, - webviewPanel: vscode.WebviewPanel, - _token: vscode.CancellationToken - ): Promise { - // Configure webview - webviewPanel.webview.options = { - enableScripts: true, - localResourceRoots: [ - vscode.Uri.joinPath(this.context.extensionUri, 'src', 'webview', 'dist'), - ], - }; - - // Set webview HTML content - webviewPanel.webview.html = getWebviewContent(webviewPanel.webview, this.context.extensionUri); - - // Parse and send initial workflow - const sendWorkflow = () => { - const { workflow, error } = this.parseWorkflow(document); - - // Detect if this is a historical version (git diff "before" side) - // Git diff uses 'git' scheme for historical versions - const isHistoricalVersion = document.uri.scheme === 'git'; - - // Check if file has git changes (for showing "After" badge on current version) - const fileHasGitChanges = - !isHistoricalVersion && document.uri.scheme === 'file' - ? hasGitChanges(document.uri.fsPath) - : false; - - if (error) { - webviewPanel.webview.postMessage({ - type: 'PREVIEW_PARSE_ERROR', - payload: { error }, - }); - } else if (workflow) { - webviewPanel.webview.postMessage({ - type: 'PREVIEW_MODE_INIT', - payload: { workflow, isHistoricalVersion, hasGitChanges: fileHasGitChanges }, - }); - } - }; - - // Send workflow after webview is ready - setTimeout(sendWorkflow, 300); - - // Listen for document changes - const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument((e) => { - if (e.document.uri.toString() === document.uri.toString()) { - const { workflow, error } = this.parseWorkflow(document); - - if (error) { - webviewPanel.webview.postMessage({ - type: 'PREVIEW_PARSE_ERROR', - payload: { error }, - }); - } else if (workflow) { - webviewPanel.webview.postMessage({ - type: 'PREVIEW_UPDATE', - payload: { workflow }, - }); - } - } - }); - - // Handle messages from webview - webviewPanel.webview.onDidReceiveMessage(async (message: { type: string }) => { - if (message.type === 'OPEN_WORKFLOW_IN_EDITOR') { - const fileName = document.uri.fsPath.split(/[\\/]/).pop() || ''; - const workflowId = fileName.replace(/\.json$/, ''); - - log('INFO', 'Opening workflow in editor from custom editor', { workflowId }); - - // Open the main Workflow Studio editor - await vscode.commands.executeCommand('cc-wf-studio.openEditor'); - - // Prepare editor for loading (show loading state) - prepareEditorForLoad(workflowId); - - // Load the workflow after a delay to allow webview to initialize - setTimeout(async () => { - const success = await loadWorkflowIntoEditor(workflowId); - if (success) { - log('INFO', 'Workflow loaded into editor successfully', { workflowId }); - } else { - log('WARN', 'Failed to load workflow into editor', { workflowId }); - } - }, 600); - } - }); - - // Cleanup on dispose - webviewPanel.onDidDispose(() => { - changeDocumentSubscription.dispose(); - }); - - log('INFO', 'Workflow Preview custom editor opened', { - fileName: document.uri.fsPath.split(/[\\/]/).pop(), - }); - } - - /** - * Parse workflow from document content - */ - private parseWorkflow(document: vscode.TextDocument): { - workflow: Workflow | null; - error: string | null; - } { - try { - const content = document.getText(); - const validationResult = validateWorkflowFile(content); - - if (!validationResult.valid || !validationResult.workflow) { - return { - workflow: null, - error: validationResult.errors?.join(', ') || 'Invalid workflow format', - }; - } - - // Apply migrations for backward compatibility - const workflow = migrateWorkflow(validationResult.workflow); - return { workflow, error: null }; - } catch (error) { - return { - workflow: null, - error: error instanceof Error ? error.message : 'Failed to parse workflow', - }; - } - } -} diff --git a/src/extension/extension.ts b/src/extension/extension.ts deleted file mode 100644 index 7ff20874..00000000 --- a/src/extension/extension.ts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * CC Workflow Studio - Extension Entry Point - * - * Main activation and deactivation logic for the VSCode extension. - */ - -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import * as vscode from 'vscode'; -import { registerOpenEditorCommand } from './commands/open-editor'; -import { handleConnectSlackManual } from './commands/slack-connect-manual'; -import { WorkflowPreviewEditorProvider } from './editors/workflow-preview-editor-provider'; -import { removeAllAgentConfigs } from './services/mcp-server-config-writer'; -import { McpServerManager } from './services/mcp-server-service'; -import { SlackApiService } from './services/slack-api-service'; -import { SlackTokenManager } from './utils/slack-token-manager'; - -/** - * Global Output Channel for logging - */ -let outputChannel: vscode.OutputChannel | null = null; - -/** - * Global MCP Server Manager instance - */ -let mcpServerManager: McpServerManager | null = null; - -/** - * Get the global output channel instance - */ -export function getOutputChannel(): vscode.OutputChannel { - if (!outputChannel) { - throw new Error('Output channel not initialized. Call activate() first.'); - } - return outputChannel; -} - -/** - * Log a message to the output channel - * - * @param level - Log level (INFO, WARN, ERROR) - * @param message - Message to log - * @param data - Optional additional data to log - */ -export function log(level: 'INFO' | 'WARN' | 'ERROR', message: string, data?: unknown): void { - const timestamp = new Date().toISOString(); - const logMessage = `[${timestamp}] [${level}] ${message}`; - - if (outputChannel) { - outputChannel.appendLine(logMessage); - if (data) { - outputChannel.appendLine(` Data: ${JSON.stringify(data, null, 2)}`); - } - } - - // Also log to console for debugging - console.log(logMessage, data ?? ''); -} - -/** - * Clean up legacy BM25 index data from globalStorageUri - * - * This function removes the old BM25 codebase index data that was stored - * when the BM25 search feature was active. The feature has been removed - * and this cleanup ensures no orphaned data remains on user devices. - * - * @param context - Extension context containing globalStorageUri - */ -async function cleanupLegacyBM25Index(context: vscode.ExtensionContext): Promise { - try { - if (!context.globalStorageUri) { - log('WARN', 'BM25 Cleanup: globalStorageUri not available, skipping cleanup'); - return; - } - - const indexesDir = path.join(context.globalStorageUri.fsPath, 'indexes'); - - // Check if the indexes directory exists - try { - await fs.access(indexesDir); - } catch { - // Directory doesn't exist, nothing to clean up - log('INFO', 'BM25 Cleanup: No legacy index data found'); - return; - } - - // Directory exists, remove it - log('INFO', 'BM25 Cleanup: Removing legacy index directory', { path: indexesDir }); - await fs.rm(indexesDir, { recursive: true, force: true }); - log('INFO', 'BM25 Cleanup: Successfully removed legacy index data'); - } catch (error) { - // Log error but don't prevent extension activation - log('ERROR', 'BM25 Cleanup: Failed to remove legacy index data', { - error: error instanceof Error ? error.message : String(error), - }); - } -} - -/** - * Get the global MCP Server Manager instance - */ -export function getMcpServerManager(): McpServerManager | null { - return mcpServerManager; -} - -/** - * Extension activation function - * Called when the extension is activated (when the command is first invoked) - */ -export function activate(context: vscode.ExtensionContext): void { - // Create output channel - outputChannel = vscode.window.createOutputChannel('CC Workflow Studio'); - context.subscriptions.push(outputChannel); - - log('INFO', 'CC Workflow Studio is now active'); - - // Create MCP Server Manager (started via UI, not automatically) - mcpServerManager = new McpServerManager(); - - // Clean up legacy BM25 index data (fire-and-forget) - cleanupLegacyBM25Index(context).catch((error) => { - log('ERROR', 'BM25 Cleanup: Unexpected error during cleanup', { error }); - }); - - // Register commands - registerOpenEditorCommand(context); - - // Register custom editor provider for workflow preview - context.subscriptions.push(WorkflowPreviewEditorProvider.register(context)); - - // Register Slack import command (T031) - context.subscriptions.push( - vscode.commands.registerCommand('claudeCodeWorkflowStudio.slack.importWorkflow', async () => { - log('INFO', 'Slack: Import Workflow command invoked'); - - // Show input box for Slack file URL or ID - const input = await vscode.window.showInputBox({ - prompt: 'Enter Slack file URL or file ID', - placeHolder: 'https://files.slack.com/... or F0123456789', - }); - - if (!input) { - log('INFO', 'User cancelled Slack import'); - return; - } - - log('INFO', 'Slack import input received', { input }); - - // TODO: Parse URL and extract file ID, then trigger import - // For now, show error message - vscode.window.showErrorMessage( - 'Slack import via command is not fully implemented yet. Use the "Import to VS Code" button in Slack messages.' - ); - }) - ); - - // Register Slack manual token connection command (T103) - context.subscriptions.push( - vscode.commands.registerCommand('claudeCodeWorkflowStudio.slack.connectManual', async () => { - log('INFO', 'Slack: Connect Workspace (Manual Token) command invoked'); - - const tokenManager = new SlackTokenManager(context); - const slackApiService = new SlackApiService(tokenManager); - - await handleConnectSlackManual(tokenManager, slackApiService); - }) - ); - - // Register URI handler for deep links (vscode://cc-wf-studio/import?...) - context.subscriptions.push( - vscode.window.registerUriHandler({ - handleUri(uri: vscode.Uri): void { - log('INFO', 'URI handler invoked', { uri: uri.toString() }); - - // Parse URI path and query parameters - const path = uri.path; - const query = new URLSearchParams(uri.query); - - if (path === '/import') { - // Extract import parameters - const fileId = query.get('fileId'); - const channelId = query.get('channelId'); - const messageTs = query.get('messageTs'); - const workspaceId = query.get('workspaceId'); - const workflowId = query.get('workflowId'); - const workspaceNameBase64 = query.get('workspaceName'); - - // Decode workspace name from Base64 if present - let workspaceName: string | undefined; - if (workspaceNameBase64) { - try { - workspaceName = Buffer.from(workspaceNameBase64, 'base64').toString('utf-8'); - } catch (_e) { - log('WARN', 'Failed to decode workspace name from Base64', { workspaceNameBase64 }); - } - } - - if (!fileId || !channelId || !messageTs || !workspaceId || !workflowId) { - log('ERROR', 'Missing required import parameters', { - fileId, - channelId, - messageTs, - workspaceId, - workflowId, - }); - vscode.window.showErrorMessage('Invalid import URL: Missing required parameters'); - return; - } - - log('INFO', 'Importing workflow from Slack via deep link', { - fileId, - channelId, - messageTs, - workspaceId, - workflowId, - workspaceName, - }); - - // Open editor with import parameters - vscode.commands - .executeCommand('cc-wf-studio.openEditor', { - fileId, - channelId, - messageTs, - workspaceId, - workflowId, - workspaceName, - }) - .then(() => { - log('INFO', 'Editor opened with import parameters', { workflowId }); - }); - } else { - log('WARN', 'Unknown URI path', { path }); - vscode.window.showErrorMessage(`Unknown deep link path: ${path}`); - } - }, - }) - ); - - log('INFO', 'CC Workflow Studio: All commands and handlers registered'); -} - -/** - * Extension deactivation function - * Called when the extension is deactivated - */ -export async function deactivate(): Promise { - // Stop MCP server and clean up configs if running - if (mcpServerManager?.isRunning()) { - try { - await mcpServerManager.stop(); - const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (workspacePath) { - await removeAllAgentConfigs(workspacePath); - } - } catch (error) { - log('ERROR', 'Failed to stop MCP server during deactivation', { - error: error instanceof Error ? error.message : String(error), - }); - } - } - mcpServerManager = null; - - log('INFO', 'CC Workflow Studio is now deactivated'); - outputChannel?.dispose(); - outputChannel = null; -} diff --git a/src/extension/i18n/i18n-service.ts b/src/extension/i18n/i18n-service.ts deleted file mode 100644 index c8a587c4..00000000 --- a/src/extension/i18n/i18n-service.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Claude Code Workflow Studio - i18n Service - * - * Handles internationalization for workflow exports - * Detects VSCode locale and provides translations - */ - -import * as vscode from 'vscode'; -import type { TranslationKeys } from './translation-keys'; -import { enTranslations } from './translations/en'; -import { jaTranslations } from './translations/ja'; -import { koTranslations } from './translations/ko'; -import { zhCNTranslations } from './translations/zh-CN'; -import { zhTWTranslations } from './translations/zh-TW'; - -type Translations = typeof enTranslations; - -/** - * Get current locale from VSCode - * - * @returns Locale code (e.g., 'en', 'ja', 'zh-CN', 'zh-TW') - */ -export function getCurrentLocale(): string { - // Get VSCode's display language - return vscode.env.language; -} - -/** - * Get translations for the current locale - * - * @returns Translation object for current locale (defaults to English) - */ -export function getTranslations(): Translations { - const locale = getCurrentLocale(); - const languageCode = locale.split('-')[0]; - - // Check full locale first (e.g., zh-CN, zh-TW) - if (locale === 'zh-CN') { - return zhCNTranslations; - } - if (locale === 'zh-TW' || locale === 'zh-HK') { - return zhTWTranslations; - } - - // Check language code (e.g., ja, ko) - switch (languageCode) { - case 'ja': - return jaTranslations; - case 'ko': - return koTranslations; - case 'zh': - // Default to Simplified Chinese if no region specified - return zhCNTranslations; - default: - return enTranslations; - } -} - -/** - * Translate a key to the current locale - * - * @param key - Translation key - * @param params - Optional parameters for string interpolation - * @returns Translated string - */ -export function translate( - key: K, - params?: Record -): string { - const translations = getTranslations(); - let text = translations[key] as string; - - // Handle nested keys (e.g., 'mermaid.start') - if (text === undefined) { - const parts = (key as string).split('.'); - // biome-ignore lint/suspicious/noExplicitAny: Dynamic nested property access requires any - let current: any = translations; - - for (const part of parts) { - current = current[part]; - if (current === undefined) { - // Fallback to English if translation is missing - current = enTranslations; - for (const part of parts) { - current = current[part]; - if (current === undefined) { - return key as string; - } - } - break; - } - } - - text = current as string; - } - - // Replace parameters (e.g., {{name}} -> value) - if (params) { - for (const [paramKey, paramValue] of Object.entries(params)) { - text = text.replace(`{{${paramKey}}}`, String(paramValue)); - } - } - - return text; -} - -/** - * Get shorthand translation function for a specific namespace - * - * @returns Translation function - */ -export function useTranslation() { - return { - t: translate, - locale: getCurrentLocale(), - }; -} diff --git a/src/extension/i18n/translation-keys.ts b/src/extension/i18n/translation-keys.ts deleted file mode 100644 index 3b05b16b..00000000 --- a/src/extension/i18n/translation-keys.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Claude Code Workflow Studio - Translation Keys Type Definition - * - * Defines the structure of translation keys for type safety - */ - -export interface TranslationKeys { - // Error messages - 'error.noWorkspaceOpen': string; - - // File picker - 'filePicker.title': string; - 'filePicker.error.invalidWorkflow': string; - 'filePicker.error.loadFailed': string; -} diff --git a/src/extension/i18n/translations/en.ts b/src/extension/i18n/translations/en.ts deleted file mode 100644 index b9680824..00000000 --- a/src/extension/i18n/translations/en.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Claude Code Workflow Studio - English Translations - */ - -import type { TranslationKeys } from '../translation-keys'; - -export const enTranslations: TranslationKeys = { - // Error messages - 'error.noWorkspaceOpen': 'Please open a folder or workspace first.', - - // File picker - 'filePicker.title': 'Select Workflow File', - 'filePicker.error.invalidWorkflow': - 'Invalid workflow file. Please select a valid JSON workflow file.', - 'filePicker.error.loadFailed': 'Failed to load workflow file.', -}; diff --git a/src/extension/i18n/translations/ja.ts b/src/extension/i18n/translations/ja.ts deleted file mode 100644 index 31f3c24b..00000000 --- a/src/extension/i18n/translations/ja.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Claude Code Workflow Studio - Japanese Translations - */ - -import type { TranslationKeys } from '../translation-keys'; - -export const jaTranslations: TranslationKeys = { - // Error messages - 'error.noWorkspaceOpen': 'フォルダまたはワークスペースを開いてから実行してください。', - - // File picker - 'filePicker.title': 'ワークフローファイルを選択', - 'filePicker.error.invalidWorkflow': - '無効なワークフローファイルです。有効なJSONワークフローファイルを選択してください。', - 'filePicker.error.loadFailed': 'ワークフローファイルの読み込みに失敗しました。', -}; diff --git a/src/extension/i18n/translations/ko.ts b/src/extension/i18n/translations/ko.ts deleted file mode 100644 index 1fe3ff53..00000000 --- a/src/extension/i18n/translations/ko.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Claude Code Workflow Studio - Korean Translations - */ - -import type { TranslationKeys } from '../translation-keys'; - -export const koTranslations: TranslationKeys = { - // Error messages - 'error.noWorkspaceOpen': '폴더 또는 워크스페이스를 먼저 열어주세요.', - - // File picker - 'filePicker.title': '워크플로 파일 선택', - 'filePicker.error.invalidWorkflow': - '잘못된 워크플로 파일입니다. 유효한 JSON 워크플로 파일을 선택해주세요.', - 'filePicker.error.loadFailed': '워크플로 파일을 불러오는데 실패했습니다.', -}; diff --git a/src/extension/i18n/translations/zh-CN.ts b/src/extension/i18n/translations/zh-CN.ts deleted file mode 100644 index af680529..00000000 --- a/src/extension/i18n/translations/zh-CN.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Claude Code Workflow Studio - Simplified Chinese Translations - */ - -import type { TranslationKeys } from '../translation-keys'; - -export const zhCNTranslations: TranslationKeys = { - // Error messages - 'error.noWorkspaceOpen': '请先打开文件夹或工作区。', - - // File picker - 'filePicker.title': '选择工作流文件', - 'filePicker.error.invalidWorkflow': '无效的工作流文件。请选择有效的JSON工作流文件。', - 'filePicker.error.loadFailed': '加载工作流文件失败。', -}; diff --git a/src/extension/i18n/translations/zh-TW.ts b/src/extension/i18n/translations/zh-TW.ts deleted file mode 100644 index 817cce69..00000000 --- a/src/extension/i18n/translations/zh-TW.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Claude Code Workflow Studio - Traditional Chinese Translations - */ - -import type { TranslationKeys } from '../translation-keys'; - -export const zhTWTranslations: TranslationKeys = { - // Error messages - 'error.noWorkspaceOpen': '請先開啟資料夾或工作區。', - - // File picker - 'filePicker.title': '選擇工作流程檔案', - 'filePicker.error.invalidWorkflow': '無效的工作流程檔案。請選擇有效的JSON工作流程檔案。', - 'filePicker.error.loadFailed': '載入工作流程檔案失敗。', -}; diff --git a/src/extension/services/ai-editing-skill-service.ts b/src/extension/services/ai-editing-skill-service.ts deleted file mode 100644 index f98c2505..00000000 --- a/src/extension/services/ai-editing-skill-service.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * AI Editing Skill Service - * - * Generates and runs AI editing skills for different providers. - * Writes a skill template to the provider-specific location and - * launches the provider to execute it. - */ - -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as vscode from 'vscode'; -import { log } from '../extension'; -import { isAntigravityInstalled } from './antigravity-extension-service'; -import { isCursorInstalled } from './cursor-extension-service'; -import { isRooCodeInstalled, startRooCodeTask } from './roo-code-extension-service'; - -export type AiEditingProvider = - | 'claude-code' - | 'copilot-cli' - | 'copilot-chat' - | 'codex' - | 'roo-code' - | 'gemini' - | 'antigravity' - | 'cursor'; - -const SKILL_NAME = 'cc-workflow-ai-editor'; - -/** - * Get the skill file destination path for a given provider - */ -function getSkillDestination(provider: AiEditingProvider, workingDirectory: string): string { - switch (provider) { - case 'claude-code': - return path.join(workingDirectory, '.claude', 'commands', `${SKILL_NAME}.md`); - case 'copilot-cli': - return path.join(workingDirectory, '.github', 'skills', SKILL_NAME, 'SKILL.md'); - case 'copilot-chat': - return path.join(workingDirectory, '.github', 'skills', SKILL_NAME, 'SKILL.md'); - case 'codex': - return path.join(workingDirectory, '.codex', 'skills', SKILL_NAME, 'SKILL.md'); - case 'roo-code': - return path.join(workingDirectory, '.roo', 'skills', SKILL_NAME, 'SKILL.md'); - case 'gemini': - return path.join(workingDirectory, '.gemini', 'skills', SKILL_NAME, 'SKILL.md'); - case 'antigravity': - return path.join(workingDirectory, '.agent', 'skills', SKILL_NAME, 'SKILL.md'); - case 'cursor': - return path.join(workingDirectory, '.cursor', 'skills', SKILL_NAME, 'SKILL.md'); - } -} - -/** - * Load the skill template from resources - */ -function loadSkillTemplate(extensionPath: string): string { - const templatePath = path.join(extensionPath, 'resources', 'ai-editing-skill-template.md'); - return fs.readFileSync(templatePath, 'utf-8'); -} - -/** - * Write skill template to the provider-specific location - */ -async function writeSkillFile(filePath: string, content: string): Promise { - const dir = path.dirname(filePath); - await fs.promises.mkdir(dir, { recursive: true }); - const fd = await fs.promises.open(filePath, 'w'); - await fd.writeFile(content, 'utf-8'); - await fd.sync(); - await fd.close(); -} - -/** - * Launch the provider to run the skill - */ -async function launchProvider( - provider: AiEditingProvider, - workingDirectory: string -): Promise { - switch (provider) { - case 'claude-code': { - const terminalName = `AI Edit: Claude Code`; - const terminal = vscode.window.createTerminal({ - name: terminalName, - cwd: workingDirectory, - }); - terminal.show(true); - terminal.sendText(`claude "/${SKILL_NAME}"`); - break; - } - - case 'copilot-cli': { - const terminalName = `AI Edit: Copilot CLI`; - const terminal = vscode.window.createTerminal({ - name: terminalName, - cwd: workingDirectory, - }); - terminal.show(true); - terminal.sendText(`copilot -i ":skill ${SKILL_NAME}" --allow-all-tools`); - break; - } - - case 'copilot-chat': { - try { - await vscode.commands.executeCommand('workbench.action.chat.newChat'); - await vscode.commands.executeCommand('workbench.action.chat.open', { - query: `/${SKILL_NAME}`, - isPartialQuery: false, - }); - } catch { - try { - await vscode.commands.executeCommand('workbench.action.chat.open'); - vscode.window.showInformationMessage( - `Skill exported. Type "/${SKILL_NAME}" in Copilot Chat to run.` - ); - } catch { - throw new Error('GitHub Copilot Chat is not installed or not available.'); - } - } - break; - } - - case 'codex': { - const terminalName = `AI Edit: Codex CLI`; - const terminal = vscode.window.createTerminal({ - name: terminalName, - cwd: workingDirectory, - }); - terminal.show(true); - terminal.sendText(`codex "\\$${SKILL_NAME}"`); - break; - } - - case 'roo-code': { - if (isRooCodeInstalled()) { - await startRooCodeTask(`:skill ${SKILL_NAME}`); - } else { - throw new Error('Roo Code extension is not installed.'); - } - break; - } - - case 'gemini': { - const terminalName = `AI Edit: Gemini CLI`; - const terminal = vscode.window.createTerminal({ - name: terminalName, - cwd: workingDirectory, - }); - terminal.show(true); - terminal.sendText(`gemini -i ":skill ${SKILL_NAME}"`); - break; - } - - case 'antigravity': { - // For Antigravity, only check installation here. - // Launch is handled separately after MCP refresh dialog in open-editor.ts. - if (!isAntigravityInstalled()) { - throw new Error('Antigravity extension is not installed.'); - } - break; - } - - case 'cursor': { - // For Cursor, check installation and launch via chat command. - if (!isCursorInstalled()) { - throw new Error('Cursor extension is not installed.'); - } - try { - await vscode.commands.executeCommand('workbench.action.chat.open', `/${SKILL_NAME}`); - } catch { - throw new Error('Failed to launch Cursor agent.'); - } - break; - } - } -} - -/** - * Generate the AI editing skill file and run it with the specified provider - */ -export async function generateAndRunAiEditingSkill( - provider: AiEditingProvider, - extensionPath: string, - workingDirectory: string -): Promise { - log('INFO', 'AI Editing Skill: generating and running', { provider }); - - // 1. Load template - const template = loadSkillTemplate(extensionPath); - - // 2. Write to provider-specific location - const destPath = getSkillDestination(provider, workingDirectory); - await writeSkillFile(destPath, template); - log('INFO', 'AI Editing Skill: wrote skill file', { destPath }); - - // 3. Launch provider - await launchProvider(provider, workingDirectory); - log('INFO', 'AI Editing Skill: provider launched', { provider }); -} diff --git a/src/extension/services/ai-metrics-service.ts b/src/extension/services/ai-metrics-service.ts deleted file mode 100644 index 844c967d..00000000 --- a/src/extension/services/ai-metrics-service.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * AI Metrics Collection Service - * - * Collects and logs metrics for A/B comparison of schema formats. - */ - -import * as vscode from 'vscode'; -import type { AIGenerationMetrics, SchemaFormat } from '../../shared/types/ai-metrics'; -import { log } from '../extension'; - -// In-memory storage for session metrics -const sessionMetrics: AIGenerationMetrics[] = []; - -/** - * Check if metrics collection is enabled - */ -export function isMetricsCollectionEnabled(): boolean { - const config = vscode.workspace.getConfiguration('cc-wf-studio'); - return config.get('ai.collectMetrics', false); -} - -/** - * Get configured schema format - */ -export function getConfiguredSchemaFormat(): SchemaFormat { - const config = vscode.workspace.getConfiguration('cc-wf-studio'); - return config.get('ai.schemaFormat', 'json'); -} - -/** - * Record AI generation metrics - */ -export function recordMetrics(metrics: AIGenerationMetrics): void { - if (!isMetricsCollectionEnabled()) { - return; - } - - sessionMetrics.push(metrics); - - // Log to output channel - log('INFO', 'AI Generation Metrics', { - requestId: metrics.requestId, - schemaFormat: metrics.schemaFormat, - promptFormat: metrics.promptFormat, - promptSize: metrics.promptSizeChars, - schemaSize: metrics.schemaSizeChars, - estimatedTokens: metrics.estimatedTokens, - executionTimeMs: metrics.executionTimeMs, - success: metrics.success, - }); - - // Log summary every 10 generations - if (sessionMetrics.length % 10 === 0) { - logSessionSummary(); - } -} - -/** - * Log session summary for comparison - */ -function logSessionSummary(): void { - const jsonMetrics = sessionMetrics.filter((m) => m.schemaFormat === 'json'); - const toonMetrics = sessionMetrics.filter((m) => m.schemaFormat === 'toon'); - - if (jsonMetrics.length === 0 || toonMetrics.length === 0) { - return; // Need both formats for comparison - } - - const avgJsonPromptSize = average(jsonMetrics.map((m) => m.promptSizeChars)); - const avgToonPromptSize = average(toonMetrics.map((m) => m.promptSizeChars)); - const avgJsonExecTime = average(jsonMetrics.map((m) => m.executionTimeMs)); - const avgToonExecTime = average(toonMetrics.map((m) => m.executionTimeMs)); - - log('INFO', 'AI Metrics Session Summary', { - totalGenerations: sessionMetrics.length, - jsonCount: jsonMetrics.length, - toonCount: toonMetrics.length, - avgPromptSizeReduction: `${(((avgJsonPromptSize - avgToonPromptSize) / avgJsonPromptSize) * 100).toFixed(1)}%`, - avgExecutionTimeDiff: `${(avgToonExecTime - avgJsonExecTime).toFixed(0)}ms`, - jsonSuccessRate: `${((jsonMetrics.filter((m) => m.success).length / jsonMetrics.length) * 100).toFixed(1)}%`, - toonSuccessRate: `${((toonMetrics.filter((m) => m.success).length / toonMetrics.length) * 100).toFixed(1)}%`, - }); -} - -function average(values: number[]): number { - return values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; -} - -/** - * Estimate token count from character count - * Using rough approximation: 1 token ~ 4 characters for English text - */ -export function estimateTokens(charCount: number): number { - return Math.ceil(charCount / 4); -} - -/** - * Clear session metrics - */ -export function clearSessionMetrics(): void { - sessionMetrics.length = 0; -} - -/** - * Get all session metrics - */ -export function getSessionMetrics(): AIGenerationMetrics[] { - return [...sessionMetrics]; -} diff --git a/src/extension/services/ai-provider.ts b/src/extension/services/ai-provider.ts deleted file mode 100644 index 197b8c69..00000000 --- a/src/extension/services/ai-provider.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * AI Provider Abstraction Layer - * - * Claude Code CLI と VS Code Language Model API を抽象化し、 - * プロバイダー選択に基づいてルーティングする。 - * - * 注意: Claude CodeとCopilotで別々のモデル設定を使用する(マッピングしない) - * - Claude Code: ClaudeModel ('sonnet' | 'opus' | 'haiku') - * - Copilot: CopilotModel ('gpt-4o' | 'gpt-4o-mini' | 'claude-3.5-sonnet') - */ - -import type { - AiCliProvider, - ClaudeModel, - CodexModel, - CodexReasoningEffort, - CopilotModel, -} from '../../shared/types/messages'; -import { log } from '../extension'; -import { - type ClaudeCodeExecutionResult, - cancelRefinement, - executeClaudeCodeCLI, - executeClaudeCodeCLIStreaming, - type StreamingProgressCallback, -} from './claude-code-service'; -import { - cancelCodexProcess, - executeCodexCLI, - executeCodexCLIStreaming, - isCodexCliAvailable, -} from './codex-cli-service'; -import { - cancelLmRequest, - checkLmApiAvailability, - executeVsCodeLm, - executeVsCodeLmStreaming, -} from './vscode-lm-service'; - -/** プロバイダー利用可否チェック結果 */ -export interface ProviderAvailability { - available: boolean; - reason?: string; -} - -/** - * プロバイダーが利用可能かチェック - * - * @param provider - チェックするプロバイダー - * @returns 利用可否とその理由 - */ -export async function isProviderAvailable(provider: AiCliProvider): Promise { - if (provider === 'copilot') { - const availability = await checkLmApiAvailability(); - if (!availability.available) { - const reasonMap: Record = { - VS_CODE_VERSION: 'VS Code 1.89+ is required for Copilot provider', - COPILOT_NOT_INSTALLED: 'GitHub Copilot extension is not installed', - NO_MODELS_FOUND: 'No Copilot models available', - }; - return { - available: false, - reason: availability.reason ? reasonMap[availability.reason] : 'Unknown error', - }; - } - return { available: true }; - } - - if (provider === 'codex') { - const availability = await isCodexCliAvailable(); - if (!availability.available) { - return { - available: false, - reason: 'Codex CLI not found. Please install Codex CLI to use this provider.', - }; - } - return { available: true }; - } - - // claude-code は常に利用可能(CLI が見つからない場合は実行時エラー) - return { available: true }; -} - -/** - * AI を実行(非ストリーミング) - * - * @param prompt - プロンプト文字列 - * @param provider - 使用するプロバイダー ('claude-code' | 'copilot' | 'codex') - * @param timeoutMs - タイムアウト(ミリ秒) - * @param requestId - リクエストID(キャンセル用) - * @param workingDirectory - 作業ディレクトリ(claude-code/codex用) - * @param model - Claude Code用モデル (provider='claude-code'時のみ使用) - * @param copilotModel - Copilot用モデル (provider='copilot'時のみ使用) - * @param allowedTools - 許可ツールリスト(claude-code用) - * @param codexModel - Codex用モデル (provider='codex'時のみ使用) - * @param codexReasoningEffort - Codex用推論努力レベル (provider='codex'時のみ使用) - * @returns 実行結果 - */ -export async function executeAi( - prompt: string, - provider: AiCliProvider, - timeoutMs?: number, - requestId?: string, - workingDirectory?: string, - model?: ClaudeModel, - copilotModel?: CopilotModel, - allowedTools?: string[], - codexModel?: CodexModel, - codexReasoningEffort?: CodexReasoningEffort -): Promise { - log('INFO', 'executeAi called', { - provider, - model, - copilotModel, - codexModel, - codexReasoningEffort, - promptLength: prompt.length, - requestId, - }); - - if (provider === 'copilot') { - // Copilot用モデルを直接使用(マッピングなし) - return executeVsCodeLm(prompt, timeoutMs, requestId, copilotModel); - } - - if (provider === 'codex') { - // Codex CLIを使用 - return executeCodexCLI( - prompt, - timeoutMs, - requestId, - workingDirectory, - codexModel, - codexReasoningEffort - ); - } - - // Default: claude-code - Claude Code用モデルを使用 - return executeClaudeCodeCLI(prompt, timeoutMs, requestId, workingDirectory, model, allowedTools); -} - -/** - * AI を実行(ストリーミング) - * - * @param prompt - プロンプト文字列 - * @param provider - 使用するプロバイダー ('claude-code' | 'copilot' | 'codex') - * @param onProgress - ストリーミング進捗コールバック - * @param timeoutMs - タイムアウト(ミリ秒) - * @param requestId - リクエストID(キャンセル用) - * @param workingDirectory - 作業ディレクトリ(claude-code/codex用) - * @param model - Claude Code用モデル (provider='claude-code'時のみ使用) - * @param copilotModel - Copilot用モデル (provider='copilot'時のみ使用) - * @param allowedTools - 許可ツールリスト(claude-code用) - * @param resumeSessionId - セッション継続用ID(claude-code用、copilot/codexでは無視) - * @param codexModel - Codex用モデル (provider='codex'時のみ使用) - * @param codexReasoningEffort - Codex用推論努力レベル (provider='codex'時のみ使用) - * @returns 実行結果 - */ -export async function executeAiStreaming( - prompt: string, - provider: AiCliProvider, - onProgress: StreamingProgressCallback, - timeoutMs?: number, - requestId?: string, - workingDirectory?: string, - model?: ClaudeModel, - copilotModel?: CopilotModel, - allowedTools?: string[], - resumeSessionId?: string, - codexModel?: CodexModel, - codexReasoningEffort?: CodexReasoningEffort -): Promise { - log('INFO', 'executeAiStreaming called', { - provider, - model, - copilotModel, - codexModel, - codexReasoningEffort, - promptLength: prompt.length, - requestId, - resumeSessionId: resumeSessionId ? '(present)' : undefined, - }); - - if (provider === 'copilot') { - // VS Code LM API はセッション継続をサポートしない - if (resumeSessionId) { - log('WARN', 'Session resume not supported with Copilot provider, ignoring sessionId'); - } - // Copilot用モデルを直接使用(マッピングなし) - return executeVsCodeLmStreaming(prompt, onProgress, timeoutMs, requestId, copilotModel); - } - - if (provider === 'codex') { - // Codex CLIを使用(セッション継続対応) - return executeCodexCLIStreaming( - prompt, - onProgress, - timeoutMs, - requestId, - workingDirectory, - codexModel, - codexReasoningEffort, - resumeSessionId - ); - } - - // Default: claude-code - Claude Code用モデルを使用 - return executeClaudeCodeCLIStreaming( - prompt, - onProgress, - timeoutMs, - requestId, - workingDirectory, - model, - allowedTools, - resumeSessionId - ); -} - -/** - * AI リクエストをキャンセル - * - * @param provider - キャンセル対象のプロバイダー - * @param requestId - キャンセルするリクエストのID - * @returns キャンセル結果 - */ -export async function cancelAiRequest( - provider: AiCliProvider, - requestId: string -): Promise<{ cancelled: boolean; executionTimeMs?: number }> { - log('INFO', 'cancelAiRequest called', { provider, requestId }); - - if (provider === 'copilot') { - return cancelLmRequest(requestId); - } - if (provider === 'codex') { - return cancelCodexProcess(requestId); - } - return cancelRefinement(requestId); -} diff --git a/src/extension/services/antigravity-extension-service.ts b/src/extension/services/antigravity-extension-service.ts deleted file mode 100644 index a9aebdee..00000000 --- a/src/extension/services/antigravity-extension-service.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Claude Code Workflow Studio - Antigravity Extension Service - * - * Wrapper for Antigravity (Google VSCode fork) Extension. - * Uses VSCode commands to launch Cascade with skill invocation. - */ - -import * as vscode from 'vscode'; - -const ANTIGRAVITY_EXTENSION_ID = 'google.antigravity'; - -/** - * Check if Antigravity extension is installed - * - * @returns True if Antigravity extension is installed - */ -export function isAntigravityInstalled(): boolean { - return vscode.extensions.getExtension(ANTIGRAVITY_EXTENSION_ID) !== undefined; -} - -/** - * Open Antigravity's MCP server management page - */ -export async function openAntigravityMcpSettings(): Promise { - try { - await vscode.commands.executeCommand('antigravity.openConfigurePluginsPage'); - } catch { - // Best-effort - } -} - -/** - * Start a task in Antigravity via Cascade - * - * Attempts to open Cascade in agent mode with the given skill name. - * Primary: workbench.action.chat.open with agent mode - * Fallback: antigravity.sendPromptToAgentPanel - * - * @param skillName - Skill name to invoke (e.g., "my-workflow") - * @returns True if the task was started successfully - */ -export async function startAntigravityTask(skillName: string): Promise { - const extension = vscode.extensions.getExtension(ANTIGRAVITY_EXTENSION_ID); - if (!extension) { - return false; - } - - if (!extension.isActive) { - await extension.activate(); - } - - const prompt = `/${skillName}`; - - try { - // Primary: Open Cascade chat in agent mode with skill invocation - await vscode.commands.executeCommand('workbench.action.chat.open', prompt); - return true; - } catch { - // Fallback: Try alternative command - try { - await vscode.commands.executeCommand('antigravity.sendPromptToAgentPanel', prompt); - return true; - } catch { - return false; - } - } -} diff --git a/src/extension/services/antigravity-skill-export-service.ts b/src/extension/services/antigravity-skill-export-service.ts deleted file mode 100644 index 23f344ca..00000000 --- a/src/extension/services/antigravity-skill-export-service.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Claude Code Workflow Studio - Antigravity Skill Export Service - * - * Handles workflow export to Antigravity Skills format (.agent/skills/name/SKILL.md) - * Antigravity reads skills from .agent/skills/ directory. - */ - -import * as path from 'node:path'; -import type { Workflow } from '../../shared/types/workflow-definition'; -import { nodeNameToFileName } from './export-service'; -import type { FileService } from './file-service'; -import { - generateExecutionInstructions, - generateMermaidFlowchart, -} from './workflow-prompt-generator'; - -/** - * Antigravity skill export result - */ -export interface AntigravitySkillExportResult { - success: boolean; - skillPath: string; - skillName: string; - errors?: string[]; -} - -/** - * Generate SKILL.md content from workflow for Antigravity - * - * @param workflow - Workflow to convert - * @returns SKILL.md content as string - */ -export function generateAntigravitySkillContent(workflow: Workflow): string { - const skillName = nodeNameToFileName(workflow.name); - - // Generate description from workflow metadata or create default - const description = - workflow.metadata?.description || - `Execute the "${workflow.name}" workflow. This skill guides through a structured workflow with defined steps and decision points.`; - - // Generate YAML frontmatter - const frontmatter = `--- -name: ${skillName} -description: ${description} ----`; - - // Generate Mermaid flowchart - const mermaidContent = generateMermaidFlowchart({ - nodes: workflow.nodes, - connections: workflow.connections, - }); - - // Generate execution instructions - const instructions = generateExecutionInstructions(workflow, { - provider: 'antigravity', - }); - - // Compose SKILL.md body - const body = `# ${workflow.name} - -## Workflow Diagram - -${mermaidContent} - -## Execution Instructions - -${instructions}`; - - return `${frontmatter}\n\n${body}`; -} - -/** - * Check if Antigravity skill already exists - * - * @param workflow - Workflow to check - * @param fileService - File service instance - * @returns Path to existing skill file, or null if not exists - */ -export async function checkExistingAntigravitySkill( - workflow: Workflow, - fileService: FileService -): Promise { - const workspacePath = fileService.getWorkspacePath(); - const skillName = nodeNameToFileName(workflow.name); - const skillPath = path.join(workspacePath, '.agent', 'skills', skillName, 'SKILL.md'); - - if (await fileService.fileExists(skillPath)) { - return skillPath; - } - return null; -} - -/** - * Export workflow as Antigravity Skill - * - * Exports to .claude/skills/{name}/SKILL.md - * - * @param workflow - Workflow to export - * @param fileService - File service instance - * @returns Export result - */ -export async function exportWorkflowAsAntigravitySkill( - workflow: Workflow, - fileService: FileService -): Promise { - try { - const workspacePath = fileService.getWorkspacePath(); - const skillName = nodeNameToFileName(workflow.name); - const skillDir = path.join(workspacePath, '.agent', 'skills', skillName); - const skillPath = path.join(skillDir, 'SKILL.md'); - - // Ensure directory exists - await fileService.createDirectory(skillDir); - - // Generate and write SKILL.md content - const content = generateAntigravitySkillContent(workflow); - await fileService.writeFile(skillPath, content); - - return { - success: true, - skillPath, - skillName, - }; - } catch (error) { - return { - success: false, - skillPath: '', - skillName: '', - errors: [error instanceof Error ? error.message : 'Unknown error'], - }; - } -} diff --git a/src/extension/services/claude-cli-path.ts b/src/extension/services/claude-cli-path.ts deleted file mode 100644 index 5c015db2..00000000 --- a/src/extension/services/claude-cli-path.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Claude CLI Path Detection Service - * - * Detects Claude CLI executable path using the shared CLI path detector. - * Uses VSCode's default terminal setting to get the user's shell, - * then executes with login shell to get the full PATH environment. - * - * This handles GUI-launched VSCode scenarios where the Extension Host - * doesn't inherit the user's shell PATH settings. - * - * Issue #375: https://github.com/breaking-brake/cc-wf-studio/issues/375 - * PR #376: https://github.com/breaking-brake/cc-wf-studio/pull/376 - */ - -import { log } from '../extension'; -import { - findExecutableInPath, - findExecutableViaDefaultShell, - verifyExecutable, -} from './cli-path-detector'; - -/** - * Cached Claude CLI path - * undefined = not checked yet - * null = not found (use npx fallback) - * string = path to claude executable - */ -let cachedClaudePath: string | null | undefined; - -/** - * Get the path to Claude CLI executable - * Detection order: - * 1. VSCode default terminal shell (handles version managers like mise, nvm) - * 2. Direct PATH lookup (fallback for terminal-launched VSCode) - * 3. npx fallback (handled in getClaudeSpawnCommand) - * - * @returns Path to claude executable (full path or 'claude' for PATH), null for npx fallback - */ -export async function getClaudeCliPath(): Promise { - // Return cached result if available - if (cachedClaudePath !== undefined) { - return cachedClaudePath; - } - - // 1. Try VSCode default terminal (handles GUI-launched VSCode + version managers) - const shellPath = await findExecutableViaDefaultShell('claude'); - if (shellPath) { - const version = await verifyExecutable(shellPath); - if (version) { - log('INFO', 'Claude CLI found via default shell', { - path: shellPath, - version, - }); - cachedClaudePath = shellPath; - return shellPath; - } - log('WARN', 'Claude CLI found but not executable', { path: shellPath }); - } - - // 2. Fall back to direct PATH lookup (terminal-launched VSCode) - const pathResult = await findExecutableInPath('claude'); - if (pathResult) { - cachedClaudePath = 'claude'; - return 'claude'; - } - - log('INFO', 'Claude CLI not found, will use npx fallback'); - cachedClaudePath = null; - return null; -} - -/** - * Clear Claude CLI path cache - * Useful for testing or when user installs Claude CLI during session - */ -export function clearClaudeCliPathCache(): void { - cachedClaudePath = undefined; -} - -/** - * Get the command and args for spawning Claude CLI - * Uses claude directly if available, otherwise falls back to 'npx claude' - * npx detection order: - * 1. VSCode default terminal shell (handles version managers) - * 2. Direct PATH lookup - * - * @param args - CLI arguments (without 'claude' command itself) - * @returns command and args for spawn - */ -export async function getClaudeSpawnCommand( - args: string[] -): Promise<{ command: string; args: string[] }> { - const claudePath = await getClaudeCliPath(); - - if (claudePath) { - return { command: claudePath, args }; - } - - // 1. Try VSCode default terminal for npx (handles version managers like mise, nvm) - const npxPath = await findExecutableViaDefaultShell('npx'); - if (npxPath) { - log('INFO', 'Using npx from default shell for Claude CLI fallback', { - path: npxPath, - }); - return { command: npxPath, args: ['claude', ...args] }; - } - - // 2. Final fallback to direct PATH lookup - log('INFO', 'Using npx from PATH for Claude CLI fallback'); - return { command: 'npx', args: ['claude', ...args] }; -} diff --git a/src/extension/services/claude-code-service.ts b/src/extension/services/claude-code-service.ts deleted file mode 100644 index 0a8aaa3a..00000000 --- a/src/extension/services/claude-code-service.ts +++ /dev/null @@ -1,877 +0,0 @@ -/** - * Claude Code CLI Service - * - * Executes Claude Code CLI commands for AI-assisted workflow generation. - * Based on: /specs/001-ai-workflow-generation/research.md Q1 - * - * Updated to use nano-spawn for cross-platform compatibility (Windows/Unix) - * See: Issue #79 - Windows environment compatibility - */ - -import type { ChildProcess } from 'node:child_process'; -import nanoSpawn from 'nano-spawn'; -import type { ClaudeModel } from '../../shared/types/messages'; -import { log } from '../extension'; -import { clearClaudeCliPathCache, getClaudeSpawnCommand } from './claude-cli-path'; - -// Re-export for external use -export { clearClaudeCliPathCache }; - -/** - * nano-spawn type definitions (manually defined for compatibility) - */ -interface SubprocessError extends Error { - stdout: string; - stderr: string; - output: string; - command: string; - durationMs: number; - exitCode?: number; - signalName?: string; - isTerminated?: boolean; - code?: string; -} - -interface Result { - stdout: string; - stderr: string; - output: string; - command: string; - durationMs: number; -} - -interface Subprocess extends Promise { - // nano-spawn v2.0.0: nodeChildProcess is a Promise that resolves to ChildProcess - // (spawnSubprocess is an async function) - nodeChildProcess: Promise; - stdout: AsyncIterable; - stderr: AsyncIterable; -} - -const spawn = - nanoSpawn.default || - (nanoSpawn as ( - file: string, - args?: readonly string[], - options?: Record - ) => Subprocess); - -/** - * Active generation processes - * Key: requestId, Value: subprocess and start time - */ -const activeProcesses = new Map(); - -export interface ClaudeCodeExecutionResult { - success: boolean; - output?: string; - error?: { - code: - | 'COMMAND_NOT_FOUND' - | 'MODEL_NOT_SUPPORTED' - | 'COPILOT_NOT_AVAILABLE' - | 'TIMEOUT' - | 'PARSE_ERROR' - | 'UNKNOWN_ERROR'; - message: string; - details?: string; - }; - executionTimeMs: number; - /** Session ID extracted from CLI output (for session continuation) */ - sessionId?: string; -} - -/** - * Map ClaudeModel type to Claude CLI model alias - * See: https://code.claude.com/docs/en/model-config.md - */ -function getCliModelName(model: ClaudeModel): string { - // Claude CLI accepts model aliases: 'sonnet', 'opus', 'haiku' - return model; -} - -/** - * Execute Claude Code CLI with a prompt and return the output - * - * @param prompt - The prompt to send to Claude Code CLI - * @param timeoutMs - Timeout in milliseconds (default: 60000) - * @param requestId - Optional request ID for cancellation support - * @param workingDirectory - Working directory for CLI execution (defaults to current directory) - * @param model - Claude model to use (default: 'sonnet') - * @param allowedTools - Optional array of allowed tool names (e.g., ['Read', 'Grep', 'Glob']) - * @returns Execution result with success status and output/error - */ -export async function executeClaudeCodeCLI( - prompt: string, - timeoutMs = 60000, - requestId?: string, - workingDirectory?: string, - model: ClaudeModel = 'sonnet', - allowedTools?: string[] -): Promise { - const startTime = Date.now(); - - const modelName = getCliModelName(model); - - log('INFO', 'Starting Claude Code CLI execution', { - promptLength: prompt.length, - timeoutMs, - model, - modelName, - allowedTools, - cwd: workingDirectory ?? process.cwd(), - }); - - try { - // Build CLI arguments - const args = ['-p', '-', '--model', modelName]; - - // Add --tools and --allowed-tools flags if provided - // --tools: whitelist restriction (only these tools available) - // --allowed-tools: no permission prompt for these tools - if (allowedTools && allowedTools.length > 0) { - args.push('--tools', allowedTools.join(',')); - args.push('--allowed-tools', allowedTools.join(',')); - } - - // Spawn Claude Code CLI process using nano-spawn (cross-platform compatible) - // Use stdin for prompt instead of -p argument to avoid Windows command line length limits - // Use claude directly if available, otherwise fall back to npx - const spawnCmd = await getClaudeSpawnCommand(args); - const subprocess = spawn(spawnCmd.command, spawnCmd.args, { - cwd: workingDirectory, - timeout: timeoutMs, - stdin: { string: prompt }, - stdout: 'pipe', - stderr: 'pipe', - }); - - // Register as active process if requestId is provided - if (requestId) { - activeProcesses.set(requestId, { subprocess, startTime }); - log('INFO', `Registered active process for requestId: ${requestId}`); - } - - // Wait for subprocess to complete - const result = await subprocess; - - // Remove from active processes - if (requestId) { - activeProcesses.delete(requestId); - log('INFO', `Removed active process (success) for requestId: ${requestId}`); - } - - const executionTimeMs = Date.now() - startTime; - - // Success - return stdout - log('INFO', 'Claude Code CLI execution succeeded', { - executionTimeMs, - outputLength: result.stdout.length, - }); - - return { - success: true, - output: result.stdout.trim(), - executionTimeMs, - }; - } catch (error) { - // Remove from active processes - if (requestId) { - activeProcesses.delete(requestId); - log('INFO', `Removed active process (error) for requestId: ${requestId}`); - } - - const executionTimeMs = Date.now() - startTime; - - // Log complete error object for debugging - log('ERROR', 'Claude Code CLI error caught', { - errorType: typeof error, - errorConstructor: error?.constructor?.name, - errorKeys: error && typeof error === 'object' ? Object.keys(error) : [], - error: error, - executionTimeMs, - }); - - // Handle SubprocessError from nano-spawn - if (isSubprocessError(error)) { - // Timeout error detection: - // - nano-spawn may set isTerminated=true and signalName='SIGTERM' - // - OR it may only set exitCode=143 (128 + 15 = SIGTERM) - const isTimeout = - (error.isTerminated && error.signalName === 'SIGTERM') || error.exitCode === 143; - - if (isTimeout) { - log('WARN', 'Claude Code CLI execution timed out', { - timeoutMs, - executionTimeMs, - exitCode: error.exitCode, - isTerminated: error.isTerminated, - signalName: error.signalName, - }); - - return { - success: false, - error: { - code: 'TIMEOUT', - message: `AI generation timed out after ${Math.floor(timeoutMs / 1000)} seconds. Try simplifying your description.`, - details: `Timeout after ${timeoutMs}ms`, - }, - executionTimeMs, - }; - } - - // Command not found (ENOENT) - if (error.code === 'ENOENT') { - log('ERROR', 'Claude Code CLI not found', { - errorCode: error.code, - errorMessage: error.message, - executionTimeMs, - }); - - return { - success: false, - error: { - code: 'COMMAND_NOT_FOUND', - message: 'Cannot connect to Claude Code - please ensure it is installed and running', - details: error.message, - }, - executionTimeMs, - }; - } - - // npx fallback failed - Claude package not found - if (error.stderr?.includes('could not determine executable to run')) { - log('WARN', 'Claude Code CLI not installed (npx fallback failed)', { - stderr: error.stderr, - executionTimeMs, - }); - return { - success: false, - error: { - code: 'COMMAND_NOT_FOUND', - message: 'Claude Code CLI not found. Please install Claude Code to use AI refinement.', - details: error.stderr, - }, - executionTimeMs, - }; - } - - // Non-zero exit code - log('ERROR', 'Claude Code CLI execution failed', { - exitCode: error.exitCode, - executionTimeMs, - stderr: error.stderr?.substring(0, 200), // Log first 200 chars of stderr - }); - - return { - success: false, - error: { - code: 'UNKNOWN_ERROR', - message: 'Generation failed - please try again or rephrase your description', - details: `Exit code: ${error.exitCode ?? 'unknown'}, stderr: ${error.stderr ?? 'none'}`, - }, - executionTimeMs, - }; - } - - // Unknown error type - log('ERROR', 'Unexpected error during Claude Code CLI execution', { - errorMessage: error instanceof Error ? error.message : String(error), - executionTimeMs, - }); - - return { - success: false, - error: { - code: 'UNKNOWN_ERROR', - message: 'An unexpected error occurred. Please try again.', - details: error instanceof Error ? error.message : String(error), - }, - executionTimeMs, - }; - } -} - -/** - * Type guard to check if an error is a SubprocessError from nano-spawn - * - * @param error - The error to check - * @returns True if error is a SubprocessError - */ -function isSubprocessError(error: unknown): error is SubprocessError { - return ( - typeof error === 'object' && - error !== null && - 'exitCode' in error && - 'stderr' in error && - 'stdout' in error - ); -} - -/** - * Extract all JSON code blocks from text - * Handles multiple ```json...``` blocks in AI responses - * - * @param text - Text containing JSON code blocks - * @returns Array of JSON content strings (without the ```json markers) - */ -function extractAllJsonBlocks(text: string): string[] { - const blocks: string[] = []; - // Non-greedy regex to match each ```json...``` block individually - const regex = /```json\s*([\s\S]*?)```/g; - let match: RegExpExecArray | null = regex.exec(text); - while (match !== null) { - const content = match[1].trim(); - if (content.length > 0) { - blocks.push(content); - } - match = regex.exec(text); - } - return blocks; -} - -/** - * Parse JSON output from Claude Code CLI - * - * Handles multiple output formats: - * 1. Markdown-wrapped: ```json { ... } ``` - * 2. Raw JSON: { ... } - * 3. Text with embedded JSON block(s): "Some text...\n```json\n{...}\n```" - * - Supports multiple JSON blocks, prioritizing those with 'status' field - * - * @param output - Raw output string from CLI - * @returns Parsed JSON object or null if parsing fails - */ -export function parseClaudeCodeOutput(output: string): unknown { - try { - const trimmed = output.trim(); - - // Strategy 1: If wrapped in ```json...```, remove outer markers only - if (trimmed.startsWith('```json') && trimmed.endsWith('```')) { - const jsonContent = trimmed - .slice(7) // Remove ```json - .slice(0, -3) // Remove trailing ``` - .trim(); - return JSON.parse(jsonContent); - } - - // Strategy 2: Try parsing as-is (raw JSON) - if (trimmed.startsWith('{')) { - return JSON.parse(trimmed); - } - - // Strategy 3: Extract all JSON blocks and find structured response - // This handles cases where AI returns multiple JSON blocks (e.g., raw data + structured response) - const jsonBlocks = extractAllJsonBlocks(trimmed); - if (jsonBlocks.length > 0) { - // Priority: Find block with 'status' field (structured AI response format) - // Search from end since structured response is typically last - for (let i = jsonBlocks.length - 1; i >= 0; i--) { - try { - const parsed = JSON.parse(jsonBlocks[i]); - if (parsed && typeof parsed === 'object' && 'status' in parsed) { - log('DEBUG', 'Found structured response with status field', { - blockIndex: i, - totalBlocks: jsonBlocks.length, - status: (parsed as Record).status, - }); - return parsed; - } - } catch { - // Continue to next block if parse fails - } - } - - // Fallback: Try last block if no status field found - log('DEBUG', 'No status field found, using last JSON block', { - totalBlocks: jsonBlocks.length, - }); - try { - return JSON.parse(jsonBlocks[jsonBlocks.length - 1]); - } catch { - // Fall through to Strategy 4 - } - } - - // Strategy 4: Try parsing as-is (fallback) - return JSON.parse(trimmed); - } catch (_error) { - // If parsing fails, return null - return null; - } -} - -/** - * Cancel an active generation process - * - * @param requestId - Request ID of the generation to cancel - * @returns True if process was found and killed, false otherwise - */ -export async function cancelGeneration(requestId: string): Promise<{ - cancelled: boolean; - executionTimeMs?: number; -}> { - const activeGen = activeProcesses.get(requestId); - - if (!activeGen) { - log('WARN', `No active generation found for requestId: ${requestId}`); - return { cancelled: false }; - } - - const { subprocess, startTime } = activeGen; - const executionTimeMs = Date.now() - startTime; - - // nano-spawn v2.0.0: nodeChildProcess is a Promise that resolves to ChildProcess - // We need to await it before calling kill() - const childProcess = await subprocess.nodeChildProcess; - - log('INFO', `Cancelling generation for requestId: ${requestId}`, { - pid: childProcess.pid, - elapsedMs: executionTimeMs, - }); - - // Kill the process (cross-platform compatible) - // On Windows: kill() sends an unconditional termination - // On Unix: kill() sends SIGTERM (graceful termination) - childProcess.kill(); - - // Force kill after 500ms if process doesn't terminate - setTimeout(() => { - if (!childProcess.killed) { - // On Unix: this would be SIGKILL, but kill() without signal works on both platforms - childProcess.kill(); - log('WARN', `Forcefully killed process for requestId: ${requestId}`); - } - }, 500); - - // Remove from active processes map - activeProcesses.delete(requestId); - - return { cancelled: true, executionTimeMs }; -} - -/** - * Progress callback for streaming CLI execution - * @param chunk - Current text chunk - * @param displayText - Display text (may include tool usage info) - for streaming display - * @param explanatoryText - Explanatory text only (no tool info) - for preserving in chat history - */ -export type StreamingProgressCallback = ( - chunk: string, - displayText: string, - explanatoryText: string, - contentType?: 'tool_use' | 'text' -) => void; - -/** - * Execute Claude Code CLI with streaming output - * - * Uses --output-format stream-json to receive real-time output from Claude Code CLI. - * The onProgress callback is invoked for each text chunk received. - * - * @param prompt - The prompt to send to Claude Code CLI - * @param onProgress - Callback invoked with each text chunk and accumulated text - * @param timeoutMs - Timeout in milliseconds (default: 60000) - * @param requestId - Optional request ID for cancellation support - * @param workingDirectory - Working directory for CLI execution - * @param model - Claude model to use (default: 'sonnet') - * @param allowedTools - Array of allowed tool names for CLI (optional) - * @param resumeSessionId - Session ID to resume (for context continuation) - * @returns Execution result with success status and output/error - */ -export async function executeClaudeCodeCLIStreaming( - prompt: string, - onProgress: StreamingProgressCallback, - timeoutMs = 60000, - requestId?: string, - workingDirectory?: string, - model: ClaudeModel = 'sonnet', - allowedTools?: string[], - resumeSessionId?: string -): Promise { - const startTime = Date.now(); - let accumulated = ''; - let extractedSessionId: string | undefined; - - const modelName = getCliModelName(model); - - log('INFO', 'Starting Claude Code CLI streaming execution', { - promptLength: prompt.length, - timeoutMs, - model, - modelName, - allowedTools, - resumeSessionId, - cwd: workingDirectory ?? process.cwd(), - }); - - try { - // Build CLI arguments - const args = ['-p', '-', '--output-format', 'stream-json', '--verbose', '--model', modelName]; - - // Add --resume flag for session continuation - if (resumeSessionId) { - args.push('--resume', resumeSessionId); - log('INFO', 'Resuming Claude Code CLI session', { sessionId: resumeSessionId }); - } - - // Add --tools and --allowed-tools flags if provided - // --tools: whitelist restriction (only these tools available) - // --allowed-tools: no permission prompt for these tools - if (allowedTools && allowedTools.length > 0) { - args.push('--tools', allowedTools.join(',')); - args.push('--allowed-tools', allowedTools.join(',')); - } - - // Spawn Claude Code CLI with streaming output format - // Note: --verbose is required when using --output-format=stream-json with -p (print mode) - // Use claude directly if available, otherwise fall back to npx - const spawnCmd = await getClaudeSpawnCommand(args); - const subprocess = spawn(spawnCmd.command, spawnCmd.args, { - cwd: workingDirectory, - timeout: timeoutMs, - stdin: { string: prompt }, - stdout: 'pipe', - stderr: 'pipe', - }); - - // Register as active process if requestId is provided - if (requestId) { - activeProcesses.set(requestId, { subprocess, startTime }); - log('INFO', `Registered active streaming process for requestId: ${requestId}`); - } - - // Track explanatory text (non-JSON text from AI, for chat history) - let explanatoryText = ''; - // Track current tool info for display (not preserved in history) - let currentToolInfo = ''; - - // Process streaming output using AsyncIterable - for await (const chunk of subprocess.stdout) { - // Normalize CRLF to LF for cross-platform compatibility (Windows support) - // Split by newlines (JSON Lines format) - const lines = chunk - .replace(/\r\n/g, '\n') - .split('\n') - .filter((line: string) => line.trim()); - - for (const line of lines) { - try { - const parsed = JSON.parse(line); - - // Log parsed streaming JSON for debugging - log('DEBUG', 'Streaming JSON line parsed', { - type: parsed.type, - hasMessage: !!parsed.message, - contentTypes: parsed.message?.content?.map((c: { type: string }) => c.type), - // Show content preview (truncated to 500 chars) - contentPreview: - parsed.type === 'assistant' && parsed.message?.content - ? parsed.message.content - .filter((c: { type: string }) => c.type === 'text') - .map((c: { text: string }) => c.text?.substring(0, 200)) - .join('') - : JSON.stringify(parsed).substring(0, 500), - }); - - // Extract session ID from init message (for session continuation) - if (parsed.type === 'system' && parsed.subtype === 'init' && parsed.session_id) { - extractedSessionId = parsed.session_id; - log('INFO', 'Extracted session ID from CLI init message', { - sessionId: extractedSessionId, - }); - } - - // Extract content from assistant messages - if (parsed.type === 'assistant' && parsed.message?.content) { - for (const content of parsed.message.content) { - // Handle tool_use content - show tool name and relevant details (display only) - if (content.type === 'tool_use' && content.name) { - const toolName = content.name; - const input = content.input || {}; - let description = ''; - - // Extract relevant info based on tool type - if (toolName === 'Read' && input.file_path) { - description = input.file_path; - } else if (toolName === 'Bash' && input.command) { - description = input.command.substring(0, 100); - } else if (toolName === 'Task' && input.description) { - description = input.description; - } else if (toolName === 'Glob' && input.pattern) { - description = input.pattern; - } else if (toolName === 'Grep' && input.pattern) { - description = input.pattern; - } else if (toolName === 'Edit' && input.file_path) { - description = input.file_path; - } else if (toolName === 'Write' && input.file_path) { - description = input.file_path; - } - - currentToolInfo = description ? `${toolName}: ${description}` : toolName; - - // Build display text (explanatory + tool info) - const displayText = explanatoryText - ? `${explanatoryText}\n\n🔧 ${currentToolInfo}` - : `🔧 ${currentToolInfo}`; - - onProgress(currentToolInfo, displayText, explanatoryText, 'tool_use'); - } - - // Handle text content - if (content.type === 'text' && content.text) { - // Add separator between text chunks for better readability - if (accumulated.length > 0 && !accumulated.endsWith('\n')) { - accumulated += '\n\n'; - } - accumulated += content.text; - - // Check if accumulated text looks like JSON response - const trimmedAccumulated = accumulated.trim(); - let strippedText = trimmedAccumulated; - - // Strip markdown code block markers - if (strippedText.startsWith('```json')) { - strippedText = strippedText.slice(7).trimStart(); - } else if (strippedText.startsWith('```')) { - strippedText = strippedText.slice(3).trimStart(); - } - if (strippedText.endsWith('```')) { - strippedText = strippedText.slice(0, -3).trimEnd(); - } - - // Try to parse as JSON - if successful, skip progress (let success handler show it) - try { - const jsonResponse = JSON.parse(strippedText); - log('DEBUG', 'JSON parse succeeded in text content handler', { - hasStatus: !!jsonResponse.status, - hasMessage: !!jsonResponse.message, - hasValues: !!jsonResponse.values, - }); - // JSON parsed successfully - don't call onProgress for JSON content - } catch { - // JSON parsing failed - this is explanatory text or incomplete JSON - // Only show if it doesn't look like JSON being built - const looksLikeJsonStart = - strippedText.startsWith('{') || trimmedAccumulated.startsWith('```'); - - log('DEBUG', 'JSON parse failed in text content handler', { - looksLikeJsonStart, - strippedTextStartsWith: strippedText.substring(0, 20), - trimmedAccumulatedStartsWith: trimmedAccumulated.substring(0, 20), - }); - - if (!looksLikeJsonStart) { - // This is explanatory text from AI - // Check if text contains ```json block and extract text before it - const jsonBlockIndex = trimmedAccumulated.indexOf('```json'); - if (jsonBlockIndex !== -1) { - explanatoryText = trimmedAccumulated.slice(0, jsonBlockIndex).trim(); - log('DEBUG', 'Extracted explanatory text before ```json', { - explanatoryTextLength: explanatoryText.length, - explanatoryTextPreview: explanatoryText.substring(0, 200), - }); - } else { - explanatoryText = trimmedAccumulated; - } - - // Clear tool info when new text comes (text replaces tool display) - currentToolInfo = ''; - - // Display text is same as explanatory text when no tool is active - onProgress(content.text, explanatoryText, explanatoryText, 'text'); - } - } - } - } - } - } catch { - // Ignore JSON parse errors (may be partial chunks) - log('DEBUG', 'Skipping non-JSON line in streaming output', { - lineLength: line.length, - }); - } - } - } - - // Wait for subprocess to complete - const result = await subprocess; - - // Remove from active processes - if (requestId) { - activeProcesses.delete(requestId); - log('INFO', `Removed active streaming process (success) for requestId: ${requestId}`); - } - - const executionTimeMs = Date.now() - startTime; - - log('INFO', 'Claude Code CLI streaming execution succeeded', { - executionTimeMs, - accumulatedLength: accumulated.length, - rawOutputLength: result.stdout.length, - sessionId: extractedSessionId, - }); - - return { - success: true, - output: accumulated || result.stdout.trim(), - executionTimeMs, - sessionId: extractedSessionId, - }; - } catch (error) { - // Remove from active processes - if (requestId) { - activeProcesses.delete(requestId); - log('INFO', `Removed active streaming process (error) for requestId: ${requestId}`); - } - - const executionTimeMs = Date.now() - startTime; - - log('ERROR', 'Claude Code CLI streaming error caught', { - errorType: typeof error, - errorConstructor: error?.constructor?.name, - executionTimeMs, - accumulatedLength: accumulated.length, - // Add detailed error info for debugging - exitCode: isSubprocessError(error) ? error.exitCode : undefined, - stderr: isSubprocessError(error) ? error.stderr?.substring(0, 500) : undefined, - stdout: isSubprocessError(error) ? error.stdout?.substring(0, 500) : undefined, - errorMessage: error instanceof Error ? error.message : String(error), - }); - - // Handle SubprocessError from nano-spawn - if (isSubprocessError(error)) { - const isTimeout = - (error.isTerminated && error.signalName === 'SIGTERM') || error.exitCode === 143; - - if (isTimeout) { - log('WARN', 'Claude Code CLI streaming execution timed out', { - timeoutMs, - executionTimeMs, - exitCode: error.exitCode, - accumulatedLength: accumulated.length, - }); - - return { - success: false, - output: accumulated, // Return accumulated content even on timeout - error: { - code: 'TIMEOUT', - message: `AI generation timed out after ${Math.floor(timeoutMs / 1000)} seconds. Try simplifying your description.`, - details: `Timeout after ${timeoutMs}ms`, - }, - executionTimeMs, - sessionId: extractedSessionId, - }; - } - - // Command not found (ENOENT) - if (error.code === 'ENOENT') { - return { - success: false, - error: { - code: 'COMMAND_NOT_FOUND', - message: 'Cannot connect to Claude Code - please ensure it is installed and running', - details: error.message, - }, - executionTimeMs, - sessionId: extractedSessionId, - }; - } - - // npx fallback failed - Claude package not found - // stderr contains "could not determine executable to run" when npx can't find the package - if (error.stderr?.includes('could not determine executable to run')) { - log('WARN', 'Claude Code CLI not installed (npx fallback failed)', { - stderr: error.stderr, - executionTimeMs, - }); - return { - success: false, - error: { - code: 'COMMAND_NOT_FOUND', - message: 'Claude Code CLI not found. Please install Claude Code to use AI refinement.', - details: error.stderr, - }, - executionTimeMs, - sessionId: extractedSessionId, - }; - } - - // Non-zero exit code - return { - success: false, - output: accumulated, // Return accumulated content even on error - error: { - code: 'UNKNOWN_ERROR', - message: 'Generation failed - please try again or rephrase your description', - details: `Exit code: ${error.exitCode ?? 'unknown'}, stderr: ${error.stderr ?? 'none'}`, - }, - executionTimeMs, - sessionId: extractedSessionId, - }; - } - - // Unknown error type - return { - success: false, - output: accumulated, - error: { - code: 'UNKNOWN_ERROR', - message: 'An unexpected error occurred. Please try again.', - details: error instanceof Error ? error.message : String(error), - }, - executionTimeMs, - sessionId: extractedSessionId, - }; - } -} - -/** - * Cancel an active refinement process - * - * @param requestId - Request ID of the refinement to cancel - * @returns True if process was found and killed, false otherwise - */ -export async function cancelRefinement(requestId: string): Promise<{ - cancelled: boolean; - executionTimeMs?: number; -}> { - const activeGen = activeProcesses.get(requestId); - - if (!activeGen) { - log('WARN', `No active refinement found for requestId: ${requestId}`); - return { cancelled: false }; - } - - const { subprocess, startTime } = activeGen; - const executionTimeMs = Date.now() - startTime; - - // nano-spawn v2.0.0: nodeChildProcess is a Promise that resolves to ChildProcess - // We need to await it before calling kill() - const childProcess = await subprocess.nodeChildProcess; - - log('INFO', `Cancelling refinement for requestId: ${requestId}`, { - pid: childProcess.pid, - elapsedMs: executionTimeMs, - }); - - // Kill the process (cross-platform compatible) - // On Windows: kill() sends an unconditional termination - // On Unix: kill() sends SIGTERM (graceful termination) - childProcess.kill(); - - // Force kill after 500ms if process doesn't terminate - setTimeout(() => { - if (!childProcess.killed) { - // On Unix: this would be SIGKILL, but kill() without signal works on both platforms - childProcess.kill(); - log('WARN', `Forcefully killed refinement process for requestId: ${requestId}`); - } - }, 500); - - // Remove from active processes map - activeProcesses.delete(requestId); - - return { cancelled: true, executionTimeMs }; -} diff --git a/src/extension/services/cli-path-detector.ts b/src/extension/services/cli-path-detector.ts deleted file mode 100644 index a1803f7d..00000000 --- a/src/extension/services/cli-path-detector.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * CLI Path Detection Service (Shared) - * - * Shared module for detecting CLI executable paths. - * Uses VSCode's default terminal setting to get the user's shell, - * then executes with login shell to get the full PATH environment. - * - * This handles GUI-launched VSCode scenarios where the Extension Host - * doesn't inherit the user's shell PATH settings. - * - * Used by: claude-cli-path.ts, codex-cli-path.ts - * Based on: Issue #375 - */ - -import * as fs from 'node:fs'; -import nanoSpawn from 'nano-spawn'; -import * as vscode from 'vscode'; -import { log } from '../extension'; - -interface Result { - stdout: string; - stderr: string; - output: string; - command: string; - durationMs: number; -} - -const spawn = - nanoSpawn.default || - (nanoSpawn as ( - file: string, - args?: readonly string[], - options?: Record - ) => Promise); - -/** - * Terminal profile configuration from VSCode settings - */ -interface TerminalProfile { - path?: string; - args?: string[]; -} - -/** - * Get the default terminal shell configuration from VSCode settings. - * - * @returns Shell path and args, or null if not configured - */ -function getDefaultShellConfig(): { path: string; args: string[] } | null { - const config = vscode.workspace.getConfiguration('terminal.integrated'); - - let platformKey: 'windows' | 'linux' | 'osx'; - if (process.platform === 'win32') { - platformKey = 'windows'; - } else if (process.platform === 'darwin') { - platformKey = 'osx'; - } else { - platformKey = 'linux'; - } - - const defaultProfileName = config.get(`defaultProfile.${platformKey}`); - const profiles = config.get>(`profiles.${platformKey}`); - - if (defaultProfileName && profiles?.[defaultProfileName]) { - const profile = profiles[defaultProfileName]; - if (profile.path) { - log('INFO', 'Using VSCode default terminal profile', { - profile: defaultProfileName, - path: profile.path, - args: profile.args, - }); - return { - path: profile.path, - args: profile.args || [], - }; - } - } - - log('INFO', 'No VSCode default terminal profile configured'); - return null; -} - -/** - * Check if the shell is PowerShell (pwsh or powershell) - */ -function isPowerShell(shellPath: string): boolean { - const lowerPath = shellPath.toLowerCase(); - return lowerPath.includes('pwsh') || lowerPath.includes('powershell'); -} - -/** - * Find an executable using a specific shell. - * - * @param executable - The executable name to find - * @param shellPath - Path to the shell executable - * @param shellArgs - Additional shell arguments from profile - * @returns Full path to executable if found, null otherwise - */ -async function findExecutableWithShell( - executable: string, - shellPath: string, - shellArgs: string[] -): Promise { - log('INFO', `Searching for ${executable} via configured shell`, { - shell: shellPath, - }); - - try { - let args: string[]; - let timeout = 15000; - - if (isPowerShell(shellPath)) { - // PowerShell: use Get-Command with -CommandType Application - // to avoid .ps1 wrapper scripts - args = [ - ...shellArgs, - '-NonInteractive', - '-Command', - `(Get-Command ${executable} -CommandType Application -ErrorAction SilentlyContinue).Source`, - ]; - } else { - // Unix shells (bash, zsh, etc.): use login shell with which command - args = [...shellArgs, '-ilc', `which ${executable}`]; - timeout = 10000; - } - - const result = await spawn(shellPath, args, { timeout }); - - log('INFO', `Shell execution completed for ${executable}`, { - shell: shellPath, - stdout: result.stdout.trim().substring(0, 300), - stderr: result.stderr.substring(0, 100), - }); - - const foundPath = result.stdout.trim().split(/\r?\n/)[0]; - if (foundPath && fs.existsSync(foundPath)) { - log('INFO', `Found ${executable} via configured shell`, { - shell: shellPath, - path: foundPath, - }); - return foundPath; - } - } catch (error) { - const err = error as { stdout?: string; stderr?: string; exitCode?: number }; - log('INFO', `${executable} not found via configured shell`, { - shell: shellPath, - error: error instanceof Error ? error.message : String(error), - stdout: err.stdout?.substring(0, 200), - stderr: err.stderr?.substring(0, 200), - }); - } - - return null; -} - -/** - * Fallback for Windows when no VSCode terminal is configured. - * Tries PowerShell 7 (pwsh) first, then PowerShell 5 (powershell). - */ -async function findExecutableViaWindowsFallback(executable: string): Promise { - const shells = ['pwsh', 'powershell']; - - for (const shell of shells) { - const result = await findExecutableWithShell(executable, shell, []); - if (result) return result; - } - - return null; -} - -/** - * Fallback for Unix/macOS when no VSCode terminal is configured. - * Tries zsh first, then bash. - */ -async function findExecutableViaUnixFallback(executable: string): Promise { - const shells = ['/bin/zsh', '/bin/bash', 'zsh', 'bash']; - - for (const shell of shells) { - const result = await findExecutableWithShell(executable, shell, []); - if (result) return result; - } - - return null; -} - -/** - * Find an executable using VSCode's default terminal shell. - * Falls back to platform-specific defaults if not configured. - * - * @param executable - The executable name to find (e.g., 'claude', 'codex', 'npx') - * @returns Full path to executable if found, null otherwise - */ -export async function findExecutableViaDefaultShell(executable: string): Promise { - const shellConfig = getDefaultShellConfig(); - - if (shellConfig) { - // Use VSCode's configured default terminal - const result = await findExecutableWithShell(executable, shellConfig.path, shellConfig.args); - if (result) return result; - } - - // Fallback to platform-specific defaults - if (process.platform === 'win32') { - return findExecutableViaWindowsFallback(executable); - } - return findExecutableViaUnixFallback(executable); -} - -/** - * Verify an executable is runnable by checking its version - * - * @param executablePath - Path to the executable - * @param versionFlag - Flag to get version (default: '--version') - * @returns Version string if executable works, null otherwise - */ -export async function verifyExecutable( - executablePath: string, - versionFlag = '--version' -): Promise { - try { - const result = await spawn(executablePath, [versionFlag], { timeout: 5000 }); - return result.stdout.trim().substring(0, 50); - } catch { - return null; - } -} - -/** - * Try to find an executable directly in PATH (for terminal-launched VSCode) - * - * @param executable - The executable name - * @param versionFlag - Flag to get version (default: '--version') - * @returns executable name if found in PATH, null otherwise - */ -export async function findExecutableInPath( - executable: string, - versionFlag = '--version' -): Promise { - try { - const result = await spawn(executable, [versionFlag], { timeout: 5000 }); - log('INFO', `${executable} found in PATH`, { - version: result.stdout.trim().substring(0, 50), - }); - return executable; - } catch { - return null; - } -} diff --git a/src/extension/services/codex-cli-path.ts b/src/extension/services/codex-cli-path.ts deleted file mode 100644 index fdf08134..00000000 --- a/src/extension/services/codex-cli-path.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Codex CLI Path Detection Service - * - * Detects Codex CLI executable path using the shared CLI path detector. - * Uses VSCode's default terminal setting to get the user's shell, - * then executes with login shell to get the full PATH environment. - * - * This handles GUI-launched VSCode scenarios where the Extension Host - * doesn't inherit the user's shell PATH settings. - * - * Based on: claude-cli-path.ts (Issue #375) - */ - -import { log } from '../extension'; -import { - findExecutableInPath, - findExecutableViaDefaultShell, - verifyExecutable, -} from './cli-path-detector'; - -/** - * Cached Codex CLI path - * undefined = not checked yet - * null = not found (use npx fallback) - * string = path to codex executable - */ -let cachedCodexPath: string | null | undefined; - -/** - * Get the path to Codex CLI executable - * Detection order: - * 1. VSCode default terminal shell (handles version managers like mise, nvm) - * 2. Direct PATH lookup (fallback for terminal-launched VSCode) - * 3. npx fallback (handled in getCodexSpawnCommand) - * - * @returns Path to codex executable (full path or 'codex' for PATH), null for npx fallback - */ -export async function getCodexCliPath(): Promise { - // Return cached result if available - if (cachedCodexPath !== undefined) { - return cachedCodexPath; - } - - // 1. Try VSCode default terminal (handles GUI-launched VSCode + version managers) - const shellPath = await findExecutableViaDefaultShell('codex'); - if (shellPath) { - const version = await verifyExecutable(shellPath); - if (version) { - log('INFO', 'Codex CLI found via default shell', { - path: shellPath, - version, - }); - cachedCodexPath = shellPath; - return shellPath; - } - log('WARN', 'Codex CLI found but not executable', { path: shellPath }); - } - - // 2. Fall back to direct PATH lookup (terminal-launched VSCode) - const pathResult = await findExecutableInPath('codex'); - if (pathResult) { - cachedCodexPath = 'codex'; - return 'codex'; - } - - log('INFO', 'Codex CLI not found, will use npx fallback'); - cachedCodexPath = null; - return null; -} - -/** - * Clear Codex CLI path cache - * Useful for testing or when user installs Codex CLI during session - */ -export function clearCodexCliPathCache(): void { - cachedCodexPath = undefined; -} - -/** - * Get the command and args for spawning Codex CLI - * Uses codex directly if available, otherwise falls back to 'npx @openai/codex' - * npx detection order: - * 1. VSCode default terminal shell (handles version managers) - * 2. Direct PATH lookup - * - * @returns command path with 'npx:' prefix if using npx fallback, or null if not found - */ -export async function getCodexSpawnCommand(): Promise { - const codexPath = await getCodexCliPath(); - - if (codexPath) { - return codexPath; - } - - // Fallback: Try npx @openai/codex - // Return a special marker that codex-cli-service will handle - const npxPath = await findExecutableViaDefaultShell('npx'); - if (npxPath) { - log('INFO', 'Using npx from default shell for Codex CLI fallback', { - path: npxPath, - }); - return `npx:${npxPath}`; - } - - // Final fallback to direct PATH lookup - log('INFO', 'Using npx from PATH for Codex CLI fallback'); - return 'npx:npx'; -} diff --git a/src/extension/services/codex-cli-service.ts b/src/extension/services/codex-cli-service.ts deleted file mode 100644 index d82c7e65..00000000 --- a/src/extension/services/codex-cli-service.ts +++ /dev/null @@ -1,1009 +0,0 @@ -/** - * Codex CLI Service - * - * Executes OpenAI Codex CLI commands for AI-assisted workflow generation and refinement. - * Based on Codex CLI documentation: https://developers.openai.com/codex/cli/reference/ - * - * Uses nano-spawn for cross-platform compatibility (Windows/Unix). - * Uses codex-cli-path.ts for cross-platform CLI path detection (handles GUI-launched VSCode). - */ - -import type { ChildProcess } from 'node:child_process'; -import nanoSpawn from 'nano-spawn'; -import type { CodexModel, CodexReasoningEffort } from '../../shared/types/messages'; -import { log } from '../extension'; -import { clearCodexCliPathCache, getCodexSpawnCommand } from './codex-cli-path'; - -// Re-export for external use -export { clearCodexCliPathCache }; - -/** - * nano-spawn type definitions (manually defined for compatibility) - */ -interface SubprocessError extends Error { - stdout: string; - stderr: string; - output: string; - command: string; - durationMs: number; - exitCode?: number; - signalName?: string; - isTerminated?: boolean; - code?: string; -} - -interface Result { - stdout: string; - stderr: string; - output: string; - command: string; - durationMs: number; -} - -interface Subprocess extends Promise { - nodeChildProcess: Promise; - stdout: AsyncIterable; - stderr: AsyncIterable; -} - -const spawn = - nanoSpawn.default || - (nanoSpawn as ( - file: string, - args?: readonly string[], - options?: Record - ) => Subprocess); - -/** - * Active generation processes - * Key: requestId, Value: subprocess and start time - */ -const activeProcesses = new Map(); - -export interface CodexExecutionResult { - success: boolean; - output?: string; - error?: { - code: 'COMMAND_NOT_FOUND' | 'MODEL_NOT_SUPPORTED' | 'TIMEOUT' | 'PARSE_ERROR' | 'UNKNOWN_ERROR'; - message: string; - details?: string; - }; - executionTimeMs: number; - /** Thread ID for session continuation (extracted from thread.started event) */ - sessionId?: string; -} - -/** Default Codex model (empty = inherit from CLI config) */ -const DEFAULT_CODEX_MODEL: CodexModel = ''; - -/** - * Type guard to check if an error is a SubprocessError from nano-spawn - */ -function isSubprocessError(error: unknown): error is SubprocessError { - return ( - typeof error === 'object' && - error !== null && - 'exitCode' in error && - 'stderr' in error && - 'stdout' in error - ); -} - -/** - * Parse the codex command path returned by getCodexSpawnCommand. - * Handles both direct path and npx fallback (prefixed with "npx:"). - * - * @param codexPath - Path from getCodexSpawnCommand - * @param args - CLI arguments - * @returns Command and args for spawn - */ -function parseCodexCommand(codexPath: string, args: string[]): { command: string; args: string[] } { - // Check for npx fallback (prefixed with "npx:") - if (codexPath.startsWith('npx:')) { - const npxPath = codexPath.slice(4); // Remove "npx:" prefix - // npx @openai/codex exec [args] - return { - command: npxPath, - args: ['@openai/codex', ...args], - }; - } - - // Direct codex path - return { command: codexPath, args }; -} - -/** - * Check if Codex CLI is available - * Uses codex-cli-path.ts for cross-platform path detection. - * - * @returns Promise resolving to availability status - */ -export async function isCodexCliAvailable(): Promise<{ - available: boolean; - reason?: string; -}> { - const codexPath = await getCodexSpawnCommand(); - - if (codexPath) { - log('INFO', 'Codex CLI is available', { path: codexPath }); - return { available: true }; - } - - log('WARN', 'Codex CLI not available'); - return { available: false, reason: 'COMMAND_NOT_FOUND' }; -} - -/** - * Execute Codex CLI with a prompt and return the output (non-streaming) - * Uses nano-spawn with stdin support for cross-platform compatibility. - * - * @param prompt - The prompt to send to Codex CLI via stdin - * @param timeoutMs - Timeout in milliseconds (default: 60000) - * @param requestId - Optional request ID for cancellation support - * @param workingDirectory - Working directory for CLI execution - * @param model - Codex model to use (default: '' = inherit from CLI config) - * @param reasoningEffort - Reasoning effort level (default: 'low') - * @param resumeSessionId - Optional thread ID to resume a previous session - * @returns Execution result with success status and output/error - */ -export async function executeCodexCLI( - prompt: string, - timeoutMs = 60000, - requestId?: string, - workingDirectory?: string, - model: CodexModel = DEFAULT_CODEX_MODEL, - reasoningEffort: CodexReasoningEffort = 'low', - resumeSessionId?: string -): Promise { - const startTime = Date.now(); - - log('INFO', 'Starting Codex CLI execution', { - promptLength: prompt.length, - timeoutMs, - model, - reasoningEffort, - cwd: workingDirectory ?? process.cwd(), - resumeSessionId: resumeSessionId ? '(present)' : undefined, - }); - - // Get Codex CLI path (handles GUI-launched VSCode where PATH is different) - const codexPath = await getCodexSpawnCommand(); - if (!codexPath) { - log('ERROR', 'Codex CLI not found during execution'); - return { - success: false, - error: { - code: 'COMMAND_NOT_FOUND', - message: 'Codex CLI not found. Please install Codex CLI to use this provider.', - details: 'Unable to locate codex executable via shell or PATH', - }, - executionTimeMs: Date.now() - startTime, - }; - } - - try { - // Build CLI arguments with '-' to read prompt from stdin - // --skip-git-repo-check: bypass trust check since user is explicitly using extension - // For session resume: codex exec resume [options] - - const args = resumeSessionId - ? ['exec', 'resume', resumeSessionId, '--json', '--skip-git-repo-check'] - : ['exec', '--json', '--skip-git-repo-check']; - if (model) { - args.push('-m', model); - } - // Add reasoning effort configuration - if (reasoningEffort) { - args.push('-c', `model_reasoning_effort="${reasoningEffort}"`); - } - args.push('--full-auto', '-'); - - // Parse command (handles npx fallback) - const spawnCmd = parseCodexCommand(codexPath, args); - - log('DEBUG', 'Spawning Codex CLI process', { - command: spawnCmd.command, - args: spawnCmd.args, - }); - - // Spawn using nano-spawn (cross-platform compatible) - const subprocess = spawn(spawnCmd.command, spawnCmd.args, { - cwd: workingDirectory, - timeout: timeoutMs > 0 ? timeoutMs : undefined, - stdin: { string: prompt }, - stdout: 'pipe', - stderr: 'pipe', - }); - - // Register as active process if requestId is provided - if (requestId) { - activeProcesses.set(requestId, { subprocess, startTime }); - log('INFO', `Registered active Codex process for requestId: ${requestId}`); - } - - // Wait for subprocess to complete - const result = await subprocess; - - // Remove from active processes - if (requestId) { - activeProcesses.delete(requestId); - log('INFO', `Removed active Codex process (success) for requestId: ${requestId}`); - } - - const executionTimeMs = Date.now() - startTime; - - // Parse output - const parsedOutput = parseCodexOutput(result.stdout); - const output = extractJsonResponse(parsedOutput); - const extractedSessionId = extractThreadIdFromOutput(result.stdout); - - log('INFO', 'Codex CLI execution succeeded', { - executionTimeMs, - outputLength: output.length, - wasExtracted: output !== parsedOutput, - sessionId: extractedSessionId, - }); - - return { - success: true, - output: output.trim(), - executionTimeMs, - sessionId: extractedSessionId, - }; - } catch (error) { - // Remove from active processes - if (requestId) { - activeProcesses.delete(requestId); - log('INFO', `Removed active Codex process (error) for requestId: ${requestId}`); - } - - const executionTimeMs = Date.now() - startTime; - - log('ERROR', 'Codex CLI error caught', { - errorType: typeof error, - errorConstructor: error?.constructor?.name, - executionTimeMs, - }); - - // Handle SubprocessError from nano-spawn - if (isSubprocessError(error)) { - const isTimeout = - (error.isTerminated && error.signalName === 'SIGTERM') || error.exitCode === 143; - - if (isTimeout) { - log('WARN', 'Codex CLI execution timed out', { - timeoutMs, - executionTimeMs, - exitCode: error.exitCode, - }); - - return { - success: false, - error: { - code: 'TIMEOUT', - message: `AI generation timed out after ${Math.floor(timeoutMs / 1000)} seconds.`, - details: `Timeout after ${timeoutMs}ms`, - }, - executionTimeMs, - }; - } - - // Command not found (ENOENT) - if (error.code === 'ENOENT') { - log('ERROR', 'Codex CLI not found', { - errorCode: error.code, - errorMessage: error.message, - }); - - return { - success: false, - error: { - code: 'COMMAND_NOT_FOUND', - message: 'Codex CLI not found. Please install Codex CLI to use this provider.', - details: error.message, - }, - executionTimeMs, - }; - } - - // npx fallback failed - if (error.stderr?.includes('could not determine executable to run')) { - log('WARN', 'Codex CLI not installed (npx fallback failed)', { - stderr: error.stderr, - }); - return { - success: false, - error: { - code: 'COMMAND_NOT_FOUND', - message: 'Codex CLI not found. Please install Codex CLI to use this provider.', - details: error.stderr, - }, - executionTimeMs, - }; - } - - // Non-zero exit code - log('ERROR', 'Codex CLI execution failed', { - exitCode: error.exitCode, - stderr: error.stderr?.substring(0, 200), - }); - - return { - success: false, - error: { - code: 'UNKNOWN_ERROR', - message: 'Generation failed - please try again or rephrase your description', - details: `Exit code: ${error.exitCode ?? 'unknown'}, stderr: ${error.stderr ?? 'none'}`, - }, - executionTimeMs, - }; - } - - // Unknown error type - log('ERROR', 'Unexpected error during Codex CLI execution', { - errorMessage: error instanceof Error ? error.message : String(error), - }); - - return { - success: false, - error: { - code: 'UNKNOWN_ERROR', - message: 'An unexpected error occurred. Please try again.', - details: error instanceof Error ? error.message : String(error), - }, - executionTimeMs, - }; - } -} - -/** - * Progress callback for streaming CLI execution - */ -export type StreamingProgressCallback = ( - chunk: string, - displayText: string, - explanatoryText: string, - contentType?: 'tool_use' | 'text' -) => void; - -/** - * Execute Codex CLI with streaming output - * Uses nano-spawn with stdin support for cross-platform compatibility. - * - * @param prompt - The prompt to send to Codex CLI via stdin - * @param onProgress - Callback invoked with each text chunk - * @param timeoutMs - Timeout in milliseconds (default: 60000) - * @param requestId - Optional request ID for cancellation support - * @param workingDirectory - Working directory for CLI execution - * @param model - Codex model to use (default: '' = inherit from CLI config) - * @param reasoningEffort - Reasoning effort level (default: 'low') - * @param resumeSessionId - Optional thread ID to resume a previous session - * @returns Execution result with success status and output/error - */ -export async function executeCodexCLIStreaming( - prompt: string, - onProgress: StreamingProgressCallback, - timeoutMs = 60000, - requestId?: string, - workingDirectory?: string, - model: CodexModel = DEFAULT_CODEX_MODEL, - reasoningEffort: CodexReasoningEffort = 'low', - resumeSessionId?: string -): Promise { - const startTime = Date.now(); - let accumulated = ''; - let extractedSessionId: string | undefined; - - log('INFO', 'Starting Codex CLI streaming execution', { - promptLength: prompt.length, - timeoutMs, - model, - reasoningEffort, - cwd: workingDirectory ?? process.cwd(), - resumeSessionId: resumeSessionId ? '(present)' : undefined, - }); - - // Get Codex CLI path (handles GUI-launched VSCode where PATH is different) - const codexPath = await getCodexSpawnCommand(); - if (!codexPath) { - log('ERROR', 'Codex CLI not found during streaming execution'); - return { - success: false, - error: { - code: 'COMMAND_NOT_FOUND', - message: 'Codex CLI not found. Please install Codex CLI to use this provider.', - details: 'Unable to locate codex executable via shell or PATH', - }, - executionTimeMs: Date.now() - startTime, - }; - } - - try { - // Build CLI arguments with '-' to read prompt from stdin - // --skip-git-repo-check: bypass trust check since user is explicitly using extension - // For session resume: codex exec resume [options] - - const args = resumeSessionId - ? ['exec', 'resume', resumeSessionId, '--json', '--skip-git-repo-check'] - : ['exec', '--json', '--skip-git-repo-check']; - if (model) { - args.push('-m', model); - } - // Add reasoning effort configuration - if (reasoningEffort) { - args.push('-c', `model_reasoning_effort="${reasoningEffort}"`); - } - args.push('--full-auto', '-'); - - // Parse command (handles npx fallback) - const spawnCmd = parseCodexCommand(codexPath, args); - - log('DEBUG', 'Spawning Codex CLI streaming process', { - command: spawnCmd.command, - args: spawnCmd.args, - }); - - // Spawn using nano-spawn (cross-platform compatible) - const subprocess = spawn(spawnCmd.command, spawnCmd.args, { - cwd: workingDirectory, - timeout: timeoutMs > 0 ? timeoutMs : undefined, - stdin: { string: prompt }, - stdout: 'pipe', - stderr: 'pipe', - }); - - // Register as active process if requestId is provided - if (requestId) { - activeProcesses.set(requestId, { subprocess, startTime }); - log('INFO', `Registered active Codex streaming process for requestId: ${requestId}`); - } - - // Track explanatory text (non-JSON text from AI, for chat history) - let explanatoryText = ''; - // Track current tool info for display - let currentToolInfo = ''; - // Line buffer for JSONL parsing - let lineBuffer = ''; - // Collect stderr for debugging - let stderrOutput = ''; - - // Start collecting stderr in background (for debugging) - const stderrPromise = (async () => { - for await (const chunk of subprocess.stderr) { - stderrOutput += chunk; - log('DEBUG', 'Codex stderr chunk received', { - chunkLength: chunk.length, - totalStderrLength: stderrOutput.length, - preview: chunk.substring(0, 200), - }); - } - })(); - - // Process streaming output using AsyncIterable - let stdoutChunkCount = 0; - for await (const chunk of subprocess.stdout) { - stdoutChunkCount++; - log('DEBUG', 'Codex stdout chunk received', { - chunkNumber: stdoutChunkCount, - chunkLength: chunk.length, - preview: chunk.substring(0, 200), - hasNewline: chunk.includes('\n'), - }); - // Normalize CRLF to LF for cross-platform compatibility - const normalizedChunk = chunk.replace(/\r\n/g, '\n'); - - // Codex CLI may output complete JSON objects without trailing newlines - // Each chunk from nano-spawn might be a complete JSONL line - // Handle both cases: chunks with newlines (split normally) and without (treat as complete line) - let linesToProcess: string[]; - - if (normalizedChunk.includes('\n')) { - // Chunk contains newlines - use normal JSONL parsing - lineBuffer += normalizedChunk; - const lines = lineBuffer.split('\n'); - // Keep the last potentially incomplete line in buffer - lineBuffer = lines.pop() || ''; - linesToProcess = lines; - } else { - // No newlines - treat entire chunk as a complete JSON line - // But first check if we have buffered content to prepend - if (lineBuffer) { - lineBuffer += normalizedChunk; - linesToProcess = [lineBuffer]; - lineBuffer = ''; - } else { - linesToProcess = [normalizedChunk]; - } - } - - for (const line of linesToProcess) { - if (!line.trim()) continue; - - try { - const parsed = JSON.parse(line); - - log('DEBUG', 'Codex streaming JSON line parsed', { - type: parsed.type, - hasContent: !!parsed.content, - hasItem: !!parsed.item, - }); - - // Handle Codex CLI JSONL event types - if (parsed.type === 'item.completed' && parsed.item) { - const item = parsed.item; - - // Extract content from item - if (item.content && Array.isArray(item.content)) { - for (const block of item.content) { - if (block.type === 'text' && block.text) { - accumulated += block.text; - explanatoryText = accumulated; - currentToolInfo = ''; - onProgress(block.text, explanatoryText, explanatoryText, 'text'); - } else if (block.type === 'tool_use' && block.name) { - currentToolInfo = block.name; - const displayText = explanatoryText - ? `${explanatoryText}\n\n🔧 ${currentToolInfo}` - : `🔧 ${currentToolInfo}`; - onProgress(currentToolInfo, displayText, explanatoryText, 'tool_use'); - } else if (block.type === 'function_call' && block.name) { - currentToolInfo = block.name; - const displayText = explanatoryText - ? `${explanatoryText}\n\n🔧 ${currentToolInfo}` - : `🔧 ${currentToolInfo}`; - onProgress(currentToolInfo, displayText, explanatoryText, 'tool_use'); - } - } - } else if (typeof item.content === 'string') { - accumulated += item.content; - explanatoryText = accumulated; - currentToolInfo = ''; - onProgress(item.content, explanatoryText, explanatoryText, 'text'); - } - - // Check for output field - if (item.output && typeof item.output === 'string') { - accumulated += item.output; - explanatoryText = accumulated; - currentToolInfo = ''; - onProgress(item.output, explanatoryText, explanatoryText, 'text'); - } - - // Codex CLI uses item.text for agent_message type - if (item.text && typeof item.text === 'string') { - const textContent = item.text; - let displayContent = item.text; - - // Try to parse item.text as JSON - try { - const textJson = JSON.parse(item.text); - if (textJson.status && textJson.message) { - displayContent = textJson.message; - log('DEBUG', 'Parsed JSON from item.text', { - status: textJson.status, - messageLength: textJson.message.length, - }); - } - } catch { - // Not JSON, use as-is - } - - accumulated += textContent; - explanatoryText = displayContent; - currentToolInfo = ''; - onProgress(displayContent, displayContent, displayContent, 'text'); - } - } else if (parsed.type === 'message' && parsed.content) { - const content = parsed.content; - - if (typeof content === 'string') { - accumulated += content; - explanatoryText = accumulated; - currentToolInfo = ''; - onProgress(content, explanatoryText, explanatoryText, 'text'); - } else if (Array.isArray(content)) { - for (const block of content) { - if (block.type === 'text' && block.text) { - accumulated += block.text; - explanatoryText = accumulated; - currentToolInfo = ''; - onProgress(block.text, explanatoryText, explanatoryText, 'text'); - } else if (block.type === 'tool_use' && block.name) { - currentToolInfo = block.name; - const displayText = explanatoryText - ? `${explanatoryText}\n\n🔧 ${currentToolInfo}` - : `🔧 ${currentToolInfo}`; - onProgress(currentToolInfo, displayText, explanatoryText, 'tool_use'); - } - } - } - } else if (parsed.type === 'tool_use' || parsed.type === 'function_call') { - const toolName = parsed.name || parsed.function?.name || 'Unknown tool'; - currentToolInfo = toolName; - const displayText = explanatoryText - ? `${explanatoryText}\n\n🔧 ${currentToolInfo}` - : `🔧 ${currentToolInfo}`; - onProgress(currentToolInfo, displayText, explanatoryText, 'tool_use'); - } else if (parsed.type === 'text' || parsed.type === 'assistant') { - const text = parsed.text || parsed.content || ''; - if (text) { - accumulated += text; - explanatoryText = accumulated; - currentToolInfo = ''; - onProgress(text, explanatoryText, explanatoryText, 'text'); - } - } else if (parsed.type === 'thread.started') { - // Extract thread_id for session continuation - if (parsed.thread_id) { - extractedSessionId = parsed.thread_id; - log('INFO', 'Extracted thread ID from Codex thread.started event', { - threadId: extractedSessionId, - }); - } - } else if (parsed.type === 'turn.started' || parsed.type === 'turn.completed') { - log('DEBUG', `Codex lifecycle event: ${parsed.type}`); - } - } catch { - log('DEBUG', 'Skipping non-JSON line in Codex streaming output', { - lineLength: line.length, - linePreview: line.substring(0, 100), - }); - } - } - } - - // Process any remaining content in lineBuffer - if (lineBuffer.trim()) { - log('DEBUG', 'Processing remaining lineBuffer content', { - bufferLength: lineBuffer.length, - bufferPreview: lineBuffer.substring(0, 200), - }); - try { - const parsed = JSON.parse(lineBuffer); - // Handle remaining JSON (same logic as above, simplified) - if (parsed.type === 'item.completed' && parsed.item?.text) { - accumulated += parsed.item.text; - } - } catch { - log('DEBUG', 'Could not parse remaining lineBuffer as JSON'); - } - } - - // Wait for subprocess to complete - const result = await subprocess; - - // Wait for stderr collection to complete - await stderrPromise; - - // Remove from active processes - if (requestId) { - activeProcesses.delete(requestId); - log('INFO', `Removed active Codex streaming process (success) for requestId: ${requestId}`); - } - - const executionTimeMs = Date.now() - startTime; - - // Extract JSON response from accumulated output - const extractedOutput = extractJsonResponse(accumulated); - - log('INFO', 'Codex CLI streaming execution succeeded', { - executionTimeMs, - stdoutChunkCount, - accumulatedLength: accumulated.length, - extractedLength: extractedOutput.length, - wasExtracted: extractedOutput !== accumulated, - sessionId: extractedSessionId, - stderrLength: stderrOutput.length, - resultStdoutLength: result.stdout.length, - resultStderrLength: result.stderr.length, - }); - - // Debug: Log raw output if nothing was accumulated - if (accumulated.length === 0) { - log('WARN', 'No output accumulated from Codex streaming', { - resultStdout: result.stdout.substring(0, 1000), - resultStderr: result.stderr.substring(0, 1000), - stderrOutput: stderrOutput.substring(0, 1000), - }); - } - - return { - success: true, - output: extractedOutput || result.stdout.trim(), - executionTimeMs, - sessionId: extractedSessionId, - }; - } catch (error) { - // Remove from active processes - if (requestId) { - activeProcesses.delete(requestId); - log('INFO', `Removed active Codex streaming process (error) for requestId: ${requestId}`); - } - - const executionTimeMs = Date.now() - startTime; - - log('ERROR', 'Codex CLI streaming error caught', { - errorType: typeof error, - errorConstructor: error?.constructor?.name, - executionTimeMs, - accumulatedLength: accumulated.length, - exitCode: isSubprocessError(error) ? error.exitCode : undefined, - stderr: isSubprocessError(error) ? error.stderr?.substring(0, 500) : undefined, - errorMessage: error instanceof Error ? error.message : String(error), - }); - - // Handle SubprocessError from nano-spawn - if (isSubprocessError(error)) { - const isTimeout = - (error.isTerminated && error.signalName === 'SIGTERM') || error.exitCode === 143; - - if (isTimeout) { - log('WARN', 'Codex CLI streaming execution timed out', { - timeoutMs, - executionTimeMs, - exitCode: error.exitCode, - accumulatedLength: accumulated.length, - }); - - return { - success: false, - output: accumulated, - error: { - code: 'TIMEOUT', - message: `AI generation timed out after ${Math.floor(timeoutMs / 1000)} seconds.`, - details: `Timeout after ${timeoutMs}ms`, - }, - executionTimeMs, - sessionId: extractedSessionId, - }; - } - - // Command not found (ENOENT) - if (error.code === 'ENOENT') { - return { - success: false, - error: { - code: 'COMMAND_NOT_FOUND', - message: 'Codex CLI not found. Please install Codex CLI to use this provider.', - details: error.message, - }, - executionTimeMs, - sessionId: extractedSessionId, - }; - } - - // npx fallback failed - if (error.stderr?.includes('could not determine executable to run')) { - log('WARN', 'Codex CLI not installed (npx fallback failed)', { - stderr: error.stderr, - }); - return { - success: false, - error: { - code: 'COMMAND_NOT_FOUND', - message: 'Codex CLI not found. Please install Codex CLI to use this provider.', - details: error.stderr, - }, - executionTimeMs, - sessionId: extractedSessionId, - }; - } - - // Non-zero exit code - return { - success: false, - output: accumulated, - error: { - code: 'UNKNOWN_ERROR', - message: 'Generation failed - please try again or rephrase your description', - details: `Exit code: ${error.exitCode ?? 'unknown'}, stderr: ${error.stderr ?? 'none'}`, - }, - executionTimeMs, - sessionId: extractedSessionId, - }; - } - - // Unknown error type - return { - success: false, - output: accumulated, - error: { - code: 'UNKNOWN_ERROR', - message: 'An unexpected error occurred. Please try again.', - details: error instanceof Error ? error.message : String(error), - }, - executionTimeMs, - sessionId: extractedSessionId, - }; - } -} - -/** - * Cancel an active Codex process - * - * @param requestId - Request ID of the process to cancel - * @returns Result indicating if cancellation was successful - */ -export async function cancelCodexProcess(requestId: string): Promise<{ - cancelled: boolean; - executionTimeMs?: number; -}> { - const activeGen = activeProcesses.get(requestId); - - if (!activeGen) { - log('WARN', `No active Codex process found for requestId: ${requestId}`); - return { cancelled: false }; - } - - const { subprocess, startTime } = activeGen; - const executionTimeMs = Date.now() - startTime; - - // nano-spawn v2.0.0: nodeChildProcess is a Promise that resolves to ChildProcess - const childProcess = await subprocess.nodeChildProcess; - - log('INFO', `Cancelling Codex process for requestId: ${requestId}`, { - pid: childProcess.pid, - elapsedMs: executionTimeMs, - }); - - // Kill the process (cross-platform compatible) - childProcess.kill(); - - // Force kill after 500ms if process doesn't terminate - setTimeout(() => { - if (!childProcess.killed) { - childProcess.kill(); - log('WARN', `Forcefully killed Codex process for requestId: ${requestId}`); - } - }, 500); - - // Remove from active processes map - activeProcesses.delete(requestId); - - return { cancelled: true, executionTimeMs }; -} - -/** - * Extract JSON response from mixed text that may contain AI reasoning - * Codex CLI may output reasoning text followed by JSON response - * When multiple JSON objects exist, returns the LAST valid one (final response) - * - * @param text - Mixed text potentially containing reasoning and JSON - * @returns Extracted JSON string if found, or original text - */ -function extractJsonResponse(text: string): string { - // Find ALL occurrences of JSON objects with {"status": pattern - const statusPattern = /\{"status"\s*:\s*"(?:success|clarification|error)"/g; - let lastValidJson = ''; - let match: RegExpExecArray | null = statusPattern.exec(text); - - while (match !== null) { - const jsonStart = match.index; - const potentialJson = text.substring(jsonStart); - - // Find the matching closing brace - let braceCount = 0; - let jsonEnd = -1; - for (let i = 0; i < potentialJson.length; i++) { - if (potentialJson[i] === '{') braceCount++; - if (potentialJson[i] === '}') braceCount--; - if (braceCount === 0) { - jsonEnd = i + 1; - break; - } - } - - if (jsonEnd > 0) { - const jsonStr = potentialJson.substring(0, jsonEnd); - try { - JSON.parse(jsonStr); // Validate it's valid JSON - lastValidJson = jsonStr; // Keep the last valid one - } catch { - // Skip invalid JSON - } - } - match = statusPattern.exec(text); - } - - if (lastValidJson) { - log('DEBUG', 'Extracted last JSON response from Codex output', { - originalLength: text.length, - jsonLength: lastValidJson.length, - }); - return lastValidJson; - } - - return text; -} - -/** - * Extract thread_id from Codex CLI JSONL output for session continuation - * Looks for the thread.started event which contains the thread_id - * - * @param output - Raw JSONL output from Codex CLI - * @returns Extracted thread_id or undefined if not found - */ -function extractThreadIdFromOutput(output: string): string | undefined { - const lines = output.trim().split('\n'); - - for (const line of lines) { - if (!line.trim()) continue; - - try { - const parsed = JSON.parse(line); - - if (parsed.type === 'thread.started' && parsed.thread_id) { - log('INFO', 'Extracted thread ID from Codex output (non-streaming)', { - threadId: parsed.thread_id, - }); - return parsed.thread_id; - } - } catch { - // Ignore parse errors - } - } - - return undefined; -} - -/** - * Parse Codex CLI JSONL output to extract the final message content - * - * @param output - Raw JSONL output from Codex CLI - * @returns Extracted message content - */ -function parseCodexOutput(output: string): string { - const lines = output.trim().split('\n'); - let finalContent = ''; - - for (const line of lines) { - if (!line.trim()) continue; - - try { - const parsed = JSON.parse(line); - - // Extract message content based on event type - if (parsed.type === 'item.completed' && parsed.item) { - const item = parsed.item; - if (item.content && Array.isArray(item.content)) { - const textBlocks = item.content - .filter((b: { type: string }) => b.type === 'text') - .map((b: { text: string }) => b.text) - .join('\n'); - if (textBlocks) { - finalContent += textBlocks; - } - } else if (typeof item.content === 'string') { - finalContent += item.content; - } - if (item.output && typeof item.output === 'string') { - finalContent += item.output; - } - if (item.text && typeof item.text === 'string') { - finalContent += item.text; - } - } else if (parsed.type === 'message' && parsed.content) { - if (typeof parsed.content === 'string') { - finalContent = parsed.content; - } else if (Array.isArray(parsed.content)) { - const textBlocks = parsed.content - .filter((b: { type: string }) => b.type === 'text') - .map((b: { text: string }) => b.text) - .join('\n'); - if (textBlocks) { - finalContent = textBlocks; - } - } - } else if (parsed.type === 'text' || parsed.type === 'assistant') { - const text = parsed.text || parsed.content; - if (text && typeof text === 'string') { - finalContent = text; - } - } else if (parsed.type === 'result' && parsed.output) { - finalContent = parsed.output; - } - } catch { - // Ignore parse errors - } - } - - return finalContent || output; -} diff --git a/src/extension/services/codex-mcp-sync-service.ts b/src/extension/services/codex-mcp-sync-service.ts deleted file mode 100644 index 38b3ab81..00000000 --- a/src/extension/services/codex-mcp-sync-service.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * Claude Code Workflow Studio - Codex CLI MCP Sync Service - * - * Handles MCP server configuration sync to $HOME/.codex/config.toml - * for OpenAI Codex CLI execution. - * - * Note: Codex CLI uses TOML format for configuration: - * - Config path: $HOME/.codex/config.toml - * - MCP servers section: [mcp_servers.{server_name}] - */ - -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import * as TOML from 'smol-toml'; -import { getMcpServerConfig } from './mcp-config-reader'; - -/** - * Codex CLI config.toml structure - */ -interface CodexConfig { - mcp_servers?: Record; - features?: { multi_agent?: boolean; [key: string]: unknown }; - [key: string]: unknown; -} - -/** - * MCP server configuration entry for Codex CLI - */ -interface CodexMcpServerEntry { - command?: string; - args?: string[]; - env?: Record; - url?: string; -} - -/** - * Preview result for MCP server sync - */ -export interface CodexMcpSyncPreviewResult { - /** Server IDs that would be added to $HOME/.codex/config.toml */ - serversToAdd: string[]; - /** Server IDs that already exist in $HOME/.codex/config.toml */ - existingServers: string[]; - /** Server IDs not found in any Claude Code config */ - missingServers: string[]; -} - -/** - * Get the Codex CLI config file path - */ -function getCodexConfigPath(): string { - return path.join(os.homedir(), '.codex', 'config.toml'); -} - -/** - * Read existing Codex CLI config - */ -async function readCodexConfig(): Promise { - const configPath = getCodexConfigPath(); - - try { - const content = await fs.readFile(configPath, 'utf-8'); - return TOML.parse(content) as CodexConfig; - } catch { - // File doesn't exist or invalid TOML - return { mcp_servers: {} }; - } -} - -/** - * Write Codex CLI config to file - * - * @param config - Config to write - */ -async function writeCodexConfig(config: CodexConfig): Promise { - const configPath = getCodexConfigPath(); - const configDir = path.dirname(configPath); - - // Ensure $HOME/.codex directory exists - await fs.mkdir(configDir, { recursive: true }); - - // Serialize config to TOML - const tomlContent = TOML.stringify(config); - await fs.writeFile(configPath, tomlContent); -} - -/** - * Preview which MCP servers would be synced to $HOME/.codex/config.toml - * - * This function checks without actually writing, allowing for confirmation dialogs. - * - * @param serverIds - Server IDs to sync - * @param workspacePath - Workspace path for resolving project-scoped configs - * @returns Preview of servers to add, existing, and missing - */ -export async function previewMcpSyncForCodexCli( - serverIds: string[], - workspacePath: string -): Promise { - if (serverIds.length === 0) { - return { serversToAdd: [], existingServers: [], missingServers: [] }; - } - - const existingConfig = await readCodexConfig(); - const existingServersMap = existingConfig.mcp_servers || {}; - - const serversToAdd: string[] = []; - const existingServers: string[] = []; - const missingServers: string[] = []; - - for (const serverId of serverIds) { - if (existingServersMap[serverId]) { - existingServers.push(serverId); - } else { - // Check if server config exists in Claude Code - const serverConfig = getMcpServerConfig(serverId, workspacePath); - if (serverConfig) { - serversToAdd.push(serverId); - } else { - missingServers.push(serverId); - } - } - } - - return { serversToAdd, existingServers, missingServers }; -} - -/** - * Sync MCP server configurations to $HOME/.codex/config.toml for Codex CLI - * - * Reads MCP server configs from all Claude Code scopes (project, local, user) - * and writes them to $HOME/.codex/config.toml in TOML format. - * Only adds servers that don't already exist in the config file. - * - * TOML output format: - * ```toml - * [mcp_servers.my-server] - * command = "npx" - * args = ["-y", "@my-mcp/server"] - * - * [mcp_servers.my-server.env] - * API_KEY = "xxx" - * ``` - * - * @param serverIds - Server IDs to sync - * @param workspacePath - Workspace path for resolving project-scoped configs - * @returns Array of synced server IDs - */ -export async function syncMcpConfigForCodexCli( - serverIds: string[], - workspacePath: string -): Promise { - if (serverIds.length === 0) { - return []; - } - - // Read existing config - const config = await readCodexConfig(); - - if (!config.mcp_servers) { - config.mcp_servers = {}; - } - - // Sync servers from all Claude Code scopes (project, local, user) - const syncedServers: string[] = []; - for (const serverId of serverIds) { - // Skip if already exists in config - if (config.mcp_servers[serverId]) { - continue; - } - - // Get server config from Claude Code (searches all scopes) - const serverConfig = getMcpServerConfig(serverId, workspacePath); - if (!serverConfig) { - continue; - } - - // Convert to Codex format - const codexEntry: CodexMcpServerEntry = {}; - - if (serverConfig.command) { - codexEntry.command = serverConfig.command; - } - if (serverConfig.args && serverConfig.args.length > 0) { - codexEntry.args = serverConfig.args; - } - if (serverConfig.env && Object.keys(serverConfig.env).length > 0) { - codexEntry.env = serverConfig.env; - } - if (serverConfig.url) { - codexEntry.url = serverConfig.url; - } - - config.mcp_servers[serverId] = codexEntry; - syncedServers.push(serverId); - } - - // Write updated config if any servers were added - if (syncedServers.length > 0) { - await writeCodexConfig(config); - } - - return syncedServers; -} - -/** - * Check if multi_agent feature is enabled in Codex CLI config - * - * @returns true if features.multi_agent is true in ~/.codex/config.toml - */ -export async function checkCodexMultiAgentEnabled(): Promise { - const config = await readCodexConfig(); - return config.features?.multi_agent === true; -} - -/** - * Enable multi_agent feature in Codex CLI config - * - * Reads existing config, sets features.multi_agent = true, and writes back. - * Creates ~/.codex/ directory if it doesn't exist. - */ -export async function enableCodexMultiAgent(): Promise { - const config = await readCodexConfig(); - - if (!config.features) { - config.features = {}; - } - config.features.multi_agent = true; - - await writeCodexConfig(config); -} diff --git a/src/extension/services/codex-skill-export-service.ts b/src/extension/services/codex-skill-export-service.ts deleted file mode 100644 index a4028232..00000000 --- a/src/extension/services/codex-skill-export-service.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Claude Code Workflow Studio - Codex Skill Export Service - * - * Handles workflow export to OpenAI Codex CLI Skills format (.codex/skills/name/SKILL.md) - * Skills format enables Codex CLI to execute workflows using $skill-name format. - */ - -import * as path from 'node:path'; -import type { Workflow } from '../../shared/types/workflow-definition'; -import { nodeNameToFileName } from './export-service'; -import type { FileService } from './file-service'; -import { - generateExecutionInstructions, - generateMermaidFlowchart, -} from './workflow-prompt-generator'; - -/** - * Codex skill export result - */ -export interface CodexSkillExportResult { - success: boolean; - skillPath: string; - skillName: string; - errors?: string[]; -} - -/** - * Generate SKILL.md content from workflow for Codex CLI - * - * @param workflow - Workflow to convert - * @returns SKILL.md content as string - */ -export function generateCodexSkillContent(workflow: Workflow): string { - const skillName = nodeNameToFileName(workflow.name); - - // Generate description from workflow metadata or create default - const description = - workflow.metadata?.description || - `Execute the "${workflow.name}" workflow. This skill guides through a structured workflow with defined steps and decision points.`; - - // Generate YAML frontmatter (same format as Copilot CLI) - const frontmatter = `--- -name: ${skillName} -description: ${description} ----`; - - // Generate Mermaid flowchart - const mermaidContent = generateMermaidFlowchart({ - nodes: workflow.nodes, - connections: workflow.connections, - }); - - // Generate execution instructions - const instructions = generateExecutionInstructions(workflow, { - provider: 'codex', - }); - - // Compose SKILL.md body - // Note: mermaidContent already includes ```mermaid and ``` wrapper - const body = `# ${workflow.name} - -## Workflow Diagram - -${mermaidContent} - -## Execution Instructions - -${instructions}`; - - return `${frontmatter}\n\n${body}`; -} - -/** - * Check if Codex skill already exists - * - * @param workflow - Workflow to check - * @param fileService - File service instance - * @returns Path to existing skill file, or null if not exists - */ -export async function checkExistingCodexSkill( - workflow: Workflow, - fileService: FileService -): Promise { - const workspacePath = fileService.getWorkspacePath(); - const skillName = nodeNameToFileName(workflow.name); - const skillPath = path.join(workspacePath, '.codex', 'skills', skillName, 'SKILL.md'); - - if (await fileService.fileExists(skillPath)) { - return skillPath; - } - return null; -} - -/** - * Export workflow as Codex Skill - * - * Exports to .codex/skills/{name}/SKILL.md - * - * @param workflow - Workflow to export - * @param fileService - File service instance - * @returns Export result - */ -export async function exportWorkflowAsCodexSkill( - workflow: Workflow, - fileService: FileService -): Promise { - try { - const workspacePath = fileService.getWorkspacePath(); - const skillName = nodeNameToFileName(workflow.name); - const skillDir = path.join(workspacePath, '.codex', 'skills', skillName); - const skillPath = path.join(skillDir, 'SKILL.md'); - - // Ensure directory exists - await fileService.createDirectory(skillDir); - - // Generate and write SKILL.md content - const content = generateCodexSkillContent(workflow); - await fileService.writeFile(skillPath, content); - - return { - success: true, - skillPath, - skillName, - }; - } catch (error) { - return { - success: false, - skillPath: '', - skillName: '', - errors: [error instanceof Error ? error.message : 'Unknown error'], - }; - } -} diff --git a/src/extension/services/copilot-cli-mcp-sync-service.ts b/src/extension/services/copilot-cli-mcp-sync-service.ts deleted file mode 100644 index 816ca016..00000000 --- a/src/extension/services/copilot-cli-mcp-sync-service.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Claude Code Workflow Studio - Copilot CLI MCP Sync Service - * - * Handles MCP server configuration sync to $HOME/.copilot/mcp-config.json - * for GitHub Copilot CLI execution. - * - * Note: Copilot CLI uses a different config path and key name than VSCode Copilot: - * - VSCode Copilot: .vscode/mcp.json with "servers" key - * - Copilot CLI: $HOME/.copilot/mcp-config.json with "mcpServers" key - */ - -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { getMcpServerConfig } from './mcp-config-reader'; - -/** - * Copilot CLI MCP configuration format - */ -interface CopilotCliMcpConfig { - mcpServers?: Record; -} - -/** - * MCP server configuration entry for Copilot CLI - * - * Note: Copilot CLI requires "tools" field to specify which tools are allowed. - * Use ["*"] to allow all tools. - */ -interface McpServerConfigEntry { - type?: 'stdio' | 'http' | 'sse'; - command?: string; - args?: string[]; - env?: Record; - url?: string; - headers?: Record; - /** Tools to allow - use ["*"] to allow all tools (required for Copilot CLI) */ - tools?: string[]; -} - -/** - * Preview result for MCP server sync - */ -export interface CopilotCliMcpSyncPreviewResult { - /** Server IDs that would be added to $HOME/.copilot/mcp-config.json */ - serversToAdd: string[]; - /** Server IDs that already exist in $HOME/.copilot/mcp-config.json */ - existingServers: string[]; - /** Server IDs not found in any Claude Code config */ - missingServers: string[]; -} - -/** - * Get the Copilot CLI MCP config file path - */ -function getCopilotCliMcpConfigPath(): string { - return path.join(os.homedir(), '.copilot', 'mcp-config.json'); -} - -/** - * Read existing Copilot CLI MCP config - */ -async function readCopilotCliMcpConfig(): Promise { - const configPath = getCopilotCliMcpConfigPath(); - - try { - const content = await fs.readFile(configPath, 'utf-8'); - return JSON.parse(content) as CopilotCliMcpConfig; - } catch { - // File doesn't exist or invalid JSON - return { mcpServers: {} }; - } -} - -/** - * Preview which MCP servers would be synced to $HOME/.copilot/mcp-config.json - * - * This function checks without actually writing, allowing for confirmation dialogs. - * - * @param serverIds - Server IDs to sync - * @param workspacePath - Workspace path for resolving project-scoped configs - * @returns Preview of servers to add, existing, and missing - */ -export async function previewMcpSyncForCopilotCli( - serverIds: string[], - workspacePath: string -): Promise { - if (serverIds.length === 0) { - return { serversToAdd: [], existingServers: [], missingServers: [] }; - } - - const existingConfig = await readCopilotCliMcpConfig(); - const existingServersMap = existingConfig.mcpServers || {}; - - const serversToAdd: string[] = []; - const existingServers: string[] = []; - const missingServers: string[] = []; - - for (const serverId of serverIds) { - if (existingServersMap[serverId]) { - existingServers.push(serverId); - } else { - // Check if server config exists in Claude Code - const serverConfig = getMcpServerConfig(serverId, workspacePath); - if (serverConfig) { - serversToAdd.push(serverId); - } else { - missingServers.push(serverId); - } - } - } - - return { serversToAdd, existingServers, missingServers }; -} - -/** - * Sync MCP server configurations to $HOME/.copilot/mcp-config.json for Copilot CLI - * - * Reads MCP server configs from all Claude Code scopes (project, local, user) - * and writes them to $HOME/.copilot/mcp-config.json. - * Only adds servers that don't already exist in the config file. - * - * @param serverIds - Server IDs to sync - * @param workspacePath - Workspace path for resolving project-scoped configs - * @returns Array of synced server IDs - */ -export async function syncMcpConfigForCopilotCli( - serverIds: string[], - workspacePath: string -): Promise { - if (serverIds.length === 0) { - return []; - } - - const configPath = getCopilotCliMcpConfigPath(); - - // Read existing config - const config = await readCopilotCliMcpConfig(); - - if (!config.mcpServers) { - config.mcpServers = {}; - } - - // Sync servers from all Claude Code scopes (project, local, user) - const syncedServers: string[] = []; - for (const serverId of serverIds) { - // Skip if already exists in config - if (config.mcpServers[serverId]) { - continue; - } - - // Get server config from Claude Code (searches all scopes) - const serverConfig = getMcpServerConfig(serverId, workspacePath); - if (!serverConfig) { - continue; - } - - // Add to config with tools: ["*"] to allow all tools (required for Copilot CLI) - config.mcpServers[serverId] = { - ...serverConfig, - tools: ['*'], - }; - syncedServers.push(serverId); - } - - // Write updated config if any servers were added - if (syncedServers.length > 0) { - // Ensure $HOME/.copilot directory exists - const copilotDir = path.dirname(configPath); - await fs.mkdir(copilotDir, { recursive: true }); - - await fs.writeFile(configPath, JSON.stringify(config, null, 2)); - } - - return syncedServers; -} diff --git a/src/extension/services/copilot-export-service.ts b/src/extension/services/copilot-export-service.ts deleted file mode 100644 index e4b91995..00000000 --- a/src/extension/services/copilot-export-service.ts +++ /dev/null @@ -1,388 +0,0 @@ -/** - * Claude Code Workflow Studio - Copilot Export Service - * - * Handles workflow export to GitHub Copilot Prompts format (.github/prompts/*.prompt.md) - * Based on: /docs/Copilot-Prompts-Guide.md - */ - -import * as path from 'node:path'; -import type { Workflow } from '../../shared/types/workflow-definition'; -import { escapeYamlString, nodeNameToFileName } from './export-service'; -import type { FileService } from './file-service'; -import { getMcpServerConfig } from './mcp-config-reader'; -import { - generateExecutionInstructions, - generateMermaidFlowchart, -} from './workflow-prompt-generator'; - -/** - * Copilot agent mode options - */ -export type CopilotAgentMode = 'ask' | 'edit' | 'agent'; - -/** - * Copilot model options - */ -export type CopilotModel = - | 'gpt-4o' - | 'gpt-4o-mini' - | 'o1-preview' - | 'o1-mini' - | 'claude-3.5-sonnet' - | 'claude-3-opus'; - -/** - * Copilot export options - */ -export interface CopilotExportOptions { - /** Export destination: copilot only, claude only, or both */ - destination: 'copilot' | 'claude' | 'both'; - /** Copilot agent mode */ - agent: CopilotAgentMode; - /** Copilot model (optional - omit to use default) */ - model?: CopilotModel; - /** Tools to enable (optional) */ - tools?: string[]; - /** Skip MCP server sync to .vscode/mcp.json (default: false) */ - skipMcpSync?: boolean; -} - -/** - * Export result - */ -export interface CopilotExportResult { - success: boolean; - exportedFiles: string[]; - errors?: string[]; - /** MCP servers synced to .vscode/mcp.json */ - syncedMcpServers?: string[]; -} - -/** - * VS Code MCP configuration format (.vscode/mcp.json) - */ -interface VscodeMcpConfig { - servers?: Record; - inputs?: unknown[]; -} - -/** - * MCP server configuration entry - */ -interface McpServerConfigEntry { - type?: 'stdio' | 'http' | 'sse'; - command?: string; - args?: string[]; - env?: Record; - url?: string; -} - -/** - * Check if any Copilot export files already exist - * - * @param workflow - Workflow to export - * @param fileService - File service instance - * @returns Array of existing file paths (empty if no conflicts) - */ -export async function checkExistingCopilotFiles( - workflow: Workflow, - fileService: FileService -): Promise { - const existingFiles: string[] = []; - const workspacePath = fileService.getWorkspacePath(); - - const promptsDir = path.join(workspacePath, '.github', 'prompts'); - const workflowBaseName = nodeNameToFileName(workflow.name); - const filePath = path.join(promptsDir, `${workflowBaseName}.prompt.md`); - - if (await fileService.fileExists(filePath)) { - existingFiles.push(filePath); - } - - return existingFiles; -} - -/** - * Export workflow to Copilot Prompts format - * - * @param workflow - Workflow to export - * @param fileService - File service instance - * @param options - Copilot export options - * @returns Export result with file paths - */ -export async function exportWorkflowForCopilot( - workflow: Workflow, - fileService: FileService, - options: CopilotExportOptions -): Promise { - const exportedFiles: string[] = []; - const errors: string[] = []; - const workspacePath = fileService.getWorkspacePath(); - let syncedMcpServers: string[] = []; - - try { - // Create .github/prompts directory if it doesn't exist - const promptsDir = path.join(workspacePath, '.github', 'prompts'); - await fileService.createDirectory(path.join(workspacePath, '.github')); - await fileService.createDirectory(promptsDir); - - // Generate Copilot prompt file - const workflowBaseName = nodeNameToFileName(workflow.name); - const filePath = path.join(promptsDir, `${workflowBaseName}.prompt.md`); - const content = generateCopilotPromptFile(workflow, options); - - await fileService.writeFile(filePath, content); - exportedFiles.push(filePath); - - // Sync MCP server configurations to .vscode/mcp.json (unless skipped) - if (!options.skipMcpSync) { - const mcpServerIds = extractMcpServerIdsFromWorkflow(workflow); - syncedMcpServers = await syncMcpConfigForCopilot(mcpServerIds, fileService); - } - } catch (error) { - errors.push(error instanceof Error ? error.message : String(error)); - } - - return { - success: errors.length === 0, - exportedFiles, - errors: errors.length > 0 ? errors : undefined, - syncedMcpServers: syncedMcpServers.length > 0 ? syncedMcpServers : undefined, - }; -} - -/** - * Preview result for MCP server sync - */ -export interface McpSyncPreviewResult { - /** Server IDs that would be added to .vscode/mcp.json */ - serversToAdd: string[]; - /** Server IDs that already exist in .vscode/mcp.json */ - existingServers: string[]; - /** Server IDs not found in any Claude Code config */ - missingServers: string[]; -} - -/** - * Preview which MCP servers would be synced to .vscode/mcp.json - * - * This function checks without actually writing, allowing for confirmation dialogs. - * - * @param workflow - Workflow definition - * @param fileService - File service instance - * @returns Preview of servers to add, existing, and missing - */ -export async function previewMcpSyncForCopilot( - workflow: Workflow, - fileService: FileService -): Promise { - const serverIds = extractMcpServerIdsFromWorkflow(workflow); - - if (serverIds.length === 0) { - return { serversToAdd: [], existingServers: [], missingServers: [] }; - } - - const workspacePath = fileService.getWorkspacePath(); - const vscodeMcpPath = path.join(workspacePath, '.vscode', 'mcp.json'); - - // Read existing VS Code mcp.json - let existingVscodeServers: Record = {}; - try { - if (await fileService.fileExists(vscodeMcpPath)) { - const content = await fileService.readFile(vscodeMcpPath); - const vscodeConfig = JSON.parse(content) as VscodeMcpConfig; - existingVscodeServers = vscodeConfig.servers || {}; - } - } catch { - // File doesn't exist or invalid JSON - } - - const serversToAdd: string[] = []; - const existingServers: string[] = []; - const missingServers: string[] = []; - - for (const serverId of serverIds) { - if (existingVscodeServers[serverId]) { - existingServers.push(serverId); - } else { - // Check if server config exists in Claude Code - const serverConfig = getMcpServerConfig(serverId, workspacePath); - if (serverConfig) { - serversToAdd.push(serverId); - } else { - missingServers.push(serverId); - } - } - } - - return { serversToAdd, existingServers, missingServers }; -} - -/** - * Execute MCP server sync to .vscode/mcp.json - * - * Call this after user confirms the sync via previewMcpSyncForCopilot. - * - * @param workflow - Workflow definition - * @param fileService - File service instance - * @returns Array of synced server IDs - */ -export async function executeMcpSyncForCopilot( - workflow: Workflow, - fileService: FileService -): Promise { - const serverIds = extractMcpServerIdsFromWorkflow(workflow); - return syncMcpConfigForCopilot(serverIds, fileService); -} - -/** - * Extract unique MCP server IDs from workflow nodes - * - * @param workflow - Workflow definition - * @returns Array of unique server IDs - */ -export function extractMcpServerIdsFromWorkflow(workflow: Workflow): string[] { - const serverIds = new Set(); - - for (const node of workflow.nodes) { - if (node.type !== 'mcp') continue; - if (!('data' in node) || !node.data) continue; - - const mcpData = node.data as { serverId?: string }; - if (mcpData.serverId?.trim()) { - serverIds.add(mcpData.serverId); - } - } - - return Array.from(serverIds); -} - -/** - * Sync MCP server configurations from Claude Code to VS Code (.vscode/mcp.json) - * - * Reads MCP server configs from all Claude Code scopes (project, local, user) - * and writes them to .vscode/mcp.json for GitHub Copilot. - * Only adds servers that don't already exist in .vscode/mcp.json. - * - * @param serverIds - Server IDs to sync - * @param fileService - File service instance - * @returns Array of synced server IDs - */ -async function syncMcpConfigForCopilot( - serverIds: string[], - fileService: FileService -): Promise { - if (serverIds.length === 0) { - return []; - } - - const workspacePath = fileService.getWorkspacePath(); - const vscodeMcpPath = path.join(workspacePath, '.vscode', 'mcp.json'); - - // Read existing VS Code mcp.json - let vscodeConfig: VscodeMcpConfig = { servers: {} }; - try { - if (await fileService.fileExists(vscodeMcpPath)) { - const content = await fileService.readFile(vscodeMcpPath); - vscodeConfig = JSON.parse(content) as VscodeMcpConfig; - } - } catch { - // File doesn't exist or invalid JSON - create new - vscodeConfig = { servers: {} }; - } - - if (!vscodeConfig.servers) { - vscodeConfig.servers = {}; - } - - // Sync servers from all Claude Code scopes (project, local, user) - const syncedServers: string[] = []; - for (const serverId of serverIds) { - // Skip if already exists in VS Code config - if (vscodeConfig.servers[serverId]) { - continue; - } - - // Get server config from Claude Code (searches all scopes) - const serverConfig = getMcpServerConfig(serverId, workspacePath); - if (!serverConfig) { - continue; - } - - // Add to VS Code config - vscodeConfig.servers[serverId] = serverConfig; - syncedServers.push(serverId); - } - - // Write updated VS Code config if any servers were added - if (syncedServers.length > 0) { - await fileService.createDirectory(path.join(workspacePath, '.vscode')); - await fileService.writeFile(vscodeMcpPath, JSON.stringify(vscodeConfig, null, 2)); - } - - return syncedServers; -} - -/** - * Generate Copilot Prompt file content - * - * @param workflow - Workflow definition - * @param options - Copilot export options - * @returns Markdown content with YAML frontmatter - */ -function generateCopilotPromptFile(workflow: Workflow, options: CopilotExportOptions): string { - const workflowName = nodeNameToFileName(workflow.name); - - // YAML frontmatter - const frontmatterLines = ['---', `name: ${workflowName}`]; - - // Add description (with YAML escaping) - if (workflow.description) { - frontmatterLines.push(`description: ${escapeYamlString(workflow.description)}`); - } else { - frontmatterLines.push(`description: ${escapeYamlString(workflow.name)}`); - } - - // Add argument-hint if configured (with YAML escaping) - if (workflow.slashCommandOptions?.argumentHint) { - frontmatterLines.push( - `argument-hint: ${escapeYamlString(workflow.slashCommandOptions.argumentHint)}` - ); - } - - // Add agent mode - frontmatterLines.push(`agent: ${options.agent}`); - - // Add model if specified - if (options.model) { - frontmatterLines.push(`model: ${options.model}`); - } - - // Add tools if explicitly specified in export options - // Note: workflow.slashCommandOptions.allowedTools is NOT used here because - // those are Claude Code-specific tool names (Bash, Read, Edit, etc.) that - // have no meaning in GitHub Copilot. When tools: is omitted, Copilot allows - // all available tools including MCP servers. - if (options.tools && options.tools.length > 0) { - frontmatterLines.push('tools:'); - for (const tool of options.tools) { - frontmatterLines.push(` - ${tool}`); - } - } - - frontmatterLines.push('---', ''); - const frontmatter = frontmatterLines.join('\n'); - - // Generate Mermaid flowchart using shared module - const mermaidFlowchart = generateMermaidFlowchart(workflow); - - // Generate execution instructions using shared module - const workflowBaseName = nodeNameToFileName(workflow.name); - const executionInstructions = generateExecutionInstructions(workflow, { - parentWorkflowName: workflowBaseName, - subAgentFlows: workflow.subAgentFlows, - provider: 'copilot', - }); - - return `${frontmatter}${mermaidFlowchart}\n\n${executionInstructions}`; -} diff --git a/src/extension/services/copilot-skill-export-service.ts b/src/extension/services/copilot-skill-export-service.ts deleted file mode 100644 index e4a24718..00000000 --- a/src/extension/services/copilot-skill-export-service.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Claude Code Workflow Studio - Copilot Skill Export Service - * - * Handles workflow export to GitHub Copilot Skills format (.github/skills/name/SKILL.md) - * Skills format enables Copilot CLI to execute workflows as slash commands. - */ - -import * as path from 'node:path'; -import type { Workflow } from '../../shared/types/workflow-definition'; -import { nodeNameToFileName } from './export-service'; -import type { FileService } from './file-service'; -import { - generateExecutionInstructions, - generateMermaidFlowchart, -} from './workflow-prompt-generator'; - -/** - * Skill export result - */ -export interface SkillExportResult { - success: boolean; - skillPath: string; - skillName: string; - errors?: string[]; -} - -/** - * Generate SKILL.md content from workflow - * - * @param workflow - Workflow to convert - * @returns SKILL.md content as string - */ -export function generateSkillContent(workflow: Workflow): string { - const skillName = nodeNameToFileName(workflow.name); - - // Generate description from workflow metadata or create default - const description = - workflow.metadata?.description || - `Execute the "${workflow.name}" workflow. This skill guides through a structured workflow with defined steps and decision points.`; - - // Generate YAML frontmatter - const frontmatter = `--- -name: ${skillName} -description: ${description} ----`; - - // Generate Mermaid flowchart - const mermaidContent = generateMermaidFlowchart({ - nodes: workflow.nodes, - connections: workflow.connections, - }); - - // Generate execution instructions - const instructions = generateExecutionInstructions(workflow, { - provider: 'copilot-cli', - }); - - // Compose SKILL.md body - // Note: mermaidContent already includes ```mermaid and ``` wrapper - const body = `# ${workflow.name} - -## Workflow Diagram - -${mermaidContent} - -## Execution Instructions - -${instructions}`; - - return `${frontmatter}\n\n${body}`; -} - -/** - * Check if skill already exists - * - * @param workflow - Workflow to check - * @param fileService - File service instance - * @returns Path to existing skill file, or null if not exists - */ -export async function checkExistingSkill( - workflow: Workflow, - fileService: FileService -): Promise { - const workspacePath = fileService.getWorkspacePath(); - const skillName = nodeNameToFileName(workflow.name); - const skillPath = path.join(workspacePath, '.github', 'skills', skillName, 'SKILL.md'); - - if (await fileService.fileExists(skillPath)) { - return skillPath; - } - return null; -} - -/** - * Export workflow as Copilot Skill - * - * @param workflow - Workflow to export - * @param fileService - File service instance - * @returns Export result - */ -export async function exportWorkflowAsSkill( - workflow: Workflow, - fileService: FileService -): Promise { - try { - const workspacePath = fileService.getWorkspacePath(); - const skillName = nodeNameToFileName(workflow.name); - const skillDir = path.join(workspacePath, '.github', 'skills', skillName); - const skillPath = path.join(skillDir, 'SKILL.md'); - - // Ensure directory exists - await fileService.createDirectory(skillDir); - - // Generate and write SKILL.md content - const content = generateSkillContent(workflow); - await fileService.writeFile(skillPath, content); - - return { - success: true, - skillPath, - skillName, - }; - } catch (error) { - return { - success: false, - skillPath: '', - skillName: '', - errors: [error instanceof Error ? error.message : 'Unknown error'], - }; - } -} diff --git a/src/extension/services/cursor-extension-service.ts b/src/extension/services/cursor-extension-service.ts deleted file mode 100644 index 9c56c785..00000000 --- a/src/extension/services/cursor-extension-service.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Claude Code Workflow Studio - Cursor Extension Service - * - * Wrapper for Cursor (Anysphere VSCode fork) Extension. - * Uses VSCode commands to launch Cursor Agent with skill invocation. - */ - -import * as vscode from 'vscode'; - -const CURSOR_EXTENSION_ID = 'anysphere.cursor-agent'; - -/** - * Check if Cursor extension is installed - * - * @returns True if Cursor extension is installed - */ -export function isCursorInstalled(): boolean { - return vscode.extensions.getExtension(CURSOR_EXTENSION_ID) !== undefined; -} - -/** - * Start a task in Cursor via Agent - * - * Attempts to open Cursor's chat in agent mode with the given skill name. - * - * @param skillName - Skill name to invoke (e.g., "my-workflow") - * @returns True if the task was started successfully - */ -export async function startCursorTask(skillName: string): Promise { - const extension = vscode.extensions.getExtension(CURSOR_EXTENSION_ID); - if (!extension) { - return false; - } - - if (!extension.isActive) { - await extension.activate(); - } - - const prompt = `/${skillName}`; - - try { - await vscode.commands.executeCommand('workbench.action.chat.open', prompt); - return true; - } catch { - return false; - } -} diff --git a/src/extension/services/cursor-skill-export-service.ts b/src/extension/services/cursor-skill-export-service.ts deleted file mode 100644 index ac232f0d..00000000 --- a/src/extension/services/cursor-skill-export-service.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Claude Code Workflow Studio - Cursor Skill Export Service - * - * Handles workflow export to Cursor Skills format (.cursor/skills/name/SKILL.md) - * Cursor reads skills from .cursor/skills/ directory. - */ - -import * as path from 'node:path'; -import type { - SubAgentFlowNode, - SubAgentNode, - Workflow, -} from '../../shared/types/workflow-definition'; -import { - generateSubAgentFile, - generateSubAgentFlowAgentFile, - nodeNameToFileName, -} from './export-service'; -import type { FileService } from './file-service'; -import { - generateExecutionInstructions, - generateMermaidFlowchart, -} from './workflow-prompt-generator'; - -/** - * Cursor skill export result - */ -export interface CursorSkillExportResult { - success: boolean; - skillPath: string; - skillName: string; - errors?: string[]; -} - -/** - * Generate SKILL.md content from workflow for Cursor - * - * @param workflow - Workflow to convert - * @returns SKILL.md content as string - */ -export function generateCursorSkillContent(workflow: Workflow): string { - const skillName = nodeNameToFileName(workflow.name); - - // Generate description from workflow metadata or create default - const description = - workflow.metadata?.description || - `Execute the "${workflow.name}" workflow. This skill guides through a structured workflow with defined steps and decision points.`; - - // Generate YAML frontmatter - const frontmatter = `--- -name: ${skillName} -description: ${description} ----`; - - // Generate Mermaid flowchart - const mermaidContent = generateMermaidFlowchart({ - nodes: workflow.nodes, - connections: workflow.connections, - }); - - // Generate execution instructions - const instructions = generateExecutionInstructions(workflow, { - provider: 'cursor', - parentWorkflowName: nodeNameToFileName(workflow.name), - subAgentFlows: workflow.subAgentFlows, - }); - - // Compose SKILL.md body - const body = `# ${workflow.name} - -## Workflow Diagram - -${mermaidContent} - -## Execution Instructions - -${instructions}`; - - return `${frontmatter}\n\n${body}`; -} - -/** - * Check if Cursor skill already exists - * - * @param workflow - Workflow to check - * @param fileService - File service instance - * @returns Path to existing skill file, or null if not exists - */ -export async function checkExistingCursorSkill( - workflow: Workflow, - fileService: FileService -): Promise { - const workspacePath = fileService.getWorkspacePath(); - const skillName = nodeNameToFileName(workflow.name); - const skillPath = path.join(workspacePath, '.cursor', 'skills', skillName, 'SKILL.md'); - - if (await fileService.fileExists(skillPath)) { - return skillPath; - } - - // Check Sub-Agent files in .cursor/agents/ - const agentsDir = path.join(workspacePath, '.cursor', 'agents'); - const subAgentNodes = workflow.nodes.filter((n) => n.type === 'subAgent'); - for (const node of subAgentNodes) { - const fileName = nodeNameToFileName(node.name); - const filePath = path.join(agentsDir, `${fileName}.md`); - if (await fileService.fileExists(filePath)) { - return filePath; - } - } - - // Check SubAgentFlow agent files - if (workflow.subAgentFlows && workflow.subAgentFlows.length > 0) { - const workflowBaseName = nodeNameToFileName(workflow.name); - for (const flow of workflow.subAgentFlows) { - const flowFileName = nodeNameToFileName(flow.name); - const fileName = `${workflowBaseName}_${flowFileName}`; - const filePath = path.join(agentsDir, `${fileName}.md`); - if (await fileService.fileExists(filePath)) { - return filePath; - } - } - } - - return null; -} - -/** - * Export workflow as Cursor Skill - * - * Exports to .cursor/skills/{name}/SKILL.md - * - * @param workflow - Workflow to export - * @param fileService - File service instance - * @returns Export result - */ -export async function exportWorkflowAsCursorSkill( - workflow: Workflow, - fileService: FileService -): Promise { - try { - const workspacePath = fileService.getWorkspacePath(); - const skillName = nodeNameToFileName(workflow.name); - const skillDir = path.join(workspacePath, '.cursor', 'skills', skillName); - const skillPath = path.join(skillDir, 'SKILL.md'); - - // Ensure directory exists - await fileService.createDirectory(skillDir); - - // Generate and write SKILL.md content - const content = generateCursorSkillContent(workflow); - await fileService.writeFile(skillPath, content); - - // Export Sub-Agent node files to .cursor/agents/ - const subAgentNodes = workflow.nodes.filter((n) => n.type === 'subAgent') as SubAgentNode[]; - if (subAgentNodes.length > 0 || (workflow.subAgentFlows && workflow.subAgentFlows.length > 0)) { - const agentsDir = path.join(workspacePath, '.cursor', 'agents'); - await fileService.createDirectory(agentsDir); - - // SubAgent nodes - for (const node of subAgentNodes) { - const fileName = nodeNameToFileName(node.name); - const filePath = path.join(agentsDir, `${fileName}.md`); - const agentContent = generateSubAgentFile(node); - await fileService.writeFile(filePath, agentContent); - } - - // SubAgentFlow nodes - if (workflow.subAgentFlows && workflow.subAgentFlows.length > 0) { - const workflowBaseName = nodeNameToFileName(workflow.name); - const subAgentFlowNodes = workflow.nodes.filter( - (n) => n.type === 'subAgentFlow' - ) as SubAgentFlowNode[]; - - for (const flow of workflow.subAgentFlows) { - const flowFileName = nodeNameToFileName(flow.name); - const fileName = `${workflowBaseName}_${flowFileName}`; - const filePath = path.join(agentsDir, `${fileName}.md`); - const referencingNode = subAgentFlowNodes.find((n) => n.data.subAgentFlowId === flow.id); - const agentContent = generateSubAgentFlowAgentFile(flow, fileName, referencingNode); - await fileService.writeFile(filePath, agentContent); - } - } - } - - return { - success: true, - skillPath, - skillName, - }; - } catch (error) { - return { - success: false, - skillPath: '', - skillName: '', - errors: [error instanceof Error ? error.message : 'Unknown error'], - }; - } -} diff --git a/src/extension/services/export-service.ts b/src/extension/services/export-service.ts deleted file mode 100644 index 91c3d1eb..00000000 --- a/src/extension/services/export-service.ts +++ /dev/null @@ -1,435 +0,0 @@ -/** - * Claude Code Workflow Studio - Export Service - * - * Handles workflow export to .claude format - * Based on: /specs/001-cc-wf-studio/spec.md Export Format Details - */ - -import * as path from 'node:path'; -import type { - SubAgentFlow, - SubAgentFlowNode, - SubAgentNode, - Workflow, -} from '../../shared/types/workflow-definition'; -import type { FileService } from './file-service'; -import { - generateExecutionInstructions, - generateMermaidFlowchart, - sanitizeNodeId, -} from './workflow-prompt-generator'; - -/** - * Check if any export files already exist - * - * @param workflow - Workflow to export - * @param fileService - File service instance - * @returns Array of existing file paths (empty if no conflicts) - */ -export async function checkExistingFiles( - workflow: Workflow, - fileService: FileService -): Promise { - const existingFiles: string[] = []; - const workspacePath = fileService.getWorkspacePath(); - - const agentsDir = path.join(workspacePath, '.claude', 'agents'); - const commandsDir = path.join(workspacePath, '.claude', 'commands'); - - // Check Sub-Agent files - const subAgentNodes = workflow.nodes.filter((node) => node.type === 'subAgent') as SubAgentNode[]; - for (const node of subAgentNodes) { - const fileName = nodeNameToFileName(node.name); - const filePath = path.join(agentsDir, `${fileName}.md`); - if (await fileService.fileExists(filePath)) { - existingFiles.push(filePath); - } - } - - // Check SubAgentFlow agent files (Issue #89) - // File format: {parent-workflow-name}_{subagentflow-name}.md - const workflowBaseName = nodeNameToFileName(workflow.name); - if (workflow.subAgentFlows && workflow.subAgentFlows.length > 0) { - for (const subAgentFlow of workflow.subAgentFlows) { - const subAgentFlowFileName = nodeNameToFileName(subAgentFlow.name); - const fileName = `${workflowBaseName}_${subAgentFlowFileName}`; - const filePath = path.join(agentsDir, `${fileName}.md`); - if (await fileService.fileExists(filePath)) { - existingFiles.push(filePath); - } - } - } - - // Check SlashCommand file - const commandFileName = workflowBaseName; - const commandFilePath = path.join(commandsDir, `${commandFileName}.md`); - if (await fileService.fileExists(commandFilePath)) { - existingFiles.push(commandFilePath); - } - - return existingFiles; -} - -/** - * Export workflow to .claude format - * - * @param workflow - Workflow to export - * @param fileService - File service instance - * @returns Array of exported file paths - */ -export async function exportWorkflow( - workflow: Workflow, - fileService: FileService -): Promise { - const exportedFiles: string[] = []; - const workspacePath = fileService.getWorkspacePath(); - - // Create .claude directories if they don't exist - const agentsDir = path.join(workspacePath, '.claude', 'agents'); - const commandsDir = path.join(workspacePath, '.claude', 'commands'); - - await fileService.createDirectory(path.join(workspacePath, '.claude')); - await fileService.createDirectory(agentsDir); - await fileService.createDirectory(commandsDir); - - // Export Sub-Agent nodes - const subAgentNodes = workflow.nodes.filter((node) => node.type === 'subAgent') as SubAgentNode[]; - for (const node of subAgentNodes) { - const fileName = nodeNameToFileName(node.name); - const filePath = path.join(agentsDir, `${fileName}.md`); - const content = generateSubAgentFile(node); - await fileService.writeFile(filePath, content); - exportedFiles.push(filePath); - } - - // Export SubAgentFlow as Sub-Agent files (Issue #89) - // File format: {parent-workflow-name}_{subagentflow-name}.md - const workflowBaseName = nodeNameToFileName(workflow.name); - if (workflow.subAgentFlows && workflow.subAgentFlows.length > 0) { - // Get all SubAgentFlow nodes to access their model/tools/color settings - const subAgentFlowNodes = workflow.nodes.filter( - (node) => node.type === 'subAgentFlow' - ) as SubAgentFlowNode[]; - - for (const subAgentFlow of workflow.subAgentFlows) { - const subAgentFlowFileName = nodeNameToFileName(subAgentFlow.name); - const fileName = `${workflowBaseName}_${subAgentFlowFileName}`; - const filePath = path.join(agentsDir, `${fileName}.md`); - - // Find the node that references this SubAgentFlow to get model/tools/color - const referencingNode = subAgentFlowNodes.find( - (node) => node.data.subAgentFlowId === subAgentFlow.id - ); - - const content = generateSubAgentFlowAgentFile(subAgentFlow, fileName, referencingNode); - await fileService.writeFile(filePath, content); - exportedFiles.push(filePath); - } - } - - // Export SlashCommand - const commandFileName = workflowBaseName; - const commandFilePath = path.join(commandsDir, `${commandFileName}.md`); - const commandContent = generateSlashCommandFile(workflow); - await fileService.writeFile(commandFilePath, commandContent); - exportedFiles.push(commandFilePath); - - return exportedFiles; -} - -/** - * Validate .claude file format - * - * @param content - File content to validate - * @param fileType - Type of file ('subAgent' or 'slashCommand') - * @throws Error if validation fails - */ -export function validateClaudeFileFormat( - content: string, - fileType: 'subAgent' | 'slashCommand' -): void { - // Check if content is non-empty - if (!content || content.trim().length === 0) { - throw new Error('File content is empty'); - } - - // Check UTF-8 encoding (string should not contain replacement characters) - if (content.includes('\uFFFD')) { - throw new Error('File content contains invalid UTF-8 characters'); - } - - // Check YAML frontmatter format - const frontmatterRegex = /^---\n([\s\S]*?)\n---\n/; - const match = content.match(frontmatterRegex); - - if (!match) { - throw new Error('Missing or invalid YAML frontmatter (must start and end with ---)'); - } - - const frontmatterContent = match[1]; - - // Validate required fields based on file type - if (fileType === 'subAgent') { - if (!frontmatterContent.includes('name:')) { - throw new Error('Sub-Agent file missing required field: name'); - } - if (!frontmatterContent.includes('description:')) { - throw new Error('Sub-Agent file missing required field: description'); - } - if (!frontmatterContent.includes('model:')) { - throw new Error('Sub-Agent file missing required field: model'); - } - } else if (fileType === 'slashCommand') { - if (!frontmatterContent.includes('description:')) { - throw new Error('SlashCommand file missing required field: description'); - } - // Issue #424: allowed-tools is optional (omit = use Claude Code default) - } - - // Check that there's content after frontmatter (prompt body) - const bodyContent = content.substring(match[0].length).trim(); - if (bodyContent.length === 0) { - throw new Error('File is missing prompt body content after frontmatter'); - } -} - -/** - * Convert node name to filename - * - * @param name - Node name - * @returns Filename (lowercase, spaces to hyphens) - */ -export function nodeNameToFileName(name: string): string { - return name - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^a-z0-9-_]/g, ''); -} - -/** - * Generate Sub-Agent configuration file content - * - * @param node - Sub-Agent node - * @returns Markdown content with YAML frontmatter - */ -export function generateSubAgentFile(node: SubAgentNode): string { - const { name, data } = node; - const agentName = nodeNameToFileName(name); - - // YAML frontmatter - const frontmatter = ['---', `name: ${agentName}`, `description: ${data.description || name}`]; - - // Add optional fields - if (data.tools && data.tools.length > 0) { - frontmatter.push(`tools: ${data.tools}`); - } - - if (data.model) { - frontmatter.push(`model: ${data.model}`); - } else { - frontmatter.push('model: sonnet'); - } - - if (data.color) { - frontmatter.push(`color: ${data.color}`); - } - - if (data.memory) { - frontmatter.push(`memory: ${data.memory}`); - } - - frontmatter.push('---'); - frontmatter.push(''); - - // Prompt body - const prompt = data.prompt || ''; - - return frontmatter.join('\n') + prompt; -} - -/** - * Generate Sub-Agent file content from SubAgentFlow (Issue #89) - * - * Converts a SubAgentFlow into a Sub-Agent .md file that can be executed - * by Claude Code. The SubAgentFlow's nodes are converted to sequential - * execution steps. - * - * @param subAgentFlow - SubAgentFlow definition - * @param agentFileName - Generated file name (format: {parent}_{subagentflow}) - * @param referencingNode - Optional SubAgentFlowNode that references this flow (for model/tools/color) - * @returns Markdown content with YAML frontmatter - */ -export function generateSubAgentFlowAgentFile( - subAgentFlow: SubAgentFlow, - agentFileName: string, - referencingNode?: SubAgentFlowNode -): string { - const agentName = agentFileName; - - // Get model/tools/color/memory from referencing node, or use defaults - const model = referencingNode?.data.model || 'sonnet'; - const tools = referencingNode?.data.tools; - const color = referencingNode?.data.color; - const memory = referencingNode?.data.memory; - - // YAML frontmatter (same structure as SubAgent) - const frontmatter = [ - '---', - `name: ${agentName}`, - `description: ${subAgentFlow.description || subAgentFlow.name}`, - ]; - - // Add optional fields - if (tools && tools.length > 0) { - frontmatter.push(`tools: ${tools}`); - } - - frontmatter.push(`model: ${model}`); - - if (color) { - frontmatter.push(`color: ${color}`); - } - - if (memory) { - frontmatter.push(`memory: ${memory}`); - } - - frontmatter.push('---'); - frontmatter.push(''); - - // Generate Mermaid flowchart using shared module - const mermaidFlowchart = generateMermaidFlowchart({ - nodes: subAgentFlow.nodes, - connections: subAgentFlow.connections, - }); - - // Create a pseudo-Workflow object to reuse generateExecutionInstructions - const pseudoWorkflow: Workflow = { - name: subAgentFlow.name, - description: subAgentFlow.description, - nodes: subAgentFlow.nodes, - connections: subAgentFlow.connections, - }; - - // Generate execution logic using shared module - const executionLogic = generateExecutionInstructions(pseudoWorkflow, { - provider: 'claude-code', - }); - - return `${frontmatter.join('\n')}${mermaidFlowchart}\n\n${executionLogic}`; -} - -/** - * Format YAML string values with proper escaping - * - * Issue #413: Used for hooks command values in frontmatter - * Issue #485: Properly escape backslashes and quotes, remove newlines - * - * @param value - String value to format - * @param alwaysQuote - Always wrap in double quotes - * @returns YAML string value with proper escaping - */ -export function escapeYamlString(value: string, alwaysQuote = false): string { - // Always quote if requested, or if the string contains special characters - if ( - alwaysQuote || - /[:[\]{}&*?|<>=!%@#`'",\n\r\\]/.test(value) || - value.startsWith(' ') || - value.endsWith(' ') - ) { - // Escape backslashes first, then double quotes, then remove newlines - const escaped = value - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/[\n\r]/g, ''); - return `"${escaped}"`; - } - return value; -} - -/** - * Generate SlashCommand file content - * - * @param workflow - Workflow definition - * @returns Markdown content with YAML frontmatter - */ -function generateSlashCommandFile(workflow: Workflow): string { - // YAML frontmatter - const frontmatterLines = [ - '---', - `description: ${escapeYamlString(workflow.description || workflow.name)}`, - ]; - - // Issue #424: Add allowed-tools only if explicitly configured (omit = use Claude Code default) - if (workflow.slashCommandOptions?.allowedTools) { - frontmatterLines.push(`allowed-tools: ${workflow.slashCommandOptions.allowedTools}`); - } - - // Add model if specified and not 'default' - if (workflow.slashCommandOptions?.model && workflow.slashCommandOptions.model !== 'default') { - frontmatterLines.push(`model: ${workflow.slashCommandOptions.model}`); - } - - // Add context if specified and not 'default' (Claude Code v2.1.0+ feature) - if (workflow.slashCommandOptions?.context && workflow.slashCommandOptions.context !== 'default') { - frontmatterLines.push(`context: ${workflow.slashCommandOptions.context}`); - } - - // Issue #426: Add disable-model-invocation if enabled - if (workflow.slashCommandOptions?.disableModelInvocation) { - frontmatterLines.push('disable-model-invocation: true'); - } - - // Issue #425: Add argument-hint if configured - if (workflow.slashCommandOptions?.argumentHint) { - frontmatterLines.push(`argument-hint: ${workflow.slashCommandOptions.argumentHint}`); - } - - // Issue #413: Add hooks if configured (Claude Code Docs compliant format) - // See: https://code.claude.com/docs/en/hooks - const hooks = workflow.slashCommandOptions?.hooks; - if (hooks && Object.keys(hooks).length > 0) { - frontmatterLines.push('hooks:'); - for (const [hookType, entries] of Object.entries(hooks)) { - if (entries && entries.length > 0) { - frontmatterLines.push(` ${hookType}:`); - for (const entry of entries) { - // matcher is optional for all hook types - if (entry.matcher) { - frontmatterLines.push(` - matcher: ${escapeYamlString(entry.matcher, true)}`); - frontmatterLines.push(' hooks:'); - } else { - // No matcher - start with hooks directly on the same line as - - frontmatterLines.push(' - hooks:'); - } - for (const action of entry.hooks) { - frontmatterLines.push(` - type: ${action.type}`); - frontmatterLines.push(` command: ${escapeYamlString(action.command, true)}`); - if (action.once) { - frontmatterLines.push(' once: true'); - } - } - } - } - } - } - - frontmatterLines.push('---', ''); - const frontmatter = frontmatterLines.join('\n'); - - // Mermaid flowchart using shared module - const mermaidFlowchart = generateMermaidFlowchart(workflow); - - // Workflow execution logic using shared module - const workflowBaseName = nodeNameToFileName(workflow.name); - const executionLogic = generateExecutionInstructions(workflow, { - parentWorkflowName: workflowBaseName, - subAgentFlows: workflow.subAgentFlows, - provider: 'claude-code', - }); - - return `${frontmatter}${mermaidFlowchart}\n\n${executionLogic}`; -} - -// Re-export sanitizeNodeId for use by other modules that may need it -export { sanitizeNodeId }; diff --git a/src/extension/services/file-service.ts b/src/extension/services/file-service.ts deleted file mode 100644 index e8c6550b..00000000 --- a/src/extension/services/file-service.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Claude Code Workflow Studio - File Service - * - * Handles file system operations using VSCode workspace.fs API - * Based on: /specs/001-cc-wf-studio/contracts/vscode-extension-api.md section 2 - */ - -import * as path from 'node:path'; -import * as vscode from 'vscode'; - -/** - * File Service for managing workflow files - */ -export class FileService { - private readonly workspacePath: string; - private readonly workflowsDirectory: string; - - constructor() { - // Get workspace root path - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders || workspaceFolders.length === 0) { - throw new Error('No workspace folder is open'); - } - - this.workspacePath = workspaceFolders[0].uri.fsPath; - this.workflowsDirectory = path.join(this.workspacePath, '.vscode', 'workflows'); - } - - /** - * Ensure the workflows directory exists - */ - async ensureWorkflowsDirectory(): Promise { - const uri = vscode.Uri.file(this.workflowsDirectory); - - try { - await vscode.workspace.fs.stat(uri); - // Directory exists - } catch { - // Directory doesn't exist, create it - await vscode.workspace.fs.createDirectory(uri); - console.log(`Created workflows directory: ${this.workflowsDirectory}`); - } - } - - /** - * Read a file from the file system - * - * @param filePath - Absolute file path - * @returns File content as string - */ - async readFile(filePath: string): Promise { - const uri = vscode.Uri.file(filePath); - const bytes = await vscode.workspace.fs.readFile(uri); - return Buffer.from(bytes).toString('utf-8'); - } - - /** - * Write content to a file - * - * @param filePath - Absolute file path - * @param content - File content to write - */ - async writeFile(filePath: string, content: string): Promise { - const uri = vscode.Uri.file(filePath); - const bytes = Buffer.from(content, 'utf-8'); - await vscode.workspace.fs.writeFile(uri, bytes); - console.log(`File written: ${filePath}`); - } - - /** - * Check if a file exists - * - * @param filePath - Absolute file path - * @returns True if file exists, false otherwise - */ - async fileExists(filePath: string): Promise { - const uri = vscode.Uri.file(filePath); - try { - await vscode.workspace.fs.stat(uri); - return true; - } catch { - return false; - } - } - - /** - * Create a directory - * - * @param dirPath - Absolute directory path - */ - async createDirectory(dirPath: string): Promise { - const uri = vscode.Uri.file(dirPath); - await vscode.workspace.fs.createDirectory(uri); - console.log(`Directory created: ${dirPath}`); - } - - /** - * Get the workflows directory path - */ - getWorkflowsDirectory(): string { - return this.workflowsDirectory; - } - - /** - * Get the workspace root path - */ - getWorkspacePath(): string { - return this.workspacePath; - } - - /** - * Get the full path for a workflow file - * - * @param workflowName - Workflow name (without .json extension) - * @returns Full file path - */ - getWorkflowFilePath(workflowName: string): string { - return path.join(this.workflowsDirectory, `${workflowName}.json`); - } - - /** - * List all workflow files in the workflows directory - * - * @returns Array of workflow file names (without .json extension) - */ - async listWorkflowFiles(): Promise { - const uri = vscode.Uri.file(this.workflowsDirectory); - - try { - const entries = await vscode.workspace.fs.readDirectory(uri); - return entries - .filter(([name, type]) => type === vscode.FileType.File && name.endsWith('.json')) - .map(([name]) => name.replace(/\.json$/, '')); - } catch { - // Directory doesn't exist yet - return []; - } - } -} diff --git a/src/extension/services/gemini-cli-path.ts b/src/extension/services/gemini-cli-path.ts deleted file mode 100644 index 4399f149..00000000 --- a/src/extension/services/gemini-cli-path.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Gemini CLI Path Detection Service - * - * Detects Gemini CLI executable path using the shared CLI path detector. - * Uses VSCode's default terminal setting to get the user's shell, - * then executes with login shell to get the full PATH environment. - * - * This handles GUI-launched VSCode scenarios where the Extension Host - * doesn't inherit the user's shell PATH settings. - * - * Based on: codex-cli-path.ts - */ - -import { log } from '../extension'; -import { - findExecutableInPath, - findExecutableViaDefaultShell, - verifyExecutable, -} from './cli-path-detector'; - -/** - * Cached Gemini CLI path - * undefined = not checked yet - * null = not found (use npx fallback) - * string = path to gemini executable - */ -let cachedGeminiPath: string | null | undefined; - -/** - * Get the path to Gemini CLI executable - * Detection order: - * 1. VSCode default terminal shell (handles version managers like mise, nvm) - * 2. Direct PATH lookup (fallback for terminal-launched VSCode) - * 3. npx fallback (handled in getGeminiSpawnCommand) - * - * @returns Path to gemini executable (full path or 'gemini' for PATH), null for npx fallback - */ -export async function getGeminiCliPath(): Promise { - // Return cached result if available - if (cachedGeminiPath !== undefined) { - return cachedGeminiPath; - } - - // 1. Try VSCode default terminal (handles GUI-launched VSCode + version managers) - const shellPath = await findExecutableViaDefaultShell('gemini'); - if (shellPath) { - const version = await verifyExecutable(shellPath); - if (version) { - log('INFO', 'Gemini CLI found via default shell', { - path: shellPath, - version, - }); - cachedGeminiPath = shellPath; - return shellPath; - } - log('WARN', 'Gemini CLI found but not executable', { path: shellPath }); - } - - // 2. Fall back to direct PATH lookup (terminal-launched VSCode) - const pathResult = await findExecutableInPath('gemini'); - if (pathResult) { - cachedGeminiPath = 'gemini'; - return 'gemini'; - } - - log('INFO', 'Gemini CLI not found, will use npx fallback'); - cachedGeminiPath = null; - return null; -} - -/** - * Clear Gemini CLI path cache - * Useful for testing or when user installs Gemini CLI during session - */ -export function clearGeminiCliPathCache(): void { - cachedGeminiPath = undefined; -} - -/** - * Get the command and args for spawning Gemini CLI - * Uses gemini directly if available, otherwise falls back to 'npx @google/gemini-cli' - * npx detection order: - * 1. VSCode default terminal shell (handles version managers) - * 2. Direct PATH lookup - * - * @returns command path with 'npx:' prefix if using npx fallback, or null if not found - */ -export async function getGeminiSpawnCommand(): Promise { - const geminiPath = await getGeminiCliPath(); - - if (geminiPath) { - return geminiPath; - } - - // Fallback: Try npx @google/gemini-cli - // Return a special marker that the caller will handle - const npxPath = await findExecutableViaDefaultShell('npx'); - if (npxPath) { - log('INFO', 'Using npx from default shell for Gemini CLI fallback', { - path: npxPath, - }); - return `npx:${npxPath}`; - } - - // Final fallback to direct PATH lookup - log('INFO', 'Using npx from PATH for Gemini CLI fallback'); - return 'npx:npx'; -} diff --git a/src/extension/services/gemini-mcp-sync-service.ts b/src/extension/services/gemini-mcp-sync-service.ts deleted file mode 100644 index 086837c5..00000000 --- a/src/extension/services/gemini-mcp-sync-service.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * Claude Code Workflow Studio - Gemini CLI MCP Sync Service - * - * Handles MCP server configuration sync to ~/.gemini/settings.json - * for Google Gemini CLI execution. - * - * Note: Gemini CLI uses JSON format for configuration: - * - Config path: ~/.gemini/settings.json - * - MCP servers section: mcpServers key - */ - -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { getMcpServerConfig } from './mcp-config-reader'; - -/** - * Gemini CLI settings.json structure - */ -interface GeminiConfig { - mcpServers?: Record; - experimental?: { enableAgents?: boolean; [key: string]: unknown }; - [key: string]: unknown; -} - -/** - * MCP server configuration entry for Gemini CLI - */ -interface GeminiMcpServerEntry { - command?: string; - args?: string[]; - env?: Record; - url?: string; -} - -/** - * Preview result for MCP server sync - */ -export interface GeminiMcpSyncPreviewResult { - /** Server IDs that would be added to ~/.gemini/settings.json */ - serversToAdd: string[]; - /** Server IDs that already exist in ~/.gemini/settings.json */ - existingServers: string[]; - /** Server IDs not found in any Claude Code config */ - missingServers: string[]; -} - -/** - * Get the Gemini CLI config file path - */ -function getGeminiConfigPath(): string { - return path.join(os.homedir(), '.gemini', 'settings.json'); -} - -/** - * Read existing Gemini CLI config - */ -async function readGeminiConfig(): Promise { - const configPath = getGeminiConfigPath(); - - try { - const content = await fs.readFile(configPath, 'utf-8'); - return JSON.parse(content) as GeminiConfig; - } catch { - // File doesn't exist or invalid JSON - return { mcpServers: {} }; - } -} - -/** - * Write Gemini CLI config to file - * - * @param config - Config to write - */ -async function writeGeminiConfig(config: GeminiConfig): Promise { - const configPath = getGeminiConfigPath(); - const configDir = path.dirname(configPath); - - // Ensure ~/.gemini directory exists - await fs.mkdir(configDir, { recursive: true }); - - // Serialize config to JSON - await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`); -} - -/** - * Preview which MCP servers would be synced to ~/.gemini/settings.json - * - * This function checks without actually writing, allowing for confirmation dialogs. - * - * @param serverIds - Server IDs to sync - * @param workspacePath - Workspace path for resolving project-scoped configs - * @returns Preview of servers to add, existing, and missing - */ -export async function previewMcpSyncForGeminiCli( - serverIds: string[], - workspacePath: string -): Promise { - if (serverIds.length === 0) { - return { serversToAdd: [], existingServers: [], missingServers: [] }; - } - - const existingConfig = await readGeminiConfig(); - const existingServersMap = existingConfig.mcpServers || {}; - - const serversToAdd: string[] = []; - const existingServers: string[] = []; - const missingServers: string[] = []; - - for (const serverId of serverIds) { - if (existingServersMap[serverId]) { - existingServers.push(serverId); - } else { - // Check if server config exists in Claude Code - const serverConfig = getMcpServerConfig(serverId, workspacePath); - if (serverConfig) { - serversToAdd.push(serverId); - } else { - missingServers.push(serverId); - } - } - } - - return { serversToAdd, existingServers, missingServers }; -} - -/** - * Sync MCP server configurations to ~/.gemini/settings.json for Gemini CLI - * - * Reads MCP server configs from all Claude Code scopes (project, local, user) - * and writes them to ~/.gemini/settings.json in JSON format. - * Only adds servers that don't already exist in the config file. - * - * JSON output format: - * ```json - * { - * "mcpServers": { - * "my-server": { - * "command": "npx", - * "args": ["-y", "@my-mcp/server"], - * "env": { "API_KEY": "xxx" } - * } - * } - * } - * ``` - * - * @param serverIds - Server IDs to sync - * @param workspacePath - Workspace path for resolving project-scoped configs - * @returns Array of synced server IDs - */ -export async function syncMcpConfigForGeminiCli( - serverIds: string[], - workspacePath: string -): Promise { - if (serverIds.length === 0) { - return []; - } - - // Read existing config - const config = await readGeminiConfig(); - - if (!config.mcpServers) { - config.mcpServers = {}; - } - - // Sync servers from all Claude Code scopes (project, local, user) - const syncedServers: string[] = []; - for (const serverId of serverIds) { - // Skip if already exists in config - if (config.mcpServers[serverId]) { - continue; - } - - // Get server config from Claude Code (searches all scopes) - const serverConfig = getMcpServerConfig(serverId, workspacePath); - if (!serverConfig) { - continue; - } - - // Convert to Gemini format - const geminiEntry: GeminiMcpServerEntry = {}; - - if (serverConfig.command) { - geminiEntry.command = serverConfig.command; - } - if (serverConfig.args && serverConfig.args.length > 0) { - geminiEntry.args = serverConfig.args; - } - if (serverConfig.env && Object.keys(serverConfig.env).length > 0) { - geminiEntry.env = serverConfig.env; - } - if (serverConfig.url) { - geminiEntry.url = serverConfig.url; - } - - config.mcpServers[serverId] = geminiEntry; - syncedServers.push(serverId); - } - - // Write updated config if any servers were added - if (syncedServers.length > 0) { - await writeGeminiConfig(config); - } - - return syncedServers; -} - -/** - * Check if enableAgents is enabled in Gemini CLI settings - * - * @returns true if experimental.enableAgents is true in ~/.gemini/settings.json - */ -export async function checkGeminiAgentsEnabled(): Promise { - const config = await readGeminiConfig(); - return config.experimental?.enableAgents === true; -} - -/** - * Enable agents feature in Gemini CLI settings - * - * Reads existing config, sets experimental.enableAgents = true, and writes back. - * Creates ~/.gemini/ directory if it doesn't exist. - */ -export async function enableGeminiAgents(): Promise { - const config = await readGeminiConfig(); - - if (!config.experimental) { - config.experimental = {}; - } - config.experimental.enableAgents = true; - - await writeGeminiConfig(config); -} diff --git a/src/extension/services/gemini-skill-export-service.ts b/src/extension/services/gemini-skill-export-service.ts deleted file mode 100644 index a137aee9..00000000 --- a/src/extension/services/gemini-skill-export-service.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Claude Code Workflow Studio - Gemini Skill Export Service - * - * Handles workflow export to Google Gemini CLI Skills format (.gemini/skills/name/SKILL.md) - */ - -import * as path from 'node:path'; -import type { Workflow } from '../../shared/types/workflow-definition'; -import { nodeNameToFileName } from './export-service'; -import type { FileService } from './file-service'; -import { - generateExecutionInstructions, - generateMermaidFlowchart, -} from './workflow-prompt-generator'; - -/** - * Gemini skill export result - */ -export interface GeminiSkillExportResult { - success: boolean; - skillPath: string; - skillName: string; - errors?: string[]; -} - -/** - * Generate SKILL.md content from workflow for Gemini CLI - * - * @param workflow - Workflow to convert - * @returns SKILL.md content as string - */ -export function generateGeminiSkillContent(workflow: Workflow): string { - const skillName = nodeNameToFileName(workflow.name); - - // Generate description from workflow metadata or create default - const description = - workflow.metadata?.description || - `Execute the "${workflow.name}" workflow. This skill guides through a structured workflow with defined steps and decision points.`; - - // Generate YAML frontmatter - const frontmatter = `--- -name: ${skillName} -description: ${description} ----`; - - // Generate Mermaid flowchart - const mermaidContent = generateMermaidFlowchart({ - nodes: workflow.nodes, - connections: workflow.connections, - }); - - // Generate execution instructions - const instructions = generateExecutionInstructions(workflow, { - provider: 'gemini', - }); - - // Compose SKILL.md body - const body = `# ${workflow.name} - -## Workflow Diagram - -${mermaidContent} - -## Execution Instructions - -${instructions}`; - - return `${frontmatter}\n\n${body}`; -} - -/** - * Check if Gemini skill already exists - * - * @param workflow - Workflow to check - * @param fileService - File service instance - * @returns Path to existing skill file, or null if not exists - */ -export async function checkExistingGeminiSkill( - workflow: Workflow, - fileService: FileService -): Promise { - const workspacePath = fileService.getWorkspacePath(); - const skillName = nodeNameToFileName(workflow.name); - const skillPath = path.join(workspacePath, '.gemini', 'skills', skillName, 'SKILL.md'); - - if (await fileService.fileExists(skillPath)) { - return skillPath; - } - return null; -} - -/** - * Export workflow as Gemini Skill - * - * Exports to .gemini/skills/{name}/SKILL.md - * - * @param workflow - Workflow to export - * @param fileService - File service instance - * @returns Export result - */ -export async function exportWorkflowAsGeminiSkill( - workflow: Workflow, - fileService: FileService -): Promise { - try { - const workspacePath = fileService.getWorkspacePath(); - const skillName = nodeNameToFileName(workflow.name); - const skillDir = path.join(workspacePath, '.gemini', 'skills', skillName); - const skillPath = path.join(skillDir, 'SKILL.md'); - - // Ensure directory exists - await fileService.createDirectory(skillDir); - - // Generate and write SKILL.md content - const content = generateGeminiSkillContent(workflow); - await fileService.writeFile(skillPath, content); - - return { - success: true, - skillPath, - skillName, - }; - } catch (error) { - return { - success: false, - skillPath: '', - skillName: '', - errors: [error instanceof Error ? error.message : 'Unknown error'], - }; - } -} diff --git a/src/extension/services/mcp-cache-service.ts b/src/extension/services/mcp-cache-service.ts deleted file mode 100644 index de4fa404..00000000 --- a/src/extension/services/mcp-cache-service.ts +++ /dev/null @@ -1,353 +0,0 @@ -/** - * MCP Cache Service - * - * In-memory caching for MCP server and tool information to improve performance. - * Cache strategy: No automatic refresh - user must manually refresh (per contracts/mcp-cli.schema.json) - */ - -import type { McpServerReference, McpToolReference } from '../../shared/types/mcp-node'; -import { log } from '../extension'; - -/** - * Cache entry for MCP servers - */ -interface ServerCacheEntry { - servers: McpServerReference[]; - timestamp: number; -} - -/** - * Cache entry for MCP server details - */ -interface ServerDetailsCacheEntry { - details: McpServerReference; - timestamp: number; -} - -/** - * Cache entry for MCP tools - */ -interface ToolsCacheEntry { - tools: McpToolReference[]; - timestamp: number; -} - -/** - * Cache entry for MCP tool schema - */ -interface ToolSchemaCacheEntry { - schema: McpToolReference; - timestamp: number; -} - -/** - * In-memory cache storage - */ -const cache = { - /** Server list cache (from 'claude mcp list') */ - serverList: null as ServerCacheEntry | null, - - /** Server details cache (from 'claude mcp get ') */ - serverDetails: new Map(), - - /** Tools cache per server (from 'claude mcp list-tools ') */ - tools: new Map(), - - /** Tool schema cache (from 'claude mcp get-tool-schema ') */ - toolSchemas: new Map(), -}; - -/** - * Cache TTL (Time To Live) settings - * - * Per contracts/mcp-cli.schema.json: "No caching; always fetch fresh data from CLI" - * However, we implement short-lived caching (30 seconds) to prevent redundant CLI calls - * within a single user interaction session. - */ -const CACHE_TTL_MS = { - SERVER_LIST: 30000, // 30 seconds - SERVER_DETAILS: 30000, // 30 seconds - TOOLS: 30000, // 30 seconds - TOOL_SCHEMA: 60000, // 60 seconds (schemas are more stable) -}; - -/** - * Check if a cache entry is still valid - */ -function isCacheValid(timestamp: number, ttlMs: number): boolean { - return Date.now() - timestamp < ttlMs; -} - -/** - * Get cached server list - * - * @returns Cached servers or null if cache miss/expired - */ -export function getCachedServerList(): McpServerReference[] | null { - if (!cache.serverList) { - return null; - } - - if (!isCacheValid(cache.serverList.timestamp, CACHE_TTL_MS.SERVER_LIST)) { - log('INFO', 'Server list cache expired'); - cache.serverList = null; - return null; - } - - log('INFO', 'Server list cache hit', { - serverCount: cache.serverList.servers.length, - ageMs: Date.now() - cache.serverList.timestamp, - }); - - return cache.serverList.servers; -} - -/** - * Set cached server list - * - * @param servers - Server list to cache - */ -export function setCachedServerList(servers: McpServerReference[]): void { - cache.serverList = { - servers, - timestamp: Date.now(), - }; - - log('INFO', 'Cached server list', { - serverCount: servers.length, - }); -} - -/** - * Get cached server details - * - * @param serverId - Server identifier - * @returns Cached details or null if cache miss/expired - */ -export function getCachedServerDetails(serverId: string): McpServerReference | null { - const entry = cache.serverDetails.get(serverId); - - if (!entry) { - return null; - } - - if (!isCacheValid(entry.timestamp, CACHE_TTL_MS.SERVER_DETAILS)) { - log('INFO', 'Server details cache expired', { serverId }); - cache.serverDetails.delete(serverId); - return null; - } - - log('INFO', 'Server details cache hit', { - serverId, - ageMs: Date.now() - entry.timestamp, - }); - - return entry.details; -} - -/** - * Set cached server details - * - * @param serverId - Server identifier - * @param details - Server details to cache - */ -export function setCachedServerDetails(serverId: string, details: McpServerReference): void { - cache.serverDetails.set(serverId, { - details, - timestamp: Date.now(), - }); - - log('INFO', 'Cached server details', { serverId }); -} - -/** - * Get cached tools for a server - * - * @param serverId - Server identifier - * @returns Cached tools or null if cache miss/expired - */ -export function getCachedTools(serverId: string): McpToolReference[] | null { - const entry = cache.tools.get(serverId); - - if (!entry) { - return null; - } - - if (!isCacheValid(entry.timestamp, CACHE_TTL_MS.TOOLS)) { - log('INFO', 'Tools cache expired', { serverId }); - cache.tools.delete(serverId); - return null; - } - - log('INFO', 'Tools cache hit', { - serverId, - toolCount: entry.tools.length, - ageMs: Date.now() - entry.timestamp, - }); - - return entry.tools; -} - -/** - * Set cached tools for a server - * - * @param serverId - Server identifier - * @param tools - Tools to cache - */ -export function setCachedTools(serverId: string, tools: McpToolReference[]): void { - cache.tools.set(serverId, { - tools, - timestamp: Date.now(), - }); - - log('INFO', 'Cached tools', { - serverId, - toolCount: tools.length, - }); -} - -/** - * Get cached tool schema - * - * @param serverId - Server identifier - * @param toolName - Tool name - * @returns Cached schema or null if cache miss/expired - */ -export function getCachedToolSchema(serverId: string, toolName: string): McpToolReference | null { - const cacheKey = `${serverId}::${toolName}`; - const entry = cache.toolSchemas.get(cacheKey); - - if (!entry) { - return null; - } - - if (!isCacheValid(entry.timestamp, CACHE_TTL_MS.TOOL_SCHEMA)) { - log('INFO', 'Tool schema cache expired', { serverId, toolName }); - cache.toolSchemas.delete(cacheKey); - return null; - } - - log('INFO', 'Tool schema cache hit', { - serverId, - toolName, - ageMs: Date.now() - entry.timestamp, - }); - - return entry.schema; -} - -/** - * Set cached tool schema - * - * @param serverId - Server identifier - * @param toolName - Tool name - * @param schema - Tool schema to cache - */ -export function setCachedToolSchema( - serverId: string, - toolName: string, - schema: McpToolReference -): void { - const cacheKey = `${serverId}::${toolName}`; - - cache.toolSchemas.set(cacheKey, { - schema, - timestamp: Date.now(), - }); - - log('INFO', 'Cached tool schema', { serverId, toolName }); -} - -/** - * Invalidate all cache entries for a specific server - * - * Useful when server configuration changes are detected. - * - * @param serverId - Server identifier - */ -export function invalidateServerCache(serverId: string): void { - log('INFO', 'Invalidating cache for server', { serverId }); - - // Remove server details - cache.serverDetails.delete(serverId); - - // Remove tools - cache.tools.delete(serverId); - - // Remove tool schemas - for (const [cacheKey] of cache.toolSchemas) { - if (cacheKey.startsWith(`${serverId}::`)) { - cache.toolSchemas.delete(cacheKey); - } - } - - // Note: Do NOT invalidate server list cache here, as it's shared across all servers -} - -/** - * Invalidate all cache entries - * - * Useful for manual "Refresh" operations in UI. - */ -export function invalidateAllCache(): void { - log('INFO', 'Invalidating all MCP cache'); - - cache.serverList = null; - cache.serverDetails.clear(); - cache.tools.clear(); - cache.toolSchemas.clear(); -} - -/** - * Get cache statistics for debugging - * - * @returns Cache statistics - */ -export function getCacheStats(): { - serverList: { cached: boolean; ageMs?: number }; - serverDetails: { count: number; entries: Array<{ serverId: string; ageMs: number }> }; - tools: { count: number; entries: Array<{ serverId: string; toolCount: number; ageMs: number }> }; - toolSchemas: { - count: number; - entries: Array<{ serverId: string; toolName: string; ageMs: number }>; - }; -} { - const now = Date.now(); - - return { - serverList: cache.serverList - ? { - cached: true, - ageMs: now - cache.serverList.timestamp, - } - : { - cached: false, - }, - serverDetails: { - count: cache.serverDetails.size, - entries: Array.from(cache.serverDetails.entries()).map(([serverId, entry]) => ({ - serverId, - ageMs: now - entry.timestamp, - })), - }, - tools: { - count: cache.tools.size, - entries: Array.from(cache.tools.entries()).map(([serverId, entry]) => ({ - serverId, - toolCount: entry.tools.length, - ageMs: now - entry.timestamp, - })), - }, - toolSchemas: { - count: cache.toolSchemas.size, - entries: Array.from(cache.toolSchemas.entries()).map(([cacheKey, entry]) => { - const [serverId, toolName] = cacheKey.split('::'); - return { - serverId, - toolName, - ageMs: now - entry.timestamp, - }; - }), - }, - }; -} diff --git a/src/extension/services/mcp-cli-service.ts b/src/extension/services/mcp-cli-service.ts deleted file mode 100644 index 3516e25c..00000000 --- a/src/extension/services/mcp-cli-service.ts +++ /dev/null @@ -1,1090 +0,0 @@ -/** - * MCP CLI Service - * - * Wrapper service for executing 'claude mcp' CLI commands. - * Based on: contracts/mcp-cli.schema.json - * - * Feature: 001-mcp-natural-language-mode - * Enhancement: T045 - Added getTools() function with caching support - * - * Updated to use nano-spawn for cross-platform compatibility (Windows/Unix) - * See: Issue #79 - Windows environment compatibility - */ - -import nanoSpawn from 'nano-spawn'; -import type { McpServerReference, McpToolReference } from '../../shared/types/mcp-node'; -import { log } from '../extension'; -import { getClaudeSpawnCommand } from './claude-cli-path'; -import { getCachedTools, setCachedTools } from './mcp-cache-service'; - -/** - * nano-spawn type definitions (manually defined for compatibility) - */ -interface SubprocessError extends Error { - stdout: string; - stderr: string; - output: string; - command: string; - durationMs: number; - exitCode?: number; - signalName?: string; - isTerminated?: boolean; - code?: string; -} - -interface Result { - stdout: string; - stderr: string; - output: string; - command: string; - durationMs: number; -} - -const spawn = - nanoSpawn.default || - (nanoSpawn as ( - file: string, - args?: readonly string[], - options?: Record - ) => Promise); - -/** - * Error codes for MCP CLI operations - */ -export type McpErrorCode = - | 'MCP_CLI_NOT_FOUND' - | 'MCP_CLI_TIMEOUT' - | 'MCP_SERVER_NOT_FOUND' - | 'MCP_CONNECTION_FAILED' - | 'MCP_PARSE_ERROR' - | 'MCP_UNKNOWN_ERROR' - | 'MCP_UNSUPPORTED_TRANSPORT' - | 'MCP_INVALID_CONFIG' - | 'MCP_CONNECTION_TIMEOUT' - | 'MCP_CONNECTION_ERROR'; - -export interface McpExecutionError { - code: McpErrorCode; - message: string; - details?: string; -} - -export interface McpExecutionResult { - success: boolean; - data?: T; - error?: McpExecutionError; - executionTimeMs: number; -} - -/** - * Default timeout for MCP CLI commands (from contracts/mcp-cli.schema.json) - */ -const DEFAULT_TIMEOUT_MS = 5000; - -/** - * Timeout for 'claude mcp list' command - * This command needs more time as it performs health checks on all servers sequentially - */ -const LIST_SERVERS_TIMEOUT_MS = 30000; - -/** - * Type guard to check if an error is a SubprocessError from nano-spawn - * - * @param error - The error to check - * @returns True if error is a SubprocessError - */ -function isSubprocessError(error: unknown): error is SubprocessError { - return ( - typeof error === 'object' && - error !== null && - 'exitCode' in error && - 'stderr' in error && - 'stdout' in error - ); -} - -/** - * Execute a Claude Code MCP CLI command - * - * @param args - CLI arguments (e.g., ['mcp', 'list']) - * @param timeoutMs - Timeout in milliseconds - * @param cwd - Working directory (optional, defaults to user's home directory) - * @returns Execution result with stdout/stderr - */ -async function executeClaudeMcpCommand( - args: string[], - timeoutMs = DEFAULT_TIMEOUT_MS, - cwd?: string -): Promise<{ success: boolean; stdout: string; stderr: string; exitCode: number | null }> { - const startTime = Date.now(); - - log('INFO', 'Executing claude mcp command', { - args, - timeoutMs, - cwd, - }); - - try { - // Spawn 'claude' CLI process using nano-spawn (cross-platform compatible) - // Use claude directly if available (from known paths or PATH), otherwise fall back to npx - // This handles GUI-launched VSCode where Extension Host doesn't inherit shell PATH - // See: Issue #375, PR #376 - const spawnCmd = await getClaudeSpawnCommand(args); - const result = await spawn(spawnCmd.command, spawnCmd.args, { - cwd, - timeout: timeoutMs, - stdin: 'ignore', - stdout: 'pipe', - stderr: 'pipe', - }); - - const executionTimeMs = Date.now() - startTime; - - log('INFO', 'MCP CLI command completed', { - args, - exitCode: 0, - executionTimeMs, - stdoutLength: result.stdout.length, - stderrLength: result.stderr.length, - }); - - return { - success: true, - stdout: result.stdout, - stderr: result.stderr, - exitCode: 0, - }; - } catch (error) { - const executionTimeMs = Date.now() - startTime; - - // Log complete error object for debugging - log('ERROR', 'MCP CLI error caught', { - errorType: typeof error, - errorConstructor: error?.constructor?.name, - errorKeys: error && typeof error === 'object' ? Object.keys(error) : [], - error: error, - executionTimeMs, - }); - - // Handle SubprocessError from nano-spawn - if (isSubprocessError(error)) { - // Timeout error - if (error.isTerminated && error.signalName === 'SIGTERM') { - log('WARN', 'MCP CLI command timed out', { - args, - timeoutMs, - executionTimeMs, - }); - - return { - success: false, - stdout: '', - stderr: `Timeout after ${timeoutMs}ms`, - exitCode: null, - }; - } - - // Command not found (ENOENT) - if (error.code === 'ENOENT') { - log('ERROR', 'MCP CLI command error', { - args, - errorCode: error.code, - errorMessage: error.message, - executionTimeMs, - }); - - return { - success: false, - stdout: '', - stderr: error.message, - exitCode: null, - }; - } - - // Non-zero exit code - log('INFO', 'MCP CLI command completed with error', { - args, - exitCode: error.exitCode, - executionTimeMs, - stdoutLength: error.stdout?.length ?? 0, - stderrLength: error.stderr?.length ?? 0, - }); - - return { - success: false, - stdout: error.stdout, - stderr: error.stderr, - exitCode: error.exitCode ?? null, - }; - } - - // Unknown error type - log('ERROR', 'Unexpected error during MCP CLI command execution', { - args, - errorMessage: error instanceof Error ? error.message : String(error), - executionTimeMs, - }); - - return { - success: false, - stdout: '', - stderr: error instanceof Error ? error.message : String(error), - exitCode: null, - }; - } -} - -/** - * List all configured MCP servers - * - * Executes: claude mcp list - * Based on: contracts/mcp-cli.schema.json - McpListCommand - * - * @param cwd - Working directory (optional, for project-scoped MCP servers) - * @returns List of MCP servers with connection status - */ -export async function listServers(cwd?: string): Promise> { - const startTime = Date.now(); - - const result = await executeClaudeMcpCommand(['mcp', 'list'], LIST_SERVERS_TIMEOUT_MS, cwd); - const executionTimeMs = Date.now() - startTime; - - if (!result.success) { - // Check for ENOENT (command not found) - if (result.stderr.includes('ENOENT') || result.exitCode === null) { - return { - success: false, - error: { - code: 'MCP_CLI_NOT_FOUND', - message: 'Claude Code CLI is not installed or not in PATH', - details: result.stderr, - }, - executionTimeMs, - }; - } - - // Check for timeout - if (result.stderr.includes('Timeout')) { - return { - success: false, - error: { - code: 'MCP_CLI_TIMEOUT', - message: 'MCP server query timed out', - details: result.stderr, - }, - executionTimeMs, - }; - } - - log('ERROR', 'MCP list command failed with unknown error', { - exitCode: result.exitCode, - stderr: result.stderr, - stdout: result.stdout, - }); - - return { - success: false, - error: { - code: 'MCP_UNKNOWN_ERROR', - message: 'Failed to list MCP servers', - details: result.stderr, - }, - executionTimeMs, - }; - } - - // Parse output - try { - const servers = parseMcpListOutput(result.stdout); - - log('INFO', 'Successfully listed MCP servers', { - serverCount: servers.length, - executionTimeMs, - }); - - return { - success: true, - data: servers, - executionTimeMs, - }; - } catch (error) { - log('ERROR', 'Failed to parse MCP list output', { - error: error instanceof Error ? error.message : String(error), - stdout: result.stdout.substring(0, 200), - }); - - return { - success: false, - error: { - code: 'MCP_PARSE_ERROR', - message: 'Failed to parse MCP server list', - details: error instanceof Error ? error.message : String(error), - }, - executionTimeMs, - }; - } -} - -/** - * Parse 'claude mcp list' output - * - * Example output: - * ``` - * Checking MCP server health... - * - * aws-knowledge-mcp: npx mcp-remote https://knowledge-mcp.global.api.aws - ✓ Connected - * local-tools: npx mcp-local /path/to/tools - ✗ Connection timeout - * ``` - * - * @param output - Raw output from 'claude mcp list' - * @returns Parsed server list - */ -function parseMcpListOutput(output: string): McpServerReference[] { - const servers: McpServerReference[] = []; - const lines = output.split('\n'); - - // Regex: /^([^:]+):\s+(.+?)\s+-\s+(.*)$/ - // Groups: (1) server name, (2) command+args, (3) status - const lineRegex = /^([^:]+):\s+(.+?)\s+-\s+(.*)$/; - - for (const line of lines) { - const trimmedLine = line.trim(); - - // Skip empty lines and header - if (!trimmedLine || trimmedLine.startsWith('Checking MCP')) { - continue; - } - - const match = lineRegex.exec(trimmedLine); - if (!match) { - log('WARN', 'parseMcpListOutput - line did not match regex', { line: trimmedLine }); - continue; - } - - const [, serverName, commandAndArgs, statusText] = match; - - // Parse command and args - const parts = commandAndArgs.trim().split(/\s+/); - const command = parts[0]; - const args = parts.slice(1); - - // Detect connection status from ✓ check mark - const status = statusText.includes('✓') ? 'connected' : 'disconnected'; - - servers.push({ - id: serverName.trim(), - name: serverName.trim(), - scope: 'user', // Will be determined by 'claude mcp get' - status: status as 'connected' | 'disconnected', - command, - args, - type: 'stdio', // Will be determined by 'claude mcp get' - }); - } - - log('INFO', 'parseMcpListOutput - completed', { serverCount: servers.length }); - - return servers; -} - -/** - * Get detailed information about a specific MCP server - * - * Executes: claude mcp get - * Based on: contracts/mcp-cli.schema.json - McpGetCommand - * - * @param serverId - Server identifier from 'claude mcp list' - * @returns Detailed server information - */ -export async function getServerDetails( - serverId: string -): Promise> { - const startTime = Date.now(); - - const result = await executeClaudeMcpCommand(['mcp', 'get', serverId]); - const executionTimeMs = Date.now() - startTime; - - if (!result.success) { - // Check for ENOENT (command not found) - if (result.stderr.includes('ENOENT') || result.exitCode === null) { - return { - success: false, - error: { - code: 'MCP_CLI_NOT_FOUND', - message: 'Claude Code CLI is not installed or not in PATH', - details: result.stderr, - }, - executionTimeMs, - }; - } - - // Check for server not found (exit code 1 + stderr pattern) - if (result.exitCode === 1 && result.stderr.includes('No MCP server found')) { - return { - success: false, - error: { - code: 'MCP_SERVER_NOT_FOUND', - message: `MCP server '${serverId}' not found`, - details: result.stderr, - }, - executionTimeMs, - }; - } - - return { - success: false, - error: { - code: 'MCP_UNKNOWN_ERROR', - message: `Failed to get details for MCP server '${serverId}'`, - details: result.stderr, - }, - executionTimeMs, - }; - } - - // Parse output - try { - const serverDetails = parseMcpGetOutput(result.stdout, serverId); - - log('INFO', 'Successfully retrieved MCP server details', { - serverId, - executionTimeMs, - }); - - return { - success: true, - data: serverDetails, - executionTimeMs, - }; - } catch (error) { - log('ERROR', 'Failed to parse MCP get output', { - serverId, - error: error instanceof Error ? error.message : String(error), - stdout: result.stdout.substring(0, 200), - }); - - return { - success: false, - error: { - code: 'MCP_PARSE_ERROR', - message: 'Failed to parse MCP server details', - details: error instanceof Error ? error.message : String(error), - }, - executionTimeMs, - }; - } -} - -/** - * Parse 'claude mcp get ' output - * - * Example output: - * ``` - * aws-knowledge-mcp: - * Scope: User config (available in all your projects) - * Status: ✓ Connected - * Type: stdio - * Command: npx - * Args: mcp-remote https://knowledge-mcp.global.api.aws - * Environment: - * - * To remove this server, run: claude mcp remove "aws-knowledge-mcp" -s user - * ``` - * - * @param output - Raw output from 'claude mcp get' - * @param serverId - Server identifier - * @returns Parsed server details - */ -function parseMcpGetOutput(output: string, serverId: string): McpServerReference { - const lines = output.split('\n'); - const details: Partial = { - id: serverId, - name: serverId, - }; - - for (const line of lines) { - const trimmedLine = line.trim(); - - // Skip empty lines and removal command - if (!trimmedLine || trimmedLine.startsWith('To remove')) { - continue; - } - - // Skip server name line (first line) - if (trimmedLine === `${serverId}:`) { - continue; - } - - // Parse key-value pairs (indented lines) - const colonIndex = trimmedLine.indexOf(':'); - if (colonIndex === -1) continue; - - const key = trimmedLine.substring(0, colonIndex).trim(); - const value = trimmedLine.substring(colonIndex + 1).trim(); - - switch (key) { - case 'Scope': - // Extract scope from parenthetical note - if (value.includes('User config')) { - details.scope = 'user'; - } else if (value.includes('Project config')) { - details.scope = 'project'; - } else if (value.includes('Enterprise')) { - details.scope = 'enterprise'; - } - break; - - case 'Status': - details.status = value.includes('✓') ? 'connected' : 'disconnected'; - break; - - case 'Type': - details.type = value as 'stdio' | 'sse' | 'http'; - break; - - case 'Command': - details.command = value; - break; - - case 'Args': - // Split on whitespace into array - details.args = value.split(/\s+/).filter((arg) => arg.length > 0); - break; - - case 'Url': - details.url = value; - break; - - case 'Environment': - // Environment variables (currently not parsed, assumed empty) - details.environment = {}; - break; - } - } - - // Validate required fields - if (!details.scope || !details.status || !details.type) { - throw new Error('Missing required fields in MCP server details'); - } - - // For stdio transport, command and args are required - // For http/sse transport, they may not be present - if (details.type === 'stdio') { - if (!details.command || !details.args) { - throw new Error('Missing command/args for stdio MCP server'); - } - } else { - // Ensure command/args have default values for non-stdio transports - if (!details.command) { - details.command = ''; - } - if (!details.args) { - details.args = []; - } - } - - return details as McpServerReference; -} - -/** - * List all tools available from a specific MCP server - * - * Uses @modelcontextprotocol/sdk to connect directly to MCP servers - * instead of using Claude Code CLI commands (which don't support tool listing). - * - * @param serverId - Server identifier - * @param workspacePath - Optional workspace path for project-scoped servers - * @returns List of available tools - */ -export async function listTools( - serverId: string, - workspacePath?: string -): Promise> { - const startTime = Date.now(); - - // Import MCP SDK services - const { getMcpServerConfig } = await import('./mcp-config-reader'); - const { listToolsFromMcpServer, listToolsFromMcpServerHttp } = await import('./mcp-sdk-client'); - - // Get server configuration from .claude.json - const serverConfig = getMcpServerConfig(serverId, workspacePath); - - if (!serverConfig) { - return { - success: false, - error: { - code: 'MCP_SERVER_NOT_FOUND', - message: `MCP server '${serverId}' not found in configuration`, - details: 'Check ~/.claude.json for available MCP servers', - }, - executionTimeMs: Date.now() - startTime, - }; - } - - // Route by transport type - if (serverConfig.type === 'http') { - if (!serverConfig.url) { - return { - success: false, - error: { - code: 'MCP_INVALID_CONFIG', - message: `MCP server '${serverId}' has HTTP transport but no URL configured`, - details: 'Missing url in server configuration', - }, - executionTimeMs: Date.now() - startTime, - }; - } - - try { - const tools = await listToolsFromMcpServerHttp(serverId, serverConfig.url); - - log('INFO', 'Successfully listed MCP tools via HTTP', { - serverId, - toolCount: tools.length, - executionTimeMs: Date.now() - startTime, - }); - - return { - success: true, - data: tools, - executionTimeMs: Date.now() - startTime, - }; - } catch (error) { - const executionTimeMs = Date.now() - startTime; - - if (error instanceof Error && error.message.includes('timeout')) { - return { - success: false, - error: { - code: 'MCP_CONNECTION_TIMEOUT', - message: `Connection to MCP server '${serverId}' timed out`, - details: error.message, - }, - executionTimeMs, - }; - } - - return { - success: false, - error: { - code: 'MCP_CONNECTION_ERROR', - message: `Failed to connect to MCP server '${serverId}' via HTTP`, - details: error instanceof Error ? error.message : String(error), - }, - executionTimeMs, - }; - } - } - - if (serverConfig.type === 'sse') { - return { - success: false, - error: { - code: 'MCP_UNSUPPORTED_TRANSPORT', - message: `MCP server '${serverId}' uses SSE transport which is deprecated`, - details: 'SSE transport is deprecated. Please migrate to HTTP (Streamable HTTP) transport.', - }, - executionTimeMs: Date.now() - startTime, - }; - } - - if (serverConfig.type !== 'stdio') { - return { - success: false, - error: { - code: 'MCP_UNSUPPORTED_TRANSPORT', - message: `MCP server '${serverId}' uses unsupported transport type: ${serverConfig.type}`, - details: 'Supported transport types: stdio, http', - }, - executionTimeMs: Date.now() - startTime, - }; - } - - // stdio transport - if (!serverConfig.command || !serverConfig.args) { - return { - success: false, - error: { - code: 'MCP_INVALID_CONFIG', - message: `MCP server '${serverId}' has invalid stdio configuration`, - details: 'Missing command or args in server configuration', - }, - executionTimeMs: Date.now() - startTime, - }; - } - - // Connect to MCP server and list tools - try { - const tools = await listToolsFromMcpServer( - serverId, - serverConfig.command, - serverConfig.args, - serverConfig.env || {} - ); - - log('INFO', 'Successfully listed MCP tools', { - serverId, - toolCount: tools.length, - executionTimeMs: Date.now() - startTime, - }); - - return { - success: true, - data: tools, - executionTimeMs: Date.now() - startTime, - }; - } catch (error) { - const executionTimeMs = Date.now() - startTime; - - // Check if it's a connection timeout - if (error instanceof Error && error.message.includes('timeout')) { - log('ERROR', 'MCP server connection timeout', { - serverId, - error: error.message, - executionTimeMs, - }); - - return { - success: false, - error: { - code: 'MCP_CONNECTION_TIMEOUT', - message: `Connection to MCP server '${serverId}' timed out`, - details: error.message, - }, - executionTimeMs, - }; - } - - // General connection error - log('ERROR', 'Failed to connect to MCP server', { - serverId, - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - executionTimeMs, - }); - - return { - success: false, - error: { - code: 'MCP_CONNECTION_ERROR', - message: `Failed to connect to MCP server '${serverId}'`, - details: error instanceof Error ? error.message : String(error), - }, - executionTimeMs, - }; - } -} - -/** - * Get tools for a server (with caching) - * - * This function wraps listTools() and adds caching support per T045. - * Cache strategy: 30 seconds TTL (defined in mcp-cache-service.ts) - * - * @param serverId - Server identifier - * @param workspacePath - Optional workspace path for project-scoped servers - * @returns List of available tools (from cache or fresh from server) - */ -export async function getTools( - serverId: string, - workspacePath?: string -): Promise> { - // Check cache first - const cachedTools = getCachedTools(serverId); - - if (cachedTools) { - log('INFO', 'getTools - cache hit', { - serverId, - toolCount: cachedTools.length, - }); - - return { - success: true, - data: cachedTools, - executionTimeMs: 0, // Cache hit, no execution time - }; - } - - // Cache miss - fetch from MCP server - log('INFO', 'getTools - cache miss, fetching from server', { serverId }); - - const result = await listTools(serverId, workspacePath); - - // Cache successful results - if (result.success && result.data) { - setCachedTools(serverId, result.data); - } - - return result; -} - -// parseMcpListToolsOutput function removed - now using MCP SDK directly - -/** - * Get JSON schema for a specific tool's parameters - * - * Uses MCP SDK to connect directly to the server and retrieve tool schema. - * This is more efficient than listing all tools when we only need one. - * - * @param serverId - Server identifier - * @param toolName - Tool name - * @param workspacePath - Optional workspace path for project-scoped servers - * @returns Tool parameter schema - */ -export async function getToolSchema( - serverId: string, - toolName: string, - workspacePath?: string -): Promise> { - const startTime = Date.now(); - - log('INFO', 'GET_TOOL_SCHEMA request started', { - serverId, - toolName, - }); - - // Import MCP SDK services - const { getMcpServerConfig } = await import('./mcp-config-reader'); - const { listToolsFromMcpServer, listToolsFromMcpServerHttp } = await import('./mcp-sdk-client'); - - // Get server configuration from .claude.json - const serverConfig = getMcpServerConfig(serverId, workspacePath); - - if (!serverConfig) { - return { - success: false, - error: { - code: 'MCP_SERVER_NOT_FOUND', - message: `MCP server '${serverId}' not found in configuration`, - details: 'Check ~/.claude.json for available MCP servers', - }, - executionTimeMs: Date.now() - startTime, - }; - } - - // Route by transport type - if (serverConfig.type === 'http') { - if (!serverConfig.url) { - return { - success: false, - error: { - code: 'MCP_INVALID_CONFIG', - message: `MCP server '${serverId}' has HTTP transport but no URL configured`, - details: 'Missing url in server configuration', - }, - executionTimeMs: Date.now() - startTime, - }; - } - - try { - const tools = await listToolsFromMcpServerHttp(serverId, serverConfig.url); - const tool = tools.find((t) => t.name === toolName); - - if (!tool) { - return { - success: false, - error: { - code: 'MCP_PARSE_ERROR', - message: `Tool '${toolName}' not found in server '${serverId}'`, - details: `Available tools: ${tools.map((t) => t.name).join(', ')}`, - }, - executionTimeMs: Date.now() - startTime, - }; - } - - log('INFO', 'GET_TOOL_SCHEMA completed successfully via HTTP', { - serverId, - toolName, - parameterCount: tool.parameters?.length || 0, - executionTimeMs: Date.now() - startTime, - }); - - return { - success: true, - data: tool, - executionTimeMs: Date.now() - startTime, - }; - } catch (error) { - const executionTimeMs = Date.now() - startTime; - - if (error instanceof Error && error.message.includes('timeout')) { - return { - success: false, - error: { - code: 'MCP_CONNECTION_TIMEOUT', - message: `Connection to MCP server '${serverId}' timed out`, - details: error.message, - }, - executionTimeMs, - }; - } - - return { - success: false, - error: { - code: 'MCP_CONNECTION_ERROR', - message: `Failed to connect to MCP server '${serverId}' via HTTP`, - details: error instanceof Error ? error.message : String(error), - }, - executionTimeMs, - }; - } - } - - if (serverConfig.type === 'sse') { - return { - success: false, - error: { - code: 'MCP_UNSUPPORTED_TRANSPORT', - message: `MCP server '${serverId}' uses SSE transport which is deprecated`, - details: 'SSE transport is deprecated. Please migrate to HTTP (Streamable HTTP) transport.', - }, - executionTimeMs: Date.now() - startTime, - }; - } - - if (serverConfig.type !== 'stdio') { - return { - success: false, - error: { - code: 'MCP_UNSUPPORTED_TRANSPORT', - message: `MCP server '${serverId}' uses unsupported transport type: ${serverConfig.type}`, - details: 'Supported transport types: stdio, http', - }, - executionTimeMs: Date.now() - startTime, - }; - } - - // stdio transport - if (!serverConfig.command || !serverConfig.args) { - return { - success: false, - error: { - code: 'MCP_INVALID_CONFIG', - message: `MCP server '${serverId}' has invalid stdio configuration`, - details: 'Missing command or args in server configuration', - }, - executionTimeMs: Date.now() - startTime, - }; - } - - // Connect to MCP server and list tools - try { - const tools = await listToolsFromMcpServer( - serverId, - serverConfig.command, - serverConfig.args, - serverConfig.env || {} - ); - - // Find the specific tool - const tool = tools.find((t) => t.name === toolName); - - if (!tool) { - log('ERROR', 'Tool not found in MCP server', { - serverId, - toolName, - availableTools: tools.map((t) => t.name), - executionTimeMs: Date.now() - startTime, - }); - - return { - success: false, - error: { - code: 'MCP_PARSE_ERROR', - message: `Tool '${toolName}' not found in server '${serverId}'`, - details: `Available tools: ${tools.map((t) => t.name).join(', ')}`, - }, - executionTimeMs: Date.now() - startTime, - }; - } - - log('INFO', 'GET_TOOL_SCHEMA completed successfully', { - serverId, - toolName, - parameterCount: tool.parameters?.length || 0, - executionTimeMs: Date.now() - startTime, - }); - - return { - success: true, - data: tool, - executionTimeMs: Date.now() - startTime, - }; - } catch (error) { - const executionTimeMs = Date.now() - startTime; - - // Check if it's a connection timeout - if (error instanceof Error && error.message.includes('timeout')) { - log('ERROR', 'MCP server connection timeout', { - serverId, - toolName, - error: error.message, - executionTimeMs, - }); - - return { - success: false, - error: { - code: 'MCP_CONNECTION_TIMEOUT', - message: `Connection to MCP server '${serverId}' timed out`, - details: error.message, - }, - executionTimeMs, - }; - } - - // General connection error - log('ERROR', 'Failed to connect to MCP server for tool schema', { - serverId, - toolName, - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - executionTimeMs, - }); - - return { - success: false, - error: { - code: 'MCP_CONNECTION_ERROR', - message: `Failed to connect to MCP server '${serverId}'`, - details: error instanceof Error ? error.message : String(error), - }, - executionTimeMs, - }; - } -} - -/** - * Execute an MCP tool with parameters - * - * NOTE: This is a placeholder - actual implementation depends on Claude Code CLI - * supporting 'claude mcp run [params]' command. - * - * @param serverId - Server identifier - * @param toolName - Tool name - * @param parameters - Tool parameters - * @returns Tool execution result - */ -export async function executeTool( - serverId: string, - toolName: string, - parameters: Record -): Promise> { - // TODO: Implement when 'claude mcp run' is available - log('WARN', 'executeTool() not yet implemented - placeholder', { - serverId, - toolName, - parameters, - }); - - return { - success: false, - error: { - code: 'MCP_UNKNOWN_ERROR', - message: 'Tool execution not yet implemented', - details: 'Waiting for Claude Code CLI support for executing tools', - }, - executionTimeMs: 0, - }; -} diff --git a/src/extension/services/mcp-config-reader.ts b/src/extension/services/mcp-config-reader.ts deleted file mode 100644 index 7a44cf03..00000000 --- a/src/extension/services/mcp-config-reader.ts +++ /dev/null @@ -1,1273 +0,0 @@ -/** - * MCP Configuration Reader Service - * - * Feature: 001-mcp-node - * Purpose: Read MCP server configurations from multiple AI coding tools - * - * This service reads MCP server configurations from multiple sources: - * - * Claude Code: - * - /.mcp.json (project-level) - * - ~/.mcp.json (user-level) - * - ~/.claude.json → projects[workspace].mcpServers (legacy, project-level) - * - ~/.claude.json → mcpServers (legacy, user-level) - * - * VSCode Copilot: - * - /.vscode/mcp.json (project-level, uses 'servers' key) - * - * Copilot CLI: - * - /.copilot/mcp-config.json (project-level) - * - ~/.copilot/mcp-config.json (user-level) - * - * Codex CLI: - * - ~/.codex/config.toml (user-level, TOML format with [mcp_servers.*] sections) - * - * Gemini CLI: - * - ~/.gemini/settings.json (user-level) - * - /.gemini/settings.json (project-level) - * - * Roo Code: - * - /.roo/mcp.json (project-level) - * - * Antigravity: - * - ~/.gemini/antigravity/mcp_config.json (user-level, uses 'serverUrl' for HTTP) - * - * Cursor: - * - ~/.cursor/mcp.json (user-level, uses 'mcpServers' key like Claude Code) - */ - -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { parse as parseToml } from 'smol-toml'; -import type { McpConfigSource } from '../../shared/types/mcp-node'; -import { log } from '../extension'; -import { - getAntigravityUserMcpConfigPath, - getCodexUserMcpConfigPath, - getCopilotUserMcpConfigPath, - getCursorUserMcpConfigPath, - getGeminiProjectMcpConfigPath, - getGeminiUserMcpConfigPath, - getRooProjectMcpConfigPath, - getVSCodeMcpConfigPath, -} from '../utils/path-utils'; - -/** - * MCP server configuration from .claude.json - */ -export interface McpServerConfig { - type: 'stdio' | 'http' | 'sse'; - command?: string; - args?: string[]; - env?: Record; - url?: string; - /** Source provider (tracked during reading, defaults to 'claude') */ - source?: McpConfigSource; -} - -/** - * Get the path to legacy .claude.json - * - * @returns Absolute path to .claude.json - */ -function getLegacyClaudeConfigPath(): string { - return path.join(os.homedir(), '.claude.json'); -} - -/** - * Get the path to project-scope .mcp.json - * - * @param workspacePath - Workspace directory path - * @returns Absolute path to /.mcp.json - */ -function getProjectMcpConfigPath(workspacePath: string): string { - return path.join(workspacePath, '.mcp.json'); -} - -/** - * Get the path to user-level ~/.mcp.json - * - * @returns Absolute path to ~/.mcp.json - */ -function getUserMcpConfigPath(): string { - return path.join(os.homedir(), '.mcp.json'); -} - -/** - * Read legacy .claude.json file - * - * @returns Parsed configuration object or null if not found - */ -function readLegacyClaudeConfig(): { - mcpServers?: Record; - [key: string]: unknown; -} | null { - const configPath = getLegacyClaudeConfigPath(); - - try { - const content = fs.readFileSync(configPath, 'utf-8'); - return JSON.parse(content); - } catch (error) { - log('WARN', 'Failed to read legacy .claude.json', { - configPath, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } -} - -/** - * Normalize MCP server configuration by inferring missing type field - * - * @param config - Raw server configuration from file - * @returns Normalized configuration with type field - */ -function normalizeServerConfig(config: Partial): McpServerConfig | null { - // If type is already specified, normalize and use it - if (config.type) { - // Normalize 'streamable-http' (used by Roo Code) to 'http' - const type = config.type === ('streamable-http' as string) ? 'http' : config.type; - return { ...config, type } as McpServerConfig; - } - - // Infer type from available fields - // Rule 1: If command exists, assume stdio transport - if (config.command) { - return { - ...config, - type: 'stdio', - } as McpServerConfig; - } - - // Rule 2: If url exists, assume http transport (same as Gemini config handling) - if (config.url) { - return { - ...config, - type: 'http', - } as McpServerConfig; - } - - // No type and no command/url - invalid configuration - return null; -} - -/** - * Read mcp.json file (Claude Code format) - * - * @param configPath - Path to mcp.json - * @returns MCP servers configuration or null if not found - */ -function readMcpConfig(configPath: string): { - mcpServers?: Record; -} | null { - try { - const content = fs.readFileSync(configPath, 'utf-8'); - const parsed = JSON.parse(content); - - log('INFO', 'Successfully read .mcp.json', { - configPath, - serverCount: parsed.mcpServers ? Object.keys(parsed.mcpServers).length : 0, - }); - - return parsed; - } catch (error) { - // File not found is expected (not all projects have .mcp.json) - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } - - log('WARN', 'Failed to read .mcp.json', { - configPath, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } -} - -/** - * Read Copilot MCP config (.copilot/mcp-config.json) - * - * Format: { "mcpServers": { ... } } - * - * @param configPath - Path to mcp-config.json - * @returns MCP servers configuration or null if not found - */ -function readCopilotMcpConfig(configPath: string): Record | null { - try { - const content = fs.readFileSync(configPath, 'utf-8'); - const parsed = JSON.parse(content); - - // Copilot CLI uses same format as Claude Code (mcpServers key) - const servers = parsed.mcpServers as Record | undefined; - - if (servers) { - log('INFO', 'Successfully read Copilot mcp-config.json', { - configPath, - serverCount: Object.keys(servers).length, - }); - return servers; - } - - return null; - } catch (error) { - // File not found is expected - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } - - log('WARN', 'Failed to read Copilot mcp-config.json', { - configPath, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } -} - -/** - * Read MCP server configuration from Gemini CLI settings.json - * - * @param configPath - Absolute path to settings.json (~/.gemini/settings.json or .gemini/settings.json) - * @returns McpServerConfig record, or null on error - */ -function readGeminiMcpConfig(configPath: string): Record | null { - try { - const content = fs.readFileSync(configPath, 'utf-8'); - const parsed = JSON.parse(content); - const rawServers = parsed.mcpServers; - - if (!rawServers || typeof rawServers !== 'object') { - return null; - } - - // Gemini settings.json entries may have url without type field. - // normalizeServerConfig cannot infer http vs sse, so we pre-normalize here: - // - url present → type 'http' - // - command present → type 'stdio' - const servers: Record = {}; - - for (const [serverId, raw] of Object.entries( - rawServers as Record> - )) { - if (raw.type) { - servers[serverId] = raw as McpServerConfig; - } else if (raw.command) { - servers[serverId] = { ...raw, type: 'stdio' } as McpServerConfig; - } else if (raw.url) { - servers[serverId] = { ...raw, type: 'http' } as McpServerConfig; - } else { - log('WARN', 'Invalid Gemini MCP server configuration (no command or url)', { - serverId, - configPath, - }); - } - } - - if (Object.keys(servers).length === 0) { - return null; - } - - log('INFO', 'Successfully read Gemini settings.json', { - configPath, - serverCount: Object.keys(servers).length, - }); - - return servers; - } catch (error) { - // File not found is expected - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } - - log('WARN', 'Failed to read Gemini settings.json', { - configPath, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } -} - -/** - * Read Roo Code MCP config (.roo/mcp.json) - * - * Format: { "mcpServers": { ... } } - * - * @param configPath - Path to .roo/mcp.json - * @returns MCP servers configuration or null if not found - */ -function readRooMcpConfig(configPath: string): Record | null { - try { - const content = fs.readFileSync(configPath, 'utf-8'); - const parsed = JSON.parse(content); - - const servers = parsed.mcpServers as Record | undefined; - - if (servers) { - log('INFO', 'Successfully read Roo Code mcp.json', { - configPath, - serverCount: Object.keys(servers).length, - }); - return servers; - } - - return null; - } catch (error) { - // File not found is expected - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } - - log('WARN', 'Failed to read Roo Code mcp.json', { - configPath, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } -} - -/** - * Read Antigravity MCP config (~/.gemini/antigravity/mcp_config.json) - * - * Format: { "mcpServers": { "name": { "serverUrl": "..." } } } - * Note: Antigravity uses 'serverUrl' instead of 'url' for HTTP transport. - * It also supports standard 'command'/'args' for stdio transport. - * - * @param configPath - Path to mcp_config.json - * @returns MCP servers configuration (normalized) or null if not found - */ -function readAntigravityMcpConfig(configPath: string): Record | null { - try { - const content = fs.readFileSync(configPath, 'utf-8'); - const parsed = JSON.parse(content); - const rawServers = parsed.mcpServers; - - if (!rawServers || typeof rawServers !== 'object') { - return null; - } - - const servers: Record = {}; - - for (const [serverId, raw] of Object.entries( - rawServers as Record & { serverUrl?: string }> - )) { - if (raw.command) { - // stdio transport (standard format) - servers[serverId] = { ...raw, type: raw.type ?? 'stdio' } as McpServerConfig; - } else if (raw.serverUrl) { - // Antigravity-specific: 'serverUrl' → normalize to 'url' - const { serverUrl, ...rest } = raw; - servers[serverId] = { ...rest, url: serverUrl, type: 'http' } as McpServerConfig; - } else if (raw.url) { - // Standard url field - servers[serverId] = { ...raw, type: raw.type ?? 'http' } as McpServerConfig; - } else { - log( - 'WARN', - 'Invalid Antigravity MCP server configuration (no command, serverUrl, or url)', - { - serverId, - configPath, - } - ); - } - } - - if (Object.keys(servers).length === 0) { - return null; - } - - log('INFO', 'Successfully read Antigravity mcp_config.json', { - configPath, - serverCount: Object.keys(servers).length, - }); - - return servers; - } catch (error) { - // File not found is expected - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } - - log('WARN', 'Failed to read Antigravity mcp_config.json', { - configPath, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } -} - -/** - * Read Cursor MCP config (~/.cursor/mcp.json) - * - * Format: { "mcpServers": { "name": { ... } } } - * Note: Cursor uses the same 'mcpServers' key as Claude Code. No special conversion needed. - * - * @param configPath - Path to mcp.json - * @returns MCP servers configuration or null if not found - */ -function readCursorMcpConfig(configPath: string): Record | null { - try { - const content = fs.readFileSync(configPath, 'utf-8'); - const parsed = JSON.parse(content); - const rawServers = parsed.mcpServers; - - if (!rawServers || typeof rawServers !== 'object') { - return null; - } - - const servers: Record = {}; - - for (const [serverId, raw] of Object.entries( - rawServers as Record> - )) { - if (raw.command) { - // stdio transport - servers[serverId] = { ...raw, type: raw.type ?? 'stdio' } as McpServerConfig; - } else if (raw.url) { - // HTTP/SSE transport - servers[serverId] = { ...raw, type: raw.type ?? 'http' } as McpServerConfig; - } else { - log('WARN', 'Invalid Cursor MCP server configuration (no command or url)', { - serverId, - configPath, - }); - } - } - - if (Object.keys(servers).length === 0) { - return null; - } - - log('INFO', 'Successfully read Cursor mcp.json', { - configPath, - serverCount: Object.keys(servers).length, - }); - - return servers; - } catch (error) { - // File not found is expected - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } - - log('WARN', 'Failed to read Cursor mcp.json', { - configPath, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } -} - -/** - * Read VSCode Copilot MCP config (.vscode/mcp.json) - * - * Format: { "servers": { ... } } - * Note: VSCode Copilot uses 'servers' key, not 'mcpServers' - * - * @param configPath - Path to .vscode/mcp.json - * @returns MCP servers configuration (normalized to mcpServers format) or null if not found - */ -function readVSCodeMcpConfig(configPath: string): Record | null { - try { - const content = fs.readFileSync(configPath, 'utf-8'); - const parsed = JSON.parse(content); - - // VSCode Copilot uses 'servers' key instead of 'mcpServers' - const servers = parsed.servers as Record | undefined; - - if (servers) { - log('INFO', 'Successfully read VSCode mcp.json', { - configPath, - serverCount: Object.keys(servers).length, - }); - return servers; - } - - return null; - } catch (error) { - // File not found is expected - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } - - log('WARN', 'Failed to read VSCode mcp.json', { - configPath, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } -} - -/** - * Codex TOML config structure for mcp_servers section - */ -interface CodexMcpServerTomlConfig { - enabled?: boolean; - command?: string; - args?: string[]; - env?: Record; - type?: 'stdio' | 'http' | 'sse'; - url?: string; -} - -/** - * Read Codex MCP config (~/.codex/config.toml) - * - * Format: - * [mcp_servers.server-name] - * enabled = true - * command = "npx" - * args = ["-y", "package"] - * - * @param configPath - Path to config.toml - * @returns MCP servers configuration (converted to McpServerConfig format) or null if not found - */ -function readCodexMcpConfig(configPath: string): Record | null { - try { - const content = fs.readFileSync(configPath, 'utf-8'); - const parsed = parseToml(content); - - // Codex stores MCP servers under [mcp_servers.*] sections - const mcpServersSection = parsed.mcp_servers as - | Record - | undefined; - - if (!mcpServersSection) { - return null; - } - - const servers: Record = {}; - - for (const [serverId, config] of Object.entries(mcpServersSection)) { - // Skip disabled servers - if (config.enabled === false) { - log('INFO', 'Skipping disabled Codex MCP server', { serverId }); - continue; - } - - // Convert Codex TOML config to McpServerConfig format - const serverConfig: Partial = { - command: config.command, - args: config.args, - env: config.env, - type: config.type, - url: config.url, - }; - - // Normalize (infer type if missing) - const normalized = normalizeServerConfig(serverConfig); - if (normalized) { - servers[serverId] = normalized; - } else { - log('WARN', 'Invalid Codex MCP server configuration', { - serverId, - configPath, - config, - }); - } - } - - if (Object.keys(servers).length > 0) { - log('INFO', 'Successfully read Codex config.toml', { - configPath, - serverCount: Object.keys(servers).length, - }); - return servers; - } - - return null; - } catch (error) { - // File not found is expected - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } - - log('WARN', 'Failed to read Codex config.toml', { - configPath, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } -} - -/** - * Get MCP server configuration by server ID - * - * @param serverId - Server identifier from 'claude mcp list' - * @param workspacePath - Optional workspace path for project-scoped servers - * @returns Server configuration or null if not found - */ -export function getMcpServerConfig( - serverId: string, - workspacePath?: string -): McpServerConfig | null { - try { - const legacyConfig = readLegacyClaudeConfig(); - - // Priority 1: Project-scope .mcp.json (/.mcp.json) - if (workspacePath) { - const projectMcpConfigPath = getProjectMcpConfigPath(workspacePath); - const projectMcpConfig = readMcpConfig(projectMcpConfigPath); - - if (projectMcpConfig?.mcpServers?.[serverId]) { - const rawConfig = projectMcpConfig.mcpServers[serverId]; - const serverConfig = normalizeServerConfig(rawConfig); - - if (!serverConfig) { - log('WARN', 'Invalid MCP server configuration in project scope', { - serverId, - scope: 'project', - configPath: projectMcpConfigPath, - rawConfig, - }); - return null; - } - - log('INFO', 'Retrieved MCP server configuration from project scope', { - serverId, - scope: 'project', - configPath: projectMcpConfigPath, - type: serverConfig.type, - hasCommand: !!serverConfig.command, - hasUrl: !!serverConfig.url, - }); - - return { ...serverConfig, source: 'claude' }; - } - } - - // Priority 2: User-level ~/.mcp.json - const userMcpConfigPath = getUserMcpConfigPath(); - const userMcpConfig = readMcpConfig(userMcpConfigPath); - - if (userMcpConfig?.mcpServers?.[serverId]) { - const rawConfig = userMcpConfig.mcpServers[serverId]; - const serverConfig = normalizeServerConfig(rawConfig); - - if (!serverConfig) { - log('WARN', 'Invalid MCP server configuration in user mcp.json', { - serverId, - scope: 'user-mcp', - configPath: userMcpConfigPath, - rawConfig, - }); - return null; - } - - log('INFO', 'Retrieved MCP server configuration from user mcp.json', { - serverId, - scope: 'user-mcp', - configPath: userMcpConfigPath, - type: serverConfig.type, - hasCommand: !!serverConfig.command, - hasUrl: !!serverConfig.url, - }); - - return { ...serverConfig, source: 'claude' }; - } - - // Priority 3: Local scope - .claude.json.projects[].mcpServers - if (legacyConfig && workspacePath) { - const projectsConfig = legacyConfig.projects as - | Record }> - | undefined; - const localConfig = projectsConfig?.[workspacePath]; - if (localConfig?.mcpServers?.[serverId]) { - const rawConfig = localConfig.mcpServers[serverId]; - const serverConfig = normalizeServerConfig(rawConfig); - - if (!serverConfig) { - log('WARN', 'Invalid MCP server configuration in local scope', { - serverId, - scope: 'local', - workspacePath, - rawConfig, - }); - return null; - } - - log('INFO', 'Retrieved MCP server configuration from local scope', { - serverId, - scope: 'local', - workspacePath, - type: serverConfig.type, - hasCommand: !!serverConfig.command, - hasUrl: !!serverConfig.url, - }); - - return { ...serverConfig, source: 'claude' }; - } - } - - // Priority 4: User scope (legacy) - .claude.json.mcpServers (top-level) - if (legacyConfig?.mcpServers?.[serverId]) { - const rawConfig = legacyConfig.mcpServers[serverId]; - const serverConfig = normalizeServerConfig(rawConfig); - - if (!serverConfig) { - log('WARN', 'Invalid MCP server configuration in user scope', { - serverId, - scope: 'user', - rawConfig, - }); - return null; - } - - log('INFO', 'Retrieved MCP server configuration from user scope', { - serverId, - scope: 'user', - type: serverConfig.type, - hasCommand: !!serverConfig.command, - hasUrl: !!serverConfig.url, - }); - - return { ...serverConfig, source: 'claude' }; - } - - // ========================================================================= - // Copilot sources (Priority 5-7) - // ========================================================================= - - // Priority 5: VSCode Copilot project-scope (.vscode/mcp.json) - if (workspacePath) { - const vscodeMcpConfigPath = getVSCodeMcpConfigPath(); - if (vscodeMcpConfigPath) { - const vscodeConfig = readVSCodeMcpConfig(vscodeMcpConfigPath); - if (vscodeConfig?.[serverId]) { - const serverConfig = normalizeServerConfig(vscodeConfig[serverId]); - if (serverConfig) { - log('INFO', 'Retrieved MCP server configuration from VSCode Copilot', { - serverId, - scope: 'vscode-copilot', - configPath: vscodeMcpConfigPath, - type: serverConfig.type, - }); - return { ...serverConfig, source: 'copilot' }; - } - } - } - } - - // Priority 6: Copilot CLI user-scope (~/.copilot/mcp-config.json) - // Note: Copilot CLI only supports user-scope MCP configuration (no project-scope) - const copilotUserConfigPath = getCopilotUserMcpConfigPath(); - const copilotUserConfig = readCopilotMcpConfig(copilotUserConfigPath); - if (copilotUserConfig?.[serverId]) { - const serverConfig = normalizeServerConfig(copilotUserConfig[serverId]); - if (serverConfig) { - log('INFO', 'Retrieved MCP server configuration from Copilot CLI user scope', { - serverId, - scope: 'copilot-user', - configPath: copilotUserConfigPath, - type: serverConfig.type, - }); - return { ...serverConfig, source: 'copilot' }; - } - } - - // ========================================================================= - // Codex source (Priority 8) - // ========================================================================= - - // Priority 8: Codex CLI user-scope (~/.codex/config.toml) - const codexConfigPath = getCodexUserMcpConfigPath(); - const codexConfig = readCodexMcpConfig(codexConfigPath); - if (codexConfig?.[serverId]) { - const serverConfig = normalizeServerConfig(codexConfig[serverId]); - if (serverConfig) { - log('INFO', 'Retrieved MCP server configuration from Codex CLI', { - serverId, - scope: 'codex-user', - configPath: codexConfigPath, - type: serverConfig.type, - }); - return { ...serverConfig, source: 'codex' }; - } - } - - // ========================================================================= - // Gemini source (Priority 9-10) - // ========================================================================= - - // Priority 9: Gemini CLI user-scope (~/.gemini/settings.json) - const geminiUserConfigPath = getGeminiUserMcpConfigPath(); - const geminiUserConfig = readGeminiMcpConfig(geminiUserConfigPath); - if (geminiUserConfig?.[serverId]) { - const serverConfig = normalizeServerConfig(geminiUserConfig[serverId]); - if (serverConfig) { - log('INFO', 'Retrieved MCP server configuration from Gemini CLI user scope', { - serverId, - scope: 'gemini-user', - configPath: geminiUserConfigPath, - type: serverConfig.type, - }); - return { ...serverConfig, source: 'gemini' }; - } - } - - // Priority 10: Gemini CLI project-scope (.gemini/settings.json) - const geminiProjectConfigPath = getGeminiProjectMcpConfigPath(); - if (geminiProjectConfigPath) { - const geminiProjectConfig = readGeminiMcpConfig(geminiProjectConfigPath); - if (geminiProjectConfig?.[serverId]) { - const serverConfig = normalizeServerConfig(geminiProjectConfig[serverId]); - if (serverConfig) { - log('INFO', 'Retrieved MCP server configuration from Gemini CLI project scope', { - serverId, - scope: 'gemini-project', - configPath: geminiProjectConfigPath, - type: serverConfig.type, - }); - return { ...serverConfig, source: 'gemini' }; - } - } - } - - // ========================================================================= - // Roo Code source (Priority 11) - // ========================================================================= - - // Priority 11: Roo Code project-scope (.roo/mcp.json) - const rooProjectConfigPath = getRooProjectMcpConfigPath(); - if (rooProjectConfigPath) { - const rooProjectConfig = readRooMcpConfig(rooProjectConfigPath); - if (rooProjectConfig?.[serverId]) { - const serverConfig = normalizeServerConfig(rooProjectConfig[serverId]); - if (serverConfig) { - log('INFO', 'Retrieved MCP server configuration from Roo Code project scope', { - serverId, - scope: 'roo-project', - configPath: rooProjectConfigPath, - type: serverConfig.type, - }); - return { ...serverConfig, source: 'roo' }; - } - } - } - - // ========================================================================= - // Antigravity source (Priority 12) - // ========================================================================= - - // Priority 12: Antigravity user-scope (~/.gemini/antigravity/mcp_config.json) - const antigravityConfigPath = getAntigravityUserMcpConfigPath(); - const antigravityConfig = readAntigravityMcpConfig(antigravityConfigPath); - if (antigravityConfig?.[serverId]) { - const serverConfig = normalizeServerConfig(antigravityConfig[serverId]); - if (serverConfig) { - log('INFO', 'Retrieved MCP server configuration from Antigravity', { - serverId, - scope: 'antigravity-user', - configPath: antigravityConfigPath, - type: serverConfig.type, - }); - return { ...serverConfig, source: 'antigravity' }; - } - } - - // ========================================================================= - // Cursor source (Priority 13) - // ========================================================================= - - // Priority 13: Cursor user-scope (~/.cursor/mcp.json) - const cursorConfigPath = getCursorUserMcpConfigPath(); - const cursorConfig = readCursorMcpConfig(cursorConfigPath); - if (cursorConfig?.[serverId]) { - const serverConfig = normalizeServerConfig(cursorConfig[serverId]); - if (serverConfig) { - log('INFO', 'Retrieved MCP server configuration from Cursor', { - serverId, - scope: 'cursor-user', - configPath: cursorConfigPath, - type: serverConfig.type, - }); - return { ...serverConfig, source: 'cursor' }; - } - } - - // Server not found in any configuration - log( - 'WARN', - 'MCP server not found in any configuration (Claude, Copilot, Codex, Gemini, Roo Code, Antigravity, Cursor)', - { - serverId, - workspacePath, - } - ); - - return null; - } catch (error) { - log('ERROR', 'Failed to get MCP server configuration', { - serverId, - workspacePath, - error: error instanceof Error ? error.message : String(error), - }); - - return null; - } -} - -/** - * Get all MCP server IDs from all configuration sources (Claude, Copilot, Codex, Gemini, Roo Code) - * - * @param workspacePath - Optional workspace path for project-scoped servers - * @returns Array of unique server IDs - */ -export function getAllMcpServerIds(workspacePath?: string): string[] { - try { - const serverIds = new Set(); - - // ========================================================================= - // Claude Code sources - // ========================================================================= - - // Collect from project-scope .mcp.json (/.mcp.json) - if (workspacePath) { - const projectMcpConfig = readMcpConfig(getProjectMcpConfigPath(workspacePath)); - if (projectMcpConfig?.mcpServers) { - for (const id of Object.keys(projectMcpConfig.mcpServers)) { - serverIds.add(id); - } - } - } - - // Collect from user-level ~/.mcp.json - const userMcpConfig = readMcpConfig(getUserMcpConfigPath()); - if (userMcpConfig?.mcpServers) { - for (const id of Object.keys(userMcpConfig.mcpServers)) { - serverIds.add(id); - } - } - - // Collect from .claude.json (legacy) - const legacyConfig = readLegacyClaudeConfig(); - if (legacyConfig) { - // Local scope (project-specific) - if (workspacePath) { - const projectsConfig = legacyConfig.projects as - | Record }> - | undefined; - const localConfig = projectsConfig?.[workspacePath]; - if (localConfig?.mcpServers) { - for (const id of Object.keys(localConfig.mcpServers)) { - serverIds.add(id); - } - } - } - - // User scope (top-level) - if (legacyConfig.mcpServers) { - for (const id of Object.keys(legacyConfig.mcpServers)) { - serverIds.add(id); - } - } - } - - // ========================================================================= - // Copilot sources - // ========================================================================= - - // Collect from VSCode Copilot (.vscode/mcp.json) - if (workspacePath) { - const vscodeMcpConfigPath = getVSCodeMcpConfigPath(); - if (vscodeMcpConfigPath) { - const vscodeConfig = readVSCodeMcpConfig(vscodeMcpConfigPath); - if (vscodeConfig) { - for (const id of Object.keys(vscodeConfig)) { - serverIds.add(id); - } - } - } - } - - // Collect from Copilot CLI user-scope (~/.copilot/mcp-config.json) - // Note: Copilot CLI only supports user-scope MCP configuration (no project-scope) - const copilotUserConfig = readCopilotMcpConfig(getCopilotUserMcpConfigPath()); - if (copilotUserConfig) { - for (const id of Object.keys(copilotUserConfig)) { - serverIds.add(id); - } - } - - // ========================================================================= - // Codex source - // ========================================================================= - - // Collect from Codex CLI user-scope (~/.codex/config.toml) - const codexConfig = readCodexMcpConfig(getCodexUserMcpConfigPath()); - if (codexConfig) { - for (const id of Object.keys(codexConfig)) { - serverIds.add(id); - } - } - - // ========================================================================= - // Gemini source - // ========================================================================= - - // Collect from Gemini CLI user-scope (~/.gemini/settings.json) - const geminiUserConfig = readGeminiMcpConfig(getGeminiUserMcpConfigPath()); - if (geminiUserConfig) { - for (const id of Object.keys(geminiUserConfig)) { - serverIds.add(id); - } - } - - // Collect from Gemini CLI project-scope (.gemini/settings.json) - const geminiProjectConfigPath = getGeminiProjectMcpConfigPath(); - if (geminiProjectConfigPath) { - const geminiProjectConfig = readGeminiMcpConfig(geminiProjectConfigPath); - if (geminiProjectConfig) { - for (const id of Object.keys(geminiProjectConfig)) { - serverIds.add(id); - } - } - } - - // ========================================================================= - // Roo Code source - // ========================================================================= - - // Collect from Roo Code project-scope (.roo/mcp.json) - const rooProjectConfigPath = getRooProjectMcpConfigPath(); - if (rooProjectConfigPath) { - const rooProjectConfig = readRooMcpConfig(rooProjectConfigPath); - if (rooProjectConfig) { - for (const id of Object.keys(rooProjectConfig)) { - serverIds.add(id); - } - } - } - - // ========================================================================= - // Antigravity source - // ========================================================================= - - // Collect from Antigravity user-scope (~/.gemini/antigravity/mcp_config.json) - const antigravityConfig = readAntigravityMcpConfig(getAntigravityUserMcpConfigPath()); - if (antigravityConfig) { - for (const id of Object.keys(antigravityConfig)) { - serverIds.add(id); - } - } - - // ========================================================================= - // Cursor source - // ========================================================================= - - // Collect from Cursor user-scope (~/.cursor/mcp.json) - const cursorConfig = readCursorMcpConfig(getCursorUserMcpConfigPath()); - if (cursorConfig) { - for (const id of Object.keys(cursorConfig)) { - serverIds.add(id); - } - } - - return Array.from(serverIds); - } catch (error) { - log('ERROR', 'Failed to get MCP server list', { - error: error instanceof Error ? error.message : String(error), - }); - - return []; - } -} - -/** - * MCP server with source tracking - */ -export interface McpServerWithSource extends McpServerConfig { - /** Server identifier */ - id: string; - /** Source provider */ - source: McpConfigSource; - /** Path to the config file this server was read from */ - configPath: string; -} - -/** - * Scan all MCP server configurations from all sources - * - * This function scans MCP server configurations from all supported AI coding tools: - * - Claude Code (.mcp.json, .claude.json) - * - VSCode Copilot (.vscode/mcp.json) - * - Copilot CLI (.copilot/mcp-config.json) - * - Codex CLI (~/.codex/config.toml) - * - Gemini CLI (~/.gemini/settings.json, .gemini/settings.json) - * - Roo Code (.roo/mcp.json) - * - Antigravity (~/.gemini/antigravity/mcp_config.json) - * - Cursor (~/.cursor/mcp.json) - * - * Priority order (first match wins for duplicate server IDs): - * 1. Project-scope Claude Code (/.mcp.json) - * 2. Project-scope VSCode Copilot (/.vscode/mcp.json) - * 3. Project-scope Copilot CLI (/.copilot/mcp-config.json) - * 4. User-scope Claude Code (~/.mcp.json) - * 5. Legacy Claude Code project (~/.claude.json → projects[workspace].mcpServers) - * 6. Legacy Claude Code user (~/.claude.json → mcpServers) - * 7. User-scope Copilot CLI (~/.copilot/mcp-config.json) - * 8. User-scope Codex CLI (~/.codex/config.toml) - * 9. User-scope Gemini CLI (~/.gemini/settings.json) - * 10. Project-scope Gemini CLI (/.gemini/settings.json) - * 11. Project-scope Roo Code (/.roo/mcp.json) - * 12. User-scope Antigravity (~/.gemini/antigravity/mcp_config.json) - * 13. User-scope Cursor (~/.cursor/mcp.json) - * - * @param workspacePath - Optional workspace path for project-scoped servers - * @returns Array of MCP server configurations with source metadata - */ -export function getAllMcpServersWithSource(workspacePath?: string): McpServerWithSource[] { - const servers: McpServerWithSource[] = []; - // Use source:id combination as unique key to allow same server ID from different sources - const seenServerKeys = new Set(); - - /** - * Helper to create unique key from source and id - */ - function getServerKey(source: McpConfigSource, serverId: string): string { - return `${source}:${serverId}`; - } - - /** - * Helper to add servers if not already seen (same source + id combination) - */ - function addServers( - configServers: Record | null, - source: McpConfigSource, - configPath: string - ): void { - if (!configServers) return; - - for (const [serverId, config] of Object.entries(configServers)) { - const key = getServerKey(source, serverId); - if (seenServerKeys.has(key)) { - log('INFO', 'Skipping duplicate MCP server (already found in same source)', { - serverId, - source, - skippedConfigPath: configPath, - }); - continue; - } - - const normalized = normalizeServerConfig(config); - if (normalized) { - seenServerKeys.add(key); - servers.push({ - ...normalized, - id: serverId, - source, - configPath, - }); - } - } - } - - try { - // ========================================================================= - // Project-scope sources (workspace-specific) - // ========================================================================= - - if (workspacePath) { - // Priority 1: Claude Code project-scope (.mcp.json) - const projectMcpConfigPath = getProjectMcpConfigPath(workspacePath); - const projectMcpConfig = readMcpConfig(projectMcpConfigPath); - addServers(projectMcpConfig?.mcpServers ?? null, 'claude', projectMcpConfigPath); - - // Priority 2: VSCode Copilot (.vscode/mcp.json) - const vscodeMcpConfigPath = getVSCodeMcpConfigPath(); - if (vscodeMcpConfigPath) { - const vscodeConfig = readVSCodeMcpConfig(vscodeMcpConfigPath); - addServers(vscodeConfig, 'copilot', vscodeMcpConfigPath); - } - - // Note: Copilot CLI project-scope (.copilot/mcp-config.json) is NOT supported - } - - // ========================================================================= - // User-scope sources (global) - // ========================================================================= - - // Priority 3: Claude Code user-scope (~/.mcp.json) - const userMcpConfigPath = getUserMcpConfigPath(); - const userMcpConfig = readMcpConfig(userMcpConfigPath); - addServers(userMcpConfig?.mcpServers ?? null, 'claude', userMcpConfigPath); - - // Priority 4 & 5: Legacy Claude Code (.claude.json) - const legacyConfig = readLegacyClaudeConfig(); - if (legacyConfig) { - const legacyConfigPath = path.join(os.homedir(), '.claude.json'); - - // Priority 4: Legacy project-scope - if (workspacePath) { - const projectsConfig = legacyConfig.projects as - | Record }> - | undefined; - const localConfig = projectsConfig?.[workspacePath]; - addServers(localConfig?.mcpServers ?? null, 'claude', legacyConfigPath); - } - - // Priority 5: Legacy user-scope - addServers(legacyConfig.mcpServers ?? null, 'claude', legacyConfigPath); - } - - // Priority 6: Copilot CLI user-scope (~/.copilot/mcp-config.json) - // Note: Copilot CLI only supports user-scope MCP configuration (no project-scope) - const copilotUserConfigPath = getCopilotUserMcpConfigPath(); - const copilotUserConfig = readCopilotMcpConfig(copilotUserConfigPath); - addServers(copilotUserConfig, 'copilot', copilotUserConfigPath); - - // Priority 7: Codex CLI user-scope (~/.codex/config.toml) - const codexConfigPath = getCodexUserMcpConfigPath(); - const codexConfig = readCodexMcpConfig(codexConfigPath); - addServers(codexConfig, 'codex', codexConfigPath); - - // Priority 8: Gemini CLI user-scope (~/.gemini/settings.json) - const geminiUserConfigPath = getGeminiUserMcpConfigPath(); - const geminiUserConfig = readGeminiMcpConfig(geminiUserConfigPath); - addServers(geminiUserConfig, 'gemini', geminiUserConfigPath); - - // Priority 9: Gemini CLI project-scope (.gemini/settings.json) - if (workspacePath) { - const geminiProjectConfigPath = getGeminiProjectMcpConfigPath(); - if (geminiProjectConfigPath) { - const geminiProjectConfig = readGeminiMcpConfig(geminiProjectConfigPath); - addServers(geminiProjectConfig, 'gemini', geminiProjectConfigPath); - } - } - - // Priority 10: Roo Code project-scope (.roo/mcp.json) - const rooProjectConfigPath = getRooProjectMcpConfigPath(); - if (rooProjectConfigPath) { - const rooProjectConfig = readRooMcpConfig(rooProjectConfigPath); - addServers(rooProjectConfig, 'roo', rooProjectConfigPath); - } - - // Priority 11: Antigravity user-scope (~/.gemini/antigravity/mcp_config.json) - const antigravityConfigPath = getAntigravityUserMcpConfigPath(); - const antigravityConfig = readAntigravityMcpConfig(antigravityConfigPath); - addServers(antigravityConfig, 'antigravity', antigravityConfigPath); - - // Priority 12: Cursor user-scope (~/.cursor/mcp.json) - const cursorConfigPath = getCursorUserMcpConfigPath(); - const cursorConfig = readCursorMcpConfig(cursorConfigPath); - addServers(cursorConfig, 'cursor', cursorConfigPath); - - log('INFO', 'Scanned all MCP server sources', { - totalServers: servers.length, - claudeCount: servers.filter((s) => s.source === 'claude').length, - copilotCount: servers.filter((s) => s.source === 'copilot').length, - codexCount: servers.filter((s) => s.source === 'codex').length, - geminiCount: servers.filter((s) => s.source === 'gemini').length, - rooCount: servers.filter((s) => s.source === 'roo').length, - antigravityCount: servers.filter((s) => s.source === 'antigravity').length, - cursorCount: servers.filter((s) => s.source === 'cursor').length, - }); - - return servers; - } catch (error) { - log('ERROR', 'Failed to scan all MCP server sources', { - error: error instanceof Error ? error.message : String(error), - }); - return []; - } -} diff --git a/src/extension/services/mcp-sdk-client.ts b/src/extension/services/mcp-sdk-client.ts deleted file mode 100644 index dedf16e4..00000000 --- a/src/extension/services/mcp-sdk-client.ts +++ /dev/null @@ -1,313 +0,0 @@ -/** - * MCP SDK Client Service - * - * Feature: 001-mcp-node - * Purpose: Connect to MCP servers using @modelcontextprotocol/sdk and retrieve tools - * - * This service provides direct connection to MCP servers instead of using Claude Code CLI, - * allowing us to retrieve tool lists directly from the MCP protocol. - */ - -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import type { McpToolReference, ToolParameter } from '../../shared/types/mcp-node'; -import { log } from '../extension'; - -/** - * Connect to an MCP server using stdio transport - * - * @param command - Command to execute (e.g., "npx") - * @param args - Command arguments (e.g., ["mcp-remote", "https://..."]) - * @param env - Environment variables - * @param timeoutMs - Connection timeout in milliseconds (default: 5000) - * @returns Connected MCP client - */ -export async function connectToMcpServer( - command: string, - args: string[], - env: Record, - timeoutMs = 5000 -): Promise { - log('INFO', 'Connecting to MCP server via SDK', { - command, - args, - timeoutMs, - }); - - const transport = new StdioClientTransport({ - command, - args, - env: { - ...(Object.fromEntries( - Object.entries(process.env).filter(([_, v]) => v !== undefined) - ) as Record), - ...env, - }, - }); - - const client = new Client( - { - name: 'cc-workflow-studio', - version: '1.0.0', - }, - { - capabilities: {}, - } - ); - - // Create timeout promise - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error(`MCP server connection timeout after ${timeoutMs}ms`)); - }, timeoutMs); - }); - - try { - // Race between connection and timeout - await Promise.race([client.connect(transport), timeoutPromise]); - - log('INFO', 'Successfully connected to MCP server', { - command, - args, - }); - - return client; - } catch (error) { - log('ERROR', 'Failed to connect to MCP server', { - command, - args, - error: error instanceof Error ? error.message : String(error), - }); - - throw error; - } -} - -/** - * Convert MCP input schema to ToolParameter array - * - * @param inputSchema - MCP tool input schema (JSON Schema format) - * @returns Array of tool parameters - */ -function convertJsonSchemaToToolParameters(inputSchema: { - type?: string; - properties?: Record; - required?: string[]; -}): ToolParameter[] { - if (!inputSchema.properties) { - return []; - } - - const required = inputSchema.required || []; - - return Object.entries(inputSchema.properties).map(([name, schema]) => { - const paramSchema = schema as { - type?: string; - description?: string; - enum?: unknown[]; - default?: unknown; - }; - - // Map JSON Schema type to ToolParameter type - let paramType: 'string' | 'number' | 'boolean' | 'integer' | 'array' | 'object' = 'string'; - const schemaType = paramSchema.type || 'string'; - - if ( - schemaType === 'string' || - schemaType === 'number' || - schemaType === 'boolean' || - schemaType === 'integer' || - schemaType === 'array' || - schemaType === 'object' - ) { - paramType = schemaType; - } - - return { - name, - type: paramType, - description: paramSchema.description || '', - required: required.includes(name), - }; - }); -} - -/** - * List all tools available from a specific MCP server using SDK - * - * @param serverId - Server identifier - * @param command - Command to execute - * @param args - Command arguments - * @param env - Environment variables - * @returns List of available tools - */ -export async function listToolsFromMcpServer( - serverId: string, - command: string, - args: string[], - env: Record -): Promise { - const startTime = Date.now(); - - log('INFO', 'Listing tools from MCP server via SDK', { - serverId, - command, - args, - }); - - let client: Client | null = null; - - try { - // Connect to MCP server - client = await connectToMcpServer(command, args, env); - - // List tools using MCP protocol - const response = await client.listTools(); - - log('INFO', 'Successfully retrieved tools from MCP server', { - serverId, - toolCount: response.tools.length, - executionTimeMs: Date.now() - startTime, - }); - - // Convert MCP tools to our internal format - return response.tools.map((tool) => ({ - serverId, - name: tool.name, - description: tool.description || '', - parameters: tool.inputSchema ? convertJsonSchemaToToolParameters(tool.inputSchema) : [], - })); - } catch (error) { - log('ERROR', 'Failed to list tools from MCP server', { - serverId, - command, - args, - error: error instanceof Error ? error.message : String(error), - executionTimeMs: Date.now() - startTime, - }); - - throw error; - } finally { - // Always close the client connection - if (client) { - try { - await client.close(); - log('INFO', 'Closed MCP server connection', { serverId }); - } catch (closeError) { - log('WARN', 'Failed to close MCP server connection', { - serverId, - error: closeError instanceof Error ? closeError.message : String(closeError), - }); - } - } - } -} - -/** - * Connect to an MCP server using HTTP (Streamable HTTP) transport - * - * @param url - Server URL (e.g., "http://localhost:3000/mcp") - * @param timeoutMs - Connection timeout in milliseconds (default: 5000) - * @returns Connected MCP client - */ -export async function connectToMcpServerHttp(url: string, timeoutMs = 5000): Promise { - log('INFO', 'Connecting to MCP server via HTTP transport', { - url, - timeoutMs, - }); - - const transport = new StreamableHTTPClientTransport(new URL(url)); - - const client = new Client( - { - name: 'cc-workflow-studio', - version: '1.0.0', - }, - { - capabilities: {}, - } - ); - - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error(`MCP server HTTP connection timeout after ${timeoutMs}ms`)); - }, timeoutMs); - }); - - try { - await Promise.race([client.connect(transport), timeoutPromise]); - - log('INFO', 'Successfully connected to MCP server via HTTP', { url }); - - return client; - } catch (error) { - log('ERROR', 'Failed to connect to MCP server via HTTP', { - url, - error: error instanceof Error ? error.message : String(error), - }); - - throw error; - } -} - -/** - * List all tools available from a specific MCP server using HTTP transport - * - * @param serverId - Server identifier - * @param url - Server URL - * @returns List of available tools - */ -export async function listToolsFromMcpServerHttp( - serverId: string, - url: string -): Promise { - const startTime = Date.now(); - - log('INFO', 'Listing tools from MCP server via HTTP', { - serverId, - url, - }); - - let client: Client | null = null; - - try { - client = await connectToMcpServerHttp(url); - - const response = await client.listTools(); - - log('INFO', 'Successfully retrieved tools from MCP server via HTTP', { - serverId, - toolCount: response.tools.length, - executionTimeMs: Date.now() - startTime, - }); - - return response.tools.map((tool) => ({ - serverId, - name: tool.name, - description: tool.description || '', - parameters: tool.inputSchema ? convertJsonSchemaToToolParameters(tool.inputSchema) : [], - })); - } catch (error) { - log('ERROR', 'Failed to list tools from MCP server via HTTP', { - serverId, - url, - error: error instanceof Error ? error.message : String(error), - executionTimeMs: Date.now() - startTime, - }); - - throw error; - } finally { - if (client) { - try { - await client.close(); - log('INFO', 'Closed MCP server HTTP connection', { serverId }); - } catch (closeError) { - log('WARN', 'Failed to close MCP server HTTP connection', { - serverId, - error: closeError instanceof Error ? closeError.message : String(closeError), - }); - } - } - } -} diff --git a/src/extension/services/mcp-server-config-writer.ts b/src/extension/services/mcp-server-config-writer.ts deleted file mode 100644 index a732d69a..00000000 --- a/src/extension/services/mcp-server-config-writer.ts +++ /dev/null @@ -1,307 +0,0 @@ -/** - * CC Workflow Studio - MCP Server Config Writer - * - * Writes the built-in MCP server URL to various AI agent configuration files - * so that external agents can discover and connect to the MCP server. - * - * Supported targets: - * - Claude Code: {workspace}/.mcp.json - * - Roo Code: {workspace}/.roo/mcp.json - * - VSCode Copilot: {workspace}/.vscode/mcp.json - * - Copilot CLI: ~/.copilot/mcp-config.json (global) - * - Codex CLI: ~/.codex/config.toml (global) - * - Antigravity: ~/.gemini/antigravity/mcp_config.json (global) - * - Cursor: ~/.cursor/mcp.json (global) - */ - -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import * as TOML from 'smol-toml'; -import type { AiEditingProvider, McpConfigTarget } from '../../shared/types/messages'; -import { log } from '../extension'; - -const SERVER_ENTRY_NAME = 'cc-workflow-studio'; - -interface JsonMcpConfig { - mcpServers?: Record; - servers?: Record; - [key: string]: unknown; -} - -interface CodexConfig { - mcp_servers?: Record; - [key: string]: unknown; -} - -/** - * Get the config file path for a given target - */ -function getConfigPath(target: McpConfigTarget, workspacePath: string): string { - switch (target) { - case 'claude-code': - return path.join(workspacePath, '.mcp.json'); - case 'roo-code': - return path.join(workspacePath, '.roo', 'mcp.json'); - case 'copilot-chat': - return path.join(workspacePath, '.vscode', 'mcp.json'); - case 'copilot-cli': - return path.join(os.homedir(), '.copilot', 'mcp-config.json'); - case 'codex': - return path.join(os.homedir(), '.codex', 'config.toml'); - case 'gemini': - return path.join(os.homedir(), '.gemini', 'settings.json'); - case 'antigravity': - return path.join(os.homedir(), '.gemini', 'antigravity', 'mcp_config.json'); - case 'cursor': - return path.join(os.homedir(), '.cursor', 'mcp.json'); - } -} - -/** - * Read a JSON config file, returning empty object on failure - */ -async function readJsonConfig(filePath: string): Promise { - try { - const content = await fs.readFile(filePath, 'utf-8'); - return JSON.parse(content) as JsonMcpConfig; - } catch { - return {}; - } -} - -/** - * Write a JSON config file, creating directories as needed - */ -async function writeJsonConfig(filePath: string, config: JsonMcpConfig): Promise { - const dir = path.dirname(filePath); - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(filePath, `${JSON.stringify(config, null, 2)}\n`); -} - -/** - * Read Codex TOML config, returning empty object on failure - */ -async function readCodexConfig(): Promise { - const configPath = path.join(os.homedir(), '.codex', 'config.toml'); - try { - const content = await fs.readFile(configPath, 'utf-8'); - return TOML.parse(content) as CodexConfig; - } catch { - return {}; - } -} - -/** - * Write Codex TOML config - */ -async function writeCodexConfig(config: CodexConfig): Promise { - const configPath = path.join(os.homedir(), '.codex', 'config.toml'); - const dir = path.dirname(configPath); - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(configPath, TOML.stringify(config)); -} - -/** - * Write MCP server URL to a specific AI agent config file - */ -export async function writeAgentConfig( - target: McpConfigTarget, - serverUrl: string, - workspacePath: string -): Promise { - try { - if (target === 'codex') { - const config = await readCodexConfig(); - if (!config.mcp_servers) { - config.mcp_servers = {}; - } - config.mcp_servers[SERVER_ENTRY_NAME] = { url: serverUrl }; - await writeCodexConfig(config); - } else if (target === 'gemini') { - // Gemini CLI uses JSON format with "mcpServers" key - const filePath = getConfigPath(target, workspacePath); - const config = await readJsonConfig(filePath); - if (!config.mcpServers) { - config.mcpServers = {}; - } - config.mcpServers[SERVER_ENTRY_NAME] = { url: serverUrl }; - await writeJsonConfig(filePath, config); - } else if (target === 'antigravity') { - // Antigravity uses "mcpServers" key with { serverUrl: "..." } format - // in ~/.gemini/antigravity/mcp_config.json - const filePath = getConfigPath(target, workspacePath); - const config = await readJsonConfig(filePath); - if (!config.mcpServers) { - config.mcpServers = {}; - } - config.mcpServers[SERVER_ENTRY_NAME] = { serverUrl }; - await writeJsonConfig(filePath, config); - } else if (target === 'copilot-chat') { - // VSCode Copilot uses "servers" key with type "http" - const filePath = getConfigPath(target, workspacePath); - const config = await readJsonConfig(filePath); - if (!config.servers) { - config.servers = {}; - } - config.servers[SERVER_ENTRY_NAME] = { type: 'http', url: serverUrl }; - await writeJsonConfig(filePath, config); - } else if (target === 'copilot-cli') { - // Copilot CLI uses "mcpServers" key with tools: ["*"] (global config) - const filePath = getConfigPath(target, workspacePath); - const config = await readJsonConfig(filePath); - if (!config.mcpServers) { - config.mcpServers = {}; - } - config.mcpServers[SERVER_ENTRY_NAME] = { type: 'http', url: serverUrl, tools: ['*'] }; - await writeJsonConfig(filePath, config); - } else if (target === 'roo-code') { - // Roo Code uses "mcpServers" key with type "streamable-http" - const filePath = getConfigPath(target, workspacePath); - const config = await readJsonConfig(filePath); - if (!config.mcpServers) { - config.mcpServers = {}; - } - config.mcpServers[SERVER_ENTRY_NAME] = { type: 'streamable-http', url: serverUrl }; - await writeJsonConfig(filePath, config); - } else { - // Claude Code uses "mcpServers" key with type "http" - const filePath = getConfigPath(target, workspacePath); - const config = await readJsonConfig(filePath); - if (!config.mcpServers) { - config.mcpServers = {}; - } - config.mcpServers[SERVER_ENTRY_NAME] = { type: 'http', url: serverUrl }; - await writeJsonConfig(filePath, config); - } - - log('INFO', `MCP Config Writer: Wrote config for ${target}`, { serverUrl }); - } catch (error) { - log('ERROR', `MCP Config Writer: Failed to write config for ${target}`, { - error: error instanceof Error ? error.message : String(error), - }); - throw error; - } -} - -/** - * Remove MCP server entry from a specific AI agent config file - */ -export async function removeAgentConfig( - target: McpConfigTarget, - workspacePath: string -): Promise { - try { - if (target === 'codex') { - const config = await readCodexConfig(); - if (config.mcp_servers?.[SERVER_ENTRY_NAME]) { - delete config.mcp_servers[SERVER_ENTRY_NAME]; - await writeCodexConfig(config); - } - } else if (target === 'gemini') { - // Gemini CLI uses JSON format with "mcpServers" key - const filePath = getConfigPath(target, workspacePath); - const config = await readJsonConfig(filePath); - if (config.mcpServers?.[SERVER_ENTRY_NAME]) { - delete config.mcpServers[SERVER_ENTRY_NAME]; - await writeJsonConfig(filePath, config); - } - } else if (target === 'antigravity') { - // Antigravity uses "mcpServers" key - const filePath = getConfigPath(target, workspacePath); - const config = await readJsonConfig(filePath); - if (config.mcpServers?.[SERVER_ENTRY_NAME]) { - delete config.mcpServers[SERVER_ENTRY_NAME]; - await writeJsonConfig(filePath, config); - } - } else if (target === 'copilot-chat') { - const filePath = getConfigPath(target, workspacePath); - const config = await readJsonConfig(filePath); - if (config.servers?.[SERVER_ENTRY_NAME]) { - delete config.servers[SERVER_ENTRY_NAME]; - await writeJsonConfig(filePath, config); - } - } else { - // claude-code, copilot-cli, roo-code all use "mcpServers" key - const filePath = getConfigPath(target, workspacePath); - const config = await readJsonConfig(filePath); - if (config.mcpServers?.[SERVER_ENTRY_NAME]) { - delete config.mcpServers[SERVER_ENTRY_NAME]; - await writeJsonConfig(filePath, config); - } - } - - log('INFO', `MCP Config Writer: Removed config for ${target}`); - } catch (error) { - // Best-effort removal, log but don't throw - log('WARN', `MCP Config Writer: Failed to remove config for ${target}`, { - error: error instanceof Error ? error.message : String(error), - }); - } -} - -/** - * Write MCP server URL to all specified targets - */ -export async function writeAllAgentConfigs( - targets: McpConfigTarget[], - serverUrl: string, - workspacePath: string -): Promise { - const written: McpConfigTarget[] = []; - - for (const target of targets) { - try { - await writeAgentConfig(target, serverUrl, workspacePath); - written.push(target); - } catch { - // Continue with other targets - } - } - - return written; -} - -/** - * Get the config targets required for a given AI editing provider - */ -export function getConfigTargetsForProvider(provider: AiEditingProvider): McpConfigTarget[] { - switch (provider) { - case 'claude-code': - return ['claude-code']; - case 'copilot-cli': - return ['copilot-cli']; - case 'copilot-chat': - return ['copilot-chat']; - case 'codex': - return ['codex']; - case 'roo-code': - return ['roo-code']; - case 'gemini': - return ['gemini']; - case 'antigravity': - return ['antigravity']; - case 'cursor': - return ['cursor']; - } -} - -/** - * Remove MCP server entry from all agent config files (best-effort) - */ -export async function removeAllAgentConfigs(workspacePath: string): Promise { - const allTargets: McpConfigTarget[] = [ - 'claude-code', - 'roo-code', - 'copilot-chat', - 'copilot-cli', - 'codex', - 'gemini', - 'antigravity', - 'cursor', - ]; - - for (const target of allTargets) { - await removeAgentConfig(target, workspacePath); - } -} diff --git a/src/extension/services/mcp-server-service.ts b/src/extension/services/mcp-server-service.ts deleted file mode 100644 index c7627075..00000000 --- a/src/extension/services/mcp-server-service.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * CC Workflow Studio - Built-in MCP Server Manager - * - * Provides an MCP server that external AI agents (Claude Code, Roo Code, Copilot, Codex, etc.) - * can connect to for workflow CRUD operations. - * - * Architecture: - * - HTTP server on 127.0.0.1 (localhost only) with dynamic port - * - StreamableHTTPServerTransport in stateless mode (no session management) - * - Webview communication via postMessage for workflow data - * - lastKnownWorkflow cache for when Webview is closed - */ - -import * as http from 'node:http'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import type * as vscode from 'vscode'; -import type { - AiEditingProvider, - ApplyWorkflowFromMcpResponsePayload, - GetCurrentWorkflowResponsePayload, - McpConfigTarget, -} from '../../shared/types/messages'; -import type { Workflow } from '../../shared/types/workflow-definition'; -import { log } from '../extension'; -import { registerMcpTools } from './mcp-server-tools'; - -const REQUEST_TIMEOUT_MS = 10000; -const APPLY_WITH_REVIEW_TIMEOUT_MS = 120000; - -interface PendingRequest { - resolve: (value: T) => void; - reject: (reason: Error) => void; - timer: ReturnType; -} - -export class McpServerManager { - private httpServer: http.Server | null = null; - private port: number | null = null; - private lastKnownWorkflow: Workflow | null = null; - private webview: vscode.Webview | null = null; - private extensionPath: string | null = null; - private writtenConfigs = new Set(); - private currentProvider: AiEditingProvider | null = null; - private reviewBeforeApply = true; - - private pendingWorkflowRequests = new Map< - string, - PendingRequest<{ workflow: Workflow | null; isStale: boolean }> - >(); - private pendingApplyRequests = new Map>(); - - async start(extensionPath: string): Promise { - if (this.httpServer) { - throw new Error('MCP server is already running'); - } - - this.extensionPath = extensionPath; - - // Create HTTP server - this.httpServer = http.createServer(async (req, res) => { - const url = new URL(req.url || '/', `http://${req.headers.host}`); - - if (url.pathname !== '/mcp') { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Not found' })); - return; - } - - // Handle MCP requests - if (req.method === 'POST' || req.method === 'GET' || req.method === 'DELETE') { - let mcpServer: McpServer | undefined; - try { - // Create a new MCP server + transport per request (stateless mode) - // McpServer.connect() can only be called once per instance, - // so we must create a fresh instance for each request. - mcpServer = new McpServer({ - name: 'cc-workflow-studio', - version: '1.0.0', - }); - registerMcpTools(mcpServer, this); - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, // stateless - }); - - await mcpServer.connect(transport); - await transport.handleRequest(req, res); - } catch (error) { - log('ERROR', 'MCP Server: Failed to handle request', { - method: req.method, - error: error instanceof Error ? error.message : String(error), - }); - if (!res.headersSent) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Internal server error' })); - } - } finally { - // Clean up to prevent EventEmitter listener accumulation - if (mcpServer) { - await mcpServer.close().catch(() => {}); - } - } - } else { - res.writeHead(405, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Method not allowed' })); - } - }); - - // Start listening on dynamic port, localhost only - const httpServer = this.httpServer; - return new Promise((resolve, reject) => { - httpServer.listen(0, '127.0.0.1', () => { - const address = httpServer.address(); - if (address && typeof address !== 'string') { - this.port = address.port; - log('INFO', `MCP Server: Started on port ${this.port}`); - resolve(this.port); - } else { - reject(new Error('Failed to get server address')); - } - }); - - httpServer.on('error', (error) => { - log('ERROR', 'MCP Server: HTTP server error', { - error: error.message, - }); - reject(error); - }); - }); - } - - async stop(): Promise { - this.writtenConfigs.clear(); - this.currentProvider = null; - - if (this.httpServer) { - const server = this.httpServer; - this.httpServer = null; - this.port = null; - - return new Promise((resolve) => { - // Force close after timeout to prevent hanging - const forceCloseTimer = setTimeout(() => { - log('WARN', 'MCP Server: Force closing after timeout'); - server.closeAllConnections(); - resolve(); - }, 3000); - - server.close(() => { - clearTimeout(forceCloseTimer); - log('INFO', 'MCP Server: Stopped'); - resolve(); - }); - }); - } - - this.port = null; - } - - isRunning(): boolean { - return !!this.httpServer?.listening; - } - - getPort(): number | null { - return this.port; - } - - getExtensionPath(): string | null { - return this.extensionPath; - } - - getWrittenConfigs(): Set { - return this.writtenConfigs; - } - - addWrittenConfigs(targets: McpConfigTarget[]): void { - for (const t of targets) { - this.writtenConfigs.add(t); - } - } - - setCurrentProvider(provider: AiEditingProvider | null): void { - this.currentProvider = provider; - } - - getCurrentProvider(): AiEditingProvider | null { - return this.currentProvider; - } - - setReviewBeforeApply(value: boolean): void { - this.reviewBeforeApply = value; - } - - getReviewBeforeApply(): boolean { - return this.reviewBeforeApply; - } - - // Webview lifecycle - setWebview(webview: vscode.Webview | null): void { - this.webview = webview; - } - - updateWorkflowCache(workflow: Workflow): void { - this.lastKnownWorkflow = workflow; - } - - // Called by MCP tools to get current workflow - async requestCurrentWorkflow(): Promise<{ workflow: Workflow | null; isStale: boolean }> { - // If webview is available, request fresh data - if (this.webview) { - const correlationId = `mcp-get-${Date.now()}-${Math.random()}`; - - return new Promise<{ workflow: Workflow | null; isStale: boolean }>((resolve, reject) => { - const timer = setTimeout(() => { - this.pendingWorkflowRequests.delete(correlationId); - // Fallback to cache on timeout - if (this.lastKnownWorkflow) { - resolve({ workflow: this.lastKnownWorkflow, isStale: true }); - } else { - reject(new Error('Timeout waiting for workflow from Webview')); - } - }, REQUEST_TIMEOUT_MS); - - this.pendingWorkflowRequests.set(correlationId, { resolve, reject, timer }); - - this.webview?.postMessage({ - type: 'GET_CURRENT_WORKFLOW_REQUEST', - payload: { correlationId }, - }); - }); - } - - // Webview is closed, return cached workflow - if (this.lastKnownWorkflow) { - return { workflow: this.lastKnownWorkflow, isStale: true }; - } - - return { workflow: null, isStale: false }; - } - - // Called by MCP tools to apply workflow to canvas - async applyWorkflowToCanvas(workflow: Workflow, description?: string): Promise { - if (!this.webview) { - throw new Error('Webview is not open. Please open CC Workflow Studio first.'); - } - - const requireConfirmation = this.reviewBeforeApply; - const timeoutMs = requireConfirmation ? APPLY_WITH_REVIEW_TIMEOUT_MS : REQUEST_TIMEOUT_MS; - const correlationId = `mcp-apply-${Date.now()}-${Math.random()}`; - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pendingApplyRequests.delete(correlationId); - reject(new Error('Timeout waiting for workflow apply confirmation')); - }, timeoutMs); - - this.pendingApplyRequests.set(correlationId, { resolve, reject, timer }); - - this.webview?.postMessage({ - type: 'APPLY_WORKFLOW_FROM_MCP', - payload: { correlationId, workflow, requireConfirmation, description }, - }); - }); - } - - // Response handlers called from open-editor.ts - handleWorkflowResponse(payload: GetCurrentWorkflowResponsePayload): void { - const pending = this.pendingWorkflowRequests.get(payload.correlationId); - if (pending) { - clearTimeout(pending.timer); - this.pendingWorkflowRequests.delete(payload.correlationId); - - // Update cache - if (payload.workflow) { - this.lastKnownWorkflow = payload.workflow; - } - - pending.resolve({ workflow: payload.workflow, isStale: false }); - } - } - - handleApplyResponse(payload: ApplyWorkflowFromMcpResponsePayload): void { - const pending = this.pendingApplyRequests.get(payload.correlationId); - if (pending) { - clearTimeout(pending.timer); - this.pendingApplyRequests.delete(payload.correlationId); - - if (payload.success) { - pending.resolve(true); - } else { - pending.reject(new Error(payload.error || 'Failed to apply workflow')); - } - } - } -} diff --git a/src/extension/services/mcp-server-tools.ts b/src/extension/services/mcp-server-tools.ts deleted file mode 100644 index 75e9898b..00000000 --- a/src/extension/services/mcp-server-tools.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * CC Workflow Studio - MCP Server Tool Definitions - * - * Registers tools on the built-in MCP server that external AI agents - * can call to interact with the workflow editor. - * - * Tools: - * - get_current_workflow: Get the currently active workflow from the canvas - * - get_workflow_schema: Get the workflow JSON schema for generating valid workflows - * - apply_workflow: Apply a workflow to the canvas (validates first) - */ - -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { validateAIGeneratedWorkflow } from '../utils/validate-workflow'; -import type { McpServerManager } from './mcp-server-service'; -import { getDefaultSchemaPath, loadWorkflowSchemaToon } from './schema-loader-service'; - -export function registerMcpTools(server: McpServer, manager: McpServerManager): void { - // Tool 1: get_current_workflow - server.tool( - 'get_current_workflow', - 'Get the currently active workflow from CC Workflow Studio canvas. Returns the workflow JSON and whether it is stale (from cache when the editor is closed).', - {}, - async () => { - try { - const result = await manager.requestCurrentWorkflow(); - - if (!result.workflow) { - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ - success: false, - error: 'No active workflow. Please open a workflow in CC Workflow Studio first.', - }), - }, - ], - }; - } - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ - success: true, - isStale: result.isStale, - workflow: result.workflow, - }), - }, - ], - }; - } catch (error) { - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ - success: false, - error: error instanceof Error ? error.message : String(error), - }), - }, - ], - isError: true, - }; - } - } - ); - - // Tool 2: get_workflow_schema - server.tool( - 'get_workflow_schema', - 'Get the workflow schema documentation in optimized TOON format. Use this to understand the valid structure for creating or modifying workflows.', - {}, - async () => { - try { - const extensionPath = manager.getExtensionPath(); - if (!extensionPath) { - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ - success: false, - error: 'Extension path not available', - }), - }, - ], - isError: true, - }; - } - - const schemaPath = getDefaultSchemaPath(extensionPath); - const result = await loadWorkflowSchemaToon(schemaPath); - - if (!result.success || !result.schemaString) { - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ - success: false, - error: result.error?.message || 'Failed to load schema', - }), - }, - ], - isError: true, - }; - } - - return { - content: [ - { - type: 'text' as const, - text: result.schemaString, - }, - ], - }; - } catch (error) { - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ - success: false, - error: error instanceof Error ? error.message : String(error), - }), - }, - ], - isError: true, - }; - } - } - ); - - // Tool 3: apply_workflow - server.tool( - 'apply_workflow', - 'Apply a workflow to the CC Workflow Studio canvas. The workflow is validated before being applied. If the user has review mode enabled, they will see a diff preview and must accept changes before they are applied. If rejected, an error with message "User rejected the changes" is returned. The editor must be open.', - { - workflow: z.string().describe('The workflow JSON string to apply to the canvas'), - description: z - .string() - .optional() - .describe( - 'A brief description of the changes being made (e.g., "Added error handling step after API call"). Shown to the user in the review dialog.' - ), - }, - async ({ workflow: workflowJson, description }) => { - try { - // Parse JSON - let parsedWorkflow: unknown; - try { - parsedWorkflow = JSON.parse(workflowJson); - } catch { - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ - success: false, - error: 'Invalid JSON: Failed to parse workflow string', - }), - }, - ], - isError: true, - }; - } - - // Validate - const validation = validateAIGeneratedWorkflow(parsedWorkflow); - if (!validation.valid) { - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ - success: false, - error: 'Validation failed', - validationErrors: validation.errors, - }), - }, - ], - isError: true, - }; - } - - // Apply to canvas - const applied = await manager.applyWorkflowToCanvas( - parsedWorkflow as import('../../shared/types/workflow-definition').Workflow, - description - ); - - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ - success: applied, - }), - }, - ], - }; - } catch (error) { - return { - content: [ - { - type: 'text' as const, - text: JSON.stringify({ - success: false, - error: error instanceof Error ? error.message : String(error), - }), - }, - ], - isError: true, - }; - } - } - ); -} diff --git a/src/extension/services/refinement-prompt-builder.ts b/src/extension/services/refinement-prompt-builder.ts deleted file mode 100644 index 9e3fbf04..00000000 --- a/src/extension/services/refinement-prompt-builder.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Refinement Prompt Builder - * - * Builds AI prompts for workflow refinement in TOON format. - * TOON format reduces token consumption by ~7% compared to freetext. - */ - -import { encode } from '@toon-format/toon'; -import type { ConversationHistory, Workflow } from '../../shared/types/workflow-definition'; -import { getCurrentLocale } from '../i18n/i18n-service'; -import { - CLARIFICATION_TRIGGERS, - EDITING_PROCESS_MERMAID_DIAGRAM, - EDITING_PROCESS_STEPS, - REQUEST_TYPE_GUIDELINES, -} from './editing-flow-constants.generated'; -import type { ValidationErrorInfo } from './refinement-service'; -import type { SchemaLoadResult } from './schema-loader-service'; -import type { SkillRelevanceScore } from './skill-relevance-matcher'; - -/** - * Prompt builder for workflow refinement - */ -export class RefinementPromptBuilder { - constructor( - private currentWorkflow: Workflow, - private conversationHistory: ConversationHistory, - private userMessage: string, - private schemaResult: SchemaLoadResult, - private filteredSkills: SkillRelevanceScore[], - private previousValidationErrors?: ValidationErrorInfo[], - private isCodexEnabled = false - ) {} - - buildPrompt(): string { - const structured = this.getStructuredPrompt(); - return encode(structured); - } - - private getStructuredPrompt(): object { - const recentMessages = this.conversationHistory.messages.slice(-6); - const locale = getCurrentLocale(); - - return { - responseLocale: locale, - role: 'expert workflow designer for CC Workflow Studio', - task: 'Refine the existing workflow based on user feedback', - // AI Editing Process Flow - MUST follow this process strictly - // Generated from: resources/ai-editing-process-flow.md - editingProcessFlow: { - description: 'You MUST follow this editing process step by step. Do NOT skip any steps.', - mermaidDiagram: EDITING_PROCESS_MERMAID_DIAGRAM, - steps: EDITING_PROCESS_STEPS, - requestTypeGuidelines: REQUEST_TYPE_GUIDELINES, - clarificationTriggers: CLARIFICATION_TRIGGERS, - }, - currentWorkflow: { - id: this.currentWorkflow.id, - name: this.currentWorkflow.name, - // Include COMPLETE node data for ALL node types to enable precise editing - nodes: this.currentWorkflow.nodes.map((n) => ({ - id: n.id, - type: n.type, - name: n.name, - position: { x: n.position.x, y: n.position.y }, - // Include data for ALL node types - this is CRITICAL for preserving existing content - data: n.data, - })), - connections: this.currentWorkflow.connections.map((c) => ({ - id: c.id, - from: c.from, - to: c.to, - fromPort: c.fromPort, - toPort: c.toPort, - })), - // Include subAgentFlows if present - ...(this.currentWorkflow.subAgentFlows && - this.currentWorkflow.subAgentFlows.length > 0 && { - subAgentFlows: this.currentWorkflow.subAgentFlows, - }), - }, - conversationHistory: recentMessages.map((m) => ({ - sender: m.sender, - content: m.content, - })), - userRequest: this.userMessage, - refinementGuidelines: [ - 'CRITICAL: Preserve ALL unchanged nodes with their EXACT original data - do not modify or regenerate', - 'Only modify nodes that are explicitly requested to change', - 'Add new nodes ONLY if user explicitly asks for new functionality', - 'Maintain workflow connectivity and validity', - 'Respect node IDs - do not regenerate IDs for unchanged nodes', - 'Node names must match pattern /^[a-zA-Z0-9_-]+$/ (ASCII alphanumeric, hyphens, underscores only)', - ], - nodePositioningGuidelines: [ - 'Horizontal spacing: 300px', - 'Spacing after Start: 250px', - 'Spacing before End: 350px', - 'Vertical spacing: 150px', - 'Calculate positions based on existing nodes', - 'Preserve existing positions unless requested', - 'Branch nodes: offset vertically by 150px', - ], - skillNodeConstraints: [ - 'Must have exactly 1 output port (outputPorts: 1)', - 'If branching needed, add ifElse/switch node after the Skill node', - 'For existing skill nodes: COPY data field exactly from currentWorkflow', - 'For NEW skill nodes: only use names from availableSkills list', - ], - branchingNodeSelection: { - ifElse: '2-way conditional branching (true/false)', - switch: '3+ way branching or multiple conditions', - rule: 'Each branch connects to exactly one downstream node', - }, - availableSkills: this.filteredSkills.map((s) => ({ - name: s.skill.name, - description: s.skill.description, - scope: s.skill.scope, - })), - // Codex Agent node constraints and guidelines (only when enabled) - ...(this.isCodexEnabled && { - codexNodeConstraints: [ - 'Must have exactly 1 output port (outputPorts: 1)', - 'If branching needed, add ifElse/switch node after the Codex node', - 'For existing codex nodes: COPY data field exactly from currentWorkflow', - 'Required fields: label, prompt (or promptGuidance for ai-generated mode), model, reasoningEffort', - 'Optional fields: sandbox (read-only/workspace-write/danger-full-access), skipGitRepoCheck', - ], - codexAgentGuidelines: { - description: - 'Codex Agent is a specialized node for executing OpenAI Codex CLI within workflows. Use it for advanced code generation, analysis, or complex reasoning tasks.', - whenToUse: [ - 'Complex code generation requiring multiple files or architectural decisions', - 'Code analysis or refactoring tasks that benefit from deep reasoning', - 'Tasks requiring workspace-level operations (with appropriate sandbox settings)', - 'Multi-step coding tasks that benefit from reasoning effort configuration', - ], - configurationOptions: { - model: 'o3 (more capable) or o4-mini (faster, cost-effective)', - reasoningEffort: 'low/medium/high - controls depth of reasoning', - promptMode: - 'fixed (user-defined prompt) or ai-generated (orchestrating AI provides prompt)', - sandbox: - 'Optional: read-only (safest), workspace-write (can modify files), danger-full-access', - skipGitRepoCheck: - 'Usually required for workflow execution outside trusted git repositories', - }, - }, - }), - workflowSchema: this.schemaResult.schemaString || JSON.stringify(this.schemaResult.schema), - outputFormat: { - description: - 'You MUST output exactly ONE JSON object. Do NOT output multiple JSON blocks or explanatory text.', - successExample: { - status: 'success', - message: 'Brief description of what was changed', - values: { - workflow: { - id: 'workflow-id', - name: 'workflow-name', - nodes: ['... all nodes with data ...'], - connections: ['... all connections ...'], - }, - }, - }, - clarificationExample: { - status: 'clarification', - message: 'Your answer or question here', - }, - errorExample: { - status: 'error', - message: 'Error description', - }, - }, - criticalRules: [ - 'OUTPUT FORMAT: You MUST output exactly ONE JSON object - no explanatory text, no multiple JSON blocks', - 'DO NOT output workflow JSON separately from status JSON - they must be combined in ONE response', - 'DO NOT wrap JSON in markdown code blocks (```json) - output raw JSON only', - 'For success: workflow MUST be nested inside values.workflow, not as a separate JSON block', - 'Follow the editingProcessFlow steps in order - do NOT skip steps', - 'For questions/understanding requests: use clarification status with your answer', - 'For unclear edit requests: use clarification status to ask for details', - 'For clear edit requests: use success status with the modified workflow inside values.workflow', - 'CRITICAL: When outputting workflow, COPY unchanged nodes with their EXACT original data', - 'NEVER regenerate or modify data for nodes that were not explicitly requested to change', - 'status and message fields are REQUIRED in every response', - ], - // Include previous validation errors for retry context - ...(this.previousValidationErrors && - this.previousValidationErrors.length > 0 && { - previousAttemptFailed: true, - previousValidationErrors: this.previousValidationErrors.map((e) => ({ - code: e.code, - message: e.message, - field: e.field, - })), - errorRecoveryInstructions: [ - 'The previous attempt failed validation with the errors listed above', - 'Please carefully review the errors and fix them in your output', - 'Pay special attention to node naming patterns and field requirements', - 'Node names must match pattern /^[a-zA-Z0-9_-]+$/ (no spaces, Japanese characters, or special chars)', - 'Ensure all required fields are present with valid values', - ], - }), - }; - } -} diff --git a/src/extension/services/refinement-service.ts b/src/extension/services/refinement-service.ts deleted file mode 100644 index 95821f11..00000000 --- a/src/extension/services/refinement-service.ts +++ /dev/null @@ -1,1585 +0,0 @@ -/** - * Workflow Refinement Service - * - * Executes AI-assisted workflow refinement based on user feedback and conversation history. - * Based on: /specs/001-ai-workflow-refinement/quickstart.md - */ - -import type { - AiCliProvider, - ClaudeModel, - CodexModel, - CodexReasoningEffort, - CopilotModel, - SkillReference, -} from '../../shared/types/messages'; -import { - type ConversationHistory, - NodeType, - type SkillNodeData, - type SubAgentFlow, - type SubAgentFlowNodeData, - type Workflow, -} from '../../shared/types/workflow-definition'; -import { log } from '../extension'; -import { validateAIGeneratedWorkflow } from '../utils/validate-workflow'; -import { - estimateTokens, - getConfiguredSchemaFormat, - isMetricsCollectionEnabled, - recordMetrics, -} from './ai-metrics-service'; -import { executeAi, executeAiStreaming } from './ai-provider'; -import { parseClaudeCodeOutput, type StreamingProgressCallback } from './claude-code-service'; -import { RefinementPromptBuilder } from './refinement-prompt-builder'; -import { loadWorkflowSchemaByFormat, type SchemaLoadResult } from './schema-loader-service'; -import { filterSkillsByRelevance, type SkillRelevanceScore } from './skill-relevance-matcher'; -import { scanAllSkills } from './skill-service'; - -/** Validation error structure */ -export interface ValidationErrorInfo { - code: string; - message: string; - field?: string; -} - -export interface RefinementResult { - success: boolean; - refinedWorkflow?: Workflow; - clarificationMessage?: string; - aiMessage?: string; // AI's response message for display in chat UI - error?: { - code: - | 'COMMAND_NOT_FOUND' - | 'MODEL_NOT_SUPPORTED' - | 'TIMEOUT' - | 'PARSE_ERROR' - | 'VALIDATION_ERROR' - | 'UNKNOWN_ERROR'; - message: string; - details?: string; - }; - /** Validation errors when code is VALIDATION_ERROR */ - validationErrors?: ValidationErrorInfo[]; - executionTimeMs: number; - /** New session ID from CLI (for session continuation) */ - newSessionId?: string; - /** Whether session was reconnected due to session expiration (fallback occurred) */ - sessionReconnected?: boolean; -} - -/** - * AI response structure for workflow refinement - * Forces AI to return structured JSON instead of plain text - */ -interface AIRefinementResponse { - status: 'success' | 'error' | 'clarification'; - values?: { - workflow: Workflow; - }; - message?: string; // For clarification or error messages -} - -/** - * AI response structure for SubAgentFlow refinement - */ -interface AISubAgentFlowResponse { - status: 'success' | 'error' | 'clarification'; - values?: { - nodes: Workflow['nodes']; - connections: Workflow['connections']; - }; - message?: string; -} - -/** - * Parse AI refinement response with structured format - * - * Includes fallback handling for non-JSON text responses: - * When AI responds with conversational text instead of JSON - * (especially common when user sends vague/initial messages), - * treat it as a clarification response to maintain UX. - * - * @param output - Raw CLI output string - * @returns Parsed AIRefinementResponse or null if parsing fails - */ -function parseRefinementResponse(output: string): AIRefinementResponse | null { - const parsed = parseClaudeCodeOutput(output); - - // Fallback: If parsing fails but we have text content, treat it as clarification - // This handles cases where AI responds with conversational text instead of JSON - // (common with non-English locales or vague initial user messages) - if (!parsed || typeof parsed !== 'object') { - const trimmedOutput = output.trim(); - if (trimmedOutput.length > 0) { - log('INFO', 'Treating non-JSON AI response as clarification message', { - outputLength: trimmedOutput.length, - outputPreview: trimmedOutput.substring(0, 100), - }); - return { - status: 'clarification', - message: trimmedOutput, - }; - } - return null; - } - - const response = parsed as AIRefinementResponse; - - // Validate required status field - if (!response.status || !['success', 'error', 'clarification'].includes(response.status)) { - return null; - } - - return response; -} - -/** - * Parse AI SubAgentFlow refinement response with structured format - * - * Includes fallback handling for non-JSON text responses (same as parseRefinementResponse). - * - * @param output - Raw CLI output string - * @returns Parsed AISubAgentFlowResponse or null if parsing fails - */ -function parseSubAgentFlowResponse(output: string): AISubAgentFlowResponse | null { - const parsed = parseClaudeCodeOutput(output); - - // Fallback: If parsing fails but we have text content, treat it as clarification - if (!parsed || typeof parsed !== 'object') { - const trimmedOutput = output.trim(); - if (trimmedOutput.length > 0) { - log('INFO', 'Treating non-JSON SubAgentFlow AI response as clarification message', { - outputLength: trimmedOutput.length, - outputPreview: trimmedOutput.substring(0, 100), - }); - return { - status: 'clarification', - message: trimmedOutput, - }; - } - return null; - } - - const response = parsed as AISubAgentFlowResponse; - - // Validate required status field - if (!response.status || !['success', 'error', 'clarification'].includes(response.status)) { - return null; - } - - return response; -} - -/** - * Construct refinement prompt with conversation context - * - * @param currentWorkflow - The current workflow state - * @param conversationHistory - Full conversation history - * @param userMessage - User's current refinement request - * @param schemaResult - Schema load result (JSON or TOON) - * @param filteredSkills - Skills filtered by relevance (optional) - * @param previousValidationErrors - Validation errors from previous failed attempt (optional, for retry) - * @returns Object with prompt string and schema size - */ -export function constructRefinementPrompt( - currentWorkflow: Workflow, - conversationHistory: ConversationHistory, - userMessage: string, - schemaResult: SchemaLoadResult, - filteredSkills: SkillRelevanceScore[] = [], - previousValidationErrors?: ValidationErrorInfo[], - isCodexEnabled = false -): { prompt: string; schemaSize: number } { - const schemaFormat = getConfiguredSchemaFormat(); - - log('INFO', 'Constructing refinement prompt', { - promptFormat: 'toon', - schemaFormat: schemaFormat, - userMessageLength: userMessage.length, - conversationHistoryLength: conversationHistory.messages.length, - filteredSkillsCount: filteredSkills.length, - hasPreviousErrors: !!previousValidationErrors && previousValidationErrors.length > 0, - previousErrorCount: previousValidationErrors?.length ?? 0, - }); - - const builder = new RefinementPromptBuilder( - currentWorkflow, - conversationHistory, - userMessage, - schemaResult, - filteredSkills, - previousValidationErrors, - isCodexEnabled - ); - - const prompt = builder.buildPrompt(); - const schemaSize = schemaResult.sizeBytes; - - log('INFO', 'Refinement prompt constructed', { - promptFormat: 'toon', - promptSizeChars: prompt.length, - estimatedTokens: estimateTokens(prompt.length), - }); - - return { prompt, schemaSize }; -} - -/** - * Default timeout for workflow refinement (90 seconds) - * Can be overridden by user selection in the Refinement Chat Panel settings - */ -export const DEFAULT_REFINEMENT_TIMEOUT_MS = 90000; - -/** - * Execute workflow refinement via Claude Code CLI - * - * @param currentWorkflow - The current workflow state - * @param conversationHistory - Full conversation history - * @param userMessage - User's current refinement request - * @param extensionPath - VSCode extension path for schema loading - * @param useSkills - Whether to include skills in refinement (default: true) - * @param timeoutMs - Timeout in milliseconds (default: 90000, can be configured via settings) - * @param requestId - Optional request ID for cancellation support - * @param workspaceRoot - The workspace root path for CLI execution - * @param onProgress - Optional callback for streaming progress updates - * @param model - Claude model to use (default: 'sonnet') - * @param allowedTools - Array of allowed tool names for CLI (optional) - * @param previousValidationErrors - Validation errors from previous failed attempt (for retry with error context) - * @param provider - AI CLI provider to use (default: 'claude-code') - * @param copilotModel - Copilot model to use when provider is 'copilot' (default: 'gpt-4o') - * @param codexModel - Codex model to use when provider is 'codex' (default: '' = inherit) - * @param codexReasoningEffort - Reasoning effort level for Codex (default: 'minimal') - * @returns Refinement result with success status and refined workflow or error - */ -export async function refineWorkflow( - currentWorkflow: Workflow, - conversationHistory: ConversationHistory, - userMessage: string, - extensionPath: string, - useSkills = true, - timeoutMs = DEFAULT_REFINEMENT_TIMEOUT_MS, - requestId?: string, - workspaceRoot?: string, - onProgress?: StreamingProgressCallback, - model: ClaudeModel = 'sonnet', - allowedTools?: string[], - previousValidationErrors?: ValidationErrorInfo[], - provider: AiCliProvider = 'claude-code', - copilotModel: CopilotModel = 'gpt-4o', - codexModel: CodexModel = '', - codexReasoningEffort: CodexReasoningEffort = 'low', - useCodex = false -): Promise { - const startTime = Date.now(); - - // Get configured schema format and metrics settings - const schemaFormat = getConfiguredSchemaFormat(); - const collectMetrics = isMetricsCollectionEnabled(); - - log('INFO', 'Starting workflow refinement', { - requestId, - workflowId: currentWorkflow.id, - messageLength: userMessage.length, - historyLength: conversationHistory.messages.length, - currentIteration: conversationHistory.currentIteration, - useSkills, - timeoutMs, - schemaFormat, - promptFormat: 'toon', - collectMetrics, - }); - - try { - // Step 1: Load workflow schema in configured format (and optionally scan skills) - let schemaResult: SchemaLoadResult; - let availableSkills: SkillReference[] = []; - let filteredSkills: SkillRelevanceScore[] = []; - - if (useSkills) { - // Scan skills in parallel with schema loading - const [loadedSchema, skillsResult] = await Promise.all([ - loadWorkflowSchemaByFormat(extensionPath, schemaFormat), - scanAllSkills(), - ]); - - schemaResult = loadedSchema; - - if (!schemaResult.success || (!schemaResult.schema && !schemaResult.schemaString)) { - log('ERROR', 'Failed to load workflow schema', { - requestId, - errorMessage: schemaResult.error?.message, - }); - - return { - success: false, - error: { - code: 'UNKNOWN_ERROR', - message: 'Failed to load workflow schema', - details: schemaResult.error?.message, - }, - executionTimeMs: Date.now() - startTime, - }; - } - - // Combine user, project, and local skills - availableSkills = [...skillsResult.user, ...skillsResult.project, ...skillsResult.local]; - - log('INFO', 'Skills scanned successfully', { - requestId, - userCount: skillsResult.user.length, - projectCount: skillsResult.project.length, - localCount: skillsResult.local.length, - totalCount: availableSkills.length, - userSkills: skillsResult.user.map((s) => s.name), - projectSkills: skillsResult.project.map((s) => s.name), - localSkills: skillsResult.local.map((s) => s.name), - }); - - // Step 2: Filter skills by relevance to user's message - filteredSkills = filterSkillsByRelevance(userMessage, availableSkills); - - log('INFO', 'Skills filtered by relevance', { - requestId, - filteredCount: filteredSkills.length, - topSkills: filteredSkills.slice(0, 5).map((s) => ({ name: s.skill.name, score: s.score })), - }); - } else { - // Skip skill scanning - schemaResult = await loadWorkflowSchemaByFormat(extensionPath, schemaFormat); - - if (!schemaResult.success || (!schemaResult.schema && !schemaResult.schemaString)) { - log('ERROR', 'Failed to load workflow schema', { - requestId, - errorMessage: schemaResult.error?.message, - }); - - return { - success: false, - error: { - code: 'UNKNOWN_ERROR', - message: 'Failed to load workflow schema', - details: schemaResult.error?.message, - }, - executionTimeMs: Date.now() - startTime, - }; - } - - log('INFO', 'Skipping skill scan (useSkills=false)', { requestId }); - } - - log('INFO', 'Workflow schema loaded successfully', { - requestId, - format: schemaResult.format, - sizeBytes: schemaResult.sizeBytes, - }); - - // Step 3: Construct refinement prompt (with or without skills/codex, and error context if retrying) - const { prompt, schemaSize } = constructRefinementPrompt( - currentWorkflow, - conversationHistory, - userMessage, - schemaResult, - filteredSkills, - previousValidationErrors, - useCodex - ); - - // Record prompt size for metrics - const promptSizeChars = prompt.length; - - // Step 4: Execute AI (streaming if onProgress callback provided) - let cliResult = onProgress - ? await executeAiStreaming( - prompt, - provider, - onProgress, - timeoutMs, - requestId, - workspaceRoot, - model, - copilotModel, - allowedTools, - conversationHistory.sessionId, - codexModel, - codexReasoningEffort - ) - : await executeAi( - prompt, - provider, - timeoutMs, - requestId, - workspaceRoot, - model, - copilotModel, - allowedTools, - codexModel, - codexReasoningEffort - ); - - // Track whether session was reconnected due to fallback - let sessionReconnected = false; - - // Fallback: Retry without session ID if session resume failed - if (!cliResult.success && conversationHistory.sessionId) { - const errorDetails = cliResult.error?.details?.toLowerCase() || ''; - const errorMessage = cliResult.error?.message?.toLowerCase() || ''; - const isSessionError = [ - // Claude Code specific patterns - 'session not found', - 'session expired', - 'invalid session', - 'no such session', - 'no conversation found with session id', - 'not a valid uuid', - // Codex CLI specific patterns (thread-based) - 'thread not found', - 'invalid thread', - 'no thread with id', - 'thread expired', - ].some((pattern) => errorDetails.includes(pattern) || errorMessage.includes(pattern)); - - if (isSessionError) { - log('WARN', 'Session resume failed, retrying without session ID', { - requestId, - previousSessionId: conversationHistory.sessionId, - errorCode: cliResult.error?.code, - errorMessage: cliResult.error?.message, - }); - - // Mark that session reconnection occurred - sessionReconnected = true; - - // Retry without session ID - cliResult = onProgress - ? await executeAiStreaming( - prompt, - provider, - onProgress, - timeoutMs, - requestId, - workspaceRoot, - model, - copilotModel, - allowedTools, - undefined, // No session ID for retry - codexModel, - codexReasoningEffort - ) - : await executeAi( - prompt, - provider, - timeoutMs, - requestId, - workspaceRoot, - model, - copilotModel, - allowedTools, - codexModel, - codexReasoningEffort - ); - } - } - - // Detect silent session switch (CLI started new session without returning error) - // This happens when CLI-side session was cleared (e.g., via /clear command) - if ( - cliResult.success && - conversationHistory.sessionId && - cliResult.sessionId && - cliResult.sessionId !== conversationHistory.sessionId - ) { - log('WARN', 'Session was silently replaced by CLI', { - requestId, - previousSessionId: conversationHistory.sessionId, - newSessionId: cliResult.sessionId, - }); - sessionReconnected = true; - } - - // Detect provider switch from Claude Code/Codex to Copilot - // Copilot doesn't support session continuation, so previous session is lost - // Note: Codex now supports session continuation via thread_id - if (provider === 'copilot' && conversationHistory.sessionId) { - log('WARN', 'Session discontinued due to provider switch to Copilot', { - requestId, - previousSessionId: conversationHistory.sessionId, - }); - sessionReconnected = true; - } - - if (!cliResult.success || !cliResult.output) { - // CLI execution failed - record metrics - if (collectMetrics) { - recordMetrics({ - requestId: requestId || `refine-${Date.now()}`, - schemaFormat: schemaResult.format, - promptFormat: 'toon', - promptSizeChars, - schemaSizeChars: schemaSize, - estimatedTokens: estimateTokens(promptSizeChars), - executionTimeMs: cliResult.executionTimeMs, - success: false, - timestamp: new Date().toISOString(), - userDescriptionLength: userMessage.length, - }); - } - - log('ERROR', 'Refinement failed during CLI execution', { - requestId, - errorCode: cliResult.error?.code, - errorMessage: cliResult.error?.message, - executionTimeMs: cliResult.executionTimeMs, - }); - - return { - success: false, - error: cliResult.error ?? { - code: 'UNKNOWN_ERROR', - message: 'Unknown error occurred during CLI execution', - }, - executionTimeMs: cliResult.executionTimeMs, - newSessionId: cliResult.sessionId, - sessionReconnected, - }; - } - - log('INFO', 'CLI execution successful, parsing structured response', { - requestId, - executionTimeMs: cliResult.executionTimeMs, - rawOutput: cliResult.output, - }); - - // Step 5: Parse structured AI response - const aiResponse = parseRefinementResponse(cliResult.output); - - if (!aiResponse) { - // Structured response parsing failed - log('ERROR', 'Failed to parse structured AI response', { - requestId, - outputPreview: cliResult.output.substring(0, 200), - executionTimeMs: cliResult.executionTimeMs, - }); - - return { - success: false, - error: { - code: 'PARSE_ERROR', - message: 'Failed to parse AI response. Please try again or rephrase your request', - details: 'AI response does not match expected structured format', - }, - executionTimeMs: cliResult.executionTimeMs, - newSessionId: cliResult.sessionId, - sessionReconnected, - }; - } - - // Step 6: Handle response based on status - if (aiResponse.status === 'clarification') { - log('INFO', 'AI is requesting clarification', { - requestId, - message: aiResponse.message?.substring(0, 200), - executionTimeMs: cliResult.executionTimeMs, - }); - - return { - success: true, - clarificationMessage: aiResponse.message || 'Please provide more details', - executionTimeMs: cliResult.executionTimeMs, - newSessionId: cliResult.sessionId, - sessionReconnected, - }; - } - - if (aiResponse.status === 'error') { - log('WARN', 'AI returned error status', { - requestId, - message: aiResponse.message, - executionTimeMs: cliResult.executionTimeMs, - }); - - return { - success: false, - error: { - code: 'PARSE_ERROR', - message: aiResponse.message || 'AI could not process the request', - details: 'AI returned error status in response', - }, - executionTimeMs: cliResult.executionTimeMs, - newSessionId: cliResult.sessionId, - sessionReconnected, - }; - } - - // status === 'success' - extract workflow - if (!aiResponse.values?.workflow) { - log('ERROR', 'AI success response missing workflow', { - requestId, - hasValues: !!aiResponse.values, - executionTimeMs: cliResult.executionTimeMs, - }); - - return { - success: false, - error: { - code: 'PARSE_ERROR', - message: 'Refinement failed - AI response missing workflow data', - details: 'Success response does not contain workflow in values', - }, - executionTimeMs: cliResult.executionTimeMs, - newSessionId: cliResult.sessionId, - sessionReconnected, - }; - } - - let refinedWorkflow = aiResponse.values.workflow; - - // Fill in missing metadata from current workflow - refinedWorkflow = { - ...refinedWorkflow, - id: refinedWorkflow.id || currentWorkflow.id, - name: refinedWorkflow.name || currentWorkflow.name, - version: refinedWorkflow.version || currentWorkflow.version || '1.0.0', - createdAt: refinedWorkflow.createdAt || currentWorkflow.createdAt || new Date(), - updatedAt: new Date(), - }; - - if (!refinedWorkflow.id || !refinedWorkflow.nodes || !refinedWorkflow.connections) { - log('ERROR', 'Parsed workflow is not valid', { - requestId, - hasId: !!refinedWorkflow.id, - hasNodes: !!refinedWorkflow.nodes, - hasConnections: !!refinedWorkflow.connections, - executionTimeMs: cliResult.executionTimeMs, - }); - - return { - success: false, - error: { - code: 'PARSE_ERROR', - message: 'Refinement failed - AI output does not match Workflow format', - details: 'Missing required workflow fields (id, nodes, or connections)', - }, - executionTimeMs: cliResult.executionTimeMs, - newSessionId: cliResult.sessionId, - sessionReconnected, - }; - } - - // Step 7: Resolve skill paths for skill nodes (only if useSkills is true) - if (useSkills) { - refinedWorkflow = await resolveSkillPaths(refinedWorkflow, availableSkills); - - log('INFO', 'Skill paths resolved', { - requestId, - skillNodesCount: refinedWorkflow.nodes.filter((n) => n.type === 'skill').length, - }); - } else { - log('INFO', 'Skipping skill path resolution (useSkills=false)', { requestId }); - } - - // Step 7.5: Resolve SubAgentFlow references - refinedWorkflow = resolveSubAgentFlows(refinedWorkflow); - - log('INFO', 'SubAgentFlow references resolved', { - requestId, - subAgentFlowNodesCount: refinedWorkflow.nodes.filter((n) => n.type === 'subAgentFlow').length, - subAgentFlowsCount: refinedWorkflow.subAgentFlows?.length || 0, - }); - - // Step 8: Validate refined workflow - const validation = validateAIGeneratedWorkflow(refinedWorkflow); - - if (!validation.valid) { - log('ERROR', 'Refined workflow failed validation', { - requestId, - validationErrors: validation.errors, - executionTimeMs: cliResult.executionTimeMs, - }); - - return { - success: false, - error: { - code: 'VALIDATION_ERROR', - message: 'Refined workflow failed validation - please try again', - details: validation.errors.map((e) => e.message).join('; '), - }, - validationErrors: validation.errors.map((e) => ({ - code: e.code, - message: e.message, - field: e.field, - })), - executionTimeMs: cliResult.executionTimeMs, - newSessionId: cliResult.sessionId, - sessionReconnected, - }; - } - - const executionTimeMs = Date.now() - startTime; - - // Record metrics for successful refinement - if (collectMetrics) { - recordMetrics({ - requestId: requestId || `refine-${Date.now()}`, - schemaFormat: schemaResult.format, - promptFormat: 'toon', - promptSizeChars, - schemaSizeChars: schemaSize, - estimatedTokens: estimateTokens(promptSizeChars), - executionTimeMs, - success: true, - timestamp: new Date().toISOString(), - userDescriptionLength: userMessage.length, - }); - } - - log('INFO', 'Workflow refinement successful', { - requestId, - executionTimeMs, - nodeCount: refinedWorkflow.nodes.length, - connectionCount: refinedWorkflow.connections.length, - aiMessage: aiResponse.message, - }); - - return { - success: true, - refinedWorkflow, - aiMessage: aiResponse.message, - executionTimeMs, - newSessionId: cliResult.sessionId, - sessionReconnected, - }; - } catch (error) { - const executionTimeMs = Date.now() - startTime; - - log('ERROR', 'Unexpected error during workflow refinement', { - requestId, - errorMessage: error instanceof Error ? error.message : String(error), - executionTimeMs, - }); - - return { - success: false, - error: { - code: 'UNKNOWN_ERROR', - message: 'An unexpected error occurred during refinement', - details: error instanceof Error ? error.message : String(error), - }, - executionTimeMs, - }; - } -} - -/** - * Resolve skill paths for skill nodes in the workflow - * - * @param workflow - The workflow containing skill nodes - * @param availableSkills - List of available skills to match against - * @returns Workflow with resolved skill paths - */ -async function resolveSkillPaths( - workflow: Workflow, - availableSkills: SkillReference[] -): Promise { - const resolvedNodes = workflow.nodes.map((node) => { - if (node.type !== 'skill') { - return node; // Not a Skill node, no changes - } - - const skillData = node.data as SkillNodeData; - - // First try: Find matching skill by name and scope - let matchedSkill = availableSkills.find( - (skill) => skill.name === skillData.name && skill.scope === skillData.scope - ); - - // Second try: Match by name only (AI may generate wrong scope) - if (!matchedSkill) { - matchedSkill = availableSkills.find((skill) => skill.name === skillData.name); - } - - if (matchedSkill) { - // Skill found - resolve path and correct scope if necessary - return { - ...node, - data: { - ...skillData, - name: matchedSkill.name, - description: matchedSkill.description, - scope: matchedSkill.scope, // Use actual scope from matched skill - skillPath: matchedSkill.skillPath, - validationStatus: matchedSkill.validationStatus, - } as SkillNodeData, - }; - } - - // Skill not found - mark as missing with empty skillPath - return { - ...node, - data: { - ...skillData, - skillPath: '', // Set empty to avoid validation error - validationStatus: 'missing' as const, - } as SkillNodeData, - }; - }); - - return { - ...workflow, - nodes: resolvedNodes, - }; -} - -/** - * Create a minimal SubAgentFlow structure (Start → End only) - * - * Used as fallback when AI generates a subAgentFlow node without - * creating the corresponding SubAgentFlow definition. - * - * @param subAgentFlowId - ID for the SubAgentFlow (matches reference node's subAgentFlowId) - * @param label - Display label from the reference node - * @param description - Optional description from the reference node - * @returns Minimal SubAgentFlow with Start and End nodes connected - */ -function createMinimalSubAgentFlow( - subAgentFlowId: string, - label: string, - description?: string -): SubAgentFlow { - return { - id: subAgentFlowId, - name: label, - description: description || `Sub-Agent Flow: ${label}`, - nodes: [ - { - id: `${subAgentFlowId}-start`, - type: NodeType.Start, - name: 'Start', - position: { x: 100, y: 200 }, - data: { label: 'Start' }, - }, - { - id: `${subAgentFlowId}-end`, - type: NodeType.End, - name: 'End', - position: { x: 400, y: 200 }, - data: { label: 'End' }, - }, - ], - connections: [ - { - id: `${subAgentFlowId}-conn`, - from: `${subAgentFlowId}-start`, - to: `${subAgentFlowId}-end`, - fromPort: 'output', - toPort: 'input', - }, - ], - }; -} - -/** - * Resolve SubAgentFlow references in refined workflows - * - * For each subAgentFlow node, ensures a corresponding SubAgentFlow - * definition exists in workflow.subAgentFlows. Creates minimal - * structures (Start → End) for any missing definitions. - * - * @param workflow - Refined workflow (may have missing subAgentFlows) - * @returns Modified workflow with resolved SubAgentFlow definitions - */ -function resolveSubAgentFlows(workflow: Workflow): Workflow { - // Find all subAgentFlow nodes - const subAgentFlowNodes = workflow.nodes.filter((n) => n.type === 'subAgentFlow'); - - if (subAgentFlowNodes.length === 0) { - return workflow; // No SubAgentFlow nodes, nothing to resolve - } - - // Initialize subAgentFlows array if not present - const existingSubAgentFlows = workflow.subAgentFlows || []; - const existingIds = new Set(existingSubAgentFlows.map((sf) => sf.id)); - - // Create missing SubAgentFlow definitions - const newSubAgentFlows: SubAgentFlow[] = []; - - for (const node of subAgentFlowNodes) { - const refData = node.data as SubAgentFlowNodeData; - const targetId = refData.subAgentFlowId; - - if (!existingIds.has(targetId)) { - // SubAgentFlow definition is missing - create minimal structure - log('INFO', 'Creating minimal SubAgentFlow for missing definition (refinement)', { - subAgentFlowId: targetId, - nodeId: node.id, - label: refData.label, - }); - - const minimalSubAgentFlow = createMinimalSubAgentFlow( - targetId, - refData.label, - refData.description - ); - - newSubAgentFlows.push(minimalSubAgentFlow); - existingIds.add(targetId); // Prevent duplicates - } - } - - return { - ...workflow, - subAgentFlows: [...existingSubAgentFlows, ...newSubAgentFlows], - }; -} - -// ============================================================================ -// SubAgentFlow Refinement Functions -// ============================================================================ - -/** - * Inner workflow representation for SubAgentFlow refinement - */ -export interface InnerWorkflow { - nodes: Workflow['nodes']; - connections: Workflow['connections']; -} - -/** - * SubAgentFlow refinement result - */ -export interface SubAgentFlowRefinementResult { - success: boolean; - refinedInnerWorkflow?: InnerWorkflow; - clarificationMessage?: string; - aiMessage?: string; // AI's response message for display in chat UI - error?: { - code: - | 'COMMAND_NOT_FOUND' - | 'TIMEOUT' - | 'PARSE_ERROR' - | 'VALIDATION_ERROR' - | 'PROHIBITED_NODE_TYPE' - | 'UNKNOWN_ERROR'; - message: string; - details?: string; - }; - executionTimeMs: number; - /** New session ID from CLI (for session continuation) */ - newSessionId?: string; - /** Whether session was reconnected due to session expiration (fallback occurred) */ - sessionReconnected?: boolean; -} - -/** - * Prohibited node types in SubAgentFlow - */ -const SUBAGENTFLOW_PROHIBITED_NODE_TYPES = ['subAgent', 'subAgentFlow', 'askUserQuestion']; - -/** - * Maximum nodes allowed in SubAgentFlow - */ -const SUBAGENTFLOW_MAX_NODES = 30; - -/** - * Construct refinement prompt for SubAgentFlow - * - * @param innerWorkflow - The current inner workflow state (nodes + connections) - * @param conversationHistory - Full conversation history - * @param userMessage - User's current refinement request - * @param schemaResult - Schema load result (JSON or TOON) - * @param filteredSkills - Skills filtered by relevance (optional) - * @returns Object with prompt string and schema size - */ -export function constructSubAgentFlowRefinementPrompt( - innerWorkflow: InnerWorkflow, - conversationHistory: ConversationHistory, - userMessage: string, - schemaResult: SchemaLoadResult, - filteredSkills: SkillRelevanceScore[] = [], - isCodexEnabled = false -): { prompt: string; schemaSize: number } { - // Get last 6 messages (3 rounds of user-AI conversation) - const recentMessages = conversationHistory.messages.slice(-6); - - const conversationContext = - recentMessages.length > 0 - ? `**Conversation History** (last ${recentMessages.length} messages): -${recentMessages.map((msg) => `[${msg.sender.toUpperCase()}]: ${msg.content}`).join('\n')}\n` - : '**Conversation History**: (This is the first message)\n'; - - // Format schema based on type - let schemaSection: string; - let schemaSize: number; - - if (schemaResult.format === 'toon' && schemaResult.schemaString) { - // TOON format - use as-is with format indicator - schemaSection = `**Workflow Schema** (TOON format - Token-Oriented Object Notation): -\`\`\`toon -${schemaResult.schemaString} -\`\`\``; - schemaSize = schemaResult.schemaString.length; - } else { - // JSON format - existing behavior - const schemaJSON = JSON.stringify(schemaResult.schema, null, 2); - schemaSection = `**Workflow Schema** (reference for valid node types and structure): -${schemaJSON}`; - schemaSize = schemaJSON.length; - } - - // Construct skills section - const skillsSection = - filteredSkills.length > 0 - ? ` - -**Available Skills** (use when user description matches their purpose): -${JSON.stringify( - filteredSkills.map((s) => ({ - name: s.skill.name, - description: s.skill.description, - scope: s.skill.scope, - })), - null, - 2 -)} - -**Instructions for Using Skills**: -- Use a Skill node when the user's description matches a Skill's documented purpose -- Copy the name, description, and scope exactly from the Available Skills list above -- Set validationStatus to "valid" and outputPorts to 1 -- Do NOT include skillPath in your response (the system will resolve it automatically) -- If both personal and project Skills match, prefer the project Skill - -` - : ''; - - // Construct Codex section (only when enabled) - const codexSection = isCodexEnabled - ? ` - -**Codex Agent Node Guidelines**: -Codex Agent is a specialized node for executing OpenAI Codex CLI within workflows. - -**When to use Codex Agent**: -- Complex code generation requiring multiple files or architectural decisions -- Code analysis or refactoring tasks that benefit from deep reasoning -- Tasks requiring workspace-level operations (with appropriate sandbox settings) -- Multi-step coding tasks that benefit from reasoning effort configuration - -**Codex Node Constraints**: -- Must have exactly 1 output port (outputPorts: 1) -- If branching needed, add ifElse/switch node after the Codex node -- Required fields: name, prompt (or promptGuidance for ai-generated mode), model, reasoningEffort -- Optional fields: sandbox (read-only/workspace-write/danger-full-access), skipGitRepoCheck - -**Configuration Options**: -- model: "o3" (more capable) or "o4-mini" (faster, cost-effective) -- reasoningEffort: "low"/"medium"/"high" - controls depth of reasoning -- promptMode: "fixed" (user-defined prompt) or "ai-generated" (orchestrating AI provides prompt) -- sandbox: Optional - "read-only" (safest), "workspace-write" (can modify files), "danger-full-access" - -` - : ''; - - const prompt = `You are an expert workflow designer for CC Workflow Studio. - -**Task**: Refine a Sub-Agent Flow based on user's feedback. - -**IMPORTANT - Sub-Agent Flow Constraints**: -Sub-Agent Flows have strict constraints that MUST be followed: -1. **Prohibited Node Types**: You MUST NOT use the following node types: - - subAgent (Claude Code constraint for sequential execution) - - subAgentFlow (no nesting allowed) - - askUserQuestion (user interaction not supported in sub-agent context) -2. **Allowed Node Types**: start, end, prompt, ifElse, switch, skill, mcp, codex -3. **Maximum Nodes**: ${SUBAGENTFLOW_MAX_NODES} nodes maximum -4. **Must have exactly one Start node and at least one End node** - -**Current Sub-Agent Flow**: -${JSON.stringify(innerWorkflow, null, 2)} - -${conversationContext} -**User's Refinement Request**: -${userMessage} - -**Refinement Guidelines**: -1. Preserve existing nodes unless explicitly requested to remove -2. Add new nodes ONLY if user asks for new functionality -3. Modify node properties (labels, descriptions, prompts) based on feedback -4. Maintain workflow connectivity and validity -5. Respect node IDs - do not regenerate IDs for unchanged nodes -6. Update only what the user requested - minimize unnecessary changes -7. **NEVER add subAgent, subAgentFlow, or askUserQuestion nodes** -8. **Node names must match pattern /^[a-zA-Z0-9_-]+$/** (ASCII alphanumeric, hyphens, underscores only - NO spaces or non-ASCII characters) - -**Node Positioning Guidelines**: -1. Horizontal spacing between regular nodes: Use 300px (e.g., x: 350, 650, 950, 1250, 1550) -2. Spacing after Start node: Use 250px (e.g., Start at x: 100, next at x: 350) -3. Spacing before End node: Use 350px (e.g., previous at x: 1550, End at x: 1900) -4. Vertical spacing: Use 150px between nodes on different branches -5. When adding new nodes, calculate positions based on existing node positions and connections -6. Preserve existing node positions unless repositioning is explicitly requested -7. For branch nodes: offset vertically by 150px from the main path - -**Skill Node Constraints**: -- Skill nodes MUST have exactly 1 output port (outputPorts: 1) -- If branching is needed after Skill execution, add an ifElse or switch node after the Skill node -- Never modify Skill node's outputPorts field - -**Branching Node Selection**: -- Use ifElse node for 2-way conditional branching (true/false) -- Use switch node for 3+ way branching or multiple conditions -- Each branch output should connect to exactly one downstream node -${skillsSection}${codexSection} -${schemaSection} - -**Output Format**: You MUST output a structured JSON response in exactly this format: - -For successful refinement (or if no changes are needed): -{ - "status": "success", - "message": "Brief description of what was changed or why no changes were needed", - "values": { - "nodes": [...], - "connections": [...] - } -} - -If you need clarification from the user: -{ - "status": "clarification", - "message": "Your question here" -} - -If there's an error or the request cannot be fulfilled: -{ - "status": "error", - "message": "Error description" -} - -CRITICAL RULES: -- ALWAYS output valid JSON, NEVER plain text explanations -- NEVER include markdown code blocks or explanations outside the JSON structure -- Even if NO changes are required, you MUST wrap the original nodes/connections in the success response format -- The "status" field is REQUIRED in every response -- The "message" field is REQUIRED for all status types - describe what was done or why`; - - return { prompt, schemaSize }; -} - -/** - * Validate that the inner workflow does not contain prohibited node types - */ -function validateSubAgentFlowNodes(innerWorkflow: InnerWorkflow): { - valid: boolean; - prohibitedNodes: string[]; -} { - const prohibitedNodes: string[] = []; - - for (const node of innerWorkflow.nodes) { - if (SUBAGENTFLOW_PROHIBITED_NODE_TYPES.includes(node.type)) { - prohibitedNodes.push(`${node.type} (${node.id})`); - } - } - - return { - valid: prohibitedNodes.length === 0, - prohibitedNodes, - }; -} - -/** - * Execute SubAgentFlow refinement via Claude Code CLI - * - * @param innerWorkflow - The current inner workflow state (nodes + connections) - * @param conversationHistory - Full conversation history - * @param userMessage - User's current refinement request - * @param extensionPath - VSCode extension path for schema loading - * @param useSkills - Whether to include skills in refinement (default: true) - * @param timeoutMs - Timeout in milliseconds (default: 90000) - * @param requestId - Optional request ID for cancellation support - * @param workspaceRoot - The workspace root path for CLI execution - * @param model - Claude model to use (default: 'sonnet') - * @param allowedTools - Optional array of allowed tool names (e.g., ['Read', 'Grep', 'Glob']) - * @param provider - AI CLI provider to use (default: 'claude-code') - * @param copilotModel - Copilot model to use when provider is 'copilot' (default: 'gpt-4o') - * @param codexModel - Codex model to use when provider is 'codex' (default: '' = inherit) - * @param codexReasoningEffort - Reasoning effort level for Codex (default: 'minimal') - * @returns SubAgentFlow refinement result - */ -export async function refineSubAgentFlow( - innerWorkflow: InnerWorkflow, - conversationHistory: ConversationHistory, - userMessage: string, - extensionPath: string, - useSkills = true, - timeoutMs = DEFAULT_REFINEMENT_TIMEOUT_MS, - requestId?: string, - workspaceRoot?: string, - model: ClaudeModel = 'sonnet', - allowedTools?: string[], - provider: AiCliProvider = 'claude-code', - copilotModel: CopilotModel = 'gpt-4o', - codexModel: CodexModel = '', - codexReasoningEffort: CodexReasoningEffort = 'low', - useCodex = false -): Promise { - const startTime = Date.now(); - - // Get configured schema format and metrics settings - const schemaFormat = getConfiguredSchemaFormat(); - const collectMetrics = isMetricsCollectionEnabled(); - - log('INFO', 'Starting SubAgentFlow refinement', { - requestId, - nodeCount: innerWorkflow.nodes.length, - messageLength: userMessage.length, - historyLength: conversationHistory.messages.length, - currentIteration: conversationHistory.currentIteration, - useSkills, - timeoutMs, - schemaFormat, - promptFormat: 'toon', - collectMetrics, - }); - - try { - // Step 1: Load workflow schema in configured format (and optionally scan skills) - let schemaResult: SchemaLoadResult; - let availableSkills: SkillReference[] = []; - let filteredSkills: SkillRelevanceScore[] = []; - - if (useSkills) { - const [loadedSchema, skillsResult] = await Promise.all([ - loadWorkflowSchemaByFormat(extensionPath, schemaFormat), - scanAllSkills(), - ]); - - schemaResult = loadedSchema; - - if (!schemaResult.success || (!schemaResult.schema && !schemaResult.schemaString)) { - log('ERROR', 'Failed to load workflow schema for SubAgentFlow', { - requestId, - errorMessage: schemaResult.error?.message, - }); - - return { - success: false, - error: { - code: 'UNKNOWN_ERROR', - message: 'Failed to load workflow schema', - details: schemaResult.error?.message, - }, - executionTimeMs: Date.now() - startTime, - }; - } - - availableSkills = [...skillsResult.user, ...skillsResult.project, ...skillsResult.local]; - filteredSkills = filterSkillsByRelevance(userMessage, availableSkills); - - log('INFO', 'Skills filtered for SubAgentFlow refinement', { - requestId, - filteredCount: filteredSkills.length, - }); - } else { - schemaResult = await loadWorkflowSchemaByFormat(extensionPath, schemaFormat); - - if (!schemaResult.success || (!schemaResult.schema && !schemaResult.schemaString)) { - return { - success: false, - error: { - code: 'UNKNOWN_ERROR', - message: 'Failed to load workflow schema', - details: schemaResult.error?.message, - }, - executionTimeMs: Date.now() - startTime, - }; - } - } - - log('INFO', 'Workflow schema loaded for SubAgentFlow', { - requestId, - format: schemaResult.format, - sizeBytes: schemaResult.sizeBytes, - }); - - // Step 2: Construct SubAgentFlow-specific refinement prompt - const { prompt, schemaSize } = constructSubAgentFlowRefinementPrompt( - innerWorkflow, - conversationHistory, - userMessage, - schemaResult, - filteredSkills, - useCodex - ); - - // Record prompt size for metrics - const promptSizeChars = prompt.length; - - // Step 3: Execute AI - const cliResult = await executeAi( - prompt, - provider, - timeoutMs, - requestId, - workspaceRoot, - model, - copilotModel, - allowedTools, - codexModel, - codexReasoningEffort - ); - - // Track whether session was reconnected due to provider switch - let sessionReconnected = false; - - // Detect provider switch from Claude Code/Codex to Copilot - // Copilot doesn't support session continuation, so previous session is lost - // Note: Codex now supports session continuation via thread_id - if (provider === 'copilot' && conversationHistory.sessionId) { - log('WARN', 'Session discontinued due to provider switch to Copilot (SubAgentFlow)', { - requestId, - previousSessionId: conversationHistory.sessionId, - }); - sessionReconnected = true; - } - - if (!cliResult.success || !cliResult.output) { - // Record metrics for failed CLI execution - if (collectMetrics) { - recordMetrics({ - requestId: requestId || `subagentflow-refine-${Date.now()}`, - schemaFormat: schemaResult.format, - promptFormat: 'toon', - promptSizeChars, - schemaSizeChars: schemaSize, - estimatedTokens: estimateTokens(promptSizeChars), - executionTimeMs: cliResult.executionTimeMs, - success: false, - timestamp: new Date().toISOString(), - userDescriptionLength: userMessage.length, - }); - } - - log('ERROR', 'SubAgentFlow refinement failed during CLI execution', { - requestId, - errorCode: cliResult.error?.code, - errorMessage: cliResult.error?.message, - executionTimeMs: cliResult.executionTimeMs, - }); - - return { - success: false, - error: cliResult.error ?? { - code: 'UNKNOWN_ERROR', - message: 'Unknown error occurred during CLI execution', - }, - executionTimeMs: cliResult.executionTimeMs, - }; - } - - log('INFO', 'SubAgentFlow CLI execution successful, parsing structured response', { - requestId, - executionTimeMs: cliResult.executionTimeMs, - rawOutput: cliResult.output, - }); - - // Step 4: Parse structured AI response - const aiResponse = parseSubAgentFlowResponse(cliResult.output); - - if (!aiResponse) { - // Structured response parsing failed - log('ERROR', 'Failed to parse structured SubAgentFlow AI response', { - requestId, - outputPreview: cliResult.output.substring(0, 200), - executionTimeMs: cliResult.executionTimeMs, - }); - - return { - success: false, - error: { - code: 'PARSE_ERROR', - message: 'Failed to parse AI response. Please try again or rephrase your request', - details: 'AI response does not match expected structured format', - }, - executionTimeMs: cliResult.executionTimeMs, - }; - } - - // Step 5: Handle response based on status - if (aiResponse.status === 'clarification') { - log('INFO', 'AI is requesting clarification for SubAgentFlow', { - requestId, - message: aiResponse.message?.substring(0, 200), - executionTimeMs: cliResult.executionTimeMs, - }); - - return { - success: true, - clarificationMessage: aiResponse.message || 'Please provide more details', - executionTimeMs: cliResult.executionTimeMs, - newSessionId: cliResult.sessionId, - sessionReconnected, - }; - } - - if (aiResponse.status === 'error') { - log('WARN', 'AI returned error status for SubAgentFlow', { - requestId, - message: aiResponse.message, - executionTimeMs: cliResult.executionTimeMs, - }); - - return { - success: false, - error: { - code: 'PARSE_ERROR', - message: aiResponse.message || 'AI could not process the request', - details: 'AI returned error status in response', - }, - executionTimeMs: cliResult.executionTimeMs, - }; - } - - // status === 'success' - extract inner workflow - if (!aiResponse.values?.nodes || !aiResponse.values?.connections) { - log('ERROR', 'AI success response missing nodes/connections', { - requestId, - hasValues: !!aiResponse.values, - hasNodes: !!aiResponse.values?.nodes, - hasConnections: !!aiResponse.values?.connections, - executionTimeMs: cliResult.executionTimeMs, - }); - - return { - success: false, - error: { - code: 'PARSE_ERROR', - message: 'Refinement failed - AI response missing workflow data', - details: 'Success response does not contain nodes/connections in values', - }, - executionTimeMs: cliResult.executionTimeMs, - }; - } - - const refinedInnerWorkflow: InnerWorkflow = { - nodes: aiResponse.values.nodes, - connections: aiResponse.values.connections, - }; - - // Step 6: Validate prohibited node types - const nodeValidation = validateSubAgentFlowNodes(refinedInnerWorkflow); - - if (!nodeValidation.valid) { - log('ERROR', 'SubAgentFlow contains prohibited node types', { - requestId, - prohibitedNodes: nodeValidation.prohibitedNodes, - executionTimeMs: cliResult.executionTimeMs, - }); - - return { - success: false, - error: { - code: 'PROHIBITED_NODE_TYPE', - message: 'Sub-Agent Flow cannot contain SubAgent, SubAgentFlow, or AskUserQuestion nodes', - details: `Prohibited nodes found: ${nodeValidation.prohibitedNodes.join(', ')}`, - }, - executionTimeMs: cliResult.executionTimeMs, - }; - } - - // Step 7: Validate node count - if (refinedInnerWorkflow.nodes.length > SUBAGENTFLOW_MAX_NODES) { - log('ERROR', 'SubAgentFlow exceeds maximum node count', { - requestId, - nodeCount: refinedInnerWorkflow.nodes.length, - maxNodes: SUBAGENTFLOW_MAX_NODES, - executionTimeMs: cliResult.executionTimeMs, - }); - - return { - success: false, - error: { - code: 'VALIDATION_ERROR', - message: `Sub-Agent Flow cannot exceed ${SUBAGENTFLOW_MAX_NODES} nodes`, - details: `Current count: ${refinedInnerWorkflow.nodes.length}`, - }, - executionTimeMs: cliResult.executionTimeMs, - }; - } - - // Step 8: Resolve skill paths if using skills - if (useSkills) { - // Create a temporary workflow object for skill path resolution - const tempWorkflow: Workflow = { - id: 'temp', - name: 'temp', - version: '1.0.0', - nodes: refinedInnerWorkflow.nodes, - connections: refinedInnerWorkflow.connections, - createdAt: new Date(), - updatedAt: new Date(), - }; - - const resolvedWorkflow = await resolveSkillPaths(tempWorkflow, availableSkills); - refinedInnerWorkflow.nodes = resolvedWorkflow.nodes; - - log('INFO', 'Skill paths resolved for SubAgentFlow', { - requestId, - skillNodesCount: refinedInnerWorkflow.nodes.filter((n) => n.type === 'skill').length, - }); - } - - const executionTimeMs = Date.now() - startTime; - - // Record metrics for successful SubAgentFlow refinement - if (collectMetrics) { - recordMetrics({ - requestId: requestId || `subagentflow-refine-${Date.now()}`, - schemaFormat: schemaResult.format, - promptFormat: 'toon', - promptSizeChars, - schemaSizeChars: schemaSize, - estimatedTokens: estimateTokens(promptSizeChars), - executionTimeMs, - success: true, - timestamp: new Date().toISOString(), - userDescriptionLength: userMessage.length, - }); - } - - log('INFO', 'SubAgentFlow refinement successful', { - requestId, - executionTimeMs, - nodeCount: refinedInnerWorkflow.nodes.length, - connectionCount: refinedInnerWorkflow.connections.length, - aiMessage: aiResponse.message, - }); - - return { - success: true, - refinedInnerWorkflow, - aiMessage: aiResponse.message, - executionTimeMs, - newSessionId: cliResult.sessionId, - sessionReconnected, - }; - } catch (error) { - const executionTimeMs = Date.now() - startTime; - - log('ERROR', 'Unexpected error during SubAgentFlow refinement', { - requestId, - errorMessage: error instanceof Error ? error.message : String(error), - executionTimeMs, - }); - - return { - success: false, - error: { - code: 'UNKNOWN_ERROR', - message: 'An unexpected error occurred during refinement', - details: error instanceof Error ? error.message : String(error), - }, - executionTimeMs, - }; - } -} diff --git a/src/extension/services/roo-code-extension-service.ts b/src/extension/services/roo-code-extension-service.ts deleted file mode 100644 index a26efd8e..00000000 --- a/src/extension/services/roo-code-extension-service.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Claude Code Workflow Studio - Roo Code Extension Service - * - * Wrapper for Roo Code VSCode Extension API. - * Uses the extension's exported API to start new tasks with :skill command. - */ - -import * as vscode from 'vscode'; - -const ROO_CODE_EXTENSION_ID = 'RooVeterinaryInc.roo-cline'; - -/** - * Check if Roo Code extension is installed - * - * @returns True if Roo Code extension is installed - */ -export function isRooCodeInstalled(): boolean { - return vscode.extensions.getExtension(ROO_CODE_EXTENSION_ID) !== undefined; -} - -/** - * Start a new task in Roo Code - * - * Activates the Roo Code extension if needed and calls startNewTask API. - * - * @param message - Message to send (e.g., ":skill my-skill") - * @returns True if the task was started successfully - */ -export async function startRooCodeTask(message: string): Promise { - const extension = vscode.extensions.getExtension(ROO_CODE_EXTENSION_ID); - if (!extension) { - return false; - } - - if (!extension.isActive) { - await extension.activate(); - } - - const api = extension.exports; - - if (api?.startNewTask) { - await api.startNewTask({ text: message }); - return true; - } - - return false; -} diff --git a/src/extension/services/roo-code-mcp-sync-service.ts b/src/extension/services/roo-code-mcp-sync-service.ts deleted file mode 100644 index 1523a4a5..00000000 --- a/src/extension/services/roo-code-mcp-sync-service.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * Claude Code Workflow Studio - Roo Code MCP Sync Service - * - * Handles MCP server configuration sync to {workspace}/.roo/mcp.json - * for Roo Code execution. - * - * Note: Roo Code uses JSON format for MCP configuration: - * - Config path: {workspace}/.roo/mcp.json - * - MCP servers section: mcpServers.{server_name} - */ - -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import { getMcpServerConfig } from './mcp-config-reader'; - -/** - * Roo Code mcp.json structure - */ -interface RooCodeMcpConfig { - mcpServers?: Record; -} - -/** - * MCP server configuration entry for Roo Code - */ -interface RooCodeMcpServerEntry { - command?: string; - args?: string[]; - env?: Record; - url?: string; -} - -/** - * Preview result for MCP server sync - */ -export interface RooCodeMcpSyncPreviewResult { - /** Server IDs that would be added to .roo/mcp.json */ - serversToAdd: string[]; - /** Server IDs that already exist in .roo/mcp.json */ - existingServers: string[]; - /** Server IDs not found in any Claude Code config */ - missingServers: string[]; -} - -/** - * Get the Roo Code MCP config file path - */ -function getRooCodeMcpConfigPath(workspacePath: string): string { - return path.join(workspacePath, '.roo', 'mcp.json'); -} - -/** - * Read existing Roo Code MCP config - */ -async function readRooCodeMcpConfig(workspacePath: string): Promise { - const configPath = getRooCodeMcpConfigPath(workspacePath); - - try { - const content = await fs.readFile(configPath, 'utf-8'); - return JSON.parse(content) as RooCodeMcpConfig; - } catch { - // File doesn't exist or invalid JSON - return { mcpServers: {} }; - } -} - -/** - * Write Roo Code MCP config to file - * - * @param workspacePath - Workspace path - * @param config - Config to write - */ -async function writeRooCodeMcpConfig( - workspacePath: string, - config: RooCodeMcpConfig -): Promise { - const configPath = getRooCodeMcpConfigPath(workspacePath); - const configDir = path.dirname(configPath); - - // Ensure .roo directory exists - await fs.mkdir(configDir, { recursive: true }); - - // Serialize config to JSON - const jsonContent = JSON.stringify(config, null, 2); - await fs.writeFile(configPath, jsonContent); -} - -/** - * Preview which MCP servers would be synced to .roo/mcp.json - * - * This function checks without actually writing, allowing for confirmation dialogs. - * - * @param serverIds - Server IDs to sync - * @param workspacePath - Workspace path for resolving project-scoped configs - * @returns Preview of servers to add, existing, and missing - */ -export async function previewMcpSyncForRooCode( - serverIds: string[], - workspacePath: string -): Promise { - if (serverIds.length === 0) { - return { serversToAdd: [], existingServers: [], missingServers: [] }; - } - - const existingConfig = await readRooCodeMcpConfig(workspacePath); - const existingServersMap = existingConfig.mcpServers || {}; - - const serversToAdd: string[] = []; - const existingServers: string[] = []; - const missingServers: string[] = []; - - for (const serverId of serverIds) { - if (existingServersMap[serverId]) { - existingServers.push(serverId); - } else { - // Check if server config exists in Claude Code - const serverConfig = getMcpServerConfig(serverId, workspacePath); - if (serverConfig) { - serversToAdd.push(serverId); - } else { - missingServers.push(serverId); - } - } - } - - return { serversToAdd, existingServers, missingServers }; -} - -/** - * Sync MCP server configurations to .roo/mcp.json for Roo Code - * - * Reads MCP server configs from all Claude Code scopes (project, local, user) - * and writes them to .roo/mcp.json in JSON format. - * Only adds servers that don't already exist in the config file. - * - * JSON output format: - * ```json - * { - * "mcpServers": { - * "server-name": { - * "command": "npx", - * "args": ["-y", "@some/mcp-server"], - * "env": { "API_KEY": "xxx" } - * } - * } - * } - * ``` - * - * @param serverIds - Server IDs to sync - * @param workspacePath - Workspace path for resolving project-scoped configs - * @returns Array of synced server IDs - */ -export async function syncMcpConfigForRooCode( - serverIds: string[], - workspacePath: string -): Promise { - if (serverIds.length === 0) { - return []; - } - - // Read existing config - const config = await readRooCodeMcpConfig(workspacePath); - - if (!config.mcpServers) { - config.mcpServers = {}; - } - - // Sync servers from all Claude Code scopes (project, local, user) - const syncedServers: string[] = []; - for (const serverId of serverIds) { - // Skip if already exists in config - if (config.mcpServers[serverId]) { - continue; - } - - // Get server config from Claude Code (searches all scopes) - const serverConfig = getMcpServerConfig(serverId, workspacePath); - if (!serverConfig) { - continue; - } - - // Convert to Roo Code format - const rooCodeEntry: RooCodeMcpServerEntry = {}; - - if (serverConfig.command) { - rooCodeEntry.command = serverConfig.command; - } - if (serverConfig.args && serverConfig.args.length > 0) { - rooCodeEntry.args = serverConfig.args; - } - if (serverConfig.env && Object.keys(serverConfig.env).length > 0) { - rooCodeEntry.env = serverConfig.env; - } - if (serverConfig.url) { - rooCodeEntry.url = serverConfig.url; - } - - config.mcpServers[serverId] = rooCodeEntry; - syncedServers.push(serverId); - } - - // Write updated config if any servers were added - if (syncedServers.length > 0) { - await writeRooCodeMcpConfig(workspacePath, config); - } - - return syncedServers; -} diff --git a/src/extension/services/roo-code-skill-export-service.ts b/src/extension/services/roo-code-skill-export-service.ts deleted file mode 100644 index 6b95bd17..00000000 --- a/src/extension/services/roo-code-skill-export-service.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Claude Code Workflow Studio - Roo Code Skill Export Service - * - * Handles workflow export to Roo Code Skills format (.roo/skills/name/SKILL.md) - * Skills format enables Roo Code to execute workflows using :skill command. - */ - -import * as path from 'node:path'; -import type { Workflow } from '../../shared/types/workflow-definition'; -import { nodeNameToFileName } from './export-service'; -import type { FileService } from './file-service'; -import { - generateExecutionInstructions, - generateMermaidFlowchart, -} from './workflow-prompt-generator'; - -/** - * Roo Code skill export result - */ -export interface RooCodeSkillExportResult { - success: boolean; - skillPath: string; - skillName: string; - errors?: string[]; -} - -/** - * Generate SKILL.md content from workflow for Roo Code - * - * @param workflow - Workflow to convert - * @returns SKILL.md content as string - */ -export function generateRooCodeSkillContent(workflow: Workflow): string { - const skillName = nodeNameToFileName(workflow.name); - - const description = - workflow.metadata?.description || - `Execute the "${workflow.name}" workflow. This skill guides through a structured workflow with defined steps and decision points.`; - - // Generate YAML frontmatter (Agent Skills specification) - const frontmatter = `--- -name: ${skillName} -description: ${description} ----`; - - // Generate Mermaid flowchart - const mermaidContent = generateMermaidFlowchart({ - nodes: workflow.nodes, - connections: workflow.connections, - }); - - // Generate execution instructions - const instructions = generateExecutionInstructions(workflow, { - provider: 'roo-code', - }); - - // Compose SKILL.md body - const body = `# ${workflow.name} - -## Workflow Diagram - -${mermaidContent} - -## Execution Instructions - -${instructions}`; - - return `${frontmatter}\n\n${body}`; -} - -/** - * Check if Roo Code skill already exists - * - * @param workflow - Workflow to check - * @param fileService - File service instance - * @returns Path to existing skill file, or null if not exists - */ -export async function checkExistingRooCodeSkill( - workflow: Workflow, - fileService: FileService -): Promise { - const workspacePath = fileService.getWorkspacePath(); - const skillName = nodeNameToFileName(workflow.name); - const skillPath = path.join(workspacePath, '.roo', 'skills', skillName, 'SKILL.md'); - - if (await fileService.fileExists(skillPath)) { - return skillPath; - } - return null; -} - -/** - * Export workflow as Roo Code Skill - * - * Exports to .roo/skills/{name}/SKILL.md - * - * @param workflow - Workflow to export - * @param fileService - File service instance - * @returns Export result - */ -export async function exportWorkflowAsRooCodeSkill( - workflow: Workflow, - fileService: FileService -): Promise { - try { - const workspacePath = fileService.getWorkspacePath(); - const skillName = nodeNameToFileName(workflow.name); - const skillDir = path.join(workspacePath, '.roo', 'skills', skillName); - const skillPath = path.join(skillDir, 'SKILL.md'); - - // Ensure directory exists - await fileService.createDirectory(skillDir); - - // Generate and write SKILL.md content - const content = generateRooCodeSkillContent(workflow); - await fileService.writeFile(skillPath, content); - - return { - success: true, - skillPath, - skillName, - }; - } catch (error) { - return { - success: false, - skillPath: '', - skillName: '', - errors: [error instanceof Error ? error.message : 'Unknown error'], - }; - } -} diff --git a/src/extension/services/schema-loader-service.ts b/src/extension/services/schema-loader-service.ts deleted file mode 100644 index 6ced3b95..00000000 --- a/src/extension/services/schema-loader-service.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Workflow Schema Loader Service - * - * Loads and caches the workflow schema documentation for AI context. - * Supports both JSON and TOON formats for A/B testing. - * Based on: /specs/001-ai-workflow-generation/research.md Q2 - */ - -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; -import type { SchemaFormat } from '../../shared/types/ai-metrics'; - -// In-memory caches for loaded schemas -let cachedJsonSchema: unknown | undefined; -let cachedToonSchema: string | undefined; - -export interface SchemaLoadResult { - success: boolean; - schema?: unknown; - schemaString?: string; // For TOON format (already serialized) - format: SchemaFormat; - sizeBytes: number; - error?: { - code: 'FILE_NOT_FOUND' | 'PARSE_ERROR' | 'UNKNOWN_ERROR'; - message: string; - details?: string; - }; -} - -/** - * Load workflow schema in JSON format (existing behavior) - * - * @param schemaPath - Absolute path to workflow-schema.json file - * @returns Load result with success status and schema/error - */ -export async function loadWorkflowSchema(schemaPath: string): Promise { - // Return cached schema if available - if (cachedJsonSchema !== undefined) { - return { - success: true, - schema: cachedJsonSchema, - format: 'json', - sizeBytes: JSON.stringify(cachedJsonSchema).length, - }; - } - - try { - // Read schema file - const schemaContent = await fs.readFile(schemaPath, 'utf-8'); - - // Parse JSON - const schema = JSON.parse(schemaContent); - - // Cache for future use - cachedJsonSchema = schema; - - return { - success: true, - schema, - format: 'json', - sizeBytes: schemaContent.length, - }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return { - success: false, - format: 'json', - sizeBytes: 0, - error: { - code: 'FILE_NOT_FOUND', - message: 'Workflow schema file not found', - details: `Path: ${schemaPath}`, - }, - }; - } - - if (error instanceof SyntaxError) { - return { - success: false, - format: 'json', - sizeBytes: 0, - error: { - code: 'PARSE_ERROR', - message: 'Failed to parse workflow schema JSON', - details: error.message, - }, - }; - } - - return { - success: false, - format: 'json', - sizeBytes: 0, - error: { - code: 'UNKNOWN_ERROR', - message: 'An unexpected error occurred while loading schema', - details: error instanceof Error ? error.message : String(error), - }, - }; - } -} - -/** - * Load workflow schema in TOON format - * Returns the raw TOON string for direct inclusion in prompts - * - * @param schemaPath - Absolute path to workflow-schema.json file (TOON path derived from it) - * @returns Load result with success status and schemaString/error - */ -export async function loadWorkflowSchemaToon(schemaPath: string): Promise { - // Derive TOON path from JSON path - const toonPath = schemaPath.replace('.json', '.toon'); - - // Return cached schema if available - if (cachedToonSchema !== undefined) { - return { - success: true, - schemaString: cachedToonSchema, - format: 'toon', - sizeBytes: cachedToonSchema.length, - }; - } - - try { - const toonContent = await fs.readFile(toonPath, 'utf-8'); - cachedToonSchema = toonContent; - - return { - success: true, - schemaString: toonContent, - format: 'toon', - sizeBytes: toonContent.length, - }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return { - success: false, - format: 'toon', - sizeBytes: 0, - error: { - code: 'FILE_NOT_FOUND', - message: 'TOON schema file not found. Falling back to JSON.', - details: `Path: ${toonPath}`, - }, - }; - } - - return { - success: false, - format: 'toon', - sizeBytes: 0, - error: { - code: 'UNKNOWN_ERROR', - message: 'An unexpected error occurred while loading TOON schema', - details: error instanceof Error ? error.message : String(error), - }, - }; - } -} - -/** - * Load workflow schema in specified format - * - * @param extensionPath - The extension's root path - * @param format - Schema format to load ('json' or 'toon') - * @returns Load result with schema data - */ -export async function loadWorkflowSchemaByFormat( - extensionPath: string, - format: SchemaFormat -): Promise { - const jsonPath = getDefaultSchemaPath(extensionPath); - - if (format === 'toon') { - const result = await loadWorkflowSchemaToon(jsonPath); - if (result.success) { - return result; - } - // Fallback to JSON if TOON fails - console.warn('TOON schema load failed, falling back to JSON'); - } - - return loadWorkflowSchema(jsonPath); -} - -/** - * Clear both schema caches (useful for testing or schema updates) - */ -export function clearSchemaCache(): void { - cachedJsonSchema = undefined; - cachedToonSchema = undefined; -} - -/** - * Get the default schema path for the extension - * - * @param extensionPath - The extension's root path from context.extensionPath - * @returns Absolute path to workflow-schema.json - */ -export function getDefaultSchemaPath(extensionPath: string): string { - return path.join(extensionPath, 'resources', 'workflow-schema.json'); -} diff --git a/src/extension/services/skill-file-generator.ts b/src/extension/services/skill-file-generator.ts deleted file mode 100644 index b5bd8cb1..00000000 --- a/src/extension/services/skill-file-generator.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * SKILL.md File Generator - * - * Feature: 001-skill-node - * Purpose: Generate SKILL.md file content from CreateSkillPayload - * - * Based on: specs/001-skill-node/tasks.md T025 - */ - -import type { CreateSkillPayload } from '../../shared/types/messages'; - -/** - * Generate SKILL.md file content - * - * Format: - * ```markdown - * --- - * name: [name] - * description: [description] - * allowed-tools: [allowedTools] (if provided) - * --- - * - * [instructions] - * ``` - * - * @param payload - Skill creation payload - * @returns SKILL.md file content string - * - * @example - * ```typescript - * const content = generateSkillFileContent({ - * name: 'test-generator', - * description: 'Generates unit tests', - * instructions: '# Test Generator\n\nGenerates tests...', - * allowedTools: 'Read, Write', - * scope: 'project', - * }); - * // Returns: - * // --- - * // name: test-generator - * // description: Generates unit tests - * // allowed-tools: Read, Write - * // --- - * // - * // # Test Generator - * // - * // Generates tests... - * ``` - */ -export function generateSkillFileContent(payload: CreateSkillPayload): string { - const frontmatter: string[] = [ - '---', - `name: ${payload.name}`, - `description: ${payload.description}`, - ]; - - // Add allowed-tools if provided - if (payload.allowedTools && payload.allowedTools.trim().length > 0) { - frontmatter.push(`allowed-tools: ${payload.allowedTools.trim()}`); - } - - frontmatter.push('---'); - - // Combine frontmatter and instructions - const content = [...frontmatter, '', payload.instructions].join('\n'); - - return content; -} diff --git a/src/extension/services/skill-normalization-service.ts b/src/extension/services/skill-normalization-service.ts deleted file mode 100644 index 7c9198cf..00000000 --- a/src/extension/services/skill-normalization-service.ts +++ /dev/null @@ -1,548 +0,0 @@ -/** - * Skill Normalization Service - * - * Handles copying skills from non-standard directories (.github/skills/, .codex/skills/, etc.) - * to .claude/skills/ for Claude Code execution. - * - * Background: - * - Skills are an Anthropic initiative; .claude/skills/ is the standard directory - * - AI agents (Claude Code, Codex CLI, Copilot CLI) should all read from .claude/skills/ - * - This service ensures compatibility when workflows reference skills from other directories - * - * Feature: Refactored from github-skill-copy-service.ts to support multiple source directories - */ - -import fs from 'node:fs/promises'; -import path from 'node:path'; -import * as vscode from 'vscode'; -import type { SkillNode, Workflow } from '../../shared/types/workflow-definition'; -import { getProjectSkillsDir, getWorkspaceRoot } from '../utils/path-utils'; - -/** - * Non-standard skill directory patterns that need normalization - * These directories are NOT the standard .claude/skills/ location - * - * To add a new AI provider's skill directory: - * 1. Add the pattern here (e.g., '.gemini/skills/') - * 2. Add the source type to SkillSourceType - * No changes required in handlers - the service handles it automatically - */ -const NON_STANDARD_SKILL_PATTERNS = [ - '.github/skills/', // GitHub Copilot CLI - '.copilot/skills/', // GitHub Copilot CLI (alternative) - '.codex/skills/', // OpenAI Codex CLI - '.roo/skills/', // Roo Code - '.gemini/skills/', // Google Gemini CLI - '.agent/skills/', // Google Antigravity - '.cursor/skills/', // Cursor (Anysphere) -] as const; - -/** - * Source type for skill directories - */ -export type SkillSourceType = 'github' | 'copilot' | 'codex' | 'roo-code' | 'gemini' | 'other'; - -/** - * Target CLI for workflow execution - * - * Each CLI has its own "native" skill directory that should be considered standard: - * - 'claude': Only .claude/skills/ is standard (default for export/Claude Code) - * - 'copilot': .claude/skills/, .github/skills/, AND .copilot/skills/ are standard - * - 'codex': .claude/skills/ AND .codex/skills/ are standard - */ -export type TargetCli = - | 'claude' - | 'copilot' - | 'codex' - | 'roo-code' - | 'gemini' - | 'antigravity' - | 'cursor'; - -/** - * Get the list of skill directory patterns that are considered "standard" for a given CLI - * - * @param targetCli - Target CLI for execution - * @returns Array of directory patterns that are standard for this CLI - */ -function getStandardSkillPatterns(targetCli: TargetCli): string[] { - // .claude/skills/ is always standard for all CLIs - const patterns = ['.claude/skills/']; - - switch (targetCli) { - case 'copilot': - // Copilot CLI considers .github/skills/ and .copilot/skills/ as native - patterns.push('.github/skills/', '.copilot/skills/'); - break; - case 'codex': - // Codex CLI considers .codex/skills/ as native - patterns.push('.codex/skills/'); - break; - case 'roo-code': - // Roo Code considers .roo/skills/ as native - patterns.push('.roo/skills/'); - break; - case 'gemini': - // Gemini CLI considers .gemini/skills/ as native - patterns.push('.gemini/skills/'); - break; - case 'antigravity': - // Antigravity reads from .agent/skills/ - patterns.push('.agent/skills/'); - break; - case 'cursor': - // Cursor reads from .cursor/skills/ - patterns.push('.cursor/skills/'); - break; - // case 'claude' falls through to default - // Claude Code only uses .claude/skills/ - } - - return patterns; -} - -/** - * Check if a skill path is from a standard directory for the given target CLI - * - * @param skillPath - Path to check (relative or absolute) - * @param targetCli - Target CLI for execution - * @returns True if the skill is from a standard directory for this CLI - */ -function isSkillFromStandardDir(skillPath: string, targetCli: TargetCli): boolean { - const normalizedPath = skillPath.replace(/\\/g, '/'); - const standardPatterns = getStandardSkillPatterns(targetCli); - - return standardPatterns.some((pattern) => normalizedPath.includes(pattern)); -} - -/** - * Information about a skill that needs to be normalized (copied) - */ -export interface SkillToNormalize { - /** Skill name (directory name) */ - name: string; - /** Source path (e.g., .github/skills/{name}/ or .codex/skills/{name}/) */ - sourcePath: string; - /** Destination path (.claude/skills/{name}/) */ - destinationPath: string; - /** Original directory type */ - sourceType: SkillSourceType; - /** Whether this would overwrite an existing skill in .claude/skills/ */ - wouldOverwrite: boolean; -} - -/** - * Result of checking which skills need normalization - */ -export interface SkillNormalizationCheckResult { - /** Skills that need to be normalized (copied to .claude/skills/) */ - skillsToNormalize: SkillToNormalize[]; - /** Skills that would overwrite existing files in .claude/skills/ */ - skillsToOverwrite: SkillToNormalize[]; - /** Skills skipped (already in .claude/skills/ or user scope) */ - skippedSkills: string[]; -} - -/** - * Result of the skill normalization operation - */ -export interface SkillNormalizationResult { - success: boolean; - cancelled?: boolean; - normalizedSkills?: string[]; - error?: string; -} - -/** - * Extract all SkillNode references from a workflow - * - * @param workflow - Workflow to extract skill nodes from - * @returns Array of SkillNode objects - */ -function extractSkillNodes(workflow: Workflow): SkillNode[] { - const skillNodes: SkillNode[] = []; - - // Extract from main workflow nodes - for (const node of workflow.nodes) { - if (node.type === 'skill') { - skillNodes.push(node as SkillNode); - } - } - - // Extract from subAgentFlows if present - if (workflow.subAgentFlows) { - for (const subFlow of workflow.subAgentFlows) { - for (const node of subFlow.nodes) { - if (node.type === 'skill') { - skillNodes.push(node as SkillNode); - } - } - } - } - - return skillNodes; -} - -/** - * Check if a skill path is from a non-standard directory - * - * Non-standard directories are any project-level skill directories - * other than .claude/skills/ (e.g., .github/skills/, .codex/skills/) - * - * @param skillPath - Path to check (relative or absolute) - * @returns True if the skill is from a non-standard directory - */ -export function isNonStandardSkillPath(skillPath: string): boolean { - // Normalize path separators for cross-platform compatibility - const normalizedPath = skillPath.replace(/\\/g, '/'); - return NON_STANDARD_SKILL_PATTERNS.some((pattern) => normalizedPath.includes(pattern)); -} - -/** - * Determine the source type based on skill path - * - * @param skillPath - Path to analyze - * @returns Source type identifier - */ -function getSourceType(skillPath: string): SkillSourceType { - const normalizedPath = skillPath.replace(/\\/g, '/'); - - if (normalizedPath.includes('.github/skills/')) { - return 'github'; - } - if (normalizedPath.includes('.copilot/skills/')) { - return 'copilot'; - } - if (normalizedPath.includes('.codex/skills/')) { - return 'codex'; - } - if (normalizedPath.includes('.roo/skills/')) { - return 'roo-code'; - } - if (normalizedPath.includes('.gemini/skills/')) { - return 'gemini'; - } - return 'other'; -} - -/** - * Get the source directory path for a given source type - * - * NOTE: Currently unused. Kept for potential future use. - * This function only supports project-scope paths, not user-scope paths (~/.copilot/skills/). - * For user-scope skills, use path.dirname(skillPath) directly. - * - * @param sourceType - Source type - * @returns Absolute path to the source skills directory, or null if no workspace - */ -function _getSourceSkillsDir(sourceType: SkillSourceType): string | null { - const workspaceRoot = getWorkspaceRoot(); - if (!workspaceRoot) { - return null; - } - - switch (sourceType) { - case 'github': - return path.join(workspaceRoot, '.github', 'skills'); - case 'copilot': - return path.join(workspaceRoot, '.copilot', 'skills'); - case 'codex': - return path.join(workspaceRoot, '.codex', 'skills'); - case 'roo-code': - return path.join(workspaceRoot, '.roo', 'skills'); - case 'gemini': - return path.join(workspaceRoot, '.gemini', 'skills'); - default: - return null; - } -} - -/** - * Extract skill directory name from a skill path - * - * @param skillPath - Path to SKILL.md file - * @returns Skill directory name (e.g., "my-skill") - */ -function getSkillName(skillPath: string): string { - // skillPath is like ".github/skills/my-skill/SKILL.md" or absolute path - const dir = path.dirname(skillPath); - return path.basename(dir); -} - -/** - * Check which skills need to be normalized (copied from non-standard directories to .claude/skills/) - * - * @param workflow - Workflow to check - * @param targetCli - Target CLI for execution (default: 'claude') - * @returns Check result with skills to normalize and overwrite information - */ -export async function checkSkillsToNormalize( - workflow: Workflow, - targetCli: TargetCli = 'claude' -): Promise { - const skillNodes = extractSkillNodes(workflow); - const workspaceRoot = getWorkspaceRoot(); - const projectSkillsDir = getProjectSkillsDir(); - - const skillsToNormalize: SkillToNormalize[] = []; - const skillsToOverwrite: SkillToNormalize[] = []; - const skippedSkills: string[] = []; - const processedNames = new Set(); - - if (!workspaceRoot || !projectSkillsDir) { - // No workspace - skip all - return { skillsToNormalize, skillsToOverwrite, skippedSkills }; - } - - for (const skillNode of skillNodes) { - const skillPath = skillNode.data.skillPath; - const skillName = getSkillName(skillPath); - - // Skip duplicates (same skill referenced multiple times) - if (processedNames.has(skillName)) { - continue; - } - processedNames.add(skillName); - - // Skip skills from standard directories for the target CLI - if (isSkillFromStandardDir(skillPath, targetCli)) { - skippedSkills.push(skillName); - continue; - } - - // Determine source type for metadata - const sourceType = getSourceType(skillPath); - - // Use the actual skill directory from skillPath - // path.resolve() handles both absolute paths (user-scope: ~/.copilot/skills/) and - // relative paths (project-scope: .github/skills/) by resolving against workspaceRoot - const sourcePath = path.resolve(workspaceRoot, path.dirname(skillPath)); - const destinationPath = path.join(projectSkillsDir, skillName); - - // Check if destination already exists - let wouldOverwrite = false; - try { - await fs.access(destinationPath); - wouldOverwrite = true; - } catch { - // Destination doesn't exist - good - } - - const skillInfo: SkillToNormalize = { - name: skillName, - sourcePath, - destinationPath, - sourceType, - wouldOverwrite, - }; - - if (wouldOverwrite) { - skillsToOverwrite.push(skillInfo); - } else { - skillsToNormalize.push(skillInfo); - } - } - - return { skillsToNormalize, skillsToOverwrite, skippedSkills }; -} - -/** - * Copy a skill directory from source to destination - * - * @param source - Source directory path - * @param destination - Destination directory path - */ -async function copySkillDirectory(source: string, destination: string): Promise { - // Create destination directory - await fs.mkdir(destination, { recursive: true }); - - // Read source directory contents - const entries = await fs.readdir(source, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = path.join(source, entry.name); - const destPath = path.join(destination, entry.name); - - if (entry.isDirectory()) { - // Recursively copy subdirectories - await copySkillDirectory(srcPath, destPath); - } else { - // Copy file - await fs.copyFile(srcPath, destPath); - } - } -} - -/** - * Get human-readable source description for display - * - * @param skills - Skills to describe - * @returns Formatted source description - */ -function getSourceDescription(skills: SkillToNormalize[]): string { - const sources = new Set(skills.map((s) => s.sourceType)); - const descriptions: string[] = []; - - if (sources.has('github')) { - descriptions.push('.github/skills/'); - } - if (sources.has('codex')) { - descriptions.push('.codex/skills/'); - } - if (sources.has('roo-code')) { - descriptions.push('.roo/skills/'); - } - if (sources.has('gemini')) { - descriptions.push('.gemini/skills/'); - } - if (sources.has('other')) { - descriptions.push('non-standard directories'); - } - - return descriptions.join(' and '); -} - -/** - * Prompt user and normalize skills (copy to .claude/skills/) - * - * Shows a confirmation dialog listing skills to copy. - * If any skills would overwrite existing files, shows a warning. - * - * @param workflow - Workflow being processed - * @param targetCli - Target CLI for execution (default: 'claude') - * @returns Normalization result - */ -export async function promptAndNormalizeSkills( - workflow: Workflow, - targetCli: TargetCli = 'claude' -): Promise { - const checkResult = await checkSkillsToNormalize(workflow, targetCli); - - const allSkillsToNormalize = [...checkResult.skillsToNormalize, ...checkResult.skillsToOverwrite]; - - // No skills need normalization - if (allSkillsToNormalize.length === 0) { - return { success: true, normalizedSkills: [] }; - } - - // Build message for confirmation dialog - const skillList = allSkillsToNormalize.map((s) => ` • ${s.name}`).join('\n'); - const sourceDescription = getSourceDescription(allSkillsToNormalize); - - let message = `This workflow uses ${allSkillsToNormalize.length} skill(s) from ${sourceDescription} that need to be copied to .claude/skills/:\n\n${skillList}`; - - // Add warning for overwrites - if (checkResult.skillsToOverwrite.length > 0) { - const overwriteList = checkResult.skillsToOverwrite.map((s) => ` • ${s.name}`).join('\n'); - message += `\n\n⚠️ The following skill(s) will be OVERWRITTEN:\n${overwriteList}`; - } - - message += '\n\nDo you want to copy these skills?'; - - // Show confirmation dialog - const answer = await vscode.window.showWarningMessage( - message, - { modal: true }, - 'Copy Skills', - 'Cancel' - ); - - if (answer !== 'Copy Skills') { - return { success: false, cancelled: true }; - } - - // Execute the normalization - return normalizeSkillsWithoutPrompt(workflow, targetCli); -} - -/** - * Normalize skills without prompting (for programmatic use or after user confirmation) - * - * @param workflow - Workflow to normalize skills for - * @param targetCli - Target CLI for execution (default: 'claude') - * @returns Normalization result - */ -export async function normalizeSkillsWithoutPrompt( - workflow: Workflow, - targetCli: TargetCli = 'claude' -): Promise { - const checkResult = await checkSkillsToNormalize(workflow, targetCli); - - const allSkillsToNormalize = [...checkResult.skillsToNormalize, ...checkResult.skillsToOverwrite]; - - // No skills need normalization - if (allSkillsToNormalize.length === 0) { - return { success: true, normalizedSkills: [] }; - } - - // Ensure .claude/skills directory exists - const projectSkillsDir = getProjectSkillsDir(); - if (!projectSkillsDir) { - return { success: false, error: 'No workspace folder found' }; - } - await fs.mkdir(projectSkillsDir, { recursive: true }); - - // Copy skills - const normalizedSkills: string[] = []; - for (const skill of allSkillsToNormalize) { - try { - // Remove existing directory if overwriting - if (skill.wouldOverwrite) { - await fs.rm(skill.destinationPath, { recursive: true, force: true }); - } - - await copySkillDirectory(skill.sourcePath, skill.destinationPath); - normalizedSkills.push(skill.name); - } catch (err) { - return { - success: false, - error: `Failed to copy skill "${skill.name}": ${err instanceof Error ? err.message : String(err)}`, - }; - } - } - - return { success: true, normalizedSkills }; -} - -/** - * Check if workflow has any skills from non-standard directories for the target CLI - * - * @param workflow - Workflow to check - * @param targetCli - Target CLI for execution (default: 'claude') - * @returns True if workflow has skills from non-standard directories for this CLI - */ -export function hasNonStandardSkills(workflow: Workflow, targetCli: TargetCli = 'claude'): boolean { - const skillNodes = extractSkillNodes(workflow); - return skillNodes.some((node) => !isSkillFromStandardDir(node.data.skillPath, targetCli)); -} - -// ============================================================================ -// Backward Compatibility Aliases (deprecated) -// ============================================================================ - -/** - * @deprecated Use hasNonStandardSkills() instead - */ -export function hasGithubSkills(workflow: Workflow, targetCli: TargetCli = 'claude'): boolean { - return hasNonStandardSkills(workflow, targetCli); -} - -/** - * @deprecated Use promptAndNormalizeSkills() instead - */ -export async function promptAndCopyGithubSkills( - workflow: Workflow, - targetCli: TargetCli = 'claude' -): Promise { - return promptAndNormalizeSkills(workflow, targetCli); -} - -/** - * @deprecated Use checkSkillsToNormalize() instead - */ -export async function checkSkillsToCopy( - workflow: Workflow, - targetCli: TargetCli = 'claude' -): Promise { - return checkSkillsToNormalize(workflow, targetCli); -} diff --git a/src/extension/services/skill-relevance-matcher.ts b/src/extension/services/skill-relevance-matcher.ts deleted file mode 100644 index 922dec86..00000000 --- a/src/extension/services/skill-relevance-matcher.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * Skill Relevance Matcher Service - * - * Feature: 001-ai-skill-generation - * Purpose: Calculate relevance scores between user descriptions and Skills using keyword matching - * - * Based on: specs/001-ai-skill-generation/data-model.md (Keyword Matching Algorithm) - */ - -import type { SkillReference } from '../../shared/types/messages'; - -/** - * Stopwords to exclude from tokenization - * Common English words that don't contribute to semantic matching - */ -const STOPWORDS = new Set([ - 'the', - 'a', - 'an', - 'is', - 'are', - 'to', - 'for', - 'of', - 'in', - 'on', - 'at', - 'with', - 'from', - 'by', - 'about', - 'as', - 'into', - 'through', - 'during', - 'and', - 'or', - 'but', - 'not', - 'so', - 'than', - 'too', - 'very', -]); - -/** - * Configuration constants - */ -export const MAX_SKILLS_IN_PROMPT = 20; // Limit to prevent timeout (plan.md constraint) -export const SKILL_RELEVANCE_THRESHOLD = 0.3; // Minimum score for inclusion (30%) - -/** - * Skill relevance score result - */ -export interface SkillRelevanceScore { - /** Reference to the Skill */ - skill: SkillReference; - /** Relevance score (0.0 ~ 1.0) */ - score: number; - /** Keywords that matched between description and Skill */ - matchedKeywords: string[]; -} - -/** - * Filtering options - */ -export interface FilterOptions { - /** Minimum relevance score (default: 0.6) */ - threshold?: number; - /** Maximum Skills to return (default: 20) */ - maxResults?: number; -} - -/** - * Tokenize text into lowercase words, excluding stopwords and short words - * - * @param text - Raw text string (user description or Skill description) - * @returns Array of lowercase words (length >= 3, stopwords removed) - * - * @example - * ```typescript - * tokenize("Create a workflow to analyze PDF documents"); - * // ["create", "workflow", "analyze", "pdf", "documents"] - * ``` - * - * Implementation: T008 - */ -export function tokenize(text: string): string[] { - return text - .toLowerCase() - .split(/\s+/) // Split by whitespace - .map((word) => word.replace(/[^a-z0-9-]/g, '')) // Remove punctuation - .filter((word) => word.length > 2) // Min length 3 chars - .filter((word) => !STOPWORDS.has(word)); // Remove common words -} - -/** - * Calculate relevance score between user description and a Skill - * - * Formula: score = |intersection| / sqrt(|userTokens| * |skillTokens|) - * - * @param userDescription - User's natural language workflow description - * @param skill - Skill to compare against - * @returns Relevance score with matched keywords - * - * @example - * ```typescript - * const score = calculateSkillRelevance( - * "Create a workflow to analyze PDF documents", - * { name: "pdf-analyzer", description: "Extracts text from PDF files", ... } - * ); - * // { skill: {...}, score: 0.72, matchedKeywords: ["pdf"] } - * ``` - * - * Implementation: T009 - */ -export function calculateSkillRelevance( - userDescription: string, - skill: SkillReference -): SkillRelevanceScore { - const userTokens = tokenize(userDescription); - // Include skill name in tokenization for matching - const skillTokens = tokenize(`${skill.name} ${skill.description}`); - - // Calculate intersection (matched keywords) - const userTokenSet = new Set(userTokens); - const matchedKeywords = skillTokens.filter((token) => userTokenSet.has(token)); - - // Calculate score using formula from data-model.md - let score = - userTokens.length === 0 || skillTokens.length === 0 - ? 0.0 - : matchedKeywords.length / Math.sqrt(userTokens.length * skillTokens.length); - - // Bonus: If user explicitly mentions the skill name, boost the score significantly - const skillNameLower = skill.name.toLowerCase(); - const userDescLower = userDescription.toLowerCase(); - if (userDescLower.includes(skillNameLower)) { - // Direct name mention = high relevance (minimum 0.8) - score = Math.max(score, 0.8); - } - - return { - skill, - score, - matchedKeywords, - }; -} - -/** - * Scope priority for sorting (higher number = higher priority) - */ -const SCOPE_PRIORITY: Record = { - project: 3, - local: 2, - user: 1, -}; - -/** - * Filter and rank Skills by relevance to user description - * - * Sorting order: - * 1. Score (descending) - * 2. Scope (project > local > user) - * 3. Name (alphabetical) - * - * @param userDescription - User's workflow description - * @param availableSkills - All scanned Skills - * @param options - Filtering options (threshold, maxResults) - * @returns Top N Skills sorted by relevance - * - * @example - * ```typescript - * const filtered = filterSkillsByRelevance( - * "Analyze PDF and extract data", - * allSkills, - * { threshold: 0.6, maxResults: 20 } - * ); - * // Returns top 20 Skills with score >= 0.6, sorted by relevance - * ``` - * - * Implementation: T011-T012 - */ -export function filterSkillsByRelevance( - userDescription: string, - availableSkills: SkillReference[], - options?: FilterOptions -): SkillRelevanceScore[] { - const threshold = options?.threshold ?? SKILL_RELEVANCE_THRESHOLD; - const maxResults = options?.maxResults ?? MAX_SKILLS_IN_PROMPT; - - // Calculate relevance for all Skills - const scored = availableSkills.map((skill) => calculateSkillRelevance(userDescription, skill)); - - // Filter by threshold - const filtered = scored.filter((item) => item.score >= threshold); - - // Handle duplicate Skill names: prefer higher-priority scope (T012) - const deduped = deduplicateSkills(filtered); - - // Sort by: score (desc), scope (project > local > user), name (alpha) - const sorted = deduped.sort((a, b) => { - // 1. Score (descending) - if (a.score !== b.score) { - return b.score - a.score; - } - - // 2. Scope priority (project > local > user) - const aPriority = SCOPE_PRIORITY[a.skill.scope] ?? 0; - const bPriority = SCOPE_PRIORITY[b.skill.scope] ?? 0; - if (aPriority !== bPriority) { - return bPriority - aPriority; - } - - // 3. Name (alphabetical) - return a.skill.name.localeCompare(b.skill.name); - }); - - // Return top N results - return sorted.slice(0, maxResults); -} - -/** - * Remove duplicate Skills by name, preferring higher-priority scope - * - * @param skills - Skills with relevance scores - * @returns Deduplicated Skills (higher-priority scope preferred) - * - * Implementation: T012 - */ -function deduplicateSkills(skills: SkillRelevanceScore[]): SkillRelevanceScore[] { - const seen = new Map(); - - for (const item of skills) { - const existing = seen.get(item.skill.name); - - if (!existing) { - // First occurrence - seen.set(item.skill.name, item); - } else { - // Prefer higher-priority scope (project > local > user) - const existingPriority = SCOPE_PRIORITY[existing.skill.scope] ?? 0; - const itemPriority = SCOPE_PRIORITY[item.skill.scope] ?? 0; - if (itemPriority > existingPriority) { - seen.set(item.skill.name, item); - } - } - } - - return Array.from(seen.values()); -} diff --git a/src/extension/services/skill-service.ts b/src/extension/services/skill-service.ts deleted file mode 100644 index 7b906cc1..00000000 --- a/src/extension/services/skill-service.ts +++ /dev/null @@ -1,582 +0,0 @@ -/** - * Skill Service - File I/O Operations for Skills - * - * Feature: 001-skill-node - * Purpose: Scan, validate, and create SKILL.md files - * - * Based on: specs/001-skill-node/research.md Section 2 - */ - -import fs from 'node:fs/promises'; -import path from 'node:path'; -import type { CreateSkillPayload, SkillReference } from '../../shared/types/messages'; -import { - getAntigravityProjectSkillsDir, - getAntigravityUserSkillsDir, - getCodexProjectSkillsDir, - getCodexUserSkillsDir, - getCopilotUserSkillsDir, - getCursorProjectSkillsDir, - getCursorUserSkillsDir, - getGeminiProjectSkillsDir, - getGeminiUserSkillsDir, - getGithubSkillsDir, - getInstalledPluginsJsonPath, - getKnownMarketplacesJsonPath, - getProjectSkillsDir, - getRooProjectSkillsDir, - getRooUserSkillsDir, - getUserSkillsDir, - getWorkspaceRoot, - toRelativePath, -} from '../utils/path-utils'; -import { generateSkillFileContent } from './skill-file-generator'; -import { parseSkillFrontmatter, type SkillMetadata } from './yaml-parser'; - -/** - * Scan a Skills directory and return available Skills - * - * @param baseDir - Base directory to scan (e.g., ~/.claude/skills/) - * @param scope - Skill scope ('user', 'project', or 'local') - * @param source - Source directory type for project skills ('claude' or 'copilot') - * @returns Array of Skill references - * - * @example - * ```typescript - * const userSkills = await scanSkills('/Users/alice/.claude/skills', 'user'); - * // [{ name: 'my-skill', description: '...', scope: 'user', ... }] - * ``` - */ -export async function scanSkills( - baseDir: string, - scope: 'user' | 'project' | 'local', - source?: 'claude' | 'copilot' | 'codex' | 'roo' | 'gemini' | 'antigravity' | 'cursor' -): Promise { - const skills: SkillReference[] = []; - - try { - const subdirs = await fs.readdir(baseDir, { withFileTypes: true }); - - for (const dirent of subdirs) { - if (!dirent.isDirectory()) { - continue; // Skip non-directories - } - - const skillPath = path.join(baseDir, dirent.name, 'SKILL.md'); - - try { - const content = await fs.readFile(skillPath, 'utf-8'); - const metadata = parseSkillFrontmatter(content); - - if (metadata) { - // Convert to relative path for project Skills (T020) - const pathToStore = scope === 'project' ? toRelativePath(skillPath, scope) : skillPath; - - skills.push({ - skillPath: pathToStore, - name: metadata.name, - description: metadata.description, - scope, - validationStatus: 'valid', - allowedTools: metadata.allowedTools, - source, // Track source for project skills (claude or github) - }); - } else { - // Invalid frontmatter - log and skip - console.warn(`[Skill Service] Invalid YAML frontmatter in ${skillPath}`); - } - } catch (err) { - // File not found or read error - skip this Skill - console.warn(`[Skill Service] Failed to read ${skillPath}:`, err); - } - } - } catch (_err) { - // Directory doesn't exist - return empty array - console.warn(`[Skill Service] Skill directory not found: ${baseDir}`); - } - - return skills; -} - -/** - * Structure of ~/.claude/plugins/installed_plugins.json - */ -interface InstalledPluginsJson { - version?: number; - plugins?: Record< - string, - Array<{ - scope?: string; - installPath?: string; - projectPath?: string; // Only for project scope - indicates which project this plugin belongs to - version?: string; - }> - >; -} - -/** - * Structure of ~/.claude/plugins/known_marketplaces.json - */ -interface KnownMarketplaces { - [marketplaceName: string]: { - source?: { - source?: string; - url?: string; - repo?: string; - path?: string; - }; - installLocation?: string; - }; -} - -/** - * Structure of .claude-plugin/marketplace.json - */ -interface MarketplaceConfig { - name?: string; - plugins?: Array<{ - name?: string; - skills?: string[]; - }>; -} - -/** - * Load known marketplaces from known_marketplaces.json - */ -async function loadKnownMarketplaces(): Promise { - const marketplacesPath = getKnownMarketplacesJsonPath(); - - try { - const content = await fs.readFile(marketplacesPath, 'utf-8'); - return JSON.parse(content) as KnownMarketplaces; - } catch { - return {}; - } -} - -/** - * Parse plugin ID to extract plugin name and marketplace name - * Format: "{plugin-name}@{marketplace-name}" - */ -function parsePluginId(pluginId: string): { pluginName: string; marketplaceName: string } | null { - const atIndex = pluginId.lastIndexOf('@'); - if (atIndex === -1) return null; - - return { - pluginName: pluginId.substring(0, atIndex), - marketplaceName: pluginId.substring(atIndex + 1), - }; -} - -/** - * Map plugin scope string to SkillReference scope - */ -function mapPluginScope(pluginScope: string | undefined): 'user' | 'project' | 'local' { - switch (pluginScope) { - case 'user': - return 'user'; - case 'project': - return 'project'; - case 'local': - return 'local'; - default: - // Default to 'user' if scope is undefined or unknown - return 'user'; - } -} - -/** - * Scan all Plugin Skills using marketplaces path - * - * Uses stable marketplaces path instead of cache path to avoid - * path invalidation when plugins are updated. - * - * Flow: - * 1. Read installed_plugins.json to get installed plugin IDs - * 2. Read settings.json to filter enabled plugins - * 3. Read known_marketplaces.json to get marketplace install locations - * 4. For each enabled plugin, scan skills from marketplaces path - * 5. Filter project-scoped plugins by projectPath matching current workspace - * - * @returns Array of Plugin Skill references with their installation scope - */ -export async function scanPluginSkills(): Promise { - const installedPluginsPath = getInstalledPluginsJsonPath(); - const skills: SkillReference[] = []; - const currentWorkspace = getWorkspaceRoot(); - - try { - // Load configuration files - // Note: enabledPlugins in settings.json is NOT used to filter plugin skills - // Plugin presence in installed_plugins.json indicates it's installed and available - const [knownMarketplaces, installedPluginsContent] = await Promise.all([ - loadKnownMarketplaces(), - fs.readFile(installedPluginsPath, 'utf-8'), - ]); - - const installedPlugins: InstalledPluginsJson = JSON.parse(installedPluginsContent); - - if (!installedPlugins.plugins) { - return skills; - } - - // Process each installed plugin - for (const pluginId of Object.keys(installedPlugins.plugins)) { - // Get the plugin's installations (may have multiple with different scopes) - const installations = installedPlugins.plugins[pluginId]; - if (!installations || installations.length === 0) continue; - - // Find the best matching installation for current workspace - // Priority: project (matching projectPath) > local > user - let selectedInstallation = installations[0]; - let skillScope = mapPluginScope(selectedInstallation.scope); - - for (const installation of installations) { - const installScope = mapPluginScope(installation.scope); - - // For project-scoped installations, check if projectPath matches current workspace - if (installScope === 'project') { - if (installation.projectPath && currentWorkspace) { - // Normalize paths for comparison - const normalizedProjectPath = path.normalize(installation.projectPath); - const normalizedWorkspace = path.normalize(currentWorkspace); - - if (normalizedProjectPath === normalizedWorkspace) { - // Found a project-scoped installation for this workspace - selectedInstallation = installation; - skillScope = 'project'; - break; // Project scope has highest priority - } - } - // Skip project-scoped installations that don't match current workspace - continue; - } - - // Prefer local over user - if (installScope === 'local' && skillScope === 'user') { - selectedInstallation = installation; - skillScope = 'local'; - } - } - - // If the selected installation is project-scoped but doesn't match, skip it - if (skillScope === 'project' && selectedInstallation.projectPath) { - if (!currentWorkspace) continue; - const normalizedProjectPath = path.normalize(selectedInstallation.projectPath); - const normalizedWorkspace = path.normalize(currentWorkspace); - if (normalizedProjectPath !== normalizedWorkspace) continue; - } - - // Parse plugin ID to get marketplace name - const parsed = parsePluginId(pluginId); - if (!parsed) continue; - - // Get marketplace install location - const marketplace = knownMarketplaces[parsed.marketplaceName]; - if (!marketplace?.installLocation) continue; - - // Scan skills from marketplace path - await scanMarketplacePlugin( - marketplace.installLocation, - parsed.pluginName, - skillScope, - skills - ); - } - } catch (_err) { - console.warn(`[Skill Service] Could not read installed_plugins.json: ${installedPluginsPath}`); - } - - return skills; -} - -/** - * Scan skills from a specific plugin within a marketplace - */ -async function scanMarketplacePlugin( - marketplaceLocation: string, - pluginName: string, - scope: 'user' | 'project' | 'local', - skills: SkillReference[] -): Promise { - const marketplaceJsonPath = path.join(marketplaceLocation, '.claude-plugin', 'marketplace.json'); - - try { - const marketplaceContent = await fs.readFile(marketplaceJsonPath, 'utf-8'); - const marketplace: MarketplaceConfig = JSON.parse(marketplaceContent); - - // Find the specific plugin in marketplace.json - const pluginConfig = marketplace.plugins?.find((p) => p.name === pluginName); - - if (pluginConfig?.skills && Array.isArray(pluginConfig.skills)) { - // Scan skills listed in plugin config - for (const skillRelPath of pluginConfig.skills) { - const skillDir = path.resolve(marketplaceLocation, skillRelPath); - const skillPath = path.join(skillDir, 'SKILL.md'); - - try { - const content = await fs.readFile(skillPath, 'utf-8'); - const metadata = parseSkillFrontmatter(content); - - if (metadata) { - // Skip if skill with same name already exists (name-based dedup) - if (skills.some((s) => s.name === metadata.name)) continue; - - skills.push({ - skillPath, - name: metadata.name, - description: metadata.description, - scope, - validationStatus: 'valid', - allowedTools: metadata.allowedTools, - }); - } - } catch { - // Skill file not found or invalid - skip - } - } - } else { - // Fallback: scan default 'skills/' directory - const defaultSkillsDir = path.join(marketplaceLocation, 'skills'); - try { - const skillDirs = await fs.readdir(defaultSkillsDir, { withFileTypes: true }); - for (const skillDir of skillDirs) { - if (!skillDir.isDirectory()) continue; - - const skillPath = path.join(defaultSkillsDir, skillDir.name, 'SKILL.md'); - - try { - const content = await fs.readFile(skillPath, 'utf-8'); - const metadata = parseSkillFrontmatter(content); - - if (metadata) { - if (skills.some((s) => s.name === metadata.name)) continue; - - skills.push({ - skillPath, - name: metadata.name, - description: metadata.description, - scope, - validationStatus: 'valid', - allowedTools: metadata.allowedTools, - }); - } - } catch { - // Skill file not found or invalid - skip - } - } - } catch { - // No default skills directory - } - } - } catch { - // No marketplace.json or invalid - skip - } -} - -/** - * Scan user, project, and plugin Skills - * - * Scans skills from multiple directories: - * - User: ~/.claude/skills/ (source: 'claude') - * - User: ~/.copilot/skills/ (source: 'copilot') - * - User: ~/.codex/skills/ (source: 'codex') - * - Project: .claude/skills/ (source: 'claude') - * - Project: .github/skills/ (source: 'copilot') - * - Project: .codex/skills/ (source: 'codex') - * - Local: Plugin-provided skills - * - * Skills are NOT deduplicated - all sources are shown so users can see all available skills. - * - * @returns Object containing user, project, and local Skills - */ -export async function scanAllSkills(): Promise<{ - user: SkillReference[]; - project: SkillReference[]; - local: SkillReference[]; -}> { - // User directories - const claudeUserDir = getUserSkillsDir(); - const copilotUserDir = getCopilotUserSkillsDir(); - const codexUserDir = getCodexUserSkillsDir(); - const rooUserDir = getRooUserSkillsDir(); - const geminiUserDir = getGeminiUserSkillsDir(); - const antigravityUserDir = getAntigravityUserSkillsDir(); - const cursorUserDir = getCursorUserSkillsDir(); - - // Project directories - const claudeProjectDir = getProjectSkillsDir(); - const githubProjectDir = getGithubSkillsDir(); - const codexProjectDir = getCodexProjectSkillsDir(); - const rooProjectDir = getRooProjectSkillsDir(); - const geminiProjectDir = getGeminiProjectSkillsDir(); - const antigravityProjectDir = getAntigravityProjectSkillsDir(); - const cursorProjectDir = getCursorProjectSkillsDir(); - - const [ - claudeUserSkills, - copilotUserSkills, - codexUserSkills, - rooUserSkills, - geminiUserSkills, - antigravityUserSkills, - cursorUserSkills, - claudeProjectSkills, - githubProjectSkills, - codexProjectSkills, - rooProjectSkills, - geminiProjectSkills, - antigravityProjectSkills, - cursorProjectSkills, - pluginSkills, - ] = await Promise.all([ - // User-scope scans - scanSkills(claudeUserDir, 'user', 'claude'), - scanSkills(copilotUserDir, 'user', 'copilot'), - scanSkills(codexUserDir, 'user', 'codex'), - scanSkills(rooUserDir, 'user', 'roo'), - scanSkills(geminiUserDir, 'user', 'gemini'), - scanSkills(antigravityUserDir, 'user', 'antigravity'), - scanSkills(cursorUserDir, 'user', 'cursor'), - // Project-scope scans - claudeProjectDir ? scanSkills(claudeProjectDir, 'project', 'claude') : Promise.resolve([]), - githubProjectDir ? scanSkills(githubProjectDir, 'project', 'copilot') : Promise.resolve([]), - codexProjectDir ? scanSkills(codexProjectDir, 'project', 'codex') : Promise.resolve([]), - rooProjectDir ? scanSkills(rooProjectDir, 'project', 'roo') : Promise.resolve([]), - geminiProjectDir ? scanSkills(geminiProjectDir, 'project', 'gemini') : Promise.resolve([]), - antigravityProjectDir - ? scanSkills(antigravityProjectDir, 'project', 'antigravity') - : Promise.resolve([]), - cursorProjectDir ? scanSkills(cursorProjectDir, 'project', 'cursor') : Promise.resolve([]), - // Plugin skills - scanPluginSkills(), - ]); - - // Merge user skills: include all sources (no deduplication - show all available skills) - const user: SkillReference[] = [ - ...claudeUserSkills, - ...copilotUserSkills, - ...codexUserSkills, - ...rooUserSkills, - ...geminiUserSkills, - ...antigravityUserSkills, - ...cursorUserSkills, - ]; - - // Merge project skills: include all sources (no deduplication - show all available skills) - const project: SkillReference[] = [ - ...claudeProjectSkills, - ...githubProjectSkills, - ...codexProjectSkills, - ...rooProjectSkills, - ...geminiProjectSkills, - ...antigravityProjectSkills, - ...cursorProjectSkills, - ]; - - // Separate plugin skills by their scope - const local: SkillReference[] = []; - for (const skill of pluginSkills) { - if (skill.scope === 'local') { - local.push(skill); - } else if (skill.scope === 'user') { - // Add to user skills, but avoid duplicates by name - if (!user.some((s) => s.name === skill.name)) { - user.push(skill); - } - } else if (skill.scope === 'project') { - // Add to project skills, but avoid duplicates by name - if (!project.some((s) => s.name === skill.name)) { - project.push(skill); - } - } - } - - return { user, project, local }; -} - -/** - * Validate a SKILL.md file and return metadata - * - * @param skillPath - Absolute path to SKILL.md file - * @returns Skill metadata - * @throws Error if file not found or invalid frontmatter - */ -export async function validateSkillFile(skillPath: string): Promise { - try { - const content = await fs.readFile(skillPath, 'utf-8'); - const metadata = parseSkillFrontmatter(content); - - if (!metadata) { - throw new Error( - 'Invalid SKILL.md frontmatter: missing required fields (name or description)' - ); - } - - return metadata; - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - throw new Error(`SKILL.md file not found at ${skillPath}`); - } - throw err; - } -} - -/** - * Create a new Skill - * - * @param payload - Skill creation payload - * @returns Absolute path to created SKILL.md file - * @throws Error if validation fails or file write fails - * - * Implementation: T024-T025 - */ -export async function createSkill(payload: CreateSkillPayload): Promise { - // 1. Get base directory for scope - const baseDir = payload.scope === 'user' ? getUserSkillsDir() : getProjectSkillsDir(); - - if (!baseDir) { - throw new Error('No workspace folder is open. Cannot create project Skill.'); - } - - // 2. Check for name collision - const skillDir = path.join(baseDir, payload.name); - const skillPath = path.join(skillDir, 'SKILL.md'); - - try { - await fs.access(skillDir); - // Directory exists - name collision - throw new Error( - `A Skill with the name "${payload.name}" already exists in ${baseDir}. Choose a different name.` - ); - } catch (err) { - // Directory doesn't exist - this is what we want (continue) - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - // Some other error occurred - throw err; - } - } - - // 3. Create directory - await fs.mkdir(skillDir, { recursive: true }); - - // 4. Generate and write SKILL.md content - const content = generateSkillFileContent(payload); - - try { - await fs.writeFile(skillPath, content, 'utf-8'); - } catch (err) { - // Clean up directory if file write fails - try { - await fs.rm(skillDir, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } - throw new Error( - `Failed to create SKILL.md file: ${err instanceof Error ? err.message : String(err)}` - ); - } - - // 5. Return absolute path - return skillPath; -} diff --git a/src/extension/services/slack-api-service.ts b/src/extension/services/slack-api-service.ts deleted file mode 100644 index f28b0764..00000000 --- a/src/extension/services/slack-api-service.ts +++ /dev/null @@ -1,522 +0,0 @@ -/** - * Slack API Service - * - * Provides high-level interface to Slack Web API. - * Handles authentication, error handling, and response parsing. - * - * Based on specs/001-slack-workflow-sharing/contracts/slack-api-contracts.md - */ - -import { WebClient } from '@slack/web-api'; -import type { SlackChannel } from '../types/slack-integration-types'; -import { handleSlackError } from '../utils/slack-error-handler'; -import { - buildWorkflowMessageBlocks, - type WorkflowMessageBlock, -} from '../utils/slack-message-builder'; -import type { SlackTokenManager } from '../utils/slack-token-manager'; - -/** - * Workflow file upload options - */ -export interface WorkflowUploadOptions { - /** Target workspace ID */ - workspaceId: string; - /** Workflow JSON content */ - content: string; - /** Filename */ - filename: string; - /** File title */ - title: string; - /** Target channel ID */ - channelId: string; - /** Initial comment (optional) */ - initialComment?: string; - /** Thread timestamp to upload file as reply (optional) */ - threadTs?: string; -} - -/** - * Message post result - */ -export interface MessagePostResult { - /** Channel ID */ - channelId: string; - /** Message timestamp */ - messageTs: string; - /** Permalink to message */ - permalink: string; -} - -/** - * File upload result - */ -export interface FileUploadResult { - /** File ID */ - fileId: string; - /** File download URL (private) */ - fileUrl: string; - /** File permalink */ - permalink: string; -} - -/** - * Workflow search options - */ -export interface WorkflowSearchOptions { - /** Target workspace ID */ - workspaceId: string; - /** Search query */ - query?: string; - /** Filter by channel ID */ - channelId?: string; - /** Number of results (max: 100) */ - count?: number; - /** Sort order (score | timestamp) */ - sort?: 'score' | 'timestamp'; -} - -/** - * Search result - */ -export interface SearchResult { - /** Message timestamp */ - messageTs: string; - /** Channel ID */ - channelId: string; - /** Channel name */ - channelName: string; - /** Message text */ - text: string; - /** User ID */ - userId: string; - /** Permalink to message */ - permalink: string; - /** Attached file ID (if exists) */ - fileId?: string; - /** File name (if exists) */ - fileName?: string; - /** File download URL (if exists) */ - fileUrl?: string; -} - -/** - * Slack API Service - * - * Wraps Slack Web API with authentication and error handling. - * Supports multiple workspace connections. - */ -export class SlackApiService { - /** Workspace-specific WebClient cache (User Token) */ - private userClients: Map = new Map(); - - constructor(private readonly tokenManager: SlackTokenManager) {} - - /** - * Initializes Slack client for specific workspace with User access token - * - * All Slack API operations use User Token to ensure user can only - * access channels they are a member of. - * - * @param workspaceId - Target workspace ID - * @returns WebClient - * @throws Error if User Token not available - */ - private async ensureUserClient(workspaceId: string): Promise { - // Return cached client if exists - let client = this.userClients.get(workspaceId); - if (client) { - return client; - } - - // Get User access token for this workspace - const userAccessToken = await this.tokenManager.getUserAccessTokenByWorkspaceId(workspaceId); - - if (!userAccessToken) { - const error = new Error( - `Workspace ${workspaceId} is not connected or User Token not available` - ); - (error as Error & { code: string; workspaceId: string }).code = 'USER_TOKEN_REQUIRED'; - (error as Error & { code: string; workspaceId: string }).workspaceId = workspaceId; - throw error; - } - - // Create and cache new client - client = new WebClient(userAccessToken); - this.userClients.set(workspaceId, client); - - return client; - } - - /** - * Invalidates cached client (forces re-authentication) - * - * @param workspaceId - Optional workspace ID. If not provided, clears all cached clients. - */ - invalidateClient(workspaceId?: string): void { - if (workspaceId) { - this.userClients.delete(workspaceId); - } else { - this.userClients.clear(); - } - } - - /** - * Gets list of Slack channels - * - * Uses User Token (xoxp-...) for accurate channel listing based on - * authenticated user's membership. - * - * @param workspaceId - Target workspace ID - * @param includePrivate - Include private channels (default: true) - * @param onlyMember - Only channels user is a member of (default: true) - * @returns Array of channels - */ - async getChannels( - workspaceId: string, - includePrivate = true, - onlyMember = true - ): Promise { - try { - const client = await this.ensureUserClient(workspaceId); - - // Build channel types filter - const types: string[] = ['public_channel']; - if (includePrivate) { - types.push('private_channel'); - } - - // Fetch channels (with pagination) - const channels: SlackChannel[] = []; - let cursor: string | undefined; - - do { - const response = await client.conversations.list({ - types: types.join(','), - exclude_archived: true, - limit: 100, - cursor, - }); - - if (!response.ok || !response.channels) { - throw new Error('チャンネル一覧の取得に失敗しました'); - } - - // Map to SlackChannel type - for (const channel of response.channels) { - const isMember = channel.is_member ?? false; - - // Filter by membership if requested - if (onlyMember && !isMember) { - continue; - } - - channels.push({ - id: channel.id as string, - name: channel.name as string, - isPrivate: channel.is_private ?? false, - isMember, - memberCount: channel.num_members, - purpose: channel.purpose?.value, - topic: channel.topic?.value, - }); - } - - cursor = response.response_metadata?.next_cursor; - } while (cursor); - - return channels; - } catch (error) { - const errorInfo = handleSlackError(error); - throw new Error(errorInfo.message); - } - } - - /** - * Uploads workflow file to Slack - * - * Uses User Token to upload files as the authenticated user. - * - * @param options - Upload options - * @returns File upload result - */ - async uploadWorkflowFile(options: WorkflowUploadOptions): Promise { - const client = await this.ensureUserClient(options.workspaceId); - - // Upload file using files.uploadV2 - const response = await client.files.uploadV2({ - channel_id: options.channelId, - file: Buffer.from(options.content, 'utf-8'), - filename: options.filename, - title: options.title, - initial_comment: options.initialComment, - thread_ts: options.threadTs, - }); - - if (!response.ok) { - throw new Error('ファイルのアップロードに失敗しました'); - } - - const responseObj = response as unknown as Record; - - // files.uploadV2 returns nested structure: response.files[0].files[0] - const file = responseObj.file as Record | undefined; - const filesWrapper = responseObj.files as Array> | undefined; - - let fileData: Record | undefined = file; - - // If no direct file object, try to get from nested structure - if (!fileData && filesWrapper && filesWrapper.length > 0) { - const innerWrapper = filesWrapper[0]; - const innerFiles = innerWrapper.files as Array> | undefined; - - if (innerFiles && innerFiles.length > 0) { - fileData = innerFiles[0]; - } - } - - if (!fileData) { - throw new Error('ファイルのアップロードに失敗しました'); - } - - return { - fileId: fileData.id as string, - fileUrl: fileData.url_private as string, - permalink: fileData.permalink as string, - }; - } - - /** - * Posts rich message card to channel - * - * Uses User Token to post messages as the authenticated user. - * This ensures the user can only post to channels they are a member of. - * - * @param workspaceId - Target workspace ID - * @param channelId - Target channel ID - * @param block - Workflow message block - * @returns Message post result - */ - async postWorkflowMessage( - workspaceId: string, - channelId: string, - block: WorkflowMessageBlock - ): Promise { - const client = await this.ensureUserClient(workspaceId); - - // Build Block Kit blocks - const blocks = buildWorkflowMessageBlocks(block); - - // Post message - const response = await client.chat.postMessage({ - channel: channelId, - text: `New workflow shared: ${block.name}`, - // biome-ignore lint/suspicious/noExplicitAny: Slack Web API type definitions are incomplete - blocks: blocks as any, - }); - - if (!response.ok) { - throw new Error('メッセージの投稿に失敗しました'); - } - - // Get permalink - const permalinkResponse = await client.chat.getPermalink({ - channel: channelId, - message_ts: response.ts as string, - }); - - return { - channelId, - messageTs: response.ts as string, - permalink: (permalinkResponse.permalink as string) || '', - }; - } - - /** - * Searches for workflow messages - * - * Uses User Token to search messages accessible to the authenticated user. - * - * @param options - Search options - * @returns Array of search results - */ - async searchWorkflows(options: WorkflowSearchOptions): Promise { - try { - const client = await this.ensureUserClient(options.workspaceId); - - // Build search query - let query = 'workflow filename:*.json'; - if (options.query) { - query = `${options.query} ${query}`; - } - if (options.channelId) { - query = `${query} in:<#${options.channelId}>`; - } - - // Search messages - const response = await client.search.messages({ - query, - count: Math.min(options.count || 20, 100), - sort: options.sort || 'timestamp', - }); - - if (!response.ok || !response.messages) { - throw new Error('ワークフロー検索に失敗しました'); - } - - const matches = response.messages.matches || []; - const results: SearchResult[] = []; - - for (const match of matches) { - const file = match.files?.[0]; - - results.push({ - messageTs: match.ts as string, - channelId: match.channel?.id as string, - channelName: match.channel?.name as string, - text: match.text as string, - userId: match.user as string, - permalink: match.permalink as string, - fileId: file?.id, - fileName: file?.name, - fileUrl: file?.url_private, - }); - } - - return results; - } catch (error) { - const errorInfo = handleSlackError(error); - throw new Error(errorInfo.message); - } - } - - /** - * Validates User Token for specific workspace - * - * @param workspaceId - Target workspace ID - * @returns True if User Token is valid - */ - async validateToken(workspaceId: string): Promise { - try { - const client = await this.ensureUserClient(workspaceId); - const response = await client.auth.test(); - return response.ok === true; - } catch (_error) { - return false; - } - } - - /** - * Gets list of connected workspaces - * - * @returns Array of workspace connections - */ - async getWorkspaces() { - return this.tokenManager.getWorkspaces(); - } - - /** - * Updates existing workflow message with new content - * - * Uses User Token to update messages posted by the authenticated user. - * - * @param workspaceId - Target workspace ID - * @param channelId - Target channel ID - * @param messageTs - Message timestamp to update - * @param block - Updated workflow message block - */ - async updateWorkflowMessage( - workspaceId: string, - channelId: string, - messageTs: string, - block: WorkflowMessageBlock - ): Promise { - const client = await this.ensureUserClient(workspaceId); - - // Build Block Kit blocks - const blocks = buildWorkflowMessageBlocks(block); - - // Update message - const response = await client.chat.update({ - channel: channelId, - ts: messageTs, - text: `Workflow shared: ${block.name}`, - // biome-ignore lint/suspicious/noExplicitAny: Slack Web API type definitions are incomplete - blocks: blocks as any, - }); - - if (!response.ok) { - throw new Error('メッセージの更新に失敗しました'); - } - } - - /** - * Downloads workflow file from Slack - * - * Uses User Token to download files accessible to the authenticated user. - * - * @param workspaceId - Target workspace ID - * @param fileId - Slack file ID to download - * @returns Workflow JSON content as string - */ - async downloadWorkflowFile(workspaceId: string, fileId: string): Promise { - try { - const client = await this.ensureUserClient(workspaceId); - - // Get file info using files.info API - const response = await client.files.info({ - file: fileId, - }); - - if (!response.ok || !response.file) { - throw new Error('ファイル情報の取得に失敗しました'); - } - - const file = response.file as Record; - const urlPrivate = file.url_private as string | undefined; - - if (!urlPrivate) { - throw new Error('ファイルのダウンロードURLが見つかりません'); - } - - // Download file content from url_private - const userAccessToken = await this.tokenManager.getUserAccessTokenByWorkspaceId(workspaceId); - if (!userAccessToken) { - const error = new Error( - `Workspace ${workspaceId} is not connected or User Token not available` - ); - (error as Error & { code: string; workspaceId: string }).code = 'USER_TOKEN_REQUIRED'; - (error as Error & { code: string; workspaceId: string }).workspaceId = workspaceId; - throw error; - } - - // Fetch file content with Authorization header - const fileResponse = await fetch(urlPrivate, { - headers: { - Authorization: `Bearer ${userAccessToken}`, - }, - }); - - if (!fileResponse.ok) { - throw new Error(`ファイルのダウンロードに失敗しました (HTTP ${fileResponse.status})`); - } - - const content = await fileResponse.text(); - - return content; - } catch (error) { - console.error('[SlackApiService] downloadWorkflowFile error:', error); - // Re-throw USER_TOKEN_REQUIRED errors directly to preserve error code - if ( - error && - typeof error === 'object' && - 'code' in error && - (error as { code: string }).code === 'USER_TOKEN_REQUIRED' - ) { - throw error; - } - const errorInfo = handleSlackError(error); - throw new Error(errorInfo.message); - } - } -} diff --git a/src/extension/services/slack-description-prompt-builder.ts b/src/extension/services/slack-description-prompt-builder.ts deleted file mode 100644 index 5c68775c..00000000 --- a/src/extension/services/slack-description-prompt-builder.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Slack Description Prompt Builder - * - * Builds AI prompts for Slack description generation in TOON format. - * TOON format reduces token consumption by ~7% compared to freetext. - */ - -import { encode } from '@toon-format/toon'; - -/** - * Prompt builder for Slack description generation - */ -export class SlackDescriptionPromptBuilder { - constructor( - private workflowJson: string, - private targetLanguage: string - ) {} - - buildPrompt(): string { - const structured = this.getStructuredPrompt(); - return encode(structured); - } - - private getStructuredPrompt(): object { - return { - responseLocale: this.targetLanguage, - role: 'technical writer creating a brief workflow description for Slack sharing', - task: 'Analyze the following workflow JSON and generate a concise description', - workflowJson: this.workflowJson, - requirements: [ - 'Maximum 200 characters (aim for 100-150 for readability)', - 'Focus on what the workflow accomplishes, not technical implementation details', - 'Use active voice and clear language', - 'Do NOT include markdown, code blocks, or formatting', - 'Output ONLY the description text, nothing else', - ], - outputFormat: 'A single line of plain text describing the workflow purpose', - }; - } -} diff --git a/src/extension/services/slack-oauth-service.ts b/src/extension/services/slack-oauth-service.ts deleted file mode 100644 index 7e5caf0b..00000000 --- a/src/extension/services/slack-oauth-service.ts +++ /dev/null @@ -1,306 +0,0 @@ -/** - * Slack OAuth Service - * - * Manages the OAuth 2.0 authentication flow for Slack. - * Uses an external OAuth server (cc-wf-studio-connectors) to handle - * authorization code exchange to protect client_secret. - * - * Flow: - * 1. Generate session_id and build authorization URL - * 2. Open browser for user to authenticate with Slack - * 3. OAuth server receives callback, stores code in KV - * 4. Poll OAuth server for authorization code - * 5. Request token exchange from OAuth server - * 6. Receive access_token and store in VSCode Secret Storage - */ - -import * as vscode from 'vscode'; -import { log } from '../extension'; - -/** - * OAuth configuration - */ -const OAUTH_CONFIG = { - /** OAuth server base URL */ - serverUrl: 'https://cc-wf-studio.com', - /** Slack OAuth Client ID (public) */ - slackClientId: '9964370319943.10022663519665', - /** Bot Token scopes (empty - all operations use User Token) */ - scopes: '', - /** User Token scopes (all functionality including message posting and file operations) */ - userScopes: 'channels:read,groups:read,chat:write,files:read,files:write', - /** Initial polling interval in milliseconds */ - pollingIntervalInitialMs: 1000, - /** Maximum polling interval in milliseconds */ - pollingIntervalMaxMs: 5000, - /** Polling interval multiplier for exponential backoff */ - pollingIntervalMultiplier: 1.5, - /** Polling timeout in milliseconds (5 minutes) */ - pollingTimeoutMs: 5 * 60 * 1000, -} as const; - -/** - * OAuth initiation result - */ -export interface OAuthInitiation { - sessionId: string; - authorizationUrl: string; -} - -/** - * OAuth token exchange response from server - */ -export interface SlackOAuthTokenResponse { - ok: boolean; - access_token?: string; - bot_user_id?: string; // Bot User ID (for membership check) - team?: { - id: string; - name: string; - }; - authed_user?: { - id: string; - access_token?: string; // User Token (xoxp-...) - }; - scope?: string; - error?: string; -} - -/** - * Poll response from OAuth server - */ -interface PollResponse { - status: 'pending' | 'completed' | 'success' | 'expired'; - code?: string; - error?: string; -} - -/** - * Generates a cryptographically secure session ID - */ -function generateSessionId(): string { - const array = new Uint8Array(32); - // Use Node.js crypto for VSCode extension - const crypto = require('node:crypto'); - crypto.getRandomValues(array); - return Array.from(array) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); -} - -/** - * Session initialization response from OAuth server - */ -interface SessionInitResponse { - ok: boolean; - session_id?: string; - error?: string; -} - -/** - * Slack OAuth Service - * - * Handles the complete OAuth flow for Slack authentication. - */ -export class SlackOAuthService { - private abortController: AbortController | null = null; - - /** - * Registers a session with the OAuth server before starting the flow - * - * @param sessionId - The session ID to register - * @throws Error if session registration fails - */ - private async registerSession(sessionId: string): Promise { - log('INFO', 'Registering OAuth session with server', { sessionId }); - - const response = await fetch(`${OAUTH_CONFIG.serverUrl}/slack/init`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ session_id: sessionId }), - }); - - if (!response.ok) { - const errorText = await response.text(); - log('ERROR', 'Session registration failed', { status: response.status, error: errorText }); - throw new Error(`Session registration failed: ${response.status}`); - } - - const data = (await response.json()) as SessionInitResponse; - - if (!data.ok) { - log('ERROR', 'OAuth server rejected session', { error: data.error }); - throw new Error(data.error || 'Session registration failed'); - } - - log('INFO', 'OAuth session registered successfully', { sessionId }); - } - - /** - * Starts the OAuth authentication flow - * - * This method now registers the session with the OAuth server before - * returning the authorization URL. - * - * @returns OAuth initiation details (sessionId and authorizationUrl) - * @throws Error if session registration fails - */ - async initiateOAuthFlow(): Promise { - const sessionId = generateSessionId(); - const redirectUri = `${OAUTH_CONFIG.serverUrl}/slack/callback`; - - // Register session with OAuth server before opening browser - await this.registerSession(sessionId); - - const authorizationUrl = new URL('https://slack.com/oauth/v2/authorize'); - authorizationUrl.searchParams.set('client_id', OAUTH_CONFIG.slackClientId); - authorizationUrl.searchParams.set('scope', OAUTH_CONFIG.scopes); - authorizationUrl.searchParams.set('user_scope', OAUTH_CONFIG.userScopes); - authorizationUrl.searchParams.set('redirect_uri', redirectUri); - authorizationUrl.searchParams.set('state', sessionId); - - log('INFO', 'OAuth flow initiated', { sessionId }); - - return { - sessionId, - authorizationUrl: authorizationUrl.toString(), - }; - } - - /** - * Polls the OAuth server for the authorization code - * - * @param sessionId - The session ID to poll for - * @returns The authorization code if found, null if cancelled or timed out - */ - async pollForCode(sessionId: string): Promise<{ code: string } | null> { - const startTime = Date.now(); - this.abortController = new AbortController(); - const { signal } = this.abortController; - let currentInterval: number = OAUTH_CONFIG.pollingIntervalInitialMs; - - log('INFO', 'Starting OAuth code polling', { sessionId }); - - while (Date.now() - startTime < OAUTH_CONFIG.pollingTimeoutMs) { - if (signal.aborted) { - log('INFO', 'OAuth polling cancelled', { sessionId }); - return null; - } - - try { - const response = await fetch(`${OAUTH_CONFIG.serverUrl}/slack/poll?session=${sessionId}`, { - signal, - }); - - if (!response.ok) { - throw new Error(`Poll request failed: ${response.status}`); - } - - const data = (await response.json()) as PollResponse; - - if ((data.status === 'completed' || data.status === 'success') && data.code) { - log('INFO', 'OAuth code received', { sessionId }); - return { code: data.code }; - } - - if (data.status === 'expired') { - log('WARN', 'OAuth session expired', { sessionId }); - return null; - } - - // Wait before next poll with exponential backoff - await new Promise((resolve) => setTimeout(resolve, currentInterval)); - currentInterval = Math.min( - currentInterval * OAUTH_CONFIG.pollingIntervalMultiplier, - OAUTH_CONFIG.pollingIntervalMaxMs - ); - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - log('INFO', 'OAuth polling aborted', { sessionId }); - return null; - } - - log('WARN', 'OAuth poll request failed, retrying', { - sessionId, - error: error instanceof Error ? error.message : String(error), - }); - - // Wait before retry with exponential backoff - await new Promise((resolve) => setTimeout(resolve, currentInterval)); - currentInterval = Math.min( - currentInterval * OAUTH_CONFIG.pollingIntervalMultiplier, - OAUTH_CONFIG.pollingIntervalMaxMs - ); - } - } - - log('WARN', 'OAuth polling timed out', { sessionId }); - return null; - } - - /** - * Exchanges the authorization code for an access token - * - * @param code - The authorization code from Slack - * @returns The token response from Slack - */ - async exchangeCodeForToken(code: string): Promise { - const redirectUri = `${OAUTH_CONFIG.serverUrl}/slack/callback`; - - log('INFO', 'Exchanging OAuth code for token'); - - const response = await fetch(`${OAUTH_CONFIG.serverUrl}/slack/exchange`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - code, - redirect_uri: redirectUri, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - log('ERROR', 'Token exchange failed', { status: response.status, error: errorText }); - throw new Error(`Token exchange failed: ${response.status}`); - } - - const data = (await response.json()) as SlackOAuthTokenResponse; - - if (!data.ok) { - log('ERROR', 'Slack OAuth error', { error: data.error }); - throw new Error(data.error || 'OAuth token exchange failed'); - } - - log('INFO', 'OAuth token exchange successful', { - teamId: data.team?.id, - teamName: data.team?.name, - }); - - return data; - } - - /** - * Cancels any ongoing OAuth polling - */ - cancelPolling(): void { - if (this.abortController) { - this.abortController.abort(); - this.abortController = null; - log('INFO', 'OAuth polling cancelled by user'); - } - } - - /** - * Opens the authorization URL in the default browser - * - * @param authorizationUrl - The Slack OAuth authorization URL - */ - async openAuthorizationUrl(authorizationUrl: string): Promise { - await vscode.env.openExternal(vscode.Uri.parse(authorizationUrl)); - log('INFO', 'Opened OAuth authorization URL in browser'); - } -} diff --git a/src/extension/services/terminal-execution-service.ts b/src/extension/services/terminal-execution-service.ts deleted file mode 100644 index 43a95032..00000000 --- a/src/extension/services/terminal-execution-service.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Claude Code Workflow Studio - Terminal Execution Service - * - * Handles execution of slash commands in VSCode integrated terminal - */ - -import * as vscode from 'vscode'; - -/** - * Options for executing a slash command in terminal - */ -export interface TerminalExecutionOptions { - /** Workflow name (used for terminal tab name and slash command) */ - workflowName: string; - /** Working directory for the terminal */ - workingDirectory: string; -} - -/** - * Result of terminal execution - */ -export interface TerminalExecutionResult { - /** Name of the created terminal */ - terminalName: string; - /** Reference to the VSCode terminal instance */ - terminal: vscode.Terminal; -} - -/** - * Execute a slash command in a new VSCode integrated terminal - * - * Creates a new terminal with the workflow name as the tab title, - * sets the working directory to the workspace root, and executes - * the Claude Code CLI with the slash command. - * - * @param options - Terminal execution options - * @returns Terminal execution result - */ -export function executeSlashCommandInTerminal( - options: TerminalExecutionOptions -): TerminalExecutionResult { - const terminalName = `Workflow: ${options.workflowName}`; - - // Create a new terminal with the workflow name - const terminal = vscode.window.createTerminal({ - name: terminalName, - cwd: options.workingDirectory, - }); - - // Show the terminal and focus on it - terminal.show(true); - - // Execute the Claude Code CLI with the slash command - // Using double quotes to handle workflow names with spaces - terminal.sendText(`claude "/${options.workflowName}"`); - - return { - terminalName, - terminal, - }; -} - -/** - * Options for executing Copilot CLI skill command - */ -export interface CopilotCliExecutionOptions { - /** Skill name (the workflow name as .github/skills/{name}/SKILL.md) */ - skillName: string; - /** Working directory for the terminal */ - workingDirectory: string; -} - -/** - * Execute Copilot CLI with skill in a new VSCode integrated terminal - * - * Creates a new terminal and executes: - * copilot -i ":skill {skillName}" --allow-all-tools - * - * @param options - Copilot CLI execution options - * @returns Terminal execution result - */ -export function executeCopilotCliInTerminal( - options: CopilotCliExecutionOptions -): TerminalExecutionResult { - const terminalName = `Copilot: ${options.skillName}`; - - // Create a new terminal - const terminal = vscode.window.createTerminal({ - name: terminalName, - cwd: options.workingDirectory, - }); - - // Show the terminal and focus on it - terminal.show(true); - - // Execute: copilot -i ":skill {skillName}" --allow-all-tools - terminal.sendText(`copilot -i ":skill ${options.skillName}" --allow-all-tools`); - - return { - terminalName, - terminal, - }; -} - -/** - * Options for executing Codex CLI skill command - */ -export interface CodexCliExecutionOptions { - /** Skill name (the workflow name as .codex/skills/{name}/SKILL.md) */ - skillName: string; - /** Working directory for the terminal */ - workingDirectory: string; -} - -/** - * Execute Codex CLI with skill in a new VSCode integrated terminal - * - * Creates a new terminal and executes: - * codex "$skill-name" - * - * Note: Codex CLI uses $skill-name format to invoke skills in interactive mode - * - * @param options - Codex CLI execution options - * @returns Terminal execution result - */ -export function executeCodexCliInTerminal( - options: CodexCliExecutionOptions -): TerminalExecutionResult { - const terminalName = `Codex: ${options.skillName}`; - - // Create a new terminal - const terminal = vscode.window.createTerminal({ - name: terminalName, - cwd: options.workingDirectory, - }); - - // Show the terminal and focus on it - terminal.show(true); - - // Execute: codex "$skill-name" (interactive mode) - terminal.sendText(`codex "\\$${options.skillName}"`); - - return { - terminalName, - terminal, - }; -} - -/** - * Options for executing Gemini CLI skill command - */ -export interface GeminiCliExecutionOptions { - /** Skill name (the workflow name as .gemini/skills/{name}/SKILL.md) */ - skillName: string; - /** Working directory for the terminal */ - workingDirectory: string; -} - -/** - * Execute Gemini CLI with skill in a new VSCode integrated terminal - * - * Creates a new terminal and executes: - * gemini -i ":skill {skillName}" - * - * @param options - Gemini CLI execution options - * @returns Terminal execution result - */ -export function executeGeminiCliInTerminal( - options: GeminiCliExecutionOptions -): TerminalExecutionResult { - const terminalName = `Gemini: ${options.skillName}`; - - // Create a new terminal - const terminal = vscode.window.createTerminal({ - name: terminalName, - cwd: options.workingDirectory, - }); - - // Show the terminal and focus on it - terminal.show(true); - - // Execute: gemini with :skill prompt to invoke the exported skill - terminal.sendText(`gemini -i ":skill ${options.skillName}"`); - - return { - terminalName, - terminal, - }; -} diff --git a/src/extension/services/vscode-lm-service.ts b/src/extension/services/vscode-lm-service.ts deleted file mode 100644 index 8431da0d..00000000 --- a/src/extension/services/vscode-lm-service.ts +++ /dev/null @@ -1,458 +0,0 @@ -/** - * VS Code Language Model Service - * - * VS Code の Language Model API (vscode.lm) を使用した AI 実行サービス。 - * VS Code 1.89+ と GitHub Copilot 拡張機能が必要。 - * - * ランタイム検出により、API が利用可能な場合のみ機能する。 - * engines.vscode は 1.80.0 のまま維持し、後方互換性を保つ。 - */ - -import * as vscode from 'vscode'; -import type { CopilotModel, CopilotModelInfo } from '../../shared/types/messages'; -import { log } from '../extension'; -import type { ClaudeCodeExecutionResult, StreamingProgressCallback } from './claude-code-service'; - -/** LM API 利用可否チェック結果 */ -export interface LmApiAvailability { - available: boolean; - reason?: 'VS_CODE_VERSION' | 'COPILOT_NOT_INSTALLED' | 'NO_MODELS_FOUND'; -} - -// アクティブなリクエストのキャンセレーショントークン管理 -const activeRequests = new Map(); - -/** - * VS Code LM API が利用可能かチェック(ランタイム検出) - * - * @returns true if vscode.lm API is available - */ -export function isVsCodeLmApiAvailable(): boolean { - // Check if vscode.lm exists and has the selectChatModels method - // This provides runtime detection without requiring minimum VS Code version - return typeof vscode.lm !== 'undefined' && typeof vscode.lm.selectChatModels === 'function'; -} - -/** - * LM API の詳細な利用可否をチェック - * - * @returns Availability status with reason if unavailable - */ -export async function checkLmApiAvailability(): Promise { - if (!isVsCodeLmApiAvailable()) { - return { available: false, reason: 'VS_CODE_VERSION' }; - } - - try { - const models = await vscode.lm.selectChatModels({ vendor: 'copilot' }); - if (!models || models.length === 0) { - return { available: false, reason: 'COPILOT_NOT_INSTALLED' }; - } - return { available: true }; - } catch (error) { - log('WARN', 'Failed to check LM API availability', { error }); - return { available: false, reason: 'NO_MODELS_FOUND' }; - } -} - -/** - * List all available Copilot models via VS Code LM API - * - * @returns List of available CopilotModelInfo objects - */ -export async function listCopilotModels(): Promise<{ - models: CopilotModelInfo[]; - available: boolean; - unavailableReason?: string; -}> { - if (!isVsCodeLmApiAvailable()) { - return { - models: [], - available: false, - unavailableReason: 'VS Code 1.89+ is required for Copilot provider', - }; - } - - try { - const models = await vscode.lm.selectChatModels({ vendor: 'copilot' }); - - if (!models || models.length === 0) { - return { - models: [], - available: false, - unavailableReason: 'GitHub Copilot extension is not installed or no models available', - }; - } - - // Deduplicate by family AND name (use first occurrence) - // Some models have different family but same display name (e.g., gpt-4o-mini and copilot-fast both show "GPT-4o mini") - const seenFamilies = new Set(); - const seenNames = new Set(); - const modelInfos: CopilotModelInfo[] = []; - for (const model of models) { - if (!seenFamilies.has(model.family) && !seenNames.has(model.name)) { - seenFamilies.add(model.family); - seenNames.add(model.name); - modelInfos.push({ - id: model.id, - name: model.name, - family: model.family, - vendor: model.vendor, - }); - } - } - - log('DEBUG', 'Listed Copilot models (after deduplication)', { - count: modelInfos.length, - rawCount: models.length, - models: modelInfos.map((m) => ({ id: m.id, family: m.family })), - }); - - return { - models: modelInfos, - available: true, - }; - } catch (error) { - log('ERROR', 'Failed to list Copilot models', { error }); - return { - models: [], - available: false, - unavailableReason: 'Failed to retrieve Copilot models', - }; - } -} - -/** - * Map CopilotModel to VS Code LM API family selector - * - * @param model - CopilotModel selection (now dynamic string) - * @returns VS Code LM family string or undefined for default - */ -function getCopilotFamily(model?: CopilotModel): string | undefined { - // CopilotModel is now a dynamic string type, so pass through directly - // The model value should match the family name from vscode.lm.selectChatModels() - if (!model || model.trim() === '') return undefined; - return model; -} - -/** - * Select a Copilot model from available models - * - * @param model - Optional CopilotModel to prefer - * @returns Selected LanguageModelChat or null if not available - */ -export async function selectCopilotModel( - model?: CopilotModel -): Promise { - if (!isVsCodeLmApiAvailable()) { - log('WARN', 'VS Code LM API not available'); - return null; - } - - try { - const selector: vscode.LanguageModelChatSelector = { vendor: 'copilot' }; - const family = getCopilotFamily(model); - if (family) { - selector.family = family; - } - - log('DEBUG', 'Selecting Copilot model', { selector, model }); - const models = await vscode.lm.selectChatModels(selector); - - if (!models || models.length === 0) { - // If specific family not found, try without family filter - if (family) { - log('WARN', `Copilot model family '${family}' not found, falling back to default`); - const fallbackModels = await vscode.lm.selectChatModels({ vendor: 'copilot' }); - return fallbackModels.length > 0 ? fallbackModels[0] : null; - } - return null; - } - - log('DEBUG', 'Selected Copilot model', { - id: models[0].id, - name: models[0].name, - family: models[0].family, - vendor: models[0].vendor, - }); - - return models[0]; - } catch (error) { - log('ERROR', 'Failed to select Copilot model', { error, model }); - return null; - } -} - -/** - * VS Code LM API でプロンプトを実行(ストリーミング) - * - * @param prompt - プロンプト文字列 - * @param onProgress - ストリーミング進捗コールバック - * @param timeoutMs - タイムアウト(ミリ秒、デフォルト: 120000) - * @param requestId - リクエストID(キャンセル用) - * @param copilotModel - 使用するCopilotモデル - * @returns 実行結果 - */ -export async function executeVsCodeLmStreaming( - prompt: string, - onProgress: StreamingProgressCallback, - timeoutMs = 120000, - requestId?: string, - copilotModel?: CopilotModel -): Promise { - const startTime = Date.now(); - - log('INFO', 'Starting VS Code LM execution', { - promptLength: prompt.length, - timeoutMs, - requestId, - copilotModel, - }); - - // Create cancellation token source - const cts = new vscode.CancellationTokenSource(); - if (requestId) { - activeRequests.set(requestId, cts); - log('DEBUG', `Registered active LM request for requestId: ${requestId}`); - } - - // Set up timeout (only if timeoutMs > 0; 0 means "unlimited") - let timeoutId: ReturnType | null = null; - if (timeoutMs > 0) { - timeoutId = setTimeout(() => { - log('WARN', 'VS Code LM request timed out', { timeoutMs, requestId }); - cts.cancel(); - }, timeoutMs); - } - - try { - const model = await selectCopilotModel(copilotModel); - if (!model) { - if (timeoutId) clearTimeout(timeoutId); - return { - success: false, - error: { - code: 'COPILOT_NOT_AVAILABLE', - message: - 'Copilot is not available. Please ensure GitHub Copilot extension is installed and VS Code is 1.89+.', - }, - executionTimeMs: Date.now() - startTime, - }; - } - - // Build messages array with user prompt - const messages = [vscode.LanguageModelChatMessage.User(prompt)]; - - // Send request to model - log('DEBUG', 'Sending request to Copilot model', { - modelId: model.id, - messageCount: messages.length, - }); - - const response = await model.sendRequest(messages, {}, cts.token); - - // Process streaming response - let accumulatedText = ''; - for await (const fragment of response.text) { - if (cts.token.isCancellationRequested) { - log('INFO', 'VS Code LM request cancelled during streaming', { requestId }); - break; - } - - accumulatedText += fragment; - // Call progress callback with same text for both display and explanatory - // (VS Code LM API doesn't have tool_use concept like Claude CLI) - onProgress(fragment, accumulatedText, accumulatedText, 'text'); - } - - if (timeoutId) clearTimeout(timeoutId); - - const executionTimeMs = Date.now() - startTime; - - log('INFO', 'VS Code LM execution succeeded', { - executionTimeMs, - outputLength: accumulatedText.length, - requestId, - }); - - return { - success: true, - output: accumulatedText, - executionTimeMs, - }; - } catch (error) { - if (timeoutId) clearTimeout(timeoutId); - - const executionTimeMs = Date.now() - startTime; - - // Handle cancellation - if (cts.token.isCancellationRequested) { - log('INFO', 'VS Code LM request was cancelled', { requestId, executionTimeMs }); - return { - success: false, - error: { - code: 'TIMEOUT', - message: 'Request was cancelled or timed out.', - }, - executionTimeMs, - }; - } - - // Log error details - log('ERROR', 'VS Code LM execution failed', { - error, - requestId, - executionTimeMs, - errorType: error?.constructor?.name, - errorMessage: error instanceof Error ? error.message : String(error), - }); - - // Parse error message for HTTP API errors (these come as regular Error, not LanguageModelError) - const errorMessage = error instanceof Error ? error.message : String(error); - const httpErrorInfo = parseHttpErrorMessage(errorMessage); - - if (httpErrorInfo) { - log('INFO', 'Parsed HTTP error from LM API', httpErrorInfo); - - if ( - httpErrorInfo.code === 'model_not_supported' || - httpErrorInfo.code === 'model_not_found' - ) { - // Model is not enabled/supported - return { - success: false, - error: { - code: 'MODEL_NOT_SUPPORTED', - message: `Model "${copilotModel}" is not supported or access is not enabled.`, - details: httpErrorInfo.message, - }, - executionTimeMs, - }; - } - } - - // Handle LanguageModelError specifically - if (error instanceof vscode.LanguageModelError) { - // Map LanguageModelError codes to our error codes - // See: https://code.visualstudio.com/api/references/vscode-api#LanguageModelError - let errorCode: ClaudeCodeExecutionResult['error'] extends { code: infer C } ? C : never = - 'UNKNOWN_ERROR'; - let message = error.message; - - // Check for common error scenarios - if (error.code === 'Blocked') { - message = 'AI access was blocked. Please check your Copilot subscription.'; - } else if (error.code === 'NoPermissions') { - message = - 'AI access denied. Please click "Allow" in the permission dialog that appeared, then try again.'; - } else if (error.code === 'NotFound') { - errorCode = 'COMMAND_NOT_FOUND'; - message = 'Copilot model not found. Please ensure GitHub Copilot is installed.'; - } - - return { - success: false, - error: { - code: errorCode, - message, - details: `LanguageModelError: ${error.code} - ${error.cause || ''}`, - }, - executionTimeMs, - }; - } - - // Unknown error - return { - success: false, - error: { - code: 'UNKNOWN_ERROR', - message: error instanceof Error ? error.message : 'An unexpected error occurred.', - details: error instanceof Error ? error.stack : String(error), - }, - executionTimeMs, - }; - } finally { - // Clean up - if (requestId) { - activeRequests.delete(requestId); - log('DEBUG', `Removed active LM request for requestId: ${requestId}`); - } - cts.dispose(); - } -} - -/** - * VS Code LM API でプロンプトを実行(非ストリーミング) - * - * @param prompt - プロンプト文字列 - * @param timeoutMs - タイムアウト(ミリ秒、デフォルト: 120000) - * @param requestId - リクエストID(キャンセル用) - * @param copilotModel - 使用するCopilotモデル - * @returns 実行結果 - */ -export async function executeVsCodeLm( - prompt: string, - timeoutMs = 120000, - requestId?: string, - copilotModel?: CopilotModel -): Promise { - // Callback captures accumulated text but we don't need it since streaming version returns it - const onProgress: StreamingProgressCallback = () => { - // No-op: we use the return value from executeVsCodeLmStreaming instead - }; - return executeVsCodeLmStreaming(prompt, onProgress, timeoutMs, requestId, copilotModel); -} - -/** - * VS Code LM リクエストをキャンセル - * - * @param requestId - キャンセルするリクエストのID - * @returns キャンセル結果 - */ -export async function cancelLmRequest(requestId: string): Promise<{ - cancelled: boolean; - executionTimeMs?: number; -}> { - const cts = activeRequests.get(requestId); - if (!cts) { - log('WARN', `No active LM request found for requestId: ${requestId}`); - return { cancelled: false }; - } - - log('INFO', `Cancelling LM request for requestId: ${requestId}`); - cts.cancel(); - activeRequests.delete(requestId); - - return { cancelled: true }; -} - -/** - * Parse HTTP error message from LM API response - * - * Error messages look like: - * "Request Failed: 400 {\"error\":{\"message\":\"The requested model is not supported.\",\"code\":\"model_not_supported\",...}}" - * - * @param errorMessage - The error message to parse - * @returns Parsed error info or null if not parseable - */ -function parseHttpErrorMessage( - errorMessage: string -): { code: string; message: string; type?: string } | null { - try { - // Look for JSON in the error message - const jsonMatch = errorMessage.match(/\{[\s\S]*\}/); - if (!jsonMatch) return null; - - const parsed = JSON.parse(jsonMatch[0]); - if (parsed.error && typeof parsed.error === 'object') { - return { - code: parsed.error.code || 'unknown', - message: parsed.error.message || errorMessage, - type: parsed.error.type, - }; - } - return null; - } catch { - return null; - } -} diff --git a/src/extension/services/workflow-name-prompt-builder.ts b/src/extension/services/workflow-name-prompt-builder.ts deleted file mode 100644 index 7a6323d9..00000000 --- a/src/extension/services/workflow-name-prompt-builder.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Workflow Name Prompt Builder - * - * Builds AI prompts for workflow name generation in TOON format. - * TOON format reduces token consumption by ~7% compared to freetext. - */ - -import { encode } from '@toon-format/toon'; - -/** - * Prompt builder for workflow name generation - */ -export class WorkflowNamePromptBuilder { - constructor( - private workflowJson: string, - private targetLanguage: string - ) {} - - buildPrompt(): string { - const structured = this.getStructuredPrompt(); - return encode(structured); - } - - private getStructuredPrompt(): object { - return { - responseLocale: this.targetLanguage, - role: 'workflow naming specialist', - task: 'Analyze the following workflow JSON and generate a concise, descriptive name', - workflowJson: this.workflowJson, - requirements: [ - 'Use kebab-case format (e.g., "data-analysis-pipeline", "user-auth-flow")', - 'Maximum 50 characters', - "Focus on the workflow's primary purpose or function", - 'Do NOT include generic words like "workflow" or "process" unless necessary', - 'Do NOT include markdown, code blocks, or formatting', - 'Output ONLY the name, nothing else', - ], - outputFormat: 'A single kebab-case name describing the workflow purpose', - }; - } -} diff --git a/src/extension/services/workflow-prompt-generator.ts b/src/extension/services/workflow-prompt-generator.ts deleted file mode 100644 index b26c2e1e..00000000 --- a/src/extension/services/workflow-prompt-generator.ts +++ /dev/null @@ -1,846 +0,0 @@ -/** - * Claude Code Workflow Studio - Workflow Prompt Generator - * - * Shared module for generating Mermaid flowcharts and execution instructions. - * Used by both Claude Code export and Copilot export services. - * - * All output is in English for consistent AI consumption. - */ - -import type { - AskUserQuestionNode, - BranchNode, - CodexNode, - IfElseNode, - McpNode, - PromptNode, - SkillNode, - SubAgentNode, - SwitchNode, - Workflow, - WorkflowNode, -} from '../../shared/types/workflow-definition'; - -/** - * Common interface for Mermaid generation - * Used by both Workflow and SubWorkflow - */ -export interface MermaidSource { - nodes: WorkflowNode[]; - connections: { from: string; to: string; fromPort?: string }[]; -} - -/** - * Sanitize node ID for Mermaid (remove special characters) - */ -export function sanitizeNodeId(id: string): string { - return id.replace(/[^a-zA-Z0-9_]/g, '_'); -} - -/** - * Escape special characters in Mermaid labels - */ -export function escapeLabel(label: string): string { - return label - .replace(/#/g, '#35;') - .replace(/"/g, '#quot;') - .replace(/\[/g, '#91;') - .replace(/\]/g, '#93;') - .replace(/\(/g, '#40;') - .replace(/\)/g, '#41;') - .replace(/\{/g, '#123;') - .replace(/\}/g, '#125;') - .replace(//g, '#62;') - .replace(/\|/g, '#124;'); -} - -/** - * Generate Mermaid flowchart from workflow or subworkflow - */ -export function generateMermaidFlowchart(source: MermaidSource): string { - const { nodes, connections } = source; - const lines: string[] = []; - - lines.push('```mermaid'); - lines.push('flowchart TD'); - - // Generate node definitions - for (const node of nodes) { - const nodeId = sanitizeNodeId(node.id); - const nodeType = node.type as string; - - if (nodeType === 'start') { - lines.push(` ${nodeId}([Start])`); - } else if (nodeType === 'end') { - lines.push(` ${nodeId}([End])`); - } else if (nodeType === 'subAgent') { - const agentName = node.name || 'Sub-Agent'; - lines.push(` ${nodeId}[${escapeLabel(`Sub-Agent: ${agentName}`)}]`); - } else if (nodeType === 'askUserQuestion') { - const askNode = node as AskUserQuestionNode; - const questionText = askNode.data.questionText || 'Question'; - lines.push( - ` ${nodeId}{${escapeLabel('AskUserQuestion')}:
${escapeLabel(questionText)}}` - ); - } else if (nodeType === 'branch') { - const branchNode = node as BranchNode; - const branchType = branchNode.data.branchType === 'conditional' ? 'Branch' : 'Switch'; - lines.push(` ${nodeId}{${escapeLabel(branchType)}:
Conditional Branch}`); - } else if (nodeType === 'ifElse') { - lines.push(` ${nodeId}{If/Else:
Conditional Branch}`); - } else if (nodeType === 'switch') { - lines.push(` ${nodeId}{Switch:
Conditional Branch}`); - } else if (nodeType === 'prompt') { - const promptNode = node as PromptNode; - const promptText = promptNode.data.prompt?.split('\n')[0] || 'Prompt'; - const label = promptText.length > 30 ? `${promptText.substring(0, 27)}...` : promptText; - lines.push(` ${nodeId}[${escapeLabel(label)}]`); - } else if (nodeType === 'skill') { - const skillNode = node as SkillNode; - const skillName = skillNode.data.name || 'Skill'; - lines.push(` ${nodeId}[[${escapeLabel(`Skill: ${skillName}`)}]]`); - } else if (nodeType === 'mcp') { - const mcpNode = node as McpNode; - const mcpData = mcpNode.data; - let mcpLabel = 'MCP Tool'; - if (mcpData) { - if (mcpData.toolName) { - mcpLabel = `MCP: ${mcpData.toolName}`; - } else if (mcpData.aiToolSelectionConfig?.taskDescription) { - const desc = mcpData.aiToolSelectionConfig.taskDescription; - mcpLabel = `MCP Task: ${desc.length > 25 ? `${desc.substring(0, 22)}...` : desc}`; - } else { - mcpLabel = `MCP: ${mcpData.serverId || 'Tool'}`; - } - } - lines.push(` ${nodeId}[[${escapeLabel(mcpLabel)}]]`); - } else if (nodeType === 'subAgentFlow') { - const label = node.name || 'Sub-Agent Flow'; - lines.push(` ${nodeId}[["${escapeLabel(label)}"]]`); - } else if (nodeType === 'codex') { - const codexNode = node as CodexNode; - const codexName = codexNode.data.label || 'Codex Agent'; - lines.push(` ${nodeId}[[${escapeLabel(`Codex: ${codexName}`)}]]`); - } - } - - lines.push(''); - - // Generate connections - for (const conn of connections) { - const fromId = sanitizeNodeId(conn.from); - const toId = sanitizeNodeId(conn.to); - const sourceNode = nodes.find((n) => n.id === conn.from); - - if (sourceNode?.type === 'askUserQuestion' && conn.fromPort) { - const askNode = sourceNode as AskUserQuestionNode; - if (askNode.data.useAiSuggestions || askNode.data.multiSelect) { - lines.push(` ${fromId} --> ${toId}`); - } else { - const branchIndex = Number.parseInt(conn.fromPort.replace('branch-', ''), 10); - const option = askNode.data.options[branchIndex]; - if (option) { - lines.push(` ${fromId} -->|${escapeLabel(option.label)}| ${toId}`); - } else { - lines.push(` ${fromId} --> ${toId}`); - } - } - } else if (sourceNode?.type === 'branch' && conn.fromPort) { - const branchIndex = Number.parseInt(conn.fromPort.replace('branch-', ''), 10); - const branchNode = sourceNode as BranchNode; - const branch = branchNode.data.branches[branchIndex]; - if (branch) { - lines.push(` ${fromId} -->|${escapeLabel(branch.label)}| ${toId}`); - } else { - lines.push(` ${fromId} --> ${toId}`); - } - } else if (sourceNode?.type === 'ifElse' && conn.fromPort) { - const branchIndex = Number.parseInt(conn.fromPort.replace('branch-', ''), 10); - const ifElseNode = sourceNode as IfElseNode; - const branch = ifElseNode.data.branches[branchIndex]; - if (branch) { - lines.push(` ${fromId} -->|${escapeLabel(branch.label)}| ${toId}`); - } else { - lines.push(` ${fromId} --> ${toId}`); - } - } else if (sourceNode?.type === 'switch' && conn.fromPort) { - const branchIndex = Number.parseInt(conn.fromPort.replace('branch-', ''), 10); - const switchNode = sourceNode as SwitchNode; - const branch = switchNode.data.branches[branchIndex]; - if (branch) { - lines.push(` ${fromId} -->|${escapeLabel(branch.label)}| ${toId}`); - } else { - lines.push(` ${fromId} --> ${toId}`); - } - } else { - lines.push(` ${fromId} --> ${toId}`); - } - } - - lines.push('```'); - return lines.join('\n'); -} - -/** - * Format MCP node in Manual Parameter Config Mode - */ -function formatManualParameterConfigMode(node: McpNode): string[] { - const sections: string[] = []; - const nodeId = sanitizeNodeId(node.id); - - sections.push(`#### ${nodeId}(${node.data.toolName || 'MCP Tool'})`); - sections.push(''); - sections.push(`**Description**: ${node.data.toolDescription || ''}`); - sections.push(''); - sections.push(`**MCP Server**: ${node.data.serverId}`); - sections.push(''); - sections.push(`**Tool Name**: ${node.data.toolName || ''}`); - sections.push(''); - sections.push(`**Validation Status**: ${node.data.validationStatus}`); - sections.push(''); - - const parameterValues = node.data.parameterValues || {}; - if (Object.keys(parameterValues).length > 0) { - sections.push('**Configured Parameters**:'); - sections.push(''); - for (const [paramName, paramValue] of Object.entries(parameterValues)) { - const parameters = node.data.parameters || []; - const paramSchema = parameters.find((p) => p.name === paramName); - const paramType = paramSchema ? ` (${paramSchema.type})` : ''; - const valueStr = - typeof paramValue === 'object' ? JSON.stringify(paramValue) : String(paramValue); - sections.push(`- \`${paramName}\`${paramType}: ${valueStr}`); - } - sections.push(''); - } - - const parameters = node.data.parameters || []; - if (parameters.length > 0) { - sections.push('**Available Parameters**:'); - sections.push(''); - for (const param of parameters) { - const requiredLabel = param.required ? ' (required)' : ' (optional)'; - const description = param.description || 'No description available'; - sections.push(`- \`${param.name}\` (${param.type})${requiredLabel}: ${description}`); - } - sections.push(''); - } - - sections.push( - 'This node invokes an MCP (Model Context Protocol) tool. When executing this workflow, use the configured parameters to call the tool via the MCP server.' - ); - sections.push(''); - - return sections; -} - -/** - * Format MCP node in AI Parameter Config Mode - */ -function formatAiParameterConfigMode(node: McpNode, provider: ExportProvider): string[] { - const sections: string[] = []; - const nodeId = sanitizeNodeId(node.id); - - sections.push(`#### ${nodeId}(${node.data.toolName || 'MCP Tool'}) - AI Parameter Config Mode`); - sections.push(''); - - const metadata = { - mode: 'aiParameterConfig', - serverId: node.data.serverId, - toolName: node.data.toolName || '', - userIntent: node.data.aiParameterConfig?.description || '', - parameterSchema: (node.data.parameters || []).map((p) => ({ - name: p.name, - type: p.type, - required: p.required, - description: p.description || '', - validation: p.validation, - })), - }; - sections.push(``); - sections.push(''); - - sections.push(`**Description**: ${node.data.toolDescription || ''}`); - sections.push(''); - sections.push(`**MCP Server**: ${node.data.serverId}`); - sections.push(''); - sections.push(`**Tool Name**: ${node.data.toolName || ''}`); - sections.push(''); - sections.push(`**Validation Status**: ${node.data.validationStatus}`); - sections.push(''); - - if (node.data.aiParameterConfig?.description) { - sections.push('**User Intent (Natural Language Parameter Description)**:'); - sections.push(''); - sections.push('```'); - sections.push(node.data.aiParameterConfig.description); - sections.push('```'); - sections.push(''); - } - - const parameters = node.data.parameters || []; - if (parameters.length > 0) { - sections.push('**Parameter Schema** (for AI interpretation):'); - sections.push(''); - for (const param of parameters) { - const requiredLabel = param.required ? ' (required)' : ' (optional)'; - const description = param.description || 'No description available'; - sections.push(`- \`${param.name}\` (${param.type})${requiredLabel}: ${description}`); - - if (param.validation) { - const constraints: string[] = []; - if (param.validation.minLength !== undefined) { - constraints.push(`minLength: ${param.validation.minLength}`); - } - if (param.validation.maxLength !== undefined) { - constraints.push(`maxLength: ${param.validation.maxLength}`); - } - if (param.validation.minimum !== undefined) { - constraints.push(`minimum: ${param.validation.minimum}`); - } - if (param.validation.maximum !== undefined) { - constraints.push(`maximum: ${param.validation.maximum}`); - } - if (param.validation.pattern) { - constraints.push(`pattern: ${param.validation.pattern}`); - } - if (param.validation.enum) { - constraints.push(`enum: ${param.validation.enum.join(', ')}`); - } - if (constraints.length > 0) { - sections.push(` - Constraints: ${constraints.join(', ')}`); - } - } - } - sections.push(''); - } - - sections.push('**Execution Method**:'); - sections.push(''); - const agentName = getAgentName(provider); - sections.push( - `${agentName} should interpret the natural language description above and set appropriate parameter values based on the parameter schema. Use your best judgment to map the user intent to concrete parameter values that satisfy the constraints.` - ); - sections.push(''); - - return sections; -} - -/** - * Format MCP node in AI Tool Selection Mode - */ -function formatAiToolSelectionMode(node: McpNode, provider: ExportProvider): string[] { - const sections: string[] = []; - const nodeId = sanitizeNodeId(node.id); - - sections.push(`#### ${nodeId}(MCP Auto-Selection) - AI Tool Selection Mode`); - sections.push(''); - - const metadata = { - mode: 'aiToolSelection', - serverId: node.data.serverId, - userIntent: node.data.aiToolSelectionConfig?.taskDescription || '', - }; - sections.push(``); - sections.push(''); - - sections.push(`**MCP Server**: ${node.data.serverId}`); - sections.push(''); - sections.push(`**Validation Status**: ${node.data.validationStatus}`); - sections.push(''); - - if (node.data.aiToolSelectionConfig?.taskDescription) { - sections.push('**User Intent (Natural Language Task Description)**:'); - sections.push(''); - sections.push('```'); - sections.push(node.data.aiToolSelectionConfig.taskDescription); - sections.push('```'); - sections.push(''); - } - - sections.push('**Execution Method**:'); - sections.push(''); - const agentName = getAgentName(provider); - sections.push( - `${agentName} should analyze the task description above and query the MCP server "${node.data.serverId}" at runtime to get the current list of tools. Then, select the most appropriate tool and determine the appropriate parameter values based on the task requirements.` - ); - sections.push(''); - - return sections; -} - -/** - * Provider type for export-specific instruction generation. - * Determines provider-appropriate tool names and descriptions. - */ -export type ExportProvider = - | 'claude-code' - | 'copilot' - | 'copilot-cli' - | 'codex' - | 'gemini' - | 'roo-code' - | 'antigravity' - | 'cursor'; - -/** - * Get the provider-specific sub-agent execution description for rectangle nodes. - */ -function getSubAgentDescription(provider: ExportProvider): string { - switch (provider) { - case 'claude-code': - return '- **Rectangle nodes (Sub-Agent: ...)**: Execute Sub-Agents'; - case 'copilot': - return '- **Rectangle nodes (Sub-Agent: ...)**: Execute Sub-Agents using the #runSubagent tool'; - case 'copilot-cli': - return '- **Rectangle nodes (Sub-Agent: ...)**: Execute Sub-Agents using the task/agent tool'; - case 'codex': - return '- **Rectangle nodes (Sub-Agent: ...)**: Execute Sub-Agents using the spawn_agent tool'; - case 'gemini': - return '- **Rectangle nodes (Sub-Agent: ...)**: Execute Sub-Agents'; - case 'roo-code': - return '- **Rectangle nodes (Sub-Agent: ...)**: Execute Sub-Agents'; - case 'antigravity': - return '- **Rectangle nodes (Sub-Agent: ...)**: Execute Sub-Agents'; - case 'cursor': - return '- **Rectangle nodes (Sub-Agent: ...)**: Execute Sub-Agents'; - default: { - const _exhaustiveCheck: never = provider; - throw new Error(`Unknown provider: ${_exhaustiveCheck}`); - } - } -} - -/** - * Get the provider-specific AskUserQuestion description for diamond nodes. - */ -function getAskUserQuestionDescription(provider: ExportProvider): string { - switch (provider) { - case 'claude-code': - return '- **Diamond nodes (AskUserQuestion:...)**: Use the AskUserQuestion tool to prompt the user and branch based on their response'; - case 'copilot': - return '- **Diamond nodes (AskUserQuestion:...)**: Use the Ask tool to prompt the user and branch based on their response'; - case 'copilot-cli': - return '- **Diamond nodes (AskUserQuestion:...)**: Prompt the user with a question and branch based on their response'; - case 'codex': - return '- **Diamond nodes (AskUserQuestion:...)**: Use the ask_user_question tool to prompt the user and branch based on their response'; - case 'gemini': - return '- **Diamond nodes (AskUserQuestion:...)**: Use the ask_user tool to prompt the user and branch based on their response'; - case 'roo-code': - return '- **Diamond nodes (AskUserQuestion:...)**: Use the ask_followup_question tool to prompt the user and branch based on their response'; - case 'antigravity': - return '- **Diamond nodes (AskUserQuestion:...)**: Prompt the user with a question and branch based on their response'; - case 'cursor': - return '- **Diamond nodes (AskUserQuestion:...)**: Prompt the user with a question and branch based on their response'; - default: { - const _exhaustiveCheck: never = provider; - throw new Error(`Unknown provider: ${_exhaustiveCheck}`); - } - } -} - -/** - * Get the provider-specific agent name for MCP execution method descriptions. - */ -function getAgentName(provider: ExportProvider): string { - switch (provider) { - case 'claude-code': - return 'Claude Code'; - case 'copilot': - return 'Copilot'; - case 'copilot-cli': - return 'Copilot CLI'; - case 'codex': - return 'Codex CLI'; - case 'gemini': - return 'Gemini CLI'; - case 'roo-code': - return 'Roo Code'; - case 'antigravity': - return 'Antigravity'; - case 'cursor': - return 'Cursor'; - default: { - const _exhaustiveCheck: never = provider; - throw new Error(`Unknown provider: ${_exhaustiveCheck}`); - } - } -} - -/** - * Get the provider-specific shell execution tool name for Codex node instructions. - */ -function getShellToolDescription(provider: ExportProvider): string { - switch (provider) { - case 'claude-code': - return 'Use the Bash tool to run'; - case 'copilot': - return 'Use the #runInTerminal tool to run'; - case 'copilot-cli': - return 'Run'; - case 'codex': - return 'Use the shell tool to run'; - case 'gemini': - return 'Use the run_shell_command tool to run'; - case 'roo-code': - return 'Use the execute_command tool to run'; - case 'antigravity': - return 'Use the Bash tool to run'; - case 'cursor': - return 'Use the Bash tool to run'; - default: { - const _exhaustiveCheck: never = provider; - throw new Error(`Unknown provider: ${_exhaustiveCheck}`); - } - } -} - -/** - * Options for generating execution instructions - */ -export interface ExecutionInstructionsOptions { - /** Parent workflow name (for SubAgentFlow file naming) */ - parentWorkflowName?: string; - /** SubAgentFlows from the parent workflow */ - subAgentFlows?: Workflow['subAgentFlows']; - /** Provider type for generating provider-specific descriptions */ - provider: ExportProvider; -} - -/** - * Generate workflow execution instructions - */ -export function generateExecutionInstructions( - workflow: Workflow, - options: ExecutionInstructionsOptions -): string { - const { nodes } = workflow; - const { provider } = options; - const sections: string[] = []; - - // Introduction - sections.push('## Workflow Execution Guide'); - sections.push(''); - sections.push( - 'Follow the Mermaid flowchart above to execute the workflow. Each node type has specific execution methods as described below.' - ); - sections.push(''); - - // Node type explanations - sections.push('### Execution Methods by Node Type'); - sections.push(''); - sections.push(getSubAgentDescription(provider)); - sections.push(getAskUserQuestionDescription(provider)); - sections.push( - '- **Diamond nodes (Branch/Switch:...)**: Automatically branch based on the results of previous processing (see details section)' - ); - sections.push( - '- **Rectangle nodes (Prompt nodes)**: Execute the prompts described in the details section below' - ); - sections.push(''); - - // Collect nodes by type - const subAgentNodes = nodes.filter((n) => n.type === 'subAgent') as SubAgentNode[]; - const promptNodes = nodes.filter((n) => n.type === 'prompt') as PromptNode[]; - const skillNodes = nodes.filter((n) => n.type === 'skill') as SkillNode[]; - const mcpNodes = nodes.filter((n) => n.type === 'mcp') as McpNode[]; - const codexNodes = nodes.filter((n) => n.type === 'codex') as CodexNode[]; - const askUserQuestionNodes = nodes.filter( - (n) => n.type === 'askUserQuestion' - ) as AskUserQuestionNode[]; - const branchNodes = nodes.filter((n) => n.type === 'branch') as BranchNode[]; - const ifElseNodes = nodes.filter((n) => n.type === 'ifElse') as IfElseNode[]; - const switchNodes = nodes.filter((n) => n.type === 'switch') as SwitchNode[]; - const subAgentFlowNodes = nodes.filter((n) => n.type === 'subAgentFlow'); - - // Sub-Agent node details - if (subAgentNodes.length > 0) { - sections.push('## Sub-Agent Node Details'); - sections.push(''); - for (const node of subAgentNodes) { - const nodeId = sanitizeNodeId(node.id); - const agentName = node.name || 'Sub-Agent'; - sections.push(`#### ${nodeId}(Sub-Agent: ${agentName})`); - sections.push(''); - if (node.data.description) { - sections.push(`**Description**: ${node.data.description}`); - sections.push(''); - } - if (node.data.model && node.data.model !== 'inherit') { - sections.push(`**Model**: ${node.data.model}`); - sections.push(''); - } - if (node.data.tools) { - sections.push(`**Tools**: ${node.data.tools}`); - sections.push(''); - } - sections.push('**Prompt**:'); - sections.push(''); - sections.push('```'); - sections.push(node.data.prompt || ''); - sections.push('```'); - sections.push(''); - } - } - - // Skill node details - if (skillNodes.length > 0) { - sections.push('## Skill Nodes'); - sections.push(''); - for (const node of skillNodes) { - const nodeId = sanitizeNodeId(node.id); - const executionMode = node.data.executionMode || 'execute'; - const skillName = node.data.name; - - sections.push(`#### ${nodeId}(${skillName})`); - sections.push(''); - if (executionMode === 'load') { - sections.push(`- **Prompt**: skill "${skillName}" load-skill-knowledge-into-context-only`); - } else if (node.data.executionPrompt) { - sections.push(`- **Prompt**: skill "${skillName}" "${node.data.executionPrompt}"`); - } else { - sections.push(`- **Prompt**: skill "${skillName}"`); - } - sections.push(''); - } - } - - // MCP node details - if (mcpNodes.length > 0) { - sections.push('## MCP Tool Nodes'); - sections.push(''); - for (const node of mcpNodes) { - const mode = node.data.mode || 'manualParameterConfig'; - let nodeSections: string[] = []; - - switch (mode) { - case 'manualParameterConfig': - nodeSections = formatManualParameterConfigMode(node); - break; - case 'aiParameterConfig': - nodeSections = formatAiParameterConfigMode(node, provider); - break; - case 'aiToolSelection': - nodeSections = formatAiToolSelectionMode(node, provider); - break; - default: - nodeSections = formatManualParameterConfigMode(node); - } - - sections.push(...nodeSections); - } - } - - // Codex node details - if (codexNodes.length > 0) { - sections.push('## Codex Agent Nodes'); - sections.push(''); - sections.push( - `Execute these nodes using the OpenAI Codex CLI. ${getShellToolDescription(provider)} the \`codex exec\` command with the specified parameters.` - ); - sections.push(''); - for (const node of codexNodes) { - const nodeId = sanitizeNodeId(node.id); - const escapedPrompt = node.data.prompt.replace(/'/g, "'\\''"); - const skipGitFlag = node.data.skipGitRepoCheck ? '--skip-git-repo-check ' : ''; - const sandboxFlag = node.data.sandbox ? `-s ${node.data.sandbox} ` : ''; - sections.push(`#### ${nodeId}(${node.data.label})`); - sections.push(''); - sections.push('**Execution Command**:'); - sections.push(''); - sections.push('```bash'); - sections.push( - `codex exec ${skipGitFlag}-m ${node.data.model} -c 'reasoning_effort="${node.data.reasoningEffort}"' ${sandboxFlag}'${escapedPrompt}'` - ); - sections.push('```'); - sections.push(''); - sections.push(`**Model**: ${node.data.model}`); - sections.push(''); - sections.push(`**Reasoning Effort**: ${node.data.reasoningEffort}`); - sections.push(''); - if (node.data.sandbox) { - sections.push(`**Sandbox Mode**: ${node.data.sandbox}`); - } else { - sections.push('**Sandbox Mode**: (default - not specified)'); - } - sections.push(''); - sections.push('**Prompt**:'); - sections.push(''); - sections.push('```'); - sections.push(node.data.prompt); - sections.push('```'); - sections.push(''); - } - } - - // SubAgentFlow node details - if (subAgentFlowNodes.length > 0 && options.parentWorkflowName && options.subAgentFlows) { - sections.push('## Sub-Agent Flow Nodes'); - sections.push(''); - for (const node of subAgentFlowNodes) { - const nodeId = sanitizeNodeId(node.id); - const label = - ('data' in node && node.data && 'label' in node.data ? node.data.label : null) || - node.name || - 'Sub-Agent Flow'; - const subAgentFlowId = - 'data' in node && node.data && 'subAgentFlowId' in node.data - ? node.data.subAgentFlowId - : null; - const linkedSubAgentFlow = options.subAgentFlows?.find((sf) => sf.id === subAgentFlowId); - - if (linkedSubAgentFlow) { - const subAgentFlowFileName = linkedSubAgentFlow.name - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/[^a-z0-9-_]/g, ''); - const agentFileName = `${options.parentWorkflowName}_${subAgentFlowFileName}`; - - sections.push(`#### ${nodeId}(${label})`); - sections.push(''); - sections.push(`@Sub-Agent: ${agentFileName}`); - sections.push(''); - } - } - } - - // Prompt node details - if (promptNodes.length > 0) { - sections.push('### Prompt Node Details'); - sections.push(''); - for (const node of promptNodes) { - const nodeId = sanitizeNodeId(node.id); - const label = node.data.prompt?.split('\n')[0] || node.name; - const displayLabel = label.length > 30 ? `${label.substring(0, 27)}...` : label; - sections.push(`#### ${nodeId}(${displayLabel})`); - sections.push(''); - sections.push('```'); - sections.push(node.data.prompt || ''); - sections.push('```'); - sections.push(''); - - if (node.data.variables && Object.keys(node.data.variables).length > 0) { - sections.push('**Available variables:**'); - for (const [key, value] of Object.entries(node.data.variables)) { - sections.push(`- \`{{${key}}}\`: ${value || '(not set)'}`); - } - sections.push(''); - } - } - } - - // AskUserQuestion node details - if (askUserQuestionNodes.length > 0) { - sections.push('### AskUserQuestion Node Details'); - sections.push(''); - sections.push('Ask the user and proceed based on their choice.'); - sections.push(''); - for (const node of askUserQuestionNodes) { - const nodeId = sanitizeNodeId(node.id); - sections.push(`#### ${nodeId}(${node.data.questionText})`); - sections.push(''); - - if (node.data.useAiSuggestions) { - sections.push( - '**Selection mode:** AI Suggestions (AI generates options dynamically based on context and presents them to the user)' - ); - sections.push(''); - if (node.data.multiSelect) { - sections.push('**Multi-select:** Enabled (user can select multiple options)'); - sections.push(''); - } - } else if (node.data.multiSelect) { - sections.push( - '**Selection mode:** Multi-select enabled (a list of selected options is passed to the next node)' - ); - sections.push(''); - sections.push('**Options:**'); - for (const option of node.data.options) { - sections.push(`- **${option.label}**: ${option.description || '(no description)'}`); - } - sections.push(''); - } else { - sections.push('**Selection mode:** Single Select (branches based on the selected option)'); - sections.push(''); - sections.push('**Options:**'); - for (const option of node.data.options) { - sections.push(`- **${option.label}**: ${option.description || '(no description)'}`); - } - sections.push(''); - } - } - } - - // Branch node details (Legacy) - if (branchNodes.length > 0) { - sections.push('### Branch Node Details'); - sections.push(''); - for (const node of branchNodes) { - const nodeId = sanitizeNodeId(node.id); - const branchTypeName = - node.data.branchType === 'conditional' ? 'Binary Branch' : 'Multiple Branch'; - sections.push(`#### ${nodeId}(${branchTypeName})`); - sections.push(''); - sections.push('**Branch conditions:**'); - for (const branch of node.data.branches) { - sections.push(`- **${branch.label}**: ${branch.condition}`); - } - sections.push(''); - sections.push( - '**Execution method**: Evaluate the results of the previous processing and automatically select the appropriate branch based on the conditions above.' - ); - sections.push(''); - } - } - - // IfElse node details - if (ifElseNodes.length > 0) { - sections.push('### If/Else Node Details'); - sections.push(''); - for (const node of ifElseNodes) { - const nodeId = sanitizeNodeId(node.id); - sections.push(`#### ${nodeId}(Binary Branch (True/False))`); - sections.push(''); - if (node.data.evaluationTarget) { - sections.push(`**Evaluation Target**: ${node.data.evaluationTarget}`); - sections.push(''); - } - sections.push('**Branch conditions:**'); - for (const branch of node.data.branches) { - sections.push(`- **${branch.label}**: ${branch.condition}`); - } - sections.push(''); - sections.push( - '**Execution method**: Evaluate the results of the previous processing and automatically select the appropriate branch based on the conditions above.' - ); - sections.push(''); - } - } - - // Switch node details - if (switchNodes.length > 0) { - sections.push('### Switch Node Details'); - sections.push(''); - for (const node of switchNodes) { - const nodeId = sanitizeNodeId(node.id); - sections.push(`#### ${nodeId}(Multiple Branch (2-N))`); - sections.push(''); - if (node.data.evaluationTarget) { - sections.push(`**Evaluation Target**: ${node.data.evaluationTarget}`); - sections.push(''); - } - sections.push('**Branch conditions:**'); - for (const branch of node.data.branches) { - sections.push(`- **${branch.label}**: ${branch.condition}`); - } - sections.push(''); - sections.push( - '**Execution method**: Evaluate the results of the previous processing and automatically select the appropriate branch based on the conditions above.' - ); - sections.push(''); - } - } - - return sections.join('\n'); -} diff --git a/src/extension/services/yaml-parser.ts b/src/extension/services/yaml-parser.ts deleted file mode 100644 index 87902d61..00000000 --- a/src/extension/services/yaml-parser.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * SKILL.md YAML Frontmatter Parser - * - * Feature: 001-skill-node - * Purpose: Parse YAML frontmatter from SKILL.md files without adding new dependencies - * - * Based on: specs/001-skill-node/research.md Section 1 - */ - -/** - * Skill metadata extracted from YAML frontmatter - */ -export interface SkillMetadata { - /** Skill name (required field in YAML frontmatter) */ - name: string; - /** Skill description (required field in YAML frontmatter) */ - description: string; - /** Optional: Allowed tools (optional field in YAML frontmatter) */ - allowedTools?: string; -} - -/** - * Parse SKILL.md YAML frontmatter and extract metadata - * - * @param content - Full content of SKILL.md file - * @returns Parsed metadata or null if invalid - * - * @example - * ```typescript - * const content = `--- - * name: my-skill - * description: Does something useful - * allowed-tools: Read, Write - * --- - * - * # Instructions - * ...`; - * - * const metadata = parseSkillFrontmatter(content); - * // { name: 'my-skill', description: 'Does something useful', allowedTools: 'Read, Write' } - * ``` - */ -export function parseSkillFrontmatter(content: string): SkillMetadata | null { - // Extract frontmatter block (delimited by ---) - // Support both LF (\n) and CRLF (\r\n) line endings for cross-platform compatibility - const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---/; - const match = content.match(frontmatterRegex); - - if (!match) { - return null; // No frontmatter found - } - - const yaml = match[1]; - - // Extract required fields using simple regex - const name = yaml.match(/^name:\s*(.+)$/m)?.[1]?.trim(); - const description = yaml.match(/^description:\s*(.+)$/m)?.[1]?.trim(); - - // Extract optional field - const allowedTools = yaml.match(/^allowed-tools:\s*(.+)$/m)?.[1]?.trim(); - - // Validate required fields - if (!name || !description) { - return null; // Required fields missing - } - - return { - name, - description, - allowedTools, - }; -} diff --git a/src/extension/types/slack-integration-types.ts b/src/extension/types/slack-integration-types.ts deleted file mode 100644 index e03e2f7e..00000000 --- a/src/extension/types/slack-integration-types.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * Slack Integration Data Models - * - * This file defines TypeScript types for Slack integration feature. - * Based on specs/001-slack-workflow-sharing/data-model.md - */ - -// ============================================================================ -// 1. SlackWorkspaceConnection -// ============================================================================ - -/** - * Slack workspace connection information - * - * Manages workspace connections using Bot User OAuth Tokens. - * Access tokens are stored in VSCode Secret Storage (encrypted). - */ -export interface SlackWorkspaceConnection { - /** Slack Workspace ID (e.g., T01234ABCD) */ - workspaceId: string; - - /** Workspace display name */ - workspaceName: string; - - /** Slack Team ID */ - teamId: string; - - /** Bot User OAuth Token (stored in VSCode Secret Storage only) */ - accessToken: string; - - /** User OAuth Token for user-specific operations like channel listing (xoxp-...) */ - userAccessToken?: string; - - /** Token scopes (e.g., ['chat:write', 'files:write']) - Optional for manual token input */ - tokenScope?: string[]; - - /** Authenticated user's Slack User ID (e.g., U01234EFGH) */ - userId: string; - - /** Bot User ID for membership check (e.g., U01234ABCD) */ - botUserId?: string; - - /** Authorization timestamp (ISO 8601) */ - authorizedAt: Date; - - /** Last token validation timestamp (ISO 8601) */ - lastValidatedAt?: Date; -} - -// ============================================================================ -// 2. SensitiveDataFinding & SensitiveDataType -// ============================================================================ - -/** - * Types of sensitive data that can be detected - */ -export enum SensitiveDataType { - AWS_ACCESS_KEY = 'AWS_ACCESS_KEY', - AWS_SECRET_KEY = 'AWS_SECRET_KEY', - API_KEY = 'API_KEY', - TOKEN = 'TOKEN', - SLACK_TOKEN = 'SLACK_TOKEN', - GITHUB_TOKEN = 'GITHUB_TOKEN', - PRIVATE_KEY = 'PRIVATE_KEY', - PASSWORD = 'PASSWORD', - CUSTOM = 'CUSTOM', // User-defined pattern -} - -/** - * Sensitive data detection result - * - * Contains masked values and severity information. - * Original values are never stored. - */ -export interface SensitiveDataFinding { - /** Type of sensitive data detected */ - type: SensitiveDataType; - - /** Masked value (first 4 + last 4 chars only, e.g., 'AKIA...X7Z9') */ - maskedValue: string; - - /** Character offset in file */ - position: number; - - /** Surrounding context (max 100 chars) */ - context?: string; - - /** Severity level (high = AWS keys, medium = API keys, low = passwords) */ - severity: 'low' | 'medium' | 'high'; -} - -// ============================================================================ -// 3. SlackChannel -// ============================================================================ - -/** - * Slack channel information - * - * Retrieved from Slack API conversations.list - */ -export interface SlackChannel { - /** Channel ID (e.g., C01234ABCD) */ - id: string; - - /** Channel name (e.g., 'general', 'team-announcements') */ - name: string; - - /** Whether channel is private */ - isPrivate: boolean; - - /** Whether user is a member of the channel */ - isMember: boolean; - - /** Number of members in the channel */ - memberCount?: number; - - /** Channel purpose (max 250 chars) */ - purpose?: string; - - /** Channel topic (max 250 chars) */ - topic?: string; -} - -// ============================================================================ -// 4. SharedWorkflowMetadata -// ============================================================================ - -/** - * Metadata for workflows shared to Slack - * - * Embedded in Slack message block kit as metadata. - * Used for workflow search and import. - */ -export interface SharedWorkflowMetadata { - /** Workflow unique ID (UUID v4) */ - id: string; - - /** Workflow name (1-100 chars) */ - name: string; - - /** Workflow description (max 500 chars) */ - description?: string; - - /** Semantic versioning (e.g., '1.0.0') */ - version: string; - - /** Author's name (from VS Code settings) */ - authorName: string; - - /** Author's email address (optional) */ - authorEmail?: string; - - /** Timestamp when shared to Slack (ISO 8601) */ - sharedAt: Date; - - /** Slack channel ID where shared */ - channelId: string; - - /** Slack channel name (for display) */ - channelName: string; - - /** Slack message timestamp (e.g., '1234567890.123456') */ - messageTs: string; - - /** Slack file ID (e.g., F01234ABCD) */ - fileId: string; - - /** Slack file download URL (private URL) */ - fileUrl: string; - - /** Number of nodes in workflow */ - nodeCount: number; - - /** Tags for search (max 10 tags, each max 30 chars) */ - tags?: string[]; - - /** Whether sensitive data was detected */ - hasSensitiveData: boolean; - - /** Whether user overrode sensitive data warning */ - sensitiveDataOverride?: boolean; -} - -// ============================================================================ -// 5. WorkflowImportRequest & ImportStatus -// ============================================================================ - -/** - * Workflow import status - */ -export enum ImportStatus { - PENDING = 'pending', // Import queued - DOWNLOADING = 'downloading', // Downloading file from Slack - VALIDATING = 'validating', // Validating file format - WRITING = 'writing', // Writing file to disk - COMPLETED = 'completed', // Import completed - FAILED = 'failed', // Import failed -} - -/** - * Workflow import request - * - * Tracks the state of importing a workflow from Slack. - */ -export interface WorkflowImportRequest { - /** Workflow ID to import (UUID v4) */ - workflowId: string; - - /** Source Slack message timestamp */ - sourceMessageTs: string; - - /** Source Slack channel ID */ - sourceChannelId: string; - - /** Slack file ID to download */ - fileId: string; - - /** Target directory (absolute path, e.g., '/Users/.../workflows/') */ - targetDirectory: string; - - /** Whether to overwrite existing file */ - overwriteExisting: boolean; - - /** Request timestamp (ISO 8601) */ - requestedAt: Date; - - /** Current import status */ - status: ImportStatus; - - /** Error message (only when status === 'failed') */ - errorMessage?: string; -} diff --git a/src/extension/types/slack-messages.ts b/src/extension/types/slack-messages.ts deleted file mode 100644 index 419fa72d..00000000 --- a/src/extension/types/slack-messages.ts +++ /dev/null @@ -1,384 +0,0 @@ -/** - * Slack Integration Message Passing Types - * - * Defines message contracts between Webview UI and Extension Host. - * Based on specs/001-slack-workflow-sharing/contracts/extension-host-api-contracts.md - */ - -import type { - SensitiveDataFinding, - SharedWorkflowMetadata, - SlackChannel, -} from './slack-integration-types'; - -// ============================================================================ -// Common Message Format -// ============================================================================ - -/** - * Generic message wrapper for Webview ↔ Extension Host communication - */ -export interface WebviewMessage { - type: string; - payload: T; -} - -// ============================================================================ -// 1. Webview → Extension Host (Commands) -// ============================================================================ - -/** - * SLACK_CONNECT - * - * Initiates Slack workspace connection (OAuth flow). - */ -export interface SlackConnectCommand { - type: 'SLACK_CONNECT'; - payload: Record; // Empty object -} - -/** - * SLACK_DISCONNECT - * - * Disconnects from Slack workspace (removes token). - */ -export interface SlackDisconnectCommand { - type: 'SLACK_DISCONNECT'; - payload: Record; -} - -/** - * GET_SLACK_CHANNELS - * - * Retrieves list of Slack channels. - */ -export interface GetSlackChannelsCommand { - type: 'GET_SLACK_CHANNELS'; - payload: { - /** Include private channels (default: true) */ - includePrivate?: boolean; - /** Only channels user is a member of (default: true) */ - onlyMember?: boolean; - }; -} - -/** - * SHARE_WORKFLOW_TO_SLACK - * - * Shares workflow to Slack channel. - */ -export interface ShareWorkflowToSlackCommand { - type: 'SHARE_WORKFLOW_TO_SLACK'; - payload: { - /** Workflow ID to share */ - workflowId: string; - /** Workflow name */ - workflowName: string; - /** Target Slack channel ID */ - channelId: string; - /** Workflow description (optional) */ - description?: string; - /** Override sensitive data warning (default: false) */ - overrideSensitiveWarning?: boolean; - }; -} - -/** - * IMPORT_WORKFLOW_FROM_SLACK - * - * Imports workflow from Slack message. - */ -export interface ImportWorkflowFromSlackCommand { - type: 'IMPORT_WORKFLOW_FROM_SLACK'; - payload: { - /** Workflow ID to import */ - workflowId: string; - /** Slack file ID */ - fileId: string; - /** Slack message timestamp */ - messageTs: string; - /** Slack channel ID */ - channelId: string; - /** Overwrite existing file (default: false) */ - overwriteExisting?: boolean; - }; -} - -/** - * SEARCH_SLACK_WORKFLOWS - * - * Searches for workflows previously shared to Slack. - */ -export interface SearchSlackWorkflowsCommand { - type: 'SEARCH_SLACK_WORKFLOWS'; - payload: { - /** Search keyword (optional) */ - query?: string; - /** Filter by channel ID (optional) */ - channelId?: string; - /** Filter by author name (optional) */ - authorName?: string; - /** Filter by start date (ISO 8601) (optional) */ - fromDate?: string; - /** Filter by end date (ISO 8601) (optional) */ - toDate?: string; - /** Result limit (default: 20, max: 100) */ - limit?: number; - }; -} - -/** - * Union type of all Webview → Extension Host commands - */ -export type WebviewToExtensionCommand = - | SlackConnectCommand - | SlackDisconnectCommand - | GetSlackChannelsCommand - | ShareWorkflowToSlackCommand - | ImportWorkflowFromSlackCommand - | SearchSlackWorkflowsCommand; - -// ============================================================================ -// 2. Extension Host → Webview (Events) -// ============================================================================ - -/** - * SLACK_CONNECT_SUCCESS - * - * Sent when Slack connection succeeds. - */ -export interface SlackConnectSuccessEvent { - type: 'SLACK_CONNECT_SUCCESS'; - payload: { - workspaceId: string; - workspaceName: string; - userId: string; - authorizedAt: string; // ISO 8601 - }; -} - -/** - * SLACK_CONNECT_FAILED - * - * Sent when Slack connection fails. - */ -export interface SlackConnectFailedEvent { - type: 'SLACK_CONNECT_FAILED'; - payload: { - errorCode: 'USER_CANCELLED' | 'OAUTH_FAILED' | 'NETWORK_ERROR' | 'UNKNOWN_ERROR'; - /** i18n message key for translation */ - messageKey: string; - /** i18n suggested action key for translation */ - suggestedActionKey?: string; - }; -} - -/** - * SLACK_DISCONNECT_SUCCESS - * - * Sent when Slack disconnection succeeds. - */ -export interface SlackDisconnectSuccessEvent { - type: 'SLACK_DISCONNECT_SUCCESS'; - payload: Record; -} - -/** - * SLACK_DISCONNECT_FAILED - * - * Sent when Slack disconnection fails. - */ -export interface SlackDisconnectFailedEvent { - type: 'SLACK_DISCONNECT_FAILED'; - payload: { - errorCode: string; - /** i18n message key for translation */ - messageKey: string; - /** i18n suggested action key for translation */ - suggestedActionKey?: string; - }; -} - -/** - * GET_SLACK_CHANNELS_SUCCESS - * - * Sent when channel list retrieval succeeds. - */ -export interface GetSlackChannelsSuccessEvent { - type: 'GET_SLACK_CHANNELS_SUCCESS'; - payload: { - channels: SlackChannel[]; - }; -} - -/** - * GET_SLACK_CHANNELS_FAILED - * - * Sent when channel list retrieval fails. - */ -export interface GetSlackChannelsFailedEvent { - type: 'GET_SLACK_CHANNELS_FAILED'; - payload: { - errorCode: string; - /** i18n message key for translation */ - messageKey: string; - /** i18n suggested action key for translation */ - suggestedActionKey?: string; - }; -} - -/** - * SENSITIVE_DATA_WARNING - * - * Sent when sensitive data is detected (requires user confirmation). - */ -export interface SensitiveDataWarningEvent { - type: 'SENSITIVE_DATA_WARNING'; - payload: { - workflowId: string; - findings: SensitiveDataFinding[]; - }; -} - -/** - * SHARE_WORKFLOW_SUCCESS - * - * Sent when workflow sharing succeeds. - */ -export interface ShareWorkflowSuccessEvent { - type: 'SHARE_WORKFLOW_SUCCESS'; - payload: { - workflowId: string; - channelId: string; - channelName: string; - messageTs: string; - fileId: string; - permalink: string; // Direct link to Slack message - }; -} - -/** - * SHARE_WORKFLOW_FAILED - * - * Sent when workflow sharing fails. - */ -export interface ShareWorkflowFailedEvent { - type: 'SHARE_WORKFLOW_FAILED'; - payload: { - workflowId: string; - errorCode: - | 'NOT_AUTHENTICATED' - | 'CHANNEL_NOT_FOUND' - | 'NOT_IN_CHANNEL' - | 'FILE_TOO_LARGE' - | 'RATE_LIMITED' - | 'NETWORK_ERROR' - | 'UNKNOWN_ERROR'; - /** i18n message key for translation */ - messageKey: string; - /** i18n suggested action key for translation */ - suggestedActionKey?: string; - /** Parameters for message interpolation (e.g., retryAfter seconds) */ - messageParams?: Record; - }; -} - -/** - * IMPORT_WORKFLOW_CONFIRM_OVERWRITE - * - * Sent when existing file is found (requires user confirmation). - */ -export interface ImportWorkflowConfirmOverwriteEvent { - type: 'IMPORT_WORKFLOW_CONFIRM_OVERWRITE'; - payload: { - workflowId: string; - existingFilePath: string; - }; -} - -/** - * IMPORT_WORKFLOW_SUCCESS - * - * Sent when workflow import succeeds. - */ -export interface ImportWorkflowSuccessEvent { - type: 'IMPORT_WORKFLOW_SUCCESS'; - payload: { - workflowId: string; - filePath: string; - workflowName: string; - /** Workflow data for loading into canvas */ - workflow: import('../../shared/types/workflow-definition').Workflow; - }; -} - -/** - * IMPORT_WORKFLOW_FAILED - * - * Sent when workflow import fails. - */ -export interface ImportWorkflowFailedEvent { - type: 'IMPORT_WORKFLOW_FAILED'; - payload: { - workflowId: string; - errorCode: string; - /** i18n message key for translation */ - messageKey: string; - /** i18n suggested action key for translation */ - suggestedActionKey?: string; - /** Parameters for message interpolation (e.g., retryAfter seconds) */ - messageParams?: Record; - /** Source workspace ID (for WORKSPACE_NOT_CONNECTED errors) */ - workspaceId?: string; - /** Workspace name for display in error dialogs */ - workspaceName?: string; - }; -} - -/** - * SEARCH_SLACK_WORKFLOWS_SUCCESS - * - * Sent when workflow search succeeds. - */ -export interface SearchSlackWorkflowsSuccessEvent { - type: 'SEARCH_SLACK_WORKFLOWS_SUCCESS'; - payload: { - workflows: SharedWorkflowMetadata[]; - total: number; - }; -} - -/** - * SEARCH_SLACK_WORKFLOWS_FAILED - * - * Sent when workflow search fails. - */ -export interface SearchSlackWorkflowsFailedEvent { - type: 'SEARCH_SLACK_WORKFLOWS_FAILED'; - payload: { - errorCode: string; - /** i18n message key for translation */ - messageKey: string; - /** i18n suggested action key for translation */ - suggestedActionKey?: string; - }; -} - -/** - * Union type of all Extension Host → Webview events - */ -export type ExtensionToWebviewEvent = - | SlackConnectSuccessEvent - | SlackConnectFailedEvent - | SlackDisconnectSuccessEvent - | SlackDisconnectFailedEvent - | GetSlackChannelsSuccessEvent - | GetSlackChannelsFailedEvent - | SensitiveDataWarningEvent - | ShareWorkflowSuccessEvent - | ShareWorkflowFailedEvent - | ImportWorkflowConfirmOverwriteEvent - | ImportWorkflowSuccessEvent - | ImportWorkflowFailedEvent - | SearchSlackWorkflowsSuccessEvent - | SearchSlackWorkflowsFailedEvent; diff --git a/src/extension/utils/migrate-workflow.ts b/src/extension/utils/migrate-workflow.ts deleted file mode 100644 index 38609200..00000000 --- a/src/extension/utils/migrate-workflow.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Workflow Migration Utility - * - * Migrates older workflow formats to current version. - * Handles backward compatibility for workflow structure changes. - */ - -import type { - SkillNodeData, - SwitchCondition, - SwitchNodeData, - Workflow, - WorkflowNode, -} from '../../shared/types/workflow-definition'; - -/** - * Generate a unique branch ID - */ -function generateBranchId(): string { - return `branch_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; -} - -/** - * Migrate Switch nodes to include default branch - * - * For existing workflows without default branch: - * - Adds a default branch at the end - * - Updates outputPorts count - * - * For existing workflows with default branch: - * - Ensures default branch is last - * - Ensures only one default branch exists - * - * @param workflow - The workflow to migrate - * @returns Migrated workflow with updated Switch nodes - */ -export function migrateSwitchNodes(workflow: Workflow): Workflow { - const migratedNodes = workflow.nodes.map((node) => { - if (node.type !== 'switch') return node; - - const switchData = node.data as SwitchNodeData; - const branches = switchData.branches || []; - - // Check if any branch has isDefault - const hasDefault = branches.some((b: SwitchCondition) => b.isDefault); - - if (hasDefault) { - // Ensure default branch is last - const defaultIndex = branches.findIndex((b: SwitchCondition) => b.isDefault); - if (defaultIndex !== branches.length - 1) { - const defaultBranch = branches[defaultIndex]; - const newBranches = [ - ...branches.slice(0, defaultIndex), - ...branches.slice(defaultIndex + 1), - defaultBranch, - ]; - return { - ...node, - data: { - ...switchData, - branches: newBranches, - outputPorts: newBranches.length, - }, - } as WorkflowNode; - } - return node; - } - - // Add default branch for legacy workflows - const newBranches: SwitchCondition[] = [ - ...branches.map((b: SwitchCondition) => ({ - ...b, - isDefault: false, - })), - { - id: generateBranchId(), - label: 'default', - condition: 'Other cases', - isDefault: true, - }, - ]; - - return { - ...node, - data: { - ...switchData, - branches: newBranches, - outputPorts: newBranches.length, - }, - } as WorkflowNode; - }); - - return { - ...workflow, - nodes: migratedNodes, - }; -} - -/** - * Migrate Skill nodes to use new scope terminology - * - * Converts legacy scope values to Anthropic official terminology: - * - 'personal' → 'user' - * - * This migration supports backward compatibility for existing workflows - * saved before the scope terminology update. - * - * @param workflow - The workflow to migrate - * @returns Migrated workflow with updated Skill node scopes - * - * @see Issue #364 - Tech Debt: Remove this migration after deprecation period - */ -export function migrateSkillScopes(workflow: Workflow): Workflow { - const migratedNodes = workflow.nodes.map((node) => { - if (node.type !== 'skill') return node; - - const data = node.data as SkillNodeData; - // Cast to allow checking for legacy 'personal' value - const currentScope = data.scope as string; - - // Migrate 'personal' → 'user' - if (currentScope === 'personal') { - console.warn( - `[Workflow Migration] Migrating Skill "${data.name}" scope: 'personal' → 'user'` - ); - return { - ...node, - data: { - ...data, - scope: 'user' as const, - }, - } as WorkflowNode; - } - - return node; - }); - - return { - ...workflow, - nodes: migratedNodes, - }; -} - -/** - * Migrate Skill nodes to include explicit executionMode - * - * For existing workflows without executionMode: - * - Sets executionMode to 'execute' (preserving existing behavior) - * - * @param workflow - The workflow to migrate - * @returns Migrated workflow with updated Skill nodes - */ -export function migrateSkillExecutionMode(workflow: Workflow): Workflow { - const migratedNodes = workflow.nodes.map((node) => { - if (node.type !== 'skill') return node; - - const data = node.data as SkillNodeData; - - if (data.executionMode === undefined) { - return { - ...node, - data: { - ...data, - executionMode: 'execute' as const, - }, - } as WorkflowNode; - } - - return node; - }); - - return { - ...workflow, - nodes: migratedNodes, - }; -} - -/** - * Apply all workflow migrations - * - * Runs all migration functions in sequence. - * Add new migration functions here as the schema evolves. - * - * @param workflow - The workflow to migrate - * @returns Fully migrated workflow - */ -export function migrateWorkflow(workflow: Workflow): Workflow { - // Apply migrations in order - let migrated = workflow; - - // Migration 1: Add default branch to Switch nodes - migrated = migrateSwitchNodes(migrated); - - // Migration 2: Update Skill node scope terminology ('personal' → 'user') - migrated = migrateSkillScopes(migrated); - - // Migration 3: Set explicit executionMode on Skill nodes - migrated = migrateSkillExecutionMode(migrated); - - // Add future migrations here... - - return migrated; -} diff --git a/src/extension/utils/path-utils.ts b/src/extension/utils/path-utils.ts deleted file mode 100644 index 83684be5..00000000 --- a/src/extension/utils/path-utils.ts +++ /dev/null @@ -1,488 +0,0 @@ -/** - * Cross-Platform Path Utilities for Skill Management - * - * Feature: 001-skill-node - * Purpose: Handle Windows/Unix path differences for Skill directories - * - * Based on: specs/001-skill-node/research.md Section 3 - */ - -import os from 'node:os'; -import path from 'node:path'; -import * as vscode from 'vscode'; - -/** - * Get the user-scope Skills directory path - * - * @returns Absolute path to ~/.claude/skills/ - * - * @example - * // Unix: /Users/username/.claude/skills - * // Windows: C:\Users\username\.claude\skills - */ -export function getUserSkillsDir(): string { - return path.join(os.homedir(), '.claude', 'skills'); -} - -/** - * @deprecated Use getUserSkillsDir() instead. Kept for backward compatibility. - */ -export function getPersonalSkillsDir(): string { - return getUserSkillsDir(); -} - -/** - * Get the current workspace root path - * - * @returns Absolute path to workspace root, or null if no workspace is open - * - * @example - * // Unix: /workspace/myproject - * // Windows: C:\workspace\myproject - */ -export function getWorkspaceRoot(): string | null { - return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? null; -} - -/** - * Get the project Skills directory path - * - * @returns Absolute path to .claude/skills/ in workspace root, or null if no workspace - * - * @example - * // Unix: /workspace/myproject/.claude/skills - * // Windows: C:\workspace\myproject\.claude\skills - */ -export function getProjectSkillsDir(): string | null { - const workspaceRoot = getWorkspaceRoot(); - if (!workspaceRoot) { - return null; - } - return path.join(workspaceRoot, '.claude', 'skills'); -} - -/** - * Get the GitHub Skills directory path (Copilot project-scope) - * - * @returns Absolute path to .github/skills/ in workspace root, or null if no workspace - * - * @example - * // Unix: /workspace/myproject/.github/skills - * // Windows: C:\workspace\myproject\.github\skills - */ -export function getGithubSkillsDir(): string | null { - const workspaceRoot = getWorkspaceRoot(); - if (!workspaceRoot) { - return null; - } - return path.join(workspaceRoot, '.github', 'skills'); -} - -/** - * Get the Copilot user-scope Skills directory path - * - * @returns Absolute path to ~/.copilot/skills/ - * - * @example - * // Unix: /Users/username/.copilot/skills - * // Windows: C:\Users\username\.copilot\skills - */ -export function getCopilotUserSkillsDir(): string { - return path.join(os.homedir(), '.copilot', 'skills'); -} - -/** - * Get the Codex user-scope Skills directory path - * - * @returns Absolute path to ~/.codex/skills/ - * - * @example - * // Unix: /Users/username/.codex/skills - * // Windows: C:\Users\username\.codex\skills - */ -export function getCodexUserSkillsDir(): string { - return path.join(os.homedir(), '.codex', 'skills'); -} - -/** - * Get the Codex project-scope Skills directory path - * - * @returns Absolute path to .codex/skills/ in workspace root, or null if no workspace - * - * @example - * // Unix: /workspace/myproject/.codex/skills - * // Windows: C:\workspace\myproject\.codex\skills - */ -export function getCodexProjectSkillsDir(): string | null { - const workspaceRoot = getWorkspaceRoot(); - if (!workspaceRoot) { - return null; - } - return path.join(workspaceRoot, '.codex', 'skills'); -} - -/** - * Get the Roo Code user-scope Skills directory path - * - * @returns Absolute path to ~/.roo/skills/ - * - * @example - * // Unix: /Users/username/.roo/skills - * // Windows: C:\Users\username\.roo\skills - */ -export function getRooUserSkillsDir(): string { - return path.join(os.homedir(), '.roo', 'skills'); -} - -/** - * Get the Roo Code project-scope Skills directory path - * - * @returns Absolute path to .roo/skills/ in workspace root, or null if no workspace - * - * @example - * // Unix: /workspace/myproject/.roo/skills - * // Windows: C:\workspace\myproject\.roo\skills - */ -export function getRooProjectSkillsDir(): string | null { - const workspaceRoot = getWorkspaceRoot(); - if (!workspaceRoot) { - return null; - } - return path.join(workspaceRoot, '.roo', 'skills'); -} - -/** - * Get the Gemini CLI user-scope Skills directory path - * - * @returns Absolute path to ~/.gemini/skills/ - * - * @example - * // Unix: /Users/username/.gemini/skills - * // Windows: C:\Users\username\.gemini\skills - */ -export function getGeminiUserSkillsDir(): string { - return path.join(os.homedir(), '.gemini', 'skills'); -} - -/** - * Get the Gemini CLI project-scope Skills directory path - * - * @returns Absolute path to .gemini/skills/ in workspace root, or null if no workspace - * - * @example - * // Unix: /workspace/myproject/.gemini/skills - * // Windows: C:\workspace\myproject\.gemini\skills - */ -export function getGeminiProjectSkillsDir(): string | null { - const workspaceRoot = getWorkspaceRoot(); - if (!workspaceRoot) { - return null; - } - return path.join(workspaceRoot, '.gemini', 'skills'); -} - -/** - * Get the Antigravity (Google VSCode fork) user-scope Skills directory path - * - * @returns Absolute path to ~/.gemini/antigravity/skills/ - * - * @example - * // Unix: /Users/username/.gemini/antigravity/skills - * // Windows: C:\Users\username\.gemini\antigravity\skills - */ -export function getAntigravityUserSkillsDir(): string { - return path.join(os.homedir(), '.gemini', 'antigravity', 'skills'); -} - -/** - * Get the Antigravity (Google VSCode fork) project-scope Skills directory path - * - * @returns Absolute path to .agent/skills/ in workspace root, or null if no workspace - * - * @example - * // Unix: /workspace/myproject/.agent/skills - * // Windows: C:\workspace\myproject\.agent\skills - */ -export function getAntigravityProjectSkillsDir(): string | null { - const workspaceRoot = getWorkspaceRoot(); - if (!workspaceRoot) { - return null; - } - return path.join(workspaceRoot, '.agent', 'skills'); -} - -/** - * Get the Cursor (Anysphere VSCode fork) user-scope Skills directory path - * - * @returns Absolute path to ~/.cursor/skills/ - * - * @example - * // Unix: /Users/username/.cursor/skills - * // Windows: C:\Users\username\.cursor\skills - */ -export function getCursorUserSkillsDir(): string { - return path.join(os.homedir(), '.cursor', 'skills'); -} - -/** - * Get the Cursor (Anysphere VSCode fork) project-scope Skills directory path - * - * @returns Absolute path to .cursor/skills/ in workspace root, or null if no workspace - * - * @example - * // Unix: /workspace/myproject/.cursor/skills - * // Windows: C:\workspace\myproject\.cursor\skills - */ -export function getCursorProjectSkillsDir(): string | null { - const workspaceRoot = getWorkspaceRoot(); - if (!workspaceRoot) { - return null; - } - return path.join(workspaceRoot, '.cursor', 'skills'); -} - -// ===================================================================== -// MCP Configuration Paths -// ===================================================================== - -/** - * Get the Copilot user-scope MCP config path (~/.copilot/mcp-config.json) - * - * Note: Copilot CLI only supports user-scope MCP configuration. - * Project-scope MCP (.copilot/mcp-config.json) is NOT supported. - * - * @returns Absolute path to ~/.copilot/mcp-config.json - * - * @example - * // Unix: /Users/username/.copilot/mcp-config.json - * // Windows: C:\Users\username\.copilot\mcp-config.json - */ -export function getCopilotUserMcpConfigPath(): string { - return path.join(os.homedir(), '.copilot', 'mcp-config.json'); -} - -/** - * Get the VSCode Copilot MCP config path (.vscode/mcp.json) - * - * @returns Absolute path to .vscode/mcp.json in workspace root, or null if no workspace - * - * @example - * // Unix: /workspace/myproject/.vscode/mcp.json - * // Windows: C:\workspace\myproject\.vscode\mcp.json - */ -export function getVSCodeMcpConfigPath(): string | null { - const workspaceRoot = getWorkspaceRoot(); - if (!workspaceRoot) { - return null; - } - return path.join(workspaceRoot, '.vscode', 'mcp.json'); -} - -/** - * Get the Codex user-scope MCP config path (~/.codex/config.toml) - * - * @returns Absolute path to ~/.codex/config.toml - * - * @example - * // Unix: /Users/username/.codex/config.toml - * // Windows: C:\Users\username\.codex\config.toml - */ -export function getCodexUserMcpConfigPath(): string { - return path.join(os.homedir(), '.codex', 'config.toml'); -} - -/** - * Get the Gemini CLI user-scope MCP config path (~/.gemini/settings.json) - * - * @returns Absolute path to user MCP config - * - * @example - * // Unix: /Users/username/.gemini/settings.json - * // Windows: C:\Users\username\.gemini\settings.json - */ -export function getGeminiUserMcpConfigPath(): string { - return path.join(os.homedir(), '.gemini', 'settings.json'); -} - -/** - * Get the Gemini CLI project-scope MCP config path (.gemini/settings.json) - * - * @returns Absolute path to project MCP config, or null if no workspace - * - * @example - * // Unix: /workspace/myproject/.gemini/settings.json - * // Windows: C:\workspace\myproject\.gemini\settings.json - */ -export function getGeminiProjectMcpConfigPath(): string | null { - const workspaceRoot = getWorkspaceRoot(); - if (!workspaceRoot) { - return null; - } - return path.join(workspaceRoot, '.gemini', 'settings.json'); -} - -/** - * Get the Antigravity user-scope MCP config path (~/.gemini/antigravity/mcp_config.json) - * - * @returns Absolute path to ~/.gemini/antigravity/mcp_config.json - * - * @example - * // Unix: /Users/username/.gemini/antigravity/mcp_config.json - * // Windows: C:\Users\username\.gemini\antigravity\mcp_config.json - */ -export function getAntigravityUserMcpConfigPath(): string { - return path.join(os.homedir(), '.gemini', 'antigravity', 'mcp_config.json'); -} - -/** - * Get the Cursor user-scope MCP config path (~/.cursor/mcp.json) - * - * Note: Cursor only supports user-scope MCP configuration. - * - * @returns Absolute path to ~/.cursor/mcp.json - * - * @example - * // Unix: /Users/username/.cursor/mcp.json - * // Windows: C:\Users\username\.cursor\mcp.json - */ -export function getCursorUserMcpConfigPath(): string { - return path.join(os.homedir(), '.cursor', 'mcp.json'); -} - -/** - * Get the Roo Code project-scope MCP config path (.roo/mcp.json) - * - * @returns Absolute path to project MCP config, or null if no workspace - * - * @example - * // Unix: /workspace/myproject/.roo/mcp.json - * // Windows: C:\workspace\myproject\.roo\mcp.json - */ -export function getRooProjectMcpConfigPath(): string | null { - const workspaceRoot = getWorkspaceRoot(); - if (!workspaceRoot) { - return null; - } - return path.join(workspaceRoot, '.roo', 'mcp.json'); -} - -/** - * Get the installed plugins JSON path - * - * @returns Absolute path to ~/.claude/plugins/installed_plugins.json - * - * @example - * // Unix: /Users/username/.claude/plugins/installed_plugins.json - * // Windows: C:\Users\username\.claude\plugins\installed_plugins.json - */ -export function getInstalledPluginsJsonPath(): string { - return path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json'); -} - -/** - * Get the Claude settings JSON path - * - * @returns Absolute path to ~/.claude/settings.json - * - * @example - * // Unix: /Users/username/.claude/settings.json - * // Windows: C:\Users\username\.claude\settings.json - */ -export function getClaudeSettingsJsonPath(): string { - return path.join(os.homedir(), '.claude', 'settings.json'); -} - -/** - * Get the known marketplaces JSON path - * - * @returns Absolute path to ~/.claude/plugins/known_marketplaces.json - * - * @example - * // Unix: /Users/username/.claude/plugins/known_marketplaces.json - * // Windows: C:\Users\username\.claude\plugins\known_marketplaces.json - */ -export function getKnownMarketplacesJsonPath(): string { - return path.join(os.homedir(), '.claude', 'plugins', 'known_marketplaces.json'); -} - -/** - * Resolve a Skill path to absolute path - * - * @param skillPath - Skill path (absolute for user/local, relative for project) - * @param scope - Skill scope ('user', 'project', or 'local') - * @returns Absolute path to SKILL.md file - * @throws Error if scope is 'project' but no workspace folder exists - * - * @example - * // User Skill (already absolute) - * resolveSkillPath('/Users/alice/.claude/skills/my-skill/SKILL.md', 'user'); - * // => '/Users/alice/.claude/skills/my-skill/SKILL.md' - * - * // Project Skill (relative → absolute) - * resolveSkillPath('.claude/skills/team-skill/SKILL.md', 'project'); - * // => '/workspace/myproject/.claude/skills/team-skill/SKILL.md' - * - * // Local Skill (already absolute, from plugin) - * resolveSkillPath('/path/to/plugin/skills/my-skill/SKILL.md', 'local'); - * // => '/path/to/plugin/skills/my-skill/SKILL.md' - */ -export function resolveSkillPath(skillPath: string, scope: 'user' | 'project' | 'local'): string { - if (scope === 'user' || scope === 'local') { - // User and Local Skills use absolute paths - return skillPath; - } - - // Project Skills: convert relative path to absolute - const projectDir = getProjectSkillsDir(); - if (!projectDir) { - throw new Error('No workspace folder found for project Skill resolution'); - } - - // If skillPath is already absolute, return as-is (backward compatibility) - if (path.isAbsolute(skillPath)) { - return skillPath; - } - - // Resolve relative path from workspace root - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (!workspaceRoot) { - throw new Error('No workspace folder found for Skill path resolution'); - } - return path.resolve(workspaceRoot, skillPath); -} - -/** - * Convert absolute Skill path to relative path (for project Skills) - * - * @param absolutePath - Absolute path to SKILL.md file - * @param scope - Skill scope ('user', 'project', or 'local') - * @returns Relative path for project Skills, absolute path for user/local Skills - * - * @example - * // Project Skill (absolute → relative) - * toRelativePath('/workspace/myproject/.claude/skills/team-skill/SKILL.md', 'project'); - * // => '.claude/skills/team-skill/SKILL.md' - * - * // User Skill (keep absolute) - * toRelativePath('/Users/alice/.claude/skills/my-skill/SKILL.md', 'user'); - * // => '/Users/alice/.claude/skills/my-skill/SKILL.md' - * - * // Local Skill (keep absolute, from plugin) - * toRelativePath('/path/to/plugin/skills/my-skill/SKILL.md', 'local'); - * // => '/path/to/plugin/skills/my-skill/SKILL.md' - */ -export function toRelativePath(absolutePath: string, scope: 'user' | 'project' | 'local'): string { - if (scope === 'user' || scope === 'local') { - // User and Local Skills always use absolute paths - return absolutePath; - } - - // Project Skills: convert to relative path from workspace root - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (!workspaceRoot) { - // No workspace: keep absolute (edge case) - return absolutePath; - } - - return path.relative(workspaceRoot, absolutePath); -} diff --git a/src/extension/utils/schema-parser.ts b/src/extension/utils/schema-parser.ts deleted file mode 100644 index 5f390ce6..00000000 --- a/src/extension/utils/schema-parser.ts +++ /dev/null @@ -1,348 +0,0 @@ -/** - * JSON Schema Parser - * - * Feature: 001-mcp-node - * Purpose: Parse and validate JSON Schema for MCP tool parameters - * - * Based on: JSON Schema Draft 7 - * Task: T030 - */ - -import type { ToolParameter } from '../../shared/types/mcp-node'; - -/** - * JSON Schema property definition - */ -export interface JsonSchemaProperty { - type?: string | string[]; - description?: string; - enum?: unknown[]; - default?: unknown; - minimum?: number; - maximum?: number; - minLength?: number; - maxLength?: number; - pattern?: string; - items?: JsonSchemaProperty; - properties?: Record; - required?: string[]; - additionalProperties?: boolean | JsonSchemaProperty; -} - -/** - * JSON Schema root definition - */ -export interface JsonSchema { - type?: string; - properties?: Record; - required?: string[]; - additionalProperties?: boolean | JsonSchemaProperty; -} - -/** - * Extended ToolParameter with additional validation metadata - */ -export interface ExtendedToolParameter extends ToolParameter { - /** Allowed enum values (if defined) */ - enum?: unknown[]; - /** Minimum value for numbers */ - minimum?: number; - /** Maximum value for numbers */ - maximum?: number; - /** Minimum length for strings */ - minLength?: number; - /** Maximum length for strings */ - maxLength?: number; - /** Regex pattern for string validation */ - pattern?: string; -} - -/** - * Parse JSON Schema and convert to ToolParameter array - * - * Extracts parameter definitions with validation metadata from JSON Schema. - * Supports string, number, boolean, integer, array, and object types. - * - * @param schema - JSON Schema object - * @returns Array of tool parameters with validation metadata - * - * @example - * ```typescript - * const schema = { - * type: 'object', - * properties: { - * region: { - * type: 'string', - * description: 'AWS region', - * enum: ['us-east-1', 'us-west-2'] - * }, - * limit: { - * type: 'integer', - * description: 'Result limit', - * minimum: 1, - * maximum: 100, - * default: 10 - * } - * }, - * required: ['region'] - * }; - * - * const params = parseJsonSchema(schema); - * // [ - * // { name: 'region', type: 'string', required: true, enum: ['us-east-1', 'us-west-2'], ... }, - * // { name: 'limit', type: 'integer', required: false, minimum: 1, maximum: 100, default: 10, ... } - * // ] - * ``` - */ -export function parseJsonSchema(schema: JsonSchema): ExtendedToolParameter[] { - if (!schema.properties) { - return []; - } - - const required = schema.required || []; - - return Object.entries(schema.properties).map(([name, propSchema]) => { - // Determine parameter type - const paramType = normalizeSchemaType(propSchema.type); - - // Base parameter - const param: ExtendedToolParameter = { - name, - type: paramType, - description: propSchema.description || '', - required: required.includes(name), - }; - - // Add enum values if defined - if (propSchema.enum) { - param.enum = propSchema.enum; - } - - // Add default value if defined - if (propSchema.default !== undefined) { - param.default = propSchema.default; - } - - // Add numeric constraints - if (paramType === 'number' || paramType === 'integer') { - if (propSchema.minimum !== undefined) { - param.minimum = propSchema.minimum; - } - if (propSchema.maximum !== undefined) { - param.maximum = propSchema.maximum; - } - } - - // Add string constraints - if (paramType === 'string') { - if (propSchema.minLength !== undefined) { - param.minLength = propSchema.minLength; - } - if (propSchema.maxLength !== undefined) { - param.maxLength = propSchema.maxLength; - } - if (propSchema.pattern) { - param.pattern = propSchema.pattern; - } - } - - // Note: Array items and object properties are not directly mapped - // because JsonSchemaProperty and ToolParameter have incompatible structures. - // These should be handled by the consumer if needed. - - return param; - }); -} - -/** - * Normalize JSON Schema type to ToolParameter type - * - * Handles both single type strings and type arrays. - * - * @param type - JSON Schema type (string or string[]) - * @returns Normalized type string - */ -function normalizeSchemaType( - type?: string | string[] -): 'string' | 'number' | 'boolean' | 'integer' | 'array' | 'object' { - // Default to string if type is not defined - if (!type) { - return 'string'; - } - - // If type is an array, use the first non-null type - if (Array.isArray(type)) { - const firstType = type.find((t) => t !== 'null'); - if (!firstType) { - return 'string'; - } - type = firstType; - } - - // Validate and return type - if ( - type === 'string' || - type === 'number' || - type === 'boolean' || - type === 'integer' || - type === 'array' || - type === 'object' - ) { - return type; - } - - // Unknown type defaults to string - return 'string'; -} - -/** - * Validate parameter value against JSON Schema constraints - * - * Checks if a value satisfies the constraints defined in the parameter schema. - * - * @param value - Value to validate - * @param param - Parameter schema with constraints - * @returns Validation result with error message if invalid - * - * @example - * ```typescript - * const param = { name: 'region', type: 'string', enum: ['us-east-1', 'us-west-2'], required: true }; - * const result = validateParameterValue('us-east-1', param); - * // { valid: true } - * - * const invalidResult = validateParameterValue('invalid-region', param); - * // { valid: false, error: 'Value must be one of: us-east-1, us-west-2' } - * ``` - */ -export function validateParameterValue( - value: unknown, - param: ExtendedToolParameter -): { valid: boolean; error?: string } { - // Check required constraint - if (param.required && (value === undefined || value === null || value === '')) { - return { valid: false, error: 'This field is required' }; - } - - // Skip validation if value is empty and not required - if (!param.required && (value === undefined || value === null || value === '')) { - return { valid: true }; - } - - // Validate by type - switch (param.type) { - case 'string': - return validateStringValue(value, param); - case 'number': - case 'integer': - return validateNumberValue(value, param); - case 'boolean': - return validateBooleanValue(value); - case 'array': - return validateArrayValue(value); - case 'object': - return validateObjectValue(value); - default: - return { valid: true }; - } -} - -/** - * Validate string value - */ -function validateStringValue( - value: unknown, - param: ExtendedToolParameter -): { valid: boolean; error?: string } { - if (typeof value !== 'string') { - return { valid: false, error: 'Value must be a string' }; - } - - // Check enum constraint - if (param.enum && !param.enum.includes(value)) { - return { valid: false, error: `Value must be one of: ${param.enum.join(', ')}` }; - } - - // Check minLength constraint - if (param.minLength !== undefined && value.length < param.minLength) { - return { valid: false, error: `Minimum length is ${param.minLength}` }; - } - - // Check maxLength constraint - if (param.maxLength !== undefined && value.length > param.maxLength) { - return { valid: false, error: `Maximum length is ${param.maxLength}` }; - } - - // Check pattern constraint - if (param.pattern) { - const regex = new RegExp(param.pattern); - if (!regex.test(value)) { - return { valid: false, error: `Value must match pattern: ${param.pattern}` }; - } - } - - return { valid: true }; -} - -/** - * Validate number value - */ -function validateNumberValue( - value: unknown, - param: ExtendedToolParameter -): { valid: boolean; error?: string } { - const num = Number(value); - - if (Number.isNaN(num)) { - return { valid: false, error: 'Value must be a number' }; - } - - // Check integer constraint - if (param.type === 'integer' && !Number.isInteger(num)) { - return { valid: false, error: 'Value must be an integer' }; - } - - // Check minimum constraint - if (param.minimum !== undefined && num < param.minimum) { - return { valid: false, error: `Minimum value is ${param.minimum}` }; - } - - // Check maximum constraint - if (param.maximum !== undefined && num > param.maximum) { - return { valid: false, error: `Maximum value is ${param.maximum}` }; - } - - return { valid: true }; -} - -/** - * Validate boolean value - */ -function validateBooleanValue(value: unknown): { valid: boolean; error?: string } { - if (typeof value !== 'boolean' && value !== 'true' && value !== 'false') { - return { valid: false, error: 'Value must be a boolean' }; - } - - return { valid: true }; -} - -/** - * Validate array value - */ -function validateArrayValue(value: unknown): { valid: boolean; error?: string } { - if (!Array.isArray(value)) { - return { valid: false, error: 'Value must be an array' }; - } - - return { valid: true }; -} - -/** - * Validate object value - */ -function validateObjectValue(value: unknown): { valid: boolean; error?: string } { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return { valid: false, error: 'Value must be an object' }; - } - - return { valid: true }; -} diff --git a/src/extension/utils/sensitive-data-detector.ts b/src/extension/utils/sensitive-data-detector.ts deleted file mode 100644 index 31e1d4c9..00000000 --- a/src/extension/utils/sensitive-data-detector.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Sensitive Data Detector Utility - * - * Detects and masks sensitive information (API keys, tokens, passwords, etc.) - * in workflow JSON files before sharing to Slack. - * - * Based on specs/001-slack-workflow-sharing/data-model.md - */ - -import { type SensitiveDataFinding, SensitiveDataType } from '../types/slack-integration-types'; - -/** - * Detection pattern definition - */ -interface DetectionPattern { - /** Pattern type */ - type: SensitiveDataType; - /** Regular expression for detection */ - pattern: RegExp; - /** Severity level */ - severity: 'low' | 'medium' | 'high'; - /** Minimum length for valid matches */ - minLength?: number; -} - -/** - * Built-in detection patterns - * - * Patterns are based on common secret formats and best practices. - */ -const DETECTION_PATTERNS: DetectionPattern[] = [ - // AWS Access Key (AKIA followed by 16 alphanumeric chars) - { - type: SensitiveDataType.AWS_ACCESS_KEY, - pattern: /AKIA[0-9A-Z]{16}/g, - severity: 'high', - }, - - // AWS Secret Key (40 chars base64-like string) - { - type: SensitiveDataType.AWS_SECRET_KEY, - pattern: /(?:aws_secret_access_key|aws[_-]?secret)["\s]*[:=]["\s]*([A-Za-z0-9/+=]{40})/gi, - severity: 'high', - minLength: 40, - }, - - // Slack Token (xoxb-, xoxp-, xoxa-, xoxo- prefixes) - { - type: SensitiveDataType.SLACK_TOKEN, - pattern: /xox[bpoa]-[A-Za-z0-9-]{10,}/g, - severity: 'high', - }, - - // GitHub Personal Access Token (ghp_ prefix, 36 chars) - { - type: SensitiveDataType.GITHUB_TOKEN, - pattern: /ghp_[A-Za-z0-9]{36}/g, - severity: 'high', - }, - - // Generic API Key patterns - { - type: SensitiveDataType.API_KEY, - pattern: /(?:api[_-]?key|apikey)["\s]*[:=]["\s]*["']?([A-Za-z0-9_-]{20,})["']?/gi, - severity: 'medium', - minLength: 20, - }, - - // Generic Token patterns - { - type: SensitiveDataType.TOKEN, - pattern: - /(?:token|auth[_-]?token|access[_-]?token)["\s]*[:=]["\s]*["']?([A-Za-z0-9_\-.]{20,})["']?/gi, - severity: 'medium', - minLength: 20, - }, - - // Private Key markers - { - type: SensitiveDataType.PRIVATE_KEY, - pattern: /-----BEGIN [A-Z ]+PRIVATE KEY-----/g, - severity: 'high', - }, - - // Password patterns - { - type: SensitiveDataType.PASSWORD, - pattern: /(?:password|passwd|pwd)["\s]*[:=]["\s]*["']?([^\s"']{8,})["']?/gi, - severity: 'low', - minLength: 8, - }, -]; - -/** - * Masks a sensitive value - * - * Shows only first 4 and last 4 characters. - * Example: "AKIAIOSFODNN7EXAMPLE" → "AKIA...MPLE" - * - * @param value - Original value to mask - * @returns Masked value - */ -function maskValue(value: string): string { - if (value.length <= 8) { - // Too short to mask meaningfully, mask completely - return '****'; - } - - const first4 = value.substring(0, 4); - const last4 = value.substring(value.length - 4); - return `${first4}...${last4}`; -} - -/** - * Extracts context around a match - * - * @param content - Full content - * @param position - Match position - * @param contextLength - Context length (default: 50 chars on each side) - * @returns Context string - */ -function extractContext(content: string, position: number, contextLength = 50): string { - const start = Math.max(0, position - contextLength); - const end = Math.min(content.length, position + contextLength); - - const contextBefore = content.substring(start, position); - const contextAfter = content.substring(position, end); - - return `...${contextBefore}[REDACTED]${contextAfter}...`; -} - -/** - * Detects sensitive data in content - * - * @param content - Content to scan (workflow JSON as string) - * @returns Array of sensitive data findings - */ -export function detectSensitiveData(content: string): SensitiveDataFinding[] { - const findings: SensitiveDataFinding[] = []; - - for (const patternDef of DETECTION_PATTERNS) { - // Reset regex state - patternDef.pattern.lastIndex = 0; - - let match: RegExpExecArray | null; - // biome-ignore lint/suspicious/noAssignInExpressions: Standard regex exec loop pattern - while ((match = patternDef.pattern.exec(content)) !== null) { - const matchedValue = match[1] || match[0]; // Use capture group if available - const position = match.index; - - // Validate minimum length if specified - if (patternDef.minLength && matchedValue.length < patternDef.minLength) { - continue; - } - - findings.push({ - type: patternDef.type, - maskedValue: maskValue(matchedValue), - position, - context: extractContext(content, position), - severity: patternDef.severity, - }); - } - } - - return findings; -} - -/** - * Checks if workflow content contains sensitive data - * - * @param workflowContent - Workflow JSON as string - * @returns True if sensitive data detected, false otherwise - */ -export function hasSensitiveData(workflowContent: string): boolean { - return detectSensitiveData(workflowContent).length > 0; -} - -/** - * Gets high severity findings only - * - * @param findings - All findings - * @returns High severity findings - */ -export function getHighSeverityFindings(findings: SensitiveDataFinding[]): SensitiveDataFinding[] { - return findings.filter((finding) => finding.severity === 'high'); -} - -/** - * Groups findings by type - * - * @param findings - All findings - * @returns Findings grouped by type - */ -export function groupFindingsByType( - findings: SensitiveDataFinding[] -): Map { - const grouped = new Map(); - - for (const finding of findings) { - const existing = grouped.get(finding.type) || []; - existing.push(finding); - grouped.set(finding.type, existing); - } - - return grouped; -} diff --git a/src/extension/utils/slack-error-handler.ts b/src/extension/utils/slack-error-handler.ts deleted file mode 100644 index e8a90775..00000000 --- a/src/extension/utils/slack-error-handler.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * Slack Error Handler Utility - * - * Provides unified error handling for Slack API operations. - * Maps Slack API errors to i18n translation keys. - * - * Based on specs/001-slack-workflow-sharing/contracts/slack-api-contracts.md - */ - -/** - * Slack error information with i18n keys - */ -export interface SlackErrorInfo { - /** Error code (for programmatic handling) */ - code: string; - /** i18n message key for translation */ - messageKey: string; - /** Whether error is recoverable */ - recoverable: boolean; - /** i18n suggested action key for translation */ - suggestedActionKey?: string; - /** Retry after seconds (for rate limiting) */ - retryAfter?: number; - /** Workspace ID (for WORKSPACE_NOT_CONNECTED errors) */ - workspaceId?: string; -} - -/** - * Error code mappings with i18n keys - */ -const ERROR_MAPPINGS: Record> = { - invalid_auth: { - messageKey: 'slack.error.invalidAuth', - recoverable: true, - suggestedActionKey: 'slack.error.action.reconnect', - }, - missing_scope: { - messageKey: 'slack.error.missingScope', - recoverable: true, - suggestedActionKey: 'slack.error.action.addPermission', - }, - rate_limited: { - messageKey: 'slack.error.rateLimited', - recoverable: true, - suggestedActionKey: 'slack.error.action.waitAndRetry', - }, - channel_not_found: { - messageKey: 'slack.error.channelNotFound', - recoverable: false, - suggestedActionKey: 'slack.error.action.checkChannelId', - }, - not_in_channel: { - messageKey: 'slack.error.notInChannel', - recoverable: true, - suggestedActionKey: 'slack.error.action.inviteBot', - }, - file_too_large: { - messageKey: 'slack.error.fileTooLarge', - recoverable: false, - suggestedActionKey: 'slack.error.action.reduceFileSize', - }, - invalid_file_type: { - messageKey: 'slack.error.invalidFileType', - recoverable: false, - suggestedActionKey: 'slack.error.action.useJsonFormat', - }, - internal_error: { - messageKey: 'slack.error.internalError', - recoverable: true, - suggestedActionKey: 'slack.error.action.waitAndRetry', - }, - not_authed: { - messageKey: 'slack.error.notAuthed', - recoverable: true, - suggestedActionKey: 'slack.error.action.connect', - }, - invalid_code: { - messageKey: 'slack.error.invalidCode', - recoverable: true, - suggestedActionKey: 'slack.error.action.restartAuth', - }, - bad_client_secret: { - messageKey: 'slack.error.badClientSecret', - recoverable: false, - suggestedActionKey: 'slack.error.action.checkAppSettings', - }, - invalid_grant_type: { - messageKey: 'slack.error.invalidGrantType', - recoverable: false, - suggestedActionKey: 'slack.error.action.checkAppSettings', - }, - account_inactive: { - messageKey: 'slack.error.accountInactive', - recoverable: false, - suggestedActionKey: 'slack.error.action.checkAccountStatus', - }, - invalid_query: { - messageKey: 'slack.error.invalidQuery', - recoverable: false, - suggestedActionKey: 'slack.error.action.checkSearchKeyword', - }, - msg_too_long: { - messageKey: 'slack.error.msgTooLong', - recoverable: false, - suggestedActionKey: 'slack.error.action.reduceDescription', - }, -}; - -/** - * Handles Slack API errors - * - * @param error - Error from Slack API call - * @returns Structured error information with i18n keys - */ -export function handleSlackError(error: unknown): SlackErrorInfo { - // Check for WORKSPACE_NOT_CONNECTED custom error - if ( - error && - typeof error === 'object' && - 'code' in error && - (error as { code: string }).code === 'WORKSPACE_NOT_CONNECTED' - ) { - const workspaceError = error as { code: string; workspaceId?: string; message?: string }; - return { - code: 'WORKSPACE_NOT_CONNECTED', - messageKey: 'slack.error.workspaceNotConnected', - recoverable: true, - suggestedActionKey: 'slack.error.action.connectAndImport', - workspaceId: workspaceError.workspaceId, - }; - } - - // Check if it's a Slack Web API error (property-based check instead of instanceof) - // This works even when @slack/web-api is an external dependency - if ( - error && - typeof error === 'object' && - 'data' in error && - error.data && - typeof error.data === 'object' - ) { - // Type assertion for Slack Web API error structure - const slackError = error as { data: { error?: string; retryAfter?: number } }; - const errorCode = slackError.data.error || 'unknown_error'; - - // Get error mapping - const mapping = ERROR_MAPPINGS[errorCode] || { - messageKey: 'slack.error.unknownApiError', - recoverable: false, - suggestedActionKey: 'slack.error.action.contactSupport', - }; - - // Extract retry-after for rate limiting - const retryAfter = slackError.data.retryAfter ? Number(slackError.data.retryAfter) : undefined; - - return { - code: errorCode, - ...mapping, - retryAfter, - }; - } - - // Network or other errors - if (error instanceof Error) { - return { - code: 'NETWORK_ERROR', - messageKey: 'slack.error.networkError', - recoverable: true, - suggestedActionKey: 'slack.error.action.checkConnection', - }; - } - - // Unknown error - return { - code: 'UNKNOWN_ERROR', - messageKey: 'slack.error.unknownError', - recoverable: false, - suggestedActionKey: 'slack.error.action.contactSupport', - }; -} - -/** - * Formats error for user display (deprecated - use i18n on Webview side) - * - * @param errorInfo - Error information - * @returns Formatted error message key (for debugging purposes) - * @deprecated Use messageKey and suggestedActionKey for i18n translation on Webview side - */ -export function formatErrorMessage(errorInfo: SlackErrorInfo): string { - // Return messageKey for debugging - actual translation happens on Webview side - let message = errorInfo.messageKey; - - if (errorInfo.suggestedActionKey) { - message += ` | ${errorInfo.suggestedActionKey}`; - } - - if (errorInfo.retryAfter) { - message += ` | retryAfter: ${errorInfo.retryAfter}`; - } - - return message; -} - -/** - * Checks if error is recoverable - * - * @param error - Error from Slack API call - * @returns True if error is recoverable - */ -export function isRecoverableError(error: unknown): boolean { - const errorInfo = handleSlackError(error); - return errorInfo.recoverable; -} - -/** - * Checks if error is authentication-related - * - * @param error - Error from Slack API call - * @returns True if authentication error - */ -export function isAuthenticationError(error: unknown): boolean { - const errorInfo = handleSlackError(error); - return ['invalid_auth', 'not_authed', 'account_inactive'].includes(errorInfo.code); -} - -/** - * Checks if error is permission-related - * - * @param error - Error from Slack API call - * @returns True if permission error - */ -export function isPermissionError(error: unknown): boolean { - const errorInfo = handleSlackError(error); - return ['missing_scope', 'not_in_channel'].includes(errorInfo.code); -} - -/** - * Checks if error is rate limiting - * - * @param error - Error from Slack API call - * @returns True if rate limiting error - */ -export function isRateLimitError(error: unknown): boolean { - const errorInfo = handleSlackError(error); - return errorInfo.code === 'rate_limited'; -} - -/** - * Gets retry delay for exponential backoff - * - * @param attempt - Retry attempt number (1-indexed) - * @param maxDelay - Maximum delay in seconds (default: 60) - * @returns Delay in seconds - */ -export function getRetryDelay(attempt: number, maxDelay = 60): number { - // Exponential backoff: 2^attempt seconds, capped at maxDelay - const delay = Math.min(2 ** attempt, maxDelay); - // Add jitter (random 0-20%) - const jitter = delay * 0.2 * Math.random(); - return delay + jitter; -} diff --git a/src/extension/utils/slack-message-builder.ts b/src/extension/utils/slack-message-builder.ts deleted file mode 100644 index bac00419..00000000 --- a/src/extension/utils/slack-message-builder.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Slack Block Kit Message Builder - * - * Builds rich message blocks for Slack using Block Kit format. - * Used for displaying workflow metadata in Slack channels. - * - * Based on specs/001-slack-workflow-sharing/contracts/slack-api-contracts.md - */ - -/** - * Supported editors for workflow import - * Each editor uses a custom URI scheme for deep linking - */ -const SUPPORTED_EDITORS = [ - { name: 'VS Code', scheme: 'vscode' }, - { name: 'Cursor', scheme: 'cursor' }, - { name: 'Windsurf', scheme: 'windsurf' }, - { name: 'Kiro', scheme: 'kiro' }, - { name: 'Antigravity', scheme: 'antigravity' }, -] as const; - -/** - * Extension ID for the workflow studio extension - */ -const EXTENSION_ID = 'breaking-brake.cc-wf-studio'; - -/** - * Builds an import URI for a specific editor - * - * @param scheme - The URI scheme for the editor (e.g., 'vscode', 'cursor') - * @param block - The workflow message block containing import parameters - * @returns The complete import URI - */ -function buildImportUri(scheme: string, block: WorkflowMessageBlock): string { - const params = new URLSearchParams({ - workflowId: block.workflowId, - fileId: block.fileId, - workspaceId: block.workspaceId || '', - channelId: block.channelId || '', - messageTs: block.messageTs || '', - }); - - // Add workspace name as Base64-encoded parameter for display in error dialogs - if (block.workspaceName) { - params.set('workspaceName', Buffer.from(block.workspaceName, 'utf-8').toString('base64')); - } - - return `${scheme}://${EXTENSION_ID}/import?${params.toString()}`; -} - -/** - * Workflow message block (Block Kit format) - */ -export interface WorkflowMessageBlock { - /** Workflow ID */ - workflowId: string; - /** Workflow name */ - name: string; - /** Workflow description */ - description?: string; - /** Workflow version */ - version: string; - /** Node count */ - nodeCount: number; - /** File ID (after upload) */ - fileId: string; - /** Workspace ID (for deep link) */ - workspaceId?: string; - /** Workspace name (for display in error dialogs, Base64 encoded in URI) */ - workspaceName?: string; - /** Channel ID (for deep link) */ - channelId?: string; - /** Message timestamp (for deep link) */ - messageTs?: string; -} - -/** - * Builds Block Kit blocks for workflow message - * - * Creates a rich message card with: - * - Header with workflow name - * - Description section (if provided) - * - Metadata fields (Date) - * - Import link with deep link to VS Code - * - * @param block - Workflow message block - * @returns Block Kit blocks array - */ -export function buildWorkflowMessageBlocks( - block: WorkflowMessageBlock -): Array> { - return [ - // Header - { - type: 'header', - text: { - type: 'plain_text', - text: block.name, - }, - }, - // Description (if provided) - ...(block.description - ? [ - { - type: 'section', - text: { - type: 'mrkdwn', - text: block.description, - }, - }, - { type: 'divider' }, - ] - : [{ type: 'divider' }]), - // Import links section - ...(block.workspaceId && block.channelId && block.messageTs && block.fileId - ? [ - { - type: 'context', - elements: [ - { - type: 'mrkdwn', - text: `📥 *Import to:* ${SUPPORTED_EDITORS.map( - (editor) => `<${buildImportUri(editor.scheme, block)}|${editor.name}>` - ).join(' · ')}`, - }, - ], - }, - ] - : [ - { - type: 'context', - elements: [ - { - type: 'mrkdwn', - text: '_Import link will be available after file upload_', - }, - ], - }, - ]), - ]; -} diff --git a/src/extension/utils/slack-token-manager.ts b/src/extension/utils/slack-token-manager.ts deleted file mode 100644 index aa5fc668..00000000 --- a/src/extension/utils/slack-token-manager.ts +++ /dev/null @@ -1,446 +0,0 @@ -/** - * Slack Token Manager - * - * Manages Slack OAuth tokens using VSCode Secret Storage. - * Provides encrypted storage for access tokens and workspace information. - * - * Based on specs/001-slack-workflow-sharing/data-model.md - */ - -import type { ExtensionContext } from 'vscode'; -import type { SlackWorkspaceConnection } from '../types/slack-integration-types'; - -/** - * Secret storage keys - */ -const SECRET_KEYS = { - /** OAuth access token key (legacy - single workspace) */ - ACCESS_TOKEN: 'slack-oauth-access-token', - /** Workspace connection data key (legacy - single workspace) */ - WORKSPACE_DATA: 'slack-workspace-connection', - /** Workspace list key (stores array of workspace IDs) */ - WORKSPACE_LIST: 'slack-workspace-list', -} as const; - -/** - * Generates workspace-specific secret key - */ -function getWorkspaceSecretKey(workspaceId: string, type: 'token' | 'data' | 'user-token'): string { - switch (type) { - case 'token': - return `slack-oauth-${workspaceId}`; - case 'user-token': - return `slack-user-oauth-${workspaceId}`; - case 'data': - return `slack-workspace-${workspaceId}`; - } -} - -/** - * Slack Token Manager - * - * Handles secure storage and retrieval of Slack authentication tokens. - */ -export class SlackTokenManager { - constructor(private readonly context: ExtensionContext) {} - - /** - * Stores Slack workspace connection - * - * @param connection - Workspace connection details - */ - async storeConnection(connection: SlackWorkspaceConnection): Promise { - const { workspaceId } = connection; - - // Store Bot access token for this workspace - const tokenKey = getWorkspaceSecretKey(workspaceId, 'token'); - await this.context.secrets.store(tokenKey, connection.accessToken); - - // Store User access token if available (for channel listing) - if (connection.userAccessToken) { - const userTokenKey = getWorkspaceSecretKey(workspaceId, 'user-token'); - await this.context.secrets.store(userTokenKey, connection.userAccessToken); - } - - // Store workspace metadata (without tokens) - const workspaceData = { - workspaceId: connection.workspaceId, - workspaceName: connection.workspaceName, - teamId: connection.teamId, - tokenScope: connection.tokenScope, - userId: connection.userId, - botUserId: connection.botUserId, - authorizedAt: connection.authorizedAt.toISOString(), - lastValidatedAt: connection.lastValidatedAt?.toISOString(), - }; - - const dataKey = getWorkspaceSecretKey(workspaceId, 'data'); - await this.context.secrets.store(dataKey, JSON.stringify(workspaceData)); - - // Add to workspace list - await this.addToWorkspaceList(workspaceId); - } - - /** - * Stores Slack workspace connection from manual token input - * - * This is a simplified version of storeConnection() for manual token input. - * It does not require full OAuth flow metadata. Only User Token is required. - * - * @param workspaceId - Workspace ID (Team ID) - * @param workspaceName - Workspace name - * @param teamId - Team ID (same as workspaceId) - * @param _accessToken - @deprecated Bot Token is no longer used, kept for backward compatibility - * @param userId - User ID (no longer used, kept for backward compatibility) - * @param userAccessToken - User OAuth Token (xoxp-...) - REQUIRED for all Slack operations - */ - async storeManualConnection( - workspaceId: string, - workspaceName: string, - teamId: string, - _accessToken: string, // @deprecated - Bot Token is no longer used - userId: string, - userAccessToken?: string - ): Promise { - // Validate User Token format (required) - if (!userAccessToken || !SlackTokenManager.validateUserTokenFormat(userAccessToken)) { - throw new Error('Invalid token format. User Token (xoxp-...) is required.'); - } - - // Store User access token (for all Slack operations) - const userTokenKey = getWorkspaceSecretKey(workspaceId, 'user-token'); - await this.context.secrets.store(userTokenKey, userAccessToken); - - // Store empty Bot token for backward compatibility - const tokenKey = getWorkspaceSecretKey(workspaceId, 'token'); - await this.context.secrets.store(tokenKey, ''); - - // Store workspace metadata (without token) - const workspaceData = { - workspaceId, - workspaceName, - teamId, - tokenScope: [], // No scope information from manual input - userId, - authorizedAt: new Date().toISOString(), - lastValidatedAt: undefined, // Will be set after first validation - }; - - const dataKey = getWorkspaceSecretKey(workspaceId, 'data'); - await this.context.secrets.store(dataKey, JSON.stringify(workspaceData)); - - // Add to workspace list - await this.addToWorkspaceList(workspaceId); - } - - /** - * Adds workspace ID to the workspace list - * - * @param workspaceId - Workspace ID to add - */ - private async addToWorkspaceList(workspaceId: string): Promise { - const listJson = await this.context.secrets.get(SECRET_KEYS.WORKSPACE_LIST); - let workspaceIds: string[] = []; - - if (listJson) { - try { - workspaceIds = JSON.parse(listJson); - } catch (_error) { - // Invalid JSON, start fresh - workspaceIds = []; - } - } - - // Add if not already in list - if (!workspaceIds.includes(workspaceId)) { - workspaceIds.push(workspaceId); - await this.context.secrets.store(SECRET_KEYS.WORKSPACE_LIST, JSON.stringify(workspaceIds)); - } - } - - /** - * Removes workspace ID from the workspace list - * - * @param workspaceId - Workspace ID to remove - */ - private async removeFromWorkspaceList(workspaceId: string): Promise { - const listJson = await this.context.secrets.get(SECRET_KEYS.WORKSPACE_LIST); - - if (!listJson) { - return; - } - - try { - let workspaceIds: string[] = JSON.parse(listJson); - workspaceIds = workspaceIds.filter((id) => id !== workspaceId); - await this.context.secrets.store(SECRET_KEYS.WORKSPACE_LIST, JSON.stringify(workspaceIds)); - } catch (_error) { - // Invalid JSON, ignore - } - } - - /** - * Retrieves all connected Slack workspaces - * - * @returns Array of workspace connections - */ - async getWorkspaces(): Promise { - const listJson = await this.context.secrets.get(SECRET_KEYS.WORKSPACE_LIST); - - if (!listJson) { - return []; - } - - try { - const workspaceIds: string[] = JSON.parse(listJson); - const connections: SlackWorkspaceConnection[] = []; - - for (const workspaceId of workspaceIds) { - const connection = await this.getConnectionByWorkspaceId(workspaceId); - if (connection) { - connections.push(connection); - } - } - - return connections; - } catch (_error) { - return []; - } - } - - /** - * Retrieves Slack workspace connection by workspace ID - * - * @param workspaceId - Workspace ID - * @returns Workspace connection if exists, null otherwise - */ - async getConnectionByWorkspaceId(workspaceId: string): Promise { - const tokenKey = getWorkspaceSecretKey(workspaceId, 'token'); - const userTokenKey = getWorkspaceSecretKey(workspaceId, 'user-token'); - const dataKey = getWorkspaceSecretKey(workspaceId, 'data'); - - const accessToken = await this.context.secrets.get(tokenKey); - const userAccessToken = await this.context.secrets.get(userTokenKey); - const workspaceDataJson = await this.context.secrets.get(dataKey); - - // User Token is required (Bot Token is deprecated) - if (!userAccessToken || !workspaceDataJson) { - return null; - } - - try { - const workspaceData = JSON.parse(workspaceDataJson); - - return { - workspaceId: workspaceData.workspaceId, - workspaceName: workspaceData.workspaceName, - teamId: workspaceData.teamId, - accessToken, - userAccessToken: userAccessToken || undefined, - tokenScope: workspaceData.tokenScope, - userId: workspaceData.userId, - botUserId: workspaceData.botUserId, - authorizedAt: new Date(workspaceData.authorizedAt), - lastValidatedAt: workspaceData.lastValidatedAt - ? new Date(workspaceData.lastValidatedAt) - : undefined, - }; - } catch (_error) { - // Invalid JSON, clear corrupted data - await this.clearConnectionByWorkspaceId(workspaceId); - return null; - } - } - - /** - * Retrieves Slack workspace connection (legacy - returns first workspace) - * - * @deprecated Use getWorkspaces() or getConnectionByWorkspaceId() instead - * @returns Workspace connection if exists, null otherwise - */ - async getConnection(): Promise { - const workspaces = await this.getWorkspaces(); - return workspaces.length > 0 ? workspaces[0] : null; - } - - /** - * Gets Bot access token for specific workspace - * - * @param workspaceId - Workspace ID - * @returns Bot access token if exists, null otherwise - */ - async getAccessTokenByWorkspaceId(workspaceId: string): Promise { - const tokenKey = getWorkspaceSecretKey(workspaceId, 'token'); - return (await this.context.secrets.get(tokenKey)) || null; - } - - /** - * Gets User access token for specific workspace - * - * User Token is used for operations that require user-specific permissions, - * such as listing channels the authenticated user is a member of. - * - * @param workspaceId - Workspace ID - * @returns User access token if exists, null otherwise - */ - async getUserAccessTokenByWorkspaceId(workspaceId: string): Promise { - const userTokenKey = getWorkspaceSecretKey(workspaceId, 'user-token'); - return (await this.context.secrets.get(userTokenKey)) || null; - } - - /** - * Gets Bot User ID for specific workspace - * - * Bot User ID is used for membership check with conversations.members API. - * - * @param workspaceId - Workspace ID - * @returns Bot User ID if exists, null otherwise - */ - async getBotUserId(workspaceId: string): Promise { - const connection = await this.getConnectionByWorkspaceId(workspaceId); - return connection?.botUserId || null; - } - - /** - * Gets access token only (legacy - returns token for first workspace) - * - * @deprecated Use getAccessTokenByWorkspaceId() instead - * @returns Access token if exists, null otherwise - */ - async getAccessToken(): Promise { - const connection = await this.getConnection(); - return connection?.accessToken || null; - } - - /** - * Updates last validated timestamp - * - * @param timestamp - Validation timestamp (default: now) - */ - async updateLastValidated(timestamp: Date = new Date()): Promise { - const workspaceDataJson = await this.context.secrets.get(SECRET_KEYS.WORKSPACE_DATA); - - if (!workspaceDataJson) { - return; - } - - try { - const workspaceData = JSON.parse(workspaceDataJson); - workspaceData.lastValidatedAt = timestamp.toISOString(); - - await this.context.secrets.store(SECRET_KEYS.WORKSPACE_DATA, JSON.stringify(workspaceData)); - } catch (_error) { - // Invalid JSON, ignore update - } - } - - /** - * Checks if workspace is connected - * - * @returns True if connected, false otherwise - */ - async isConnected(): Promise { - const accessToken = await this.context.secrets.get(SECRET_KEYS.ACCESS_TOKEN); - return !!accessToken; - } - - /** - * Gets workspace ID only - * - * @returns Workspace ID if exists, null otherwise - */ - async getWorkspaceId(): Promise { - const workspaceDataJson = await this.context.secrets.get(SECRET_KEYS.WORKSPACE_DATA); - - if (!workspaceDataJson) { - return null; - } - - try { - const workspaceData = JSON.parse(workspaceDataJson); - return workspaceData.workspaceId || null; - } catch (_error) { - return null; - } - } - - /** - * Gets workspace name only - * - * @returns Workspace name if exists, null otherwise - */ - async getWorkspaceName(): Promise { - const workspaceDataJson = await this.context.secrets.get(SECRET_KEYS.WORKSPACE_DATA); - - if (!workspaceDataJson) { - return null; - } - - try { - const workspaceData = JSON.parse(workspaceDataJson); - return workspaceData.workspaceName || null; - } catch (_error) { - return null; - } - } - - /** - * Clears specific workspace connection - * - * @param workspaceId - Workspace ID to clear - */ - async clearConnectionByWorkspaceId(workspaceId: string): Promise { - const tokenKey = getWorkspaceSecretKey(workspaceId, 'token'); - const userTokenKey = getWorkspaceSecretKey(workspaceId, 'user-token'); - const dataKey = getWorkspaceSecretKey(workspaceId, 'data'); - - await this.context.secrets.delete(tokenKey); - await this.context.secrets.delete(userTokenKey); - await this.context.secrets.delete(dataKey); - - // Remove from workspace list - await this.removeFromWorkspaceList(workspaceId); - } - - /** - * Clears all workspace connections (logout from all workspaces) - */ - async clearConnection(): Promise { - const workspaces = await this.getWorkspaces(); - - for (const workspace of workspaces) { - await this.clearConnectionByWorkspaceId(workspace.workspaceId); - } - - // Clear workspace list - await this.context.secrets.delete(SECRET_KEYS.WORKSPACE_LIST); - } - - /** - * Validates Bot token format - * - * Checks if token follows Slack Bot token format (xoxb- prefix). - * - * @param token - Token to validate - * @returns True if valid format, false otherwise - */ - static validateTokenFormat(token: string): boolean { - // Slack Bot tokens start with xoxb- - // Minimum length: 40 characters - return /^xoxb-[A-Za-z0-9-]{36,}$/.test(token); - } - - /** - * Validates User token format - * - * Checks if token follows Slack User token format (xoxp- prefix). - * - * @param token - Token to validate - * @returns True if valid format, false otherwise - */ - static validateUserTokenFormat(token: string): boolean { - // Slack User tokens start with xoxp- - // Minimum length: 40 characters - return /^xoxp-[A-Za-z0-9-]{36,}$/.test(token); - } -} diff --git a/src/extension/utils/validate-workflow.ts b/src/extension/utils/validate-workflow.ts deleted file mode 100644 index 9a3b1209..00000000 --- a/src/extension/utils/validate-workflow.ts +++ /dev/null @@ -1,1068 +0,0 @@ -/** - * Workflow Validation Utility - * - * Validates AI-generated workflows against schema rules. - * Based on: /specs/001-ai-workflow-generation/research.md Q3 - */ - -import { - type Connection, - type HookType, - type McpNodeData, - NodeType, - type SkillNodeData, - type SubAgentFlow, - type SubAgentFlowNodeData, - type SwitchNodeData, - VALIDATION_RULES, - type Workflow, - type WorkflowHooks, - type WorkflowNode, -} from '../../shared/types/workflow-definition'; - -export interface ValidationError { - code: string; - message: string; - field?: string; -} - -export interface ValidationResult { - valid: boolean; - errors: ValidationError[]; -} - -/** - * Validate a workflow generated by AI - * - * @param workflow - The workflow object to validate - * @returns Validation result with errors if invalid - */ -export function validateAIGeneratedWorkflow(workflow: unknown): ValidationResult { - const errors: ValidationError[] = []; - - // Type check: Is it an object? - if (typeof workflow !== 'object' || workflow === null) { - return { - valid: false, - errors: [{ code: 'INVALID_TYPE', message: 'Workflow must be an object' }], - }; - } - - const wf = workflow as Partial; - - // Required fields check - if (!wf.id || typeof wf.id !== 'string') { - errors.push({ code: 'MISSING_FIELD', message: 'Workflow must have an id', field: 'id' }); - } - - if (!wf.name || typeof wf.name !== 'string') { - errors.push({ code: 'MISSING_FIELD', message: 'Workflow must have a name', field: 'name' }); - } else if (!VALIDATION_RULES.WORKFLOW.NAME_PATTERN.test(wf.name)) { - errors.push({ - code: 'INVALID_FORMAT', - message: - 'Workflow name must contain only lowercase letters (a-z), numbers, hyphens, and underscores', - field: 'name', - }); - } - - if (!wf.version || typeof wf.version !== 'string') { - errors.push({ - code: 'MISSING_FIELD', - message: 'Workflow must have a version', - field: 'version', - }); - } else if (!VALIDATION_RULES.WORKFLOW.VERSION_PATTERN.test(wf.version)) { - errors.push({ - code: 'INVALID_FORMAT', - message: 'Version must follow semantic versioning (e.g., 1.0.0)', - field: 'version', - }); - } - - if (!Array.isArray(wf.nodes)) { - errors.push({ - code: 'MISSING_FIELD', - message: 'Workflow must have a nodes array', - field: 'nodes', - }); - // Cannot continue validation without nodes - return { valid: false, errors }; - } - - if (!Array.isArray(wf.connections)) { - errors.push({ - code: 'MISSING_FIELD', - message: 'Workflow must have a connections array', - field: 'connections', - }); - } - - // Node count validation - if (wf.nodes.length > VALIDATION_RULES.WORKFLOW.MAX_NODES) { - errors.push({ - code: 'MAX_NODES_EXCEEDED', - message: `Generated workflow exceeds maximum node limit (${VALIDATION_RULES.WORKFLOW.MAX_NODES}). Please simplify your description.`, - field: 'nodes', - }); - } - - // Node-specific validation - const nodeErrors = validateNodes(wf.nodes); - errors.push(...nodeErrors); - - // Connection validation (only if connections array exists) - if (Array.isArray(wf.connections)) { - const connectionErrors = validateConnections(wf.connections, wf.nodes); - errors.push(...connectionErrors); - } - - // Start/End node validation - const startNodes = wf.nodes.filter((n) => n.type === NodeType.Start); - const endNodes = wf.nodes.filter((n) => n.type === NodeType.End); - - if (startNodes.length === 0) { - errors.push({ - code: 'MISSING_START_NODE', - message: 'Workflow must have at least one Start node', - }); - } - - if (startNodes.length > 1) { - errors.push({ - code: 'MULTIPLE_START_NODES', - message: 'Workflow must have exactly one Start node', - }); - } - - if (endNodes.length === 0) { - errors.push({ - code: 'MISSING_END_NODE', - message: 'Workflow must have at least one End node', - }); - } - - // SubAgentFlow reference validation - const subAgentFlowErrors = validateSubAgentFlowReferences(wf as Workflow); - errors.push(...subAgentFlowErrors); - - // Issue #413: Hooks validation - if (wf.hooks) { - const hooksErrors = validateHooks(wf.hooks); - errors.push(...hooksErrors); - } - - return { - valid: errors.length === 0, - errors, - }; -} - -/** - * Validate SubAgentFlow references in workflow - * - * Ensures all subAgentFlow nodes have corresponding SubAgentFlow definitions - * and all SubAgentFlow definitions are valid. - * - * @param workflow - Workflow to validate - * @returns Array of validation errors - */ -function validateSubAgentFlowReferences(workflow: Workflow): ValidationError[] { - const errors: ValidationError[] = []; - - const subAgentFlowNodes = workflow.nodes.filter((n) => n.type === NodeType.SubAgentFlow); - - if (subAgentFlowNodes.length === 0) { - return errors; // No SubAgentFlow nodes, nothing to validate - } - - const subAgentFlowIds = new Set((workflow.subAgentFlows || []).map((sf) => sf.id)); - - // Check each subAgentFlow node has a corresponding definition - for (const node of subAgentFlowNodes) { - const refData = node.data as SubAgentFlowNodeData; - - if (!subAgentFlowIds.has(refData.subAgentFlowId)) { - errors.push({ - code: 'SUBAGENTFLOW_MISSING_DEFINITION', - message: `SubAgentFlow node "${node.id}" references non-existent SubAgentFlow "${refData.subAgentFlowId}"`, - field: `nodes[${node.id}].data.subAgentFlowId`, - }); - } - } - - // Validate each SubAgentFlow definition - for (const subAgentFlow of workflow.subAgentFlows || []) { - const subAgentFlowErrors = validateSubAgentFlow(subAgentFlow); - errors.push(...subAgentFlowErrors); - } - - return errors; -} - -/** - * Validate all nodes in the workflow - */ -function validateNodes(nodes: WorkflowNode[]): ValidationError[] { - const errors: ValidationError[] = []; - const nodeIds = new Set(); - - for (const node of nodes) { - // Check for duplicate IDs - if (nodeIds.has(node.id)) { - errors.push({ - code: 'DUPLICATE_NODE_ID', - message: `Duplicate node ID: ${node.id}`, - field: `nodes[${node.id}]`, - }); - } - nodeIds.add(node.id); - - // Validate node name - if (!node.name || !VALIDATION_RULES.NODE.NAME_PATTERN.test(node.name)) { - errors.push({ - code: 'INVALID_NODE_NAME', - message: `Node name must match pattern ${VALIDATION_RULES.NODE.NAME_PATTERN}`, - field: `nodes[${node.id}].name`, - }); - } - - // Validate node type - if (!Object.values(NodeType).includes(node.type)) { - errors.push({ - code: 'INVALID_NODE_TYPE', - message: `Invalid node type: ${node.type}`, - field: `nodes[${node.id}].type`, - }); - } - - // Validate position - if ( - !node.position || - typeof node.position.x !== 'number' || - typeof node.position.y !== 'number' - ) { - errors.push({ - code: 'INVALID_POSITION', - message: 'Node must have valid position with x and y coordinates', - field: `nodes[${node.id}].position`, - }); - } - - // Validate Skill nodes (T026) - if (node.type === NodeType.Skill) { - const skillErrors = validateSkillNode(node); - errors.push(...skillErrors); - } - - // Validate MCP nodes (T017) - if (node.type === NodeType.Mcp) { - const mcpErrors = validateMcpNode(node); - errors.push(...mcpErrors); - } - - // Validate Switch nodes - if (node.type === NodeType.Switch) { - const switchErrors = validateSwitchNode(node); - errors.push(...switchErrors); - } - - // Validate SubAgentFlow nodes (Feature: 089-subworkflow) - if (node.type === NodeType.SubAgentFlow) { - const subAgentFlowErrors = validateSubAgentFlowNode(node); - errors.push(...subAgentFlowErrors); - } - - // Validate SubAgent memory enum (Feature: 540-persistent-memory) - if (node.type === NodeType.SubAgent) { - const subAgentData = node.data as { memory?: string }; - if (subAgentData.memory !== undefined) { - const validMemoryScopes = ['user', 'project', 'local']; - if (!validMemoryScopes.includes(subAgentData.memory)) { - errors.push({ - code: 'SUBAGENT_INVALID_MEMORY', - message: `SubAgent memory must be one of: ${validMemoryScopes.join(', ')}`, - field: `nodes[${node.id}].data.memory`, - }); - } - } - } - } - - return errors; -} - -/** - * Validate Switch node structure and default branch rules - * - * @param node - Switch node to validate - * @returns Array of validation errors - */ -function validateSwitchNode(node: WorkflowNode): ValidationError[] { - const errors: ValidationError[] = []; - const switchData = node.data as Partial; - - if (!switchData.branches || !Array.isArray(switchData.branches)) { - errors.push({ - code: 'SWITCH_MISSING_BRANCHES', - message: 'Switch node must have branches array', - field: `nodes[${node.id}].data.branches`, - }); - return errors; - } - - // Check for multiple default branches - const defaultBranches = switchData.branches.filter((b) => b.isDefault); - if (defaultBranches.length > 1) { - errors.push({ - code: 'SWITCH_MULTIPLE_DEFAULT', - message: 'Switch node can only have one default branch', - field: `nodes[${node.id}].data.branches`, - }); - } - - // Check that default branch is last (if it exists) - if (defaultBranches.length === 1) { - const lastBranch = switchData.branches[switchData.branches.length - 1]; - if (!lastBranch?.isDefault) { - errors.push({ - code: 'SWITCH_DEFAULT_NOT_LAST', - message: 'Default branch must be the last branch in Switch node', - field: `nodes[${node.id}].data.branches`, - }); - } - } - - return errors; -} - -/** - * Validate SubAgentFlow node structure and fields - * - * Feature: 089-subworkflow - * - * @param node - SubAgentFlow node to validate - * @returns Array of validation errors - */ -function validateSubAgentFlowNode(node: WorkflowNode): ValidationError[] { - const errors: ValidationError[] = []; - const refData = node.data as Partial; - - // Required field: subAgentFlowId - if (!refData.subAgentFlowId || typeof refData.subAgentFlowId !== 'string') { - errors.push({ - code: 'SUBAGENTFLOW_MISSING_REF_ID', - message: 'SubAgentFlow node must have a subAgentFlowId', - field: `nodes[${node.id}].data.subAgentFlowId`, - }); - } - - // Required field: label - if (!refData.label || typeof refData.label !== 'string') { - errors.push({ - code: 'SUBAGENTFLOW_MISSING_LABEL', - message: 'SubAgentFlow node must have a label', - field: `nodes[${node.id}].data.label`, - }); - } - - // Output ports validation - if (refData.outputPorts !== VALIDATION_RULES.SUB_AGENT_FLOW.OUTPUT_PORTS) { - errors.push({ - code: 'SUBAGENTFLOW_INVALID_PORTS', - message: 'SubAgentFlow outputPorts must equal 1', - field: `nodes[${node.id}].data.outputPorts`, - }); - } - - // Memory enum validation - if (refData.memory !== undefined) { - const validMemoryScopes = ['user', 'project', 'local']; - if (!validMemoryScopes.includes(refData.memory)) { - errors.push({ - code: 'SUBAGENTFLOW_INVALID_MEMORY', - message: `SubAgentFlow memory must be one of: ${validMemoryScopes.join(', ')}`, - field: `nodes[${node.id}].data.memory`, - }); - } - } - - return errors; -} - -/** - * Validate SubAgentFlow structure (for use within a workflow) - * - * Feature: 089-subworkflow - * MVP constraints: - * - No SubAgent nodes allowed - * - No nested SubAgentFlowRef nodes allowed - * - Must have exactly one Start node and at least one End node - * - * @param subAgentFlow - SubAgentFlow to validate - * @returns Array of validation errors - */ -export function validateSubAgentFlow(subAgentFlow: SubAgentFlow): ValidationError[] { - const errors: ValidationError[] = []; - - // Required fields - if (!subAgentFlow.id) { - errors.push({ - code: 'SUBAGENTFLOW_MISSING_ID', - message: 'SubAgentFlow must have an id', - field: 'id', - }); - } - - if (!subAgentFlow.name) { - errors.push({ - code: 'SUBAGENTFLOW_MISSING_NAME', - message: 'SubAgentFlow must have a name', - field: 'name', - }); - } - - if (!Array.isArray(subAgentFlow.nodes)) { - errors.push({ - code: 'SUBAGENTFLOW_MISSING_NODES', - message: 'SubAgentFlow must have a nodes array', - field: 'nodes', - }); - return errors; - } - - // Start/End node validation - const startNodes = subAgentFlow.nodes.filter((n) => n.type === NodeType.Start); - const endNodes = subAgentFlow.nodes.filter((n) => n.type === NodeType.End); - - if (startNodes.length === 0) { - errors.push({ - code: 'SUBAGENTFLOW_INVALID_START', - message: `SubAgentFlow "${subAgentFlow.name}" must have a Start node`, - }); - } - - if (startNodes.length > 1) { - errors.push({ - code: 'SUBAGENTFLOW_MULTIPLE_START', - message: `SubAgentFlow "${subAgentFlow.name}" must have exactly one Start node`, - }); - } - - if (endNodes.length === 0) { - errors.push({ - code: 'SUBAGENTFLOW_MISSING_END', - message: `SubAgentFlow "${subAgentFlow.name}" must have at least one End node`, - }); - } - - // MVP constraint: No SubAgent nodes in SubAgentFlows - const subAgentNodes = subAgentFlow.nodes.filter((n) => n.type === NodeType.SubAgent); - if (subAgentNodes.length > 0) { - errors.push({ - code: 'SUBAGENTFLOW_CONTAINS_SUBAGENT', - message: `SubAgentFlow "${subAgentFlow.name}" cannot contain SubAgent nodes (MVP constraint)`, - }); - } - - // MVP constraint: No nested SubAgentFlow nodes - const nestedRefs = subAgentFlow.nodes.filter((n) => n.type === NodeType.SubAgentFlow); - if (nestedRefs.length > 0) { - errors.push({ - code: 'SUBAGENTFLOW_NESTED_REF', - message: `SubAgentFlow "${subAgentFlow.name}" cannot contain SubAgentFlow nodes (no nesting allowed in MVP)`, - }); - } - - return errors; -} - -/** - * Validate Skill node structure and fields - * - * Based on: /specs/001-ai-skill-generation/contracts/skill-scanning-api.md Section 5.1 - * - * @param node - Skill node to validate - * @returns Array of validation errors (T024-T025) - */ -function validateSkillNode(node: WorkflowNode): ValidationError[] { - const errors: ValidationError[] = []; - const skillData = node.data as Partial; - - // Required fields check - // Note: skillPath is optional when validationStatus is 'missing' (skill not found) - const requiredFields: (keyof SkillNodeData)[] = [ - 'name', - 'description', - 'scope', - 'validationStatus', - 'outputPorts', - ]; - - for (const field of requiredFields) { - if (!skillData[field]) { - errors.push({ - code: 'SKILL_MISSING_FIELD', - message: `Skill node missing required field: ${field}`, - field: `nodes[${node.id}].data.${field}`, - }); - } - } - - // skillPath is required only when skill is valid (not missing) - if (skillData.validationStatus !== 'missing' && !skillData.skillPath) { - errors.push({ - code: 'SKILL_MISSING_FIELD', - message: 'Skill node missing required field: skillPath', - field: `nodes[${node.id}].data.skillPath`, - }); - } - - // Name validation - if (skillData.name) { - if (!VALIDATION_RULES.SKILL.NAME_PATTERN.test(skillData.name)) { - errors.push({ - code: 'SKILL_INVALID_NAME', - message: 'Skill name must be lowercase with hyphens only', - field: `nodes[${node.id}].data.name`, - }); - } - - if (skillData.name.length > VALIDATION_RULES.SKILL.NAME_MAX_LENGTH) { - errors.push({ - code: 'SKILL_NAME_TOO_LONG', - message: `Skill name exceeds ${VALIDATION_RULES.SKILL.NAME_MAX_LENGTH} characters`, - field: `nodes[${node.id}].data.name`, - }); - } - } - - // Description validation - if (skillData.description) { - if (skillData.description.length > VALIDATION_RULES.SKILL.DESCRIPTION_MAX_LENGTH) { - errors.push({ - code: 'SKILL_DESC_TOO_LONG', - message: `Skill description exceeds ${VALIDATION_RULES.SKILL.DESCRIPTION_MAX_LENGTH} characters`, - field: `nodes[${node.id}].data.description`, - }); - } - } - - // Output ports validation - if (skillData.outputPorts !== VALIDATION_RULES.SKILL.OUTPUT_PORTS) { - errors.push({ - code: 'SKILL_INVALID_PORTS', - message: - 'Skill outputPorts must equal 1. For branching, use ifElse or switch nodes after the Skill node.', - field: `nodes[${node.id}].data.outputPorts`, - }); - } - - // Execution mode validation - if (skillData.executionMode !== undefined) { - const validModes = ['load', 'execute']; - if (!validModes.includes(skillData.executionMode)) { - errors.push({ - code: 'SKILL_INVALID_EXECUTION_MODE', - message: `Skill executionMode must be one of: ${validModes.join(', ')}`, - field: `nodes[${node.id}].data.executionMode`, - }); - } - } - - // Execution prompt length validation - if (skillData.executionPrompt) { - if (skillData.executionPrompt.length > VALIDATION_RULES.SKILL.EXECUTION_PROMPT_MAX_LENGTH) { - errors.push({ - code: 'SKILL_EXECUTION_PROMPT_TOO_LONG', - message: `Skill executionPrompt exceeds ${VALIDATION_RULES.SKILL.EXECUTION_PROMPT_MAX_LENGTH} characters`, - field: `nodes[${node.id}].data.executionPrompt`, - }); - } - } - - return errors; -} - -/** - * Validate MCP node structure and fields - * - * Based on: contracts/workflow-mcp-node.schema.json - * - * @param node - MCP node to validate - * @returns Array of validation errors (T017) - */ -function validateMcpNode(node: WorkflowNode): ValidationError[] { - const errors: ValidationError[] = []; - const mcpData = node.data as Partial; - - // Common required fields (all modes) - const commonRequiredFields: (keyof McpNodeData)[] = [ - 'serverId', - 'validationStatus', - 'outputPorts', - ]; - - for (const field of commonRequiredFields) { - const value = mcpData[field as keyof typeof mcpData]; - if (value === undefined || value === null || value === '') { - errors.push({ - code: 'MCP_MISSING_FIELD', - message: `MCP node missing required field: ${field}`, - field: `nodes[${node.id}].data.${field}`, - }); - } - } - - // Server ID validation - if (mcpData.serverId) { - if (mcpData.serverId.length < VALIDATION_RULES.MCP.SERVER_ID_MIN_LENGTH) { - errors.push({ - code: 'MCP_INVALID_SERVER_ID', - message: `MCP server ID too short (min ${VALIDATION_RULES.MCP.SERVER_ID_MIN_LENGTH} characters)`, - field: `nodes[${node.id}].data.serverId`, - }); - } - - if (mcpData.serverId.length > VALIDATION_RULES.MCP.SERVER_ID_MAX_LENGTH) { - errors.push({ - code: 'MCP_SERVER_ID_TOO_LONG', - message: `MCP server ID exceeds ${VALIDATION_RULES.MCP.SERVER_ID_MAX_LENGTH} characters`, - field: `nodes[${node.id}].data.serverId`, - }); - } - } - - // Tool name validation - if (mcpData.toolName) { - if (mcpData.toolName.length < VALIDATION_RULES.MCP.TOOL_NAME_MIN_LENGTH) { - errors.push({ - code: 'MCP_INVALID_TOOL_NAME', - message: `MCP tool name too short (min ${VALIDATION_RULES.MCP.TOOL_NAME_MIN_LENGTH} characters)`, - field: `nodes[${node.id}].data.toolName`, - }); - } - - if (mcpData.toolName.length > VALIDATION_RULES.MCP.TOOL_NAME_MAX_LENGTH) { - errors.push({ - code: 'MCP_TOOL_NAME_TOO_LONG', - message: `MCP tool name exceeds ${VALIDATION_RULES.MCP.TOOL_NAME_MAX_LENGTH} characters`, - field: `nodes[${node.id}].data.toolName`, - }); - } - } - - // Tool description validation - if ( - mcpData.toolDescription && - mcpData.toolDescription.length > VALIDATION_RULES.MCP.TOOL_DESCRIPTION_MAX_LENGTH - ) { - errors.push({ - code: 'MCP_TOOL_DESC_TOO_LONG', - message: `MCP tool description exceeds ${VALIDATION_RULES.MCP.TOOL_DESCRIPTION_MAX_LENGTH} characters`, - field: `nodes[${node.id}].data.toolDescription`, - }); - } - - // Parameters array validation - if (mcpData.parameters) { - if (!Array.isArray(mcpData.parameters)) { - errors.push({ - code: 'MCP_INVALID_PARAMETERS', - message: 'MCP parameters must be an array', - field: `nodes[${node.id}].data.parameters`, - }); - } - } - - // Parameter values validation - if (mcpData.parameterValues) { - if ( - typeof mcpData.parameterValues !== 'object' || - mcpData.parameterValues === null || - Array.isArray(mcpData.parameterValues) - ) { - errors.push({ - code: 'MCP_INVALID_PARAMETER_VALUES', - message: 'MCP parameterValues must be an object', - field: `nodes[${node.id}].data.parameterValues`, - }); - } - } - - // Validation status check - if (mcpData.validationStatus) { - const validStatuses = ['valid', 'missing', 'invalid']; - if (!validStatuses.includes(mcpData.validationStatus)) { - errors.push({ - code: 'MCP_INVALID_STATUS', - message: `MCP validationStatus must be one of: ${validStatuses.join(', ')}`, - field: `nodes[${node.id}].data.validationStatus`, - }); - } - } - - // Output ports validation - if (mcpData.outputPorts !== VALIDATION_RULES.MCP.OUTPUT_PORTS) { - errors.push({ - code: 'MCP_INVALID_PORTS', - message: - 'MCP outputPorts must equal 1. For branching, use ifElse or switch nodes after the MCP node.', - field: `nodes[${node.id}].data.outputPorts`, - }); - } - - // Mode-specific configuration validation (T058) - const mode = mcpData.mode || 'manualParameterConfig'; - - switch (mode) { - case 'manualParameterConfig': - // Manual mode requires toolName, toolDescription, parameters, parameterValues - if (!mcpData.toolName || mcpData.toolName.trim().length === 0) { - errors.push({ - code: 'MCP_MODE_CONFIG_MISMATCH', - message: 'Manual parameter config mode requires toolName to be set', - field: `nodes[${node.id}].data.toolName`, - }); - } - if (!mcpData.toolDescription || mcpData.toolDescription.trim().length === 0) { - errors.push({ - code: 'MCP_MODE_CONFIG_MISMATCH', - message: 'Manual parameter config mode requires toolDescription to be set', - field: `nodes[${node.id}].data.toolDescription`, - }); - } - // parameters配列が定義されていない場合のみエラー(空配列はOK - パラメータなしツール用) - if (!mcpData.parameters) { - errors.push({ - code: 'MCP_MODE_CONFIG_MISMATCH', - message: 'Manual parameter config mode requires parameters array to be set', - field: `nodes[${node.id}].data.parameters`, - }); - } - // parametersが空でない場合のみ、parameterValuesの存在をチェック - if (mcpData.parameters && mcpData.parameters.length > 0) { - if (!mcpData.parameterValues || Object.keys(mcpData.parameterValues).length === 0) { - errors.push({ - code: 'MCP_MODE_CONFIG_MISMATCH', - message: 'Manual parameter config mode requires parameterValues to be configured', - field: `nodes[${node.id}].data.parameterValues`, - }); - } - } - break; - - case 'aiParameterConfig': - // AI parameter config mode requires toolName, toolDescription, parameters, aiParameterConfig - if (!mcpData.toolName || mcpData.toolName.trim().length === 0) { - errors.push({ - code: 'MCP_MODE_CONFIG_MISMATCH', - message: 'AI parameter config mode requires toolName to be set', - field: `nodes[${node.id}].data.toolName`, - }); - } - if (!mcpData.toolDescription || mcpData.toolDescription.trim().length === 0) { - errors.push({ - code: 'MCP_MODE_CONFIG_MISMATCH', - message: 'AI parameter config mode requires toolDescription to be set', - field: `nodes[${node.id}].data.toolDescription`, - }); - } - if (!mcpData.parameters || mcpData.parameters.length === 0) { - errors.push({ - code: 'MCP_MODE_CONFIG_MISMATCH', - message: 'AI parameter config mode requires parameters array to be set', - field: `nodes[${node.id}].data.parameters`, - }); - } - if (!mcpData.aiParameterConfig) { - errors.push({ - code: 'MCP_MODE_CONFIG_MISMATCH', - message: 'AI parameter config mode requires aiParameterConfig to be set', - field: `nodes[${node.id}].data.aiParameterConfig`, - }); - } else if ( - !mcpData.aiParameterConfig.description || - mcpData.aiParameterConfig.description.trim().length === 0 - ) { - errors.push({ - code: 'MCP_MODE_CONFIG_MISMATCH', - message: - 'AI parameter config mode requires aiParameterConfig.description to be non-empty', - field: `nodes[${node.id}].data.aiParameterConfig.description`, - }); - } - // parameterValues is optional (AI will set values based on description) - break; - - case 'aiToolSelection': - // AI tool selection mode requires aiToolSelectionConfig - if (!mcpData.aiToolSelectionConfig) { - errors.push({ - code: 'MCP_MODE_CONFIG_MISMATCH', - message: 'AI tool selection mode requires aiToolSelectionConfig to be set', - field: `nodes[${node.id}].data.aiToolSelectionConfig`, - }); - } else if ( - !mcpData.aiToolSelectionConfig.taskDescription || - mcpData.aiToolSelectionConfig.taskDescription.trim().length === 0 - ) { - errors.push({ - code: 'MCP_MODE_CONFIG_MISMATCH', - message: - 'AI tool selection mode requires aiToolSelectionConfig.taskDescription to be non-empty', - field: `nodes[${node.id}].data.aiToolSelectionConfig.taskDescription`, - }); - } - // toolName is optional for AI tool selection mode (AI will select the tool) - break; - - default: - // Unknown mode - errors.push({ - code: 'MCP_INVALID_MODE', - message: `Invalid MCP mode: ${mode}. Must be one of: manualParameterConfig, aiParameterConfig, aiToolSelection`, - field: `nodes[${node.id}].data.mode`, - }); - } - - return errors; -} - -/** - * Validate all connections in the workflow - */ -function validateConnections(connections: Connection[], nodes: WorkflowNode[]): ValidationError[] { - const errors: ValidationError[] = []; - const nodeIds = new Set(nodes.map((n) => n.id)); - const connectionIds = new Set(); - - for (const conn of connections) { - // Check for duplicate connection IDs - if (connectionIds.has(conn.id)) { - errors.push({ - code: 'DUPLICATE_CONNECTION_ID', - message: `Duplicate connection ID: ${conn.id}`, - field: `connections[${conn.id}]`, - }); - } - connectionIds.add(conn.id); - - // Validate from/to node IDs exist - if (!nodeIds.has(conn.from)) { - errors.push({ - code: 'INVALID_CONNECTION', - message: `Connection references non-existent from node: ${conn.from}`, - field: `connections[${conn.id}].from`, - }); - } - - if (!nodeIds.has(conn.to)) { - errors.push({ - code: 'INVALID_CONNECTION', - message: `Connection references non-existent to node: ${conn.to}`, - field: `connections[${conn.id}].to`, - }); - } - - // Validate no self-connections - if (conn.from === conn.to) { - errors.push({ - code: 'SELF_CONNECTION', - message: 'Node cannot connect to itself', - field: `connections[${conn.id}]`, - }); - } - - // Validate Start/End node connection rules - const fromNode = nodes.find((n) => n.id === conn.from); - const toNode = nodes.find((n) => n.id === conn.to); - - if (toNode?.type === NodeType.Start) { - errors.push({ - code: 'INVALID_CONNECTION', - message: 'Start node cannot have input connections', - field: `connections[${conn.id}]`, - }); - } - - if (fromNode?.type === NodeType.End) { - errors.push({ - code: 'INVALID_CONNECTION', - message: 'End node cannot have output connections', - field: `connections[${conn.id}]`, - }); - } - } - - // Check for cycles (simplified check - full cycle detection would be more complex) - // For MVP, we'll rely on the AI to generate acyclic workflows - - return errors; -} - -/** - * Validate hooks configuration - * - * Issue #413: Hooks configuration for workflow execution - * See: https://code.claude.com/docs/en/hooks - * - * @param hooks - Hooks configuration to validate - * @returns Array of validation errors - */ -function validateHooks(hooks: WorkflowHooks): ValidationError[] { - const errors: ValidationError[] = []; - - const validHookTypes: HookType[] = ['PreToolUse', 'PostToolUse', 'Stop']; - const validActionTypes = ['command', 'prompt']; - - for (const [hookType, entries] of Object.entries(hooks)) { - // Validate hook type - if (!validHookTypes.includes(hookType as HookType)) { - errors.push({ - code: 'HOOKS_INVALID_TYPE', - message: `Invalid hook type: ${hookType}. Must be one of: ${validHookTypes.join(', ')}`, - field: `hooks.${hookType}`, - }); - continue; - } - - // Validate entries array - if (!Array.isArray(entries)) { - errors.push({ - code: 'HOOKS_INVALID_ENTRIES', - message: `Hook ${hookType} entries must be an array`, - field: `hooks.${hookType}`, - }); - continue; - } - - // Validate max entries per hook - if (entries.length > VALIDATION_RULES.HOOKS.MAX_ENTRIES_PER_HOOK) { - errors.push({ - code: 'HOOKS_TOO_MANY_ENTRIES', - message: `Hook ${hookType} exceeds maximum of ${VALIDATION_RULES.HOOKS.MAX_ENTRIES_PER_HOOK} entries`, - field: `hooks.${hookType}`, - }); - } - - // Validate each entry - for (let i = 0; i < entries.length; i++) { - const entry = entries[i]; - - if (!entry || typeof entry !== 'object') { - errors.push({ - code: 'HOOKS_INVALID_ENTRY', - message: `Hook ${hookType}[${i}] must be an object`, - field: `hooks.${hookType}[${i}]`, - }); - continue; - } - - // Validate matcher (optional for all hook types, but check length if provided) - if (entry.matcher) { - if (typeof entry.matcher !== 'string') { - errors.push({ - code: 'HOOKS_INVALID_MATCHER', - message: `Hook ${hookType}[${i}] matcher must be a string`, - field: `hooks.${hookType}[${i}].matcher`, - }); - } else if (entry.matcher.length > VALIDATION_RULES.HOOKS.MATCHER_MAX_LENGTH) { - errors.push({ - code: 'HOOKS_MATCHER_TOO_LONG', - message: `Hook ${hookType}[${i}] matcher exceeds ${VALIDATION_RULES.HOOKS.MATCHER_MAX_LENGTH} characters`, - field: `hooks.${hookType}[${i}].matcher`, - }); - } - } - - // Validate hooks array (actions) - if (!entry.hooks || !Array.isArray(entry.hooks)) { - errors.push({ - code: 'HOOKS_MISSING_ACTIONS', - message: `Hook ${hookType}[${i}] must have a hooks array`, - field: `hooks.${hookType}[${i}].hooks`, - }); - continue; - } - - if (entry.hooks.length === 0) { - errors.push({ - code: 'HOOKS_EMPTY_ACTIONS', - message: `Hook ${hookType}[${i}].hooks cannot be empty`, - field: `hooks.${hookType}[${i}].hooks`, - }); - } - - if (entry.hooks.length > VALIDATION_RULES.HOOKS.MAX_ACTIONS_PER_ENTRY) { - errors.push({ - code: 'HOOKS_TOO_MANY_ACTIONS', - message: `Hook ${hookType}[${i}].hooks exceeds maximum of ${VALIDATION_RULES.HOOKS.MAX_ACTIONS_PER_ENTRY} actions`, - field: `hooks.${hookType}[${i}].hooks`, - }); - } - - // Validate each action - for (let j = 0; j < entry.hooks.length; j++) { - const action = entry.hooks[j]; - - if (!action || typeof action !== 'object') { - errors.push({ - code: 'HOOKS_INVALID_ACTION', - message: `Hook ${hookType}[${i}].hooks[${j}] must be an object`, - field: `hooks.${hookType}[${i}].hooks[${j}]`, - }); - continue; - } - - // Validate action type - if (!action.type || !validActionTypes.includes(action.type)) { - errors.push({ - code: 'HOOKS_INVALID_ACTION_TYPE', - message: `Hook ${hookType}[${i}].hooks[${j}].type must be one of: ${validActionTypes.join(', ')}`, - field: `hooks.${hookType}[${i}].hooks[${j}].type`, - }); - } - - // Validate command (required for type: 'command') - if (action.type === 'command') { - if (!action.command || typeof action.command !== 'string') { - errors.push({ - code: 'HOOKS_MISSING_COMMAND', - message: `Hook ${hookType}[${i}].hooks[${j}] requires a command string`, - field: `hooks.${hookType}[${i}].hooks[${j}].command`, - }); - } else { - // Validate command length - if (action.command.length < VALIDATION_RULES.HOOKS.COMMAND_MIN_LENGTH) { - errors.push({ - code: 'HOOKS_COMMAND_EMPTY', - message: `Hook ${hookType}[${i}].hooks[${j}].command cannot be empty`, - field: `hooks.${hookType}[${i}].hooks[${j}].command`, - }); - } - - if (action.command.length > VALIDATION_RULES.HOOKS.COMMAND_MAX_LENGTH) { - errors.push({ - code: 'HOOKS_COMMAND_TOO_LONG', - message: `Hook ${hookType}[${i}].hooks[${j}].command exceeds ${VALIDATION_RULES.HOOKS.COMMAND_MAX_LENGTH} characters`, - field: `hooks.${hookType}[${i}].hooks[${j}].command`, - }); - } - } - } - - // Validate once (optional boolean) - if (action.once !== undefined && typeof action.once !== 'boolean') { - errors.push({ - code: 'HOOKS_INVALID_ONCE', - message: `Hook ${hookType}[${i}].hooks[${j}].once must be a boolean`, - field: `hooks.${hookType}[${i}].hooks[${j}].once`, - }); - } - } - } - } - - return errors; -} diff --git a/src/extension/utils/workflow-validator.ts b/src/extension/utils/workflow-validator.ts deleted file mode 100644 index eb98230e..00000000 --- a/src/extension/utils/workflow-validator.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Workflow Validator - * - * Validates workflow JSON files downloaded from Slack. - * Ensures required fields exist and structure is valid before import. - * - * Based on specs/001-slack-workflow-sharing/contracts/extension-host-api-contracts.md - */ - -import type { Workflow } from '../../shared/types/workflow'; - -/** - * Validation result - */ -export interface ValidationResult { - /** Whether the workflow is valid */ - valid: boolean; - /** Validation error messages (if invalid) */ - errors?: string[]; - /** Parsed workflow object (if valid) */ - workflow?: Workflow; -} - -/** - * Validates workflow JSON content - * - * Checks: - * 1. Valid JSON format - * 2. Required fields exist (id, name, version, nodes, connections) - * 3. Basic structure validation - * - * @param content - Workflow JSON string - * @returns Validation result with errors or parsed workflow - */ -export function validateWorkflowFile(content: string): ValidationResult { - const errors: string[] = []; - - // Step 1: Parse JSON - let parsedData: unknown; - try { - parsedData = JSON.parse(content); - } catch (error) { - return { - valid: false, - errors: [`Invalid JSON format: ${error instanceof Error ? error.message : String(error)}`], - }; - } - - // Step 2: Type check - if (typeof parsedData !== 'object' || parsedData === null) { - return { - valid: false, - errors: ['Workflow must be a JSON object'], - }; - } - - const workflow = parsedData as Record; - - // Step 3: Required field validation - const requiredFields: Array = ['id', 'name', 'version', 'nodes', 'connections']; - - for (const field of requiredFields) { - if (!(field in workflow)) { - errors.push(`Missing required field: ${field}`); - } - } - - // Step 4: Field type validation - if ('id' in workflow && typeof workflow.id !== 'string') { - errors.push('Field "id" must be a string'); - } - - if ('name' in workflow && typeof workflow.name !== 'string') { - errors.push('Field "name" must be a string'); - } - - if ('version' in workflow && typeof workflow.version !== 'string') { - errors.push('Field "version" must be a string'); - } - - if ('nodes' in workflow && !Array.isArray(workflow.nodes)) { - errors.push('Field "nodes" must be an array'); - } - - if ('connections' in workflow && !Array.isArray(workflow.connections)) { - errors.push('Field "connections" must be an array'); - } - - // Step 5: Return validation result - if (errors.length > 0) { - return { - valid: false, - errors, - }; - } - - return { - valid: true, - workflow: workflow as Workflow, - }; -} diff --git a/src/extension/webview-content.ts b/src/extension/webview-content.ts deleted file mode 100644 index d11a6cfb..00000000 --- a/src/extension/webview-content.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * CC Workflow Studio - Webview HTML Generator - * - * Generates the HTML content for the Webview panel - * Based on: /specs/001-cc-wf-studio/contracts/vscode-extension-api.md section 4.2 - */ - -import * as vscode from 'vscode'; -import { getCurrentLocale } from './i18n/i18n-service'; - -/** - * Generate HTML content for the Webview - * - * @param webview - VSCode Webview instance - * @param extensionUri - Extension URI for resource loading - * @returns HTML string with CSP, nonce, and resource URIs - */ -export function getWebviewContent(webview: vscode.Webview, extensionUri: vscode.Uri): string { - // Generate a nonce for Content Security Policy - const nonce = getNonce(); - - // Get current locale for i18n - const locale = getCurrentLocale(); - - // Get URIs for webview resources - const scriptUri = webview.asWebviewUri( - vscode.Uri.joinPath(extensionUri, 'src', 'webview', 'dist', 'assets', 'main.js') - ); - const styleUri = webview.asWebviewUri( - vscode.Uri.joinPath(extensionUri, 'src', 'webview', 'dist', 'assets', 'main.css') - ); - - return ` - - - - - - - - - - - - CC Workflow Studio - - -
- - - -`; -} - -/** - * Generate a cryptographically secure nonce - * - * @returns A random 32-character hexadecimal string - */ -function getNonce(): string { - let text = ''; - const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - for (let i = 0; i < 32; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); - } - return text; -} diff --git a/src/webview/src/main.tsx b/src/webview/src/main.tsx index 5df39389..2fa968e4 100644 --- a/src/webview/src/main.tsx +++ b/src/webview/src/main.tsx @@ -2,7 +2,7 @@ * Claude Code Workflow Studio - Webview Entry Point * * React 18 root initialization with platform-agnostic bridge detection. - * Supports VSCode, Electron, and Dev mode environments. + * Supports VSCode, Electron, Web (standalone), and Dev mode environments. */ import React from 'react'; @@ -13,6 +13,7 @@ import { I18nProvider } from './i18n/i18n-context'; import { type IHostBridge, setBridge } from './services/bridge'; import { createElectronBridge } from './services/electron-bridge-adapter'; import { createVSCodeBridge } from './services/vscode-bridge-adapter'; +import { createWebBridge } from './services/web-bridge-adapter'; import 'reactflow/dist/style.css'; import './styles/standalone-theme.css'; import './styles/main.css'; @@ -75,8 +76,11 @@ if (window.acquireVsCodeApi) { } else if (window.electronAPI) { // Electron renderer context bridge = createElectronBridge(); +} else if (import.meta.env.VITE_WEB_MODE === 'true' || window.location.pathname !== '/') { + // Standalone web app mode (served by Hono backend with WebSocket) + bridge = createWebBridge(); } else { - // Dev mode (browser) + // Dev mode (browser) — fallback for Vite dev server without backend bridge = createDevBridge(); } diff --git a/src/webview/src/services/web-bridge-adapter.ts b/src/webview/src/services/web-bridge-adapter.ts new file mode 100644 index 00000000..42e27cfd --- /dev/null +++ b/src/webview/src/services/web-bridge-adapter.ts @@ -0,0 +1,117 @@ +/** + * Web Bridge Adapter + * + * IHostBridge implementation for standalone web app mode. + * Uses WebSocket for communication with the Hono backend server, + * maintaining the same message protocol as the VSCode postMessage API. + */ + +import type { IHostBridge } from './bridge'; + +const WS_RECONNECT_DELAY = 2000; +const WS_MAX_RECONNECT_ATTEMPTS = 10; + +export function createWebBridge(): IHostBridge { + let socket: WebSocket | null = null; + let messageHandler: ((event: { data: unknown }) => void) | null = null; + let reconnectAttempts = 0; + let pendingMessages: string[] = []; + + function getWebSocketUrl(): string { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + return `${protocol}//${host}/ws`; + } + + function connect(): void { + try { + socket = new WebSocket(getWebSocketUrl()); + + socket.onopen = () => { + console.log('[Web Bridge] WebSocket connected'); + reconnectAttempts = 0; + + // Send any queued messages + for (const msg of pendingMessages) { + socket?.send(msg); + } + pendingMessages = []; + }; + + socket.onmessage = (event: MessageEvent) => { + try { + const data = JSON.parse(event.data as string); + // Handle special web-mode messages + if (data.type === 'OPEN_URL' && data.payload?.url) { + window.open(data.payload.url, '_blank'); + return; + } + // Forward to registered handler as if it were a postMessage event + messageHandler?.({ data }); + } catch { + console.error('[Web Bridge] Failed to parse message:', event.data); + } + }; + + socket.onclose = () => { + console.log('[Web Bridge] WebSocket disconnected'); + socket = null; + + if (reconnectAttempts < WS_MAX_RECONNECT_ATTEMPTS) { + reconnectAttempts++; + console.log( + `[Web Bridge] Reconnecting (attempt ${reconnectAttempts}/${WS_MAX_RECONNECT_ATTEMPTS})...` + ); + setTimeout(connect, WS_RECONNECT_DELAY); + } else { + console.error('[Web Bridge] Max reconnection attempts reached'); + } + }; + + socket.onerror = (error) => { + console.error('[Web Bridge] WebSocket error:', error); + }; + } catch (error) { + console.error('[Web Bridge] Failed to create WebSocket:', error); + } + } + + // Initiate connection + connect(); + + return { + postMessage: (message: { type: string; requestId?: string; payload?: unknown }) => { + const serialized = JSON.stringify(message); + + if (socket?.readyState === WebSocket.OPEN) { + socket.send(serialized); + } else { + // Queue message for when connection is established + pendingMessages.push(serialized); + } + }, + + onMessage: (handler: (event: { data: unknown }) => void) => { + messageHandler = handler; + return () => { + messageHandler = null; + }; + }, + + getState: () => { + try { + return JSON.parse(localStorage.getItem('cc-wf-studio-state') || 'null'); + } catch { + return null; + } + }, + + setState: (state: unknown) => { + try { + localStorage.setItem('cc-wf-studio-state', JSON.stringify(state)); + } catch { + console.error('[Web Bridge] Failed to persist state'); + } + }, + }; +} diff --git a/src/webview/vite.config.ts b/src/webview/vite.config.ts index 9f2cde8b..6669c483 100644 --- a/src/webview/vite.config.ts +++ b/src/webview/vite.config.ts @@ -10,8 +10,11 @@ import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; // https://vitejs.dev/config/ -export default defineConfig({ +export default defineConfig(({ mode }) => ({ plugins: [react()], + define: { + 'import.meta.env.VITE_WEB_MODE': JSON.stringify(mode === 'web' ? 'true' : 'false'), + }, build: { outDir: 'dist', emptyOutDir: true, @@ -44,5 +47,15 @@ export default defineConfig({ server: { port: 5173, strictPort: true, + // Proxy WebSocket to backend in web dev mode + proxy: + mode === 'web' + ? { + '/ws': { + target: 'ws://localhost:3001', + ws: true, + }, + } + : undefined, }, -}); +})); diff --git a/tsconfig.json b/tsconfig.json index fd9f61ff..219f4b9d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,8 @@ "compilerOptions": { "target": "ES2020", "lib": ["ES2020"], - "module": "commonjs", + "module": "ESNext", + "moduleResolution": "bundler", "rootDir": "./src", "outDir": "./out", "sourceMap": true, @@ -10,7 +11,6 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "moduleResolution": "node", "resolveJsonModule": true, "noImplicitAny": true, "noUnusedLocals": true, @@ -19,7 +19,7 @@ "noFallthroughCasesInSwitch": true, "declaration": true, "declarationMap": true, - "types": ["vscode", "node"] + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "out", "dist", "src/webview"] diff --git a/vite.extension.config.ts b/vite.extension.config.ts deleted file mode 100644 index d483add5..00000000 --- a/vite.extension.config.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { resolve } from 'node:path'; -import { defineConfig } from 'vite'; - -/** - * Vite configuration for bundling VSCode Extension Host code - * - * This bundles src/extension/** TypeScript files into a single output file - * with all dependencies (including @modelcontextprotocol/sdk) included. - */ -export default defineConfig({ - build: { - // Library mode for Node.js environment (Extension Host) - lib: { - entry: resolve(__dirname, 'src/extension/extension.ts'), - formats: ['cjs'], - fileName: () => 'extension.js', - }, - - // Output directory - outDir: 'dist', - - // Generate source maps for debugging - sourcemap: true, - - // Minification (disable for easier debugging, enable for production) - minify: false, - - rollupOptions: { - // Mark vscode module and Node.js built-ins as external - external: [ - 'vscode', - // Node.js built-in modules (with and without node: prefix) - 'node:assert', - 'node:child_process', - 'node:crypto', - 'node:events', - 'node:fs', - 'node:fs/promises', - 'node:http', - 'node:http2', - 'node:https', - 'node:os', - 'node:path', - 'node:process', - 'node:querystring', - 'node:readline/promises', - 'node:stream', - 'node:stream/promises', - 'node:tty', - 'node:util', - 'node:url', - 'node:zlib', - 'assert', - 'child_process', - 'crypto', - 'events', - 'fs', - 'fs/promises', - 'http', - 'http2', - 'https', - 'os', - 'path', - 'process', - 'querystring', - 'stream', - 'tty', - 'util', - 'url', - 'zlib', - ], - - output: { - // Ensure proper external module handling - globals: { - vscode: 'vscode', - }, - // Disable code splitting - bundle everything into a single file - inlineDynamicImports: true, - }, - }, - - // Target Node.js environment (Extension Host runs in Node.js) - target: 'node18', - - // Don't emit index.html - emptyOutDir: false, - }, - - // Resolve configuration - resolve: { - // Use Node.js builds for dependencies (prevents "window is not defined" errors) - conditions: ['node'], - // Ignore 'browser' field in package.json, use 'main' field instead - mainFields: ['module', 'jsnext:main', 'jsnext', 'main'], - alias: { - '@': resolve(__dirname, 'src'), - }, - }, -});