diff --git a/CHANGELOG.md b/CHANGELOG.md index abc50325..2aeed404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [3.15.2](https://github.com/breaking-brake/cc-wf-studio/compare/v3.15.1...v3.15.2) (2026-01-16) + +### Improvements + +* add Copilot execution mode selection and consolidate prompt generation ([#461](https://github.com/breaking-brake/cc-wf-studio/issues/461)) ([0447683](https://github.com/breaking-brake/cc-wf-studio/commit/0447683231ee4f51810d2485c28bdcad65794d2c)) + ## [3.15.1](https://github.com/breaking-brake/cc-wf-studio/compare/v3.15.0...v3.15.1) (2026-01-15) ### Improvements diff --git a/README.md b/README.md index 13f7d4a8..6563d179 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ All operations run locally within VSCode. **Note:** MCP Tool nodes may require n 💬 **Slack Workflow Sharing (β)** - Share workflows directly to Slack channels with preview cards and one-click import links for seamless team collaboration -🤖 **GitHub Copilot Export (β)** - Export workflows to GitHub Copilot Prompts format (`.github/prompts/*.prompt.md`) and run them directly in Copilot Chat. Enable from **More** menu in the toolbar. **Note:** This is an experimental feature with limited support. Some features (e.g., Skill nodes) are not yet supported in Copilot export +🤖 **GitHub Copilot Export (β)** - Export workflows to GitHub Copilot format with two execution modes: **VSCode Copilot** (`.github/prompts/*.prompt.md` for Copilot Chat) and **Copilot CLI** (`.github/skills/{name}/SKILL.md` for terminal execution). Select your preferred mode from the dropdown menu. **Note:** This is an experimental feature with limited support. Some features (e.g., Skill nodes) are not yet supported in Copilot export 🧩 **Rich Node Types** - Build complex workflows with diverse node types: Prompt (templates), Sub-Agent (AI tasks), Skill (Claude Code Skills), MCP (external tools), IfElse/Switch (conditional branching), and AskUserQuestion (user decisions) diff --git a/package-lock.json b/package-lock.json index 3a651128..6e6a11ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-wf-studio", - "version": "3.15.1", + "version": "3.15.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-wf-studio", - "version": "3.15.1", + "version": "3.15.2", "license": "AGPL-3.0-or-later", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", diff --git a/package.json b/package.json index 21cca677..dc975f77 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "cc-wf-studio", "displayName": "Claude Code Workflow Studio", "description": "Visual workflow editor for Claude Code Slash Commands, Sub Agents, Agent Skills, and MCP Tools", - "version": "3.15.1", + "version": "3.15.2", "publisher": "breaking-brake", "icon": "resources/icon.png", "repository": { diff --git a/src/extension/commands/copilot-handlers.ts b/src/extension/commands/copilot-handlers.ts index 077f3cef..b690c793 100644 --- a/src/extension/commands/copilot-handlers.ts +++ b/src/extension/commands/copilot-handlers.ts @@ -9,20 +9,34 @@ 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 { executeCopilotCliInTerminal } from '../services/terminal-execution-service'; /** * Handle Export for Copilot request @@ -156,6 +170,26 @@ export async function handleRunForCopilot( 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; @@ -204,7 +238,9 @@ export async function handleRunForCopilot( let copilotChatOpened = false; try { - // Use workbench.action.chat.open to open Copilot Chat + // 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 @@ -265,3 +301,207 @@ export async function handleRunForCopilot( }); } } + +/** + * 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 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/open-editor.ts b/src/extension/commands/open-editor.ts index fa7393f0..524f6e6d 100644 --- a/src/extension/commands/open-editor.ts +++ b/src/extension/commands/open-editor.ts @@ -16,7 +16,12 @@ 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 { handleExportForCopilot, handleRunForCopilot } from './copilot-handlers'; +import { + handleExportForCopilot, + handleExportForCopilotCli, + handleRunForCopilot, + handleRunForCopilotCli, +} from './copilot-handlers'; import { handleExportWorkflow, handleExportWorkflowForExecution } from './export-workflow'; import { loadWorkflow } from './load-workflow'; import { loadWorkflowList } from './load-workflow-list'; @@ -325,7 +330,7 @@ export function registerOpenEditorCommand( break; case 'RUN_FOR_COPILOT': - // Run workflow for Copilot (Beta) + // Run workflow for Copilot (Beta) - VSCode Copilot Chat mode if (message.payload?.workflow) { await handleRunForCopilot(fileService, webview, message.payload, message.requestId); } else { @@ -341,6 +346,50 @@ export function registerOpenEditorCommand( } 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 'LOAD_WORKFLOW_LIST': // Load workflow list await loadWorkflowList(fileService, webview, message.requestId); diff --git a/src/extension/i18n/translation-keys.ts b/src/extension/i18n/translation-keys.ts index 5b14893d..3b05b16b 100644 --- a/src/extension/i18n/translation-keys.ts +++ b/src/extension/i18n/translation-keys.ts @@ -5,66 +5,6 @@ */ export interface TranslationKeys { - // Mermaid flowchart labels - 'mermaid.start': string; - 'mermaid.end': string; - 'mermaid.question': string; - 'mermaid.conditionalBranch': string; - - // Workflow execution guide - 'guide.title': string; - 'guide.intro': string; - 'guide.nodeTypesTitle': string; - 'guide.nodeTypes.subAgent': string; - 'guide.nodeTypes.askUserQuestion': string; - 'guide.nodeTypes.branch': string; - 'guide.nodeTypes.prompt': string; - - // Prompt node details - 'promptNode.title': string; - 'promptNode.availableVariables': string; - 'promptNode.variableNotSet': string; - - // AskUserQuestion node details - 'askNode.title': string; - 'askNode.selectionMode': string; - 'askNode.aiSuggestions': string; - 'askNode.multiSelect': string; - 'askNode.singleSelect': string; - 'askNode.options': string; - 'askNode.noDescription': string; - 'askNode.multiSelectExplanation': string; - - // Branch node details (Legacy) - 'branchNode.title': string; - 'branchNode.binary': string; - 'branchNode.multiple': string; - 'branchNode.conditions': string; - 'branchNode.executionMethod': string; - - // IfElse node details - 'ifElseNode.title': string; - 'ifElseNode.binary': string; - 'ifElseNode.evaluationTarget': string; - - // Switch node details - 'switchNode.title': string; - 'switchNode.multiple': string; - 'switchNode.evaluationTarget': string; - - // MCP node details - 'mcpNode.title': string; - 'mcpNode.description': string; - 'mcpNode.server': string; - 'mcpNode.toolName': string; - 'mcpNode.validationStatus': string; - 'mcpNode.configuredParameters': string; - 'mcpNode.availableParameters': string; - 'mcpNode.required': string; - 'mcpNode.optional': string; - 'mcpNode.noDescription': string; - 'mcpNode.executionMethod': string; - // Error messages 'error.noWorkspaceOpen': string; diff --git a/src/extension/i18n/translations/en.ts b/src/extension/i18n/translations/en.ts index 53411153..b9680824 100644 --- a/src/extension/i18n/translations/en.ts +++ b/src/extension/i18n/translations/en.ts @@ -5,74 +5,6 @@ import type { TranslationKeys } from '../translation-keys'; export const enTranslations: TranslationKeys = { - // Mermaid flowchart labels - 'mermaid.start': 'Start', - 'mermaid.end': 'End', - 'mermaid.question': 'Question', - 'mermaid.conditionalBranch': 'Conditional Branch', - - // Workflow execution guide - 'guide.title': '## Workflow Execution Guide', - 'guide.intro': - 'Follow the Mermaid flowchart above to execute the workflow. Each node type has specific execution methods as described below.', - 'guide.nodeTypesTitle': '### Execution Methods by Node Type', - 'guide.nodeTypes.subAgent': '- **Rectangle nodes**: Execute Sub-Agents using the Task tool', - 'guide.nodeTypes.askUserQuestion': - '- **Diamond nodes (AskUserQuestion:...)**: Use the AskUserQuestion tool to prompt the user and branch based on their response', - 'guide.nodeTypes.branch': - '- **Diamond nodes (Branch/Switch:...)**: Automatically branch based on the results of previous processing (see details section)', - 'guide.nodeTypes.prompt': - '- **Rectangle nodes (Prompt nodes)**: Execute the prompts described in the details section below', - - // Prompt node details - 'promptNode.title': '### Prompt Node Details', - 'promptNode.availableVariables': '**Available variables:**', - 'promptNode.variableNotSet': '(not set)', - - // AskUserQuestion node details - 'askNode.title': '### AskUserQuestion Node Details', - 'askNode.selectionMode': '**Selection mode:**', - 'askNode.aiSuggestions': - 'AI Suggestions (AI generates options dynamically based on context and presents them to the user)', - 'askNode.multiSelect': '**Multi-select:** Enabled (user can select multiple options)', - 'askNode.singleSelect': 'Single Select (branches based on the selected option)', - 'askNode.options': '**Options:**', - 'askNode.noDescription': '(no description)', - 'askNode.multiSelectExplanation': - 'Multi-select enabled (a list of selected options is passed to the next node)', - - // Branch node details (Legacy) - 'branchNode.title': '### Branch Node Details', - 'branchNode.binary': 'Binary Branch', - 'branchNode.multiple': 'Multiple Branch', - 'branchNode.conditions': '**Branch conditions:**', - 'branchNode.executionMethod': - '**Execution method**: Evaluate the results of the previous processing and automatically select the appropriate branch based on the conditions above.', - - // IfElse node details - 'ifElseNode.title': '### If/Else Node Details', - 'ifElseNode.binary': 'Binary Branch (True/False)', - 'ifElseNode.evaluationTarget': 'Evaluation Target', - - // Switch node details - 'switchNode.title': '### Switch Node Details', - 'switchNode.multiple': 'Multiple Branch (2-N)', - 'switchNode.evaluationTarget': 'Evaluation Target', - - // MCP node details - 'mcpNode.title': '## MCP Tool Nodes', - 'mcpNode.description': '**Description**', - 'mcpNode.server': '**MCP Server**', - 'mcpNode.toolName': '**Tool Name**', - 'mcpNode.validationStatus': '**Validation Status**', - 'mcpNode.configuredParameters': '**Configured Parameters**', - 'mcpNode.availableParameters': '**Available Parameters**', - 'mcpNode.required': 'required', - 'mcpNode.optional': 'optional', - 'mcpNode.noDescription': 'No description available', - 'mcpNode.executionMethod': - '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.', - // Error messages 'error.noWorkspaceOpen': 'Please open a folder or workspace first.', diff --git a/src/extension/i18n/translations/ja.ts b/src/extension/i18n/translations/ja.ts index 7a011c95..31f3c24b 100644 --- a/src/extension/i18n/translations/ja.ts +++ b/src/extension/i18n/translations/ja.ts @@ -5,73 +5,6 @@ import type { TranslationKeys } from '../translation-keys'; export const jaTranslations: TranslationKeys = { - // Mermaid flowchart labels - 'mermaid.start': '開始', - 'mermaid.end': '終了', - 'mermaid.question': '質問', - 'mermaid.conditionalBranch': '条件分岐', - - // Workflow execution guide - 'guide.title': '## ワークフロー実行ガイド', - 'guide.intro': - '上記のMermaidフローチャートに従ってワークフローを実行してください。各ノードタイプの実行方法は以下の通りです。', - 'guide.nodeTypesTitle': '### ノードタイプ別実行方法', - 'guide.nodeTypes.subAgent': '- **四角形のノード**: Taskツールを使用してSub-Agentを実行します', - 'guide.nodeTypes.askUserQuestion': - '- **ひし形のノード(AskUserQuestion:...)**: AskUserQuestionツールを使用してユーザーに質問し、回答に応じて分岐します', - 'guide.nodeTypes.branch': - '- **ひし形のノード(Branch/Switch:...)**: 前処理の結果に応じて自動的に分岐します(詳細セクション参照)', - 'guide.nodeTypes.prompt': - '- **四角形のノード(Promptノード)**: 以下の詳細セクションに記載されたプロンプトを実行します', - - // Prompt node details - 'promptNode.title': '### Promptノード詳細', - 'promptNode.availableVariables': '**使用可能な変数:**', - 'promptNode.variableNotSet': '(未設定)', - - // AskUserQuestion node details - 'askNode.title': '### AskUserQuestionノード詳細', - 'askNode.selectionMode': '**選択モード:**', - 'askNode.aiSuggestions': 'AI提案(AIが文脈に基づいて選択肢を動的に生成し、ユーザーに提示します)', - 'askNode.multiSelect': '**複数選択:** 有効(ユーザーは複数の選択肢を選べます)', - 'askNode.singleSelect': '単一選択(選択された選択肢に応じて分岐します)', - 'askNode.options': '**選択肢:**', - 'askNode.noDescription': '(説明なし)', - 'askNode.multiSelectExplanation': - '複数選択可能(選択された選択肢のリストが次のノードに渡されます)', - - // Branch node details (Legacy) - 'branchNode.title': '### Branchノード詳細', - 'branchNode.binary': '2分岐', - 'branchNode.multiple': '複数分岐', - 'branchNode.conditions': '**分岐条件:**', - 'branchNode.executionMethod': - '**実行方法**: 前段の処理結果を評価し、上記の条件に基づいて自動的に適切な分岐を選択してください。', - - // IfElse node details - 'ifElseNode.title': '### If/Elseノード詳細', - 'ifElseNode.binary': '2分岐(True/False)', - 'ifElseNode.evaluationTarget': '評価対象', - - // Switch node details - 'switchNode.title': '### Switchノード詳細', - 'switchNode.multiple': '複数分岐(2-N)', - 'switchNode.evaluationTarget': '評価対象', - - // MCP node details - 'mcpNode.title': '## MCPツールノード', - 'mcpNode.description': '**説明**', - 'mcpNode.server': '**MCPサーバー**', - 'mcpNode.toolName': '**ツール名**', - 'mcpNode.validationStatus': '**検証状態**', - 'mcpNode.configuredParameters': '**設定済みパラメータ**', - 'mcpNode.availableParameters': '**利用可能なパラメータ**', - 'mcpNode.required': '必須', - 'mcpNode.optional': '任意', - 'mcpNode.noDescription': '説明なし', - 'mcpNode.executionMethod': - 'このノードはMCP(Model Context Protocol)ツールを呼び出します。ワークフロー実行時は、設定されたパラメータを使用してMCPサーバー経由でツールを呼び出してください。', - // Error messages 'error.noWorkspaceOpen': 'フォルダまたはワークスペースを開いてから実行してください。', diff --git a/src/extension/i18n/translations/ko.ts b/src/extension/i18n/translations/ko.ts index 071a5e7e..1fe3ff53 100644 --- a/src/extension/i18n/translations/ko.ts +++ b/src/extension/i18n/translations/ko.ts @@ -5,73 +5,6 @@ import type { TranslationKeys } from '../translation-keys'; export const koTranslations: TranslationKeys = { - // Mermaid flowchart labels - 'mermaid.start': '시작', - 'mermaid.end': '종료', - 'mermaid.question': '질문', - 'mermaid.conditionalBranch': '조건 분기', - - // Workflow execution guide - 'guide.title': '## 워크플로 실행 가이드', - 'guide.intro': - '위의 Mermaid 플로우차트를 따라 워크플로를 실행하세요. 각 노드 유형의 실행 방법은 아래에 설명되어 있습니다.', - 'guide.nodeTypesTitle': '### 노드 유형별 실행 방법', - 'guide.nodeTypes.subAgent': '- **사각형 노드**: Task 도구를 사용하여 서브 에이전트 실행', - 'guide.nodeTypes.askUserQuestion': - '- **다이아몬드 노드(AskUserQuestion:...)**: AskUserQuestion 도구를 사용하여 사용자에게 질문하고 응답에 따라 분기', - 'guide.nodeTypes.branch': - '- **다이아몬드 노드(Branch/Switch:...)**: 이전 처리 결과에 따라 자동으로 분기(세부 정보 섹션 참조)', - 'guide.nodeTypes.prompt': - '- **사각형 노드(Prompt 노드)**: 아래 세부 정보 섹션에 설명된 프롬프트 실행', - - // Prompt node details - 'promptNode.title': '### Prompt 노드 세부 정보', - 'promptNode.availableVariables': '**사용 가능한 변수:**', - 'promptNode.variableNotSet': '(설정되지 않음)', - - // AskUserQuestion node details - 'askNode.title': '### AskUserQuestion 노드 세부 정보', - 'askNode.selectionMode': '**선택 모드:**', - 'askNode.aiSuggestions': - 'AI 제안(AI가 컨텍스트를 기반으로 옵션을 동적으로 생성하여 사용자에게 제시)', - 'askNode.multiSelect': '**다중 선택:** 활성화됨(사용자가 여러 옵션을 선택할 수 있음)', - 'askNode.singleSelect': '단일 선택(선택한 옵션에 따라 분기)', - 'askNode.options': '**옵션:**', - 'askNode.noDescription': '(설명 없음)', - 'askNode.multiSelectExplanation': '다중 선택 활성화됨(선택한 옵션 목록이 다음 노드로 전달됨)', - - // Branch node details (Legacy) - 'branchNode.title': '### Branch 노드 세부 정보', - 'branchNode.binary': '이진 분기', - 'branchNode.multiple': '다중 분기', - 'branchNode.conditions': '**분기 조건:**', - 'branchNode.executionMethod': - '**실행 방법**: 이전 처리 결과를 평가하고 위의 조건에 따라 적절한 분기를 자동으로 선택합니다.', - - // IfElse node details - 'ifElseNode.title': '### If/Else 노드 세부 정보', - 'ifElseNode.binary': '이진 분기 (True/False)', - 'ifElseNode.evaluationTarget': '평가 대상', - - // Switch node details - 'switchNode.title': '### Switch 노드 세부 정보', - 'switchNode.multiple': '다중 분기 (2-N)', - 'switchNode.evaluationTarget': '평가 대상', - - // MCP node details - 'mcpNode.title': '## MCP 도구 노드', - 'mcpNode.description': '**설명**', - 'mcpNode.server': '**MCP 서버**', - 'mcpNode.toolName': '**도구 이름**', - 'mcpNode.validationStatus': '**검증 상태**', - 'mcpNode.configuredParameters': '**구성된 매개변수**', - 'mcpNode.availableParameters': '**사용 가능한 매개변수**', - 'mcpNode.required': '필수', - 'mcpNode.optional': '선택 사항', - 'mcpNode.noDescription': '설명 없음', - 'mcpNode.executionMethod': - '이 노드는 MCP(Model Context Protocol) 도구를 호출합니다. 워크플로를 실행할 때 구성된 매개변수를 사용하여 MCP 서버를 통해 도구를 호출하세요.', - // Error messages 'error.noWorkspaceOpen': '폴더 또는 워크스페이스를 먼저 열어주세요.', diff --git a/src/extension/i18n/translations/zh-CN.ts b/src/extension/i18n/translations/zh-CN.ts index 2b862bb8..af680529 100644 --- a/src/extension/i18n/translations/zh-CN.ts +++ b/src/extension/i18n/translations/zh-CN.ts @@ -5,70 +5,6 @@ import type { TranslationKeys } from '../translation-keys'; export const zhCNTranslations: TranslationKeys = { - // Mermaid flowchart labels - 'mermaid.start': '开始', - 'mermaid.end': '结束', - 'mermaid.question': '问题', - 'mermaid.conditionalBranch': '条件分支', - - // Workflow execution guide - 'guide.title': '## 工作流执行指南', - 'guide.intro': '按照上方的Mermaid流程图执行工作流。每种节点类型的执行方法如下所述。', - 'guide.nodeTypesTitle': '### 各节点类型的执行方法', - 'guide.nodeTypes.subAgent': '- **矩形节点**:使用Task工具执行子代理', - 'guide.nodeTypes.askUserQuestion': - '- **菱形节点(AskUserQuestion:...)**:使用AskUserQuestion工具提示用户并根据其响应进行分支', - 'guide.nodeTypes.branch': - '- **菱形节点(Branch/Switch:...)**:根据先前处理的结果自动分支(参见详细信息部分)', - 'guide.nodeTypes.prompt': '- **矩形节点(Prompt节点)**:执行下面详细信息部分中描述的提示', - - // Prompt node details - 'promptNode.title': '### Prompt节点详细信息', - 'promptNode.availableVariables': '**可用变量:**', - 'promptNode.variableNotSet': '(未设置)', - - // AskUserQuestion node details - 'askNode.title': '### AskUserQuestion节点详细信息', - 'askNode.selectionMode': '**选择模式:**', - 'askNode.aiSuggestions': 'AI建议(AI根据上下文动态生成选项并呈现给用户)', - 'askNode.multiSelect': '**多选:** 已启用(用户可以选择多个选项)', - 'askNode.singleSelect': '单选(根据所选选项进行分支)', - 'askNode.options': '**选项:**', - 'askNode.noDescription': '(无描述)', - 'askNode.multiSelectExplanation': '多选已启用(所选选项列表将传递到下一个节点)', - - // Branch node details (Legacy) - 'branchNode.title': '### Branch节点详细信息', - 'branchNode.binary': '二分支', - 'branchNode.multiple': '多分支', - 'branchNode.conditions': '**分支条件:**', - 'branchNode.executionMethod': - '**执行方法**:评估先前处理的结果,并根据上述条件自动选择适当的分支。', - - // IfElse node details - 'ifElseNode.title': '### If/Else节点详细信息', - 'ifElseNode.binary': '二分支 (True/False)', - 'ifElseNode.evaluationTarget': '评估目标', - - // Switch node details - 'switchNode.title': '### Switch节点详细信息', - 'switchNode.multiple': '多分支 (2-N)', - 'switchNode.evaluationTarget': '评估目标', - - // MCP node details - 'mcpNode.title': '## MCP工具节点', - 'mcpNode.description': '**描述**', - 'mcpNode.server': '**MCP服务器**', - 'mcpNode.toolName': '**工具名称**', - 'mcpNode.validationStatus': '**验证状态**', - 'mcpNode.configuredParameters': '**已配置参数**', - 'mcpNode.availableParameters': '**可用参数**', - 'mcpNode.required': '必需', - 'mcpNode.optional': '可选', - 'mcpNode.noDescription': '无描述', - 'mcpNode.executionMethod': - '此节点调用MCP(Model Context Protocol)工具。执行此工作流时,请使用已配置的参数通过MCP服务器调用该工具。', - // Error messages 'error.noWorkspaceOpen': '请先打开文件夹或工作区。', diff --git a/src/extension/i18n/translations/zh-TW.ts b/src/extension/i18n/translations/zh-TW.ts index 48fb35f5..817cce69 100644 --- a/src/extension/i18n/translations/zh-TW.ts +++ b/src/extension/i18n/translations/zh-TW.ts @@ -5,70 +5,6 @@ import type { TranslationKeys } from '../translation-keys'; export const zhTWTranslations: TranslationKeys = { - // Mermaid flowchart labels - 'mermaid.start': '開始', - 'mermaid.end': '結束', - 'mermaid.question': '問題', - 'mermaid.conditionalBranch': '條件分支', - - // Workflow execution guide - 'guide.title': '## 工作流執行指南', - 'guide.intro': '按照上方的Mermaid流程圖執行工作流。每種節點類型的執行方法如下所述。', - 'guide.nodeTypesTitle': '### 各節點類型的執行方法', - 'guide.nodeTypes.subAgent': '- **矩形節點**:使用Task工具執行子代理', - 'guide.nodeTypes.askUserQuestion': - '- **菱形節點(AskUserQuestion:...)**:使用AskUserQuestion工具提示用戶並根據其響應進行分支', - 'guide.nodeTypes.branch': - '- **菱形節點(Branch/Switch:...)**:根據先前處理的結果自動分支(參見詳細資訊部分)', - 'guide.nodeTypes.prompt': '- **矩形節點(Prompt節點)**:執行下面詳細資訊部分中描述的提示', - - // Prompt node details - 'promptNode.title': '### Prompt節點詳細資訊', - 'promptNode.availableVariables': '**可用變數:**', - 'promptNode.variableNotSet': '(未設置)', - - // AskUserQuestion node details - 'askNode.title': '### AskUserQuestion節點詳細資訊', - 'askNode.selectionMode': '**選擇模式:**', - 'askNode.aiSuggestions': 'AI建議(AI根據上下文動態生成選項並呈現給用戶)', - 'askNode.multiSelect': '**多選:** 已啟用(用戶可以選擇多個選項)', - 'askNode.singleSelect': '單選(根據所選選項進行分支)', - 'askNode.options': '**選項:**', - 'askNode.noDescription': '(無描述)', - 'askNode.multiSelectExplanation': '多選已啟用(所選選項列表將傳遞到下一個節點)', - - // Branch node details (Legacy) - 'branchNode.title': '### Branch節點詳細資訊', - 'branchNode.binary': '二分支', - 'branchNode.multiple': '多分支', - 'branchNode.conditions': '**分支條件:**', - 'branchNode.executionMethod': - '**執行方法**:評估先前處理的結果,並根據上述條件自動選擇適當的分支。', - - // IfElse node details - 'ifElseNode.title': '### If/Else節點詳細資訊', - 'ifElseNode.binary': '二分支 (True/False)', - 'ifElseNode.evaluationTarget': '評估目標', - - // Switch node details - 'switchNode.title': '### Switch節點詳細資訊', - 'switchNode.multiple': '多分支 (2-N)', - 'switchNode.evaluationTarget': '評估目標', - - // MCP node details - 'mcpNode.title': '## MCP工具節點', - 'mcpNode.description': '**描述**', - 'mcpNode.server': '**MCP伺服器**', - 'mcpNode.toolName': '**工具名稱**', - 'mcpNode.validationStatus': '**驗證狀態**', - 'mcpNode.configuredParameters': '**已配置參數**', - 'mcpNode.availableParameters': '**可用參數**', - 'mcpNode.required': '必需', - 'mcpNode.optional': '可選', - 'mcpNode.noDescription': '無描述', - 'mcpNode.executionMethod': - '此節點調用MCP(Model Context Protocol)工具。執行此工作流時,請使用已配置的參數通過MCP伺服器調用該工具。', - // Error messages 'error.noWorkspaceOpen': '請先開啟資料夾或工作區。', diff --git a/src/extension/services/copilot-cli-mcp-sync-service.ts b/src/extension/services/copilot-cli-mcp-sync-service.ts new file mode 100644 index 00000000..816ca016 --- /dev/null +++ b/src/extension/services/copilot-cli-mcp-sync-service.ts @@ -0,0 +1,176 @@ +/** + * 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 index 9fcaca00..18abd35c 100644 --- a/src/extension/services/copilot-export-service.ts +++ b/src/extension/services/copilot-export-service.ts @@ -8,11 +8,14 @@ */ import * as path from 'node:path'; -import type { McpNodeData } from '../../shared/types/mcp-node'; import type { Workflow } from '../../shared/types/workflow-definition'; import { 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 @@ -240,14 +243,14 @@ export async function executeMcpSyncForCopilot( * @param workflow - Workflow definition * @returns Array of unique server IDs */ -function extractMcpServerIdsFromWorkflow(workflow: Workflow): string[] { +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 McpNodeData; + const mcpData = node.data as { serverId?: string }; if (mcpData.serverId?.trim()) { serverIds.add(mcpData.serverId); } @@ -387,265 +390,15 @@ function generateCopilotPromptFile(workflow: Workflow, options: CopilotExportOpt frontmatterLines.push('---', ''); const frontmatter = frontmatterLines.join('\n'); - // Generate Mermaid flowchart - const mermaidFlowchart = generateMermaidFlowchartForCopilot(workflow); + // Generate Mermaid flowchart using shared module + const mermaidFlowchart = generateMermaidFlowchart(workflow); - // Generate execution instructions - const executionInstructions = generateExecutionInstructionsForCopilot(workflow); + // Generate execution instructions using shared module + const workflowBaseName = nodeNameToFileName(workflow.name); + const executionInstructions = generateExecutionInstructions(workflow, { + parentWorkflowName: workflowBaseName, + subAgentFlows: workflow.subAgentFlows, + }); return `${frontmatter}${mermaidFlowchart}\n\n${executionInstructions}`; } - -/** - * Sanitize node ID for Mermaid (remove special characters) - * - * @param id - Node ID - * @returns Sanitized ID - */ -function sanitizeNodeId(id: string): string { - return id.replace(/[^a-zA-Z0-9_]/g, '_'); -} - -/** - * Escape special characters in Mermaid labels - * - * @param label - Label text - * @returns Escaped label - */ -function escapeLabel(label: string): string { - return label.replace(/"/g, '#quot;').replace(/\[/g, '#91;').replace(/\]/g, '#93;'); -} - -/** - * Generate Mermaid flowchart for Copilot - * - * @param workflow - Workflow definition - * @returns Mermaid flowchart markdown - */ -function generateMermaidFlowchartForCopilot(workflow: Workflow): string { - const { nodes, connections } = workflow; - 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(agentName)}]`); - } else if (nodeType === 'askUserQuestion') { - const questionText = - 'data' in node && node.data && 'questionText' in node.data - ? (node.data.questionText as string) - : 'Question'; - lines.push(` ${nodeId}{${escapeLabel(`Question: ${questionText}`)}}`); - } else if (nodeType === 'ifElse' || nodeType === 'branch') { - lines.push(` ${nodeId}{${escapeLabel('Condition')}}`); - } else if (nodeType === 'switch') { - lines.push(` ${nodeId}{${escapeLabel('Switch')}}`); - } else if (nodeType === 'prompt') { - const promptData = 'data' in node && node.data && 'prompt' in node.data ? node.data : null; - const promptText = promptData - ? String(promptData.prompt).split('\n')[0] || 'Prompt' - : 'Prompt'; - const label = promptText.length > 30 ? `${promptText.substring(0, 27)}...` : promptText; - lines.push(` ${nodeId}[${escapeLabel(label)}]`); - } else if (nodeType === 'skill') { - const skillData = 'data' in node && node.data && 'name' in node.data ? node.data : null; - const skillName = skillData ? String(skillData.name) : 'Skill'; - lines.push(` ${nodeId}[[${escapeLabel(`Skill: ${skillName}`)}]]`); - } else if (nodeType === 'mcp') { - const mcpData = 'data' in node && node.data ? (node.data as McpNodeData) : null; - let mcpLabel = 'MCP Tool'; - if (mcpData) { - if (mcpData.toolName) { - mcpLabel = `MCP: ${mcpData.toolName}`; - } else if (mcpData.aiToolSelectionConfig?.taskDescription) { - // aiToolSelection mode: show task description - 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)}"]]`); - } - } - - lines.push(''); - - // Generate connections - for (const conn of connections) { - const fromId = sanitizeNodeId(conn.from); - const toId = sanitizeNodeId(conn.to); - lines.push(` ${fromId} --> ${toId}`); - } - - lines.push('```'); - - return lines.join('\n'); -} - -/** - * Generate execution instructions for Copilot - * - * @param workflow - Workflow definition - * @returns Markdown execution instructions - */ -function generateExecutionInstructionsForCopilot(workflow: Workflow): string { - const sections: string[] = []; - - sections.push('# Workflow Execution Instructions'); - sections.push(''); - sections.push( - 'Follow the flowchart above to execute this workflow. Each node represents a step to perform.' - ); - sections.push(''); - - // Add node-specific instructions - const { nodes } = workflow; - - // Prompt nodes - const promptNodes = nodes.filter((n) => n.type === 'prompt'); - if (promptNodes.length > 0) { - sections.push('## Prompts'); - sections.push(''); - for (const node of promptNodes) { - const promptData = 'data' in node && node.data && 'prompt' in node.data ? node.data : null; - if (promptData) { - sections.push(`### ${node.name || 'Prompt'}`); - sections.push(''); - sections.push('```'); - sections.push(String(promptData.prompt || '')); - sections.push('```'); - sections.push(''); - } - } - } - - // SubAgent nodes - const subAgentNodes = nodes.filter((n) => n.type === 'subAgent'); - if (subAgentNodes.length > 0) { - sections.push('## Sub-Agents'); - sections.push(''); - for (const node of subAgentNodes) { - const agentData = - 'data' in node && node.data && 'description' in node.data ? node.data : null; - if (agentData) { - sections.push(`### ${node.name || 'Sub-Agent'}`); - sections.push(''); - sections.push(`**Description**: ${agentData.description || 'No description'}`); - sections.push(''); - if ('prompt' in agentData && agentData.prompt) { - sections.push('**Prompt**:'); - sections.push('```'); - sections.push(String(agentData.prompt)); - sections.push('```'); - sections.push(''); - } - } - } - } - - // AskUserQuestion nodes - const askNodes = nodes.filter((n) => n.type === 'askUserQuestion'); - if (askNodes.length > 0) { - sections.push('## User Questions'); - sections.push(''); - for (const node of askNodes) { - const askData = 'data' in node && node.data && 'questionText' in node.data ? node.data : null; - if (askData) { - sections.push(`### ${node.name || 'Question'}`); - sections.push(''); - sections.push(`**Question**: ${askData.questionText || 'No question text'}`); - sections.push(''); - if ('options' in askData && Array.isArray(askData.options) && askData.options.length > 0) { - sections.push('**Options**:'); - for (const opt of askData.options) { - if (opt && typeof opt === 'object' && 'label' in opt) { - sections.push(`- **${opt.label}**: ${opt.description || ''}`); - } - } - sections.push(''); - } - } - } - } - - // Skill nodes - const skillNodes = nodes.filter((n) => n.type === 'skill'); - if (skillNodes.length > 0) { - sections.push('## Skills'); - sections.push(''); - for (const node of skillNodes) { - const skillData = 'data' in node && node.data && 'name' in node.data ? node.data : null; - if (skillData) { - sections.push(`### ${skillData.name || 'Skill'}`); - sections.push(''); - sections.push(`**Description**: ${skillData.description || 'No description'}`); - sections.push(''); - if ('skillPath' in skillData && skillData.skillPath) { - sections.push(`**Path**: \`${skillData.skillPath}\``); - sections.push(''); - } - } - } - } - - // MCP nodes - const mcpNodes = nodes.filter((n) => n.type === 'mcp'); - if (mcpNodes.length > 0) { - sections.push('## MCP Tools'); - sections.push(''); - for (const node of mcpNodes) { - const mcpData = 'data' in node && node.data ? (node.data as McpNodeData) : null; - if (mcpData) { - const mode = mcpData.mode || 'manualParameterConfig'; - - if (mode === 'aiToolSelection') { - // AI Tool Selection mode: show task description - const taskDesc = mcpData.aiToolSelectionConfig?.taskDescription || 'No task description'; - sections.push(`### MCP Task: ${mcpData.serverId || 'Unknown Server'}`); - sections.push(''); - sections.push(`**Server**: ${mcpData.serverId || 'Unknown'}`); - sections.push(''); - sections.push(`**Mode**: AI Tool Selection`); - sections.push(''); - sections.push(`**Task Description**: ${taskDesc}`); - sections.push(''); - sections.push( - '> Use the MCP tools from this server to accomplish the task described above.' - ); - sections.push(''); - } else { - // Manual or AI Parameter Config mode: show tool details - sections.push(`### ${mcpData.toolName || 'MCP Tool'}`); - sections.push(''); - sections.push(`**Server**: ${mcpData.serverId || 'Unknown'}`); - sections.push(''); - if (mcpData.toolDescription) { - sections.push(`**Description**: ${mcpData.toolDescription}`); - sections.push(''); - } - if (mode === 'aiParameterConfig' && mcpData.aiParameterConfig?.description) { - sections.push(`**Parameter Instructions**: ${mcpData.aiParameterConfig.description}`); - sections.push(''); - } - } - } - } - } - - return sections.join('\n'); -} diff --git a/src/extension/services/copilot-skill-export-service.ts b/src/extension/services/copilot-skill-export-service.ts new file mode 100644 index 00000000..eda36328 --- /dev/null +++ b/src/extension/services/copilot-skill-export-service.ts @@ -0,0 +1,131 @@ +/** + * 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. + * + * @beta This is a PoC feature for GitHub Copilot CLI integration + */ + +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); + + // 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/export-service.ts b/src/extension/services/export-service.ts index 04600864..6500e744 100644 --- a/src/extension/services/export-service.ts +++ b/src/extension/services/export-service.ts @@ -7,21 +7,17 @@ import * as path from 'node:path'; import type { - AskUserQuestionNode, - BranchNode, - IfElseNode, - McpNode, - PromptNode, - SkillNode, SubAgentFlow, SubAgentFlowNode, SubAgentNode, - SwitchNode, Workflow, - WorkflowNode, } from '../../shared/types/workflow-definition'; -import { translate } from '../i18n/i18n-service'; import type { FileService } from './file-service'; +import { + generateExecutionInstructions, + generateMermaidFlowchart, + sanitizeNodeId, +} from './workflow-prompt-generator'; /** * Check if any export files already exist @@ -292,14 +288,13 @@ function generateSubAgentFlowAgentFile( frontmatter.push('---'); frontmatter.push(''); - // Generate Mermaid flowchart (same as SlashCommand) + // Generate Mermaid flowchart using shared module const mermaidFlowchart = generateMermaidFlowchart({ nodes: subAgentFlow.nodes, connections: subAgentFlow.connections, }); - // Create a pseudo-Workflow object to reuse generateWorkflowExecutionLogic - // This ensures SubAgentFlow export format matches SlashCommand format exactly + // Create a pseudo-Workflow object to reuse generateExecutionInstructions const pseudoWorkflow: Workflow = { name: subAgentFlow.name, description: subAgentFlow.description, @@ -307,182 +302,12 @@ function generateSubAgentFlowAgentFile( connections: subAgentFlow.connections, }; - // Generate execution logic (same format as SlashCommand) - const executionLogic = generateWorkflowExecutionLogic(pseudoWorkflow); + // Generate execution logic using shared module + const executionLogic = generateExecutionInstructions(pseudoWorkflow); return `${frontmatter.join('\n')}${mermaidFlowchart}\n\n${executionLogic}`; } -/** - * Common interface for Mermaid generation - * Used by both Workflow and SubWorkflow - */ -interface MermaidSource { - nodes: WorkflowNode[]; - connections: { from: string; to: string; fromPort?: string }[]; -} - -/** - * Generate Mermaid flowchart from workflow or subworkflow - * - * @param source - Workflow or SubWorkflow definition - * @returns Mermaid flowchart markdown - */ -function generateMermaidFlowchart(source: MermaidSource): string { - const { nodes, connections } = source; - const lines: string[] = []; - - // Start Mermaid code block - 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}([${translate('mermaid.start')}])`); - } else if (nodeType === 'end') { - lines.push(` ${nodeId}([${translate('mermaid.end')}])`); - } else if (nodeType === 'subAgent') { - const agentName = node.name || 'Sub-Agent'; - lines.push(` ${nodeId}[${escapeLabel(agentName)}]`); - } else if (nodeType === 'askUserQuestion') { - const askNode = node as AskUserQuestionNode; - const questionText = askNode.data.questionText || translate('mermaid.question'); - lines.push(` ${nodeId}{${escapeLabel(`AskUserQuestion:
${questionText}`)}}`); - } else if (nodeType === 'branch') { - const branchNode = node as BranchNode; - const branchType = branchNode.data.branchType === 'conditional' ? 'Branch' : 'Switch'; - lines.push( - ` ${nodeId}{${escapeLabel(`${branchType}:
${translate('mermaid.conditionalBranch')}`)}}` - ); - } else if (nodeType === 'ifElse') { - lines.push( - ` ${nodeId}{${escapeLabel(`If/Else:
${translate('mermaid.conditionalBranch')}`)}}` - ); - } else if (nodeType === 'switch') { - lines.push( - ` ${nodeId}{${escapeLabel(`Switch:
${translate('mermaid.conditionalBranch')}`)}}` - ); - } else if (nodeType === 'prompt') { - const promptNode = node as PromptNode; - // Use first line of prompt or default label - 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 toolName = mcpNode.data.toolName || 'MCP Tool'; - lines.push(` ${nodeId}[[${escapeLabel(`MCP: ${toolName}`)}]]`); - } else if (nodeType === 'subAgentFlow') { - // Feature: 089-subworkflow - SubAgentFlow node uses subroutine shape - const label = node.name || 'Sub-Agent Flow'; - lines.push(` ${nodeId}[["${escapeLabel(label)}"]]`); - } - } - - // Add empty line between nodes and connections - lines.push(''); - - // Generate connections - for (const conn of connections) { - const fromId = sanitizeNodeId(conn.from); - const toId = sanitizeNodeId(conn.to); - - // Find source node to determine if it's an AskUserQuestion or Branch with labeled branches - const sourceNode = nodes.find((n) => n.id === conn.from); - - if (sourceNode?.type === 'askUserQuestion' && conn.fromPort) { - const askNode = sourceNode as AskUserQuestionNode; - - // AI suggestions or multi-select: single output without labels - if (askNode.data.useAiSuggestions || askNode.data.multiSelect) { - lines.push(` ${fromId} --> ${toId}`); - } else { - // Single select with user-defined options: labeled branches by option - const branchIndex = Number.parseInt(conn.fromPort.replace('branch-', ''), 10); - const option = askNode.data.options[branchIndex]; - - if (option) { - const label = escapeLabel(option.label); - lines.push(` ${fromId} -->|${label}| ${toId}`); - } else { - lines.push(` ${fromId} --> ${toId}`); - } - } - } else if (sourceNode?.type === 'branch' && conn.fromPort) { - // Extract branch index from fromPort (e.g., "branch-0" -> 0) - const branchIndex = Number.parseInt(conn.fromPort.replace('branch-', ''), 10); - const branchNode = sourceNode as BranchNode; - const branch = branchNode.data.branches[branchIndex]; - - if (branch) { - const label = escapeLabel(branch.label); - lines.push(` ${fromId} -->|${label}| ${toId}`); - } else { - lines.push(` ${fromId} --> ${toId}`); - } - } else if (sourceNode?.type === 'ifElse' && conn.fromPort) { - // Extract branch index from fromPort (e.g., "branch-0" -> 0) - const branchIndex = Number.parseInt(conn.fromPort.replace('branch-', ''), 10); - const ifElseNode = sourceNode as IfElseNode; - const branch = ifElseNode.data.branches[branchIndex]; - - if (branch) { - const label = escapeLabel(branch.label); - lines.push(` ${fromId} -->|${label}| ${toId}`); - } else { - lines.push(` ${fromId} --> ${toId}`); - } - } else if (sourceNode?.type === 'switch' && conn.fromPort) { - // Extract branch index from fromPort (e.g., "branch-0" -> 0) - const branchIndex = Number.parseInt(conn.fromPort.replace('branch-', ''), 10); - const switchNode = sourceNode as SwitchNode; - const branch = switchNode.data.branches[branchIndex]; - - if (branch) { - const label = escapeLabel(branch.label); - lines.push(` ${fromId} -->|${label}| ${toId}`); - } else { - lines.push(` ${fromId} --> ${toId}`); - } - } else { - lines.push(` ${fromId} --> ${toId}`); - } - } - - // End Mermaid code block - lines.push('```'); - - return lines.join('\n'); -} - -/** - * Sanitize node ID for Mermaid (remove special characters) - * - * @param id - Node ID - * @returns Sanitized ID - */ -function sanitizeNodeId(id: string): string { - return id.replace(/[^a-zA-Z0-9_]/g, '_'); -} - -/** - * Escape special characters in Mermaid labels - * - * @param label - Label text - * @returns Escaped label - */ -function escapeLabel(label: string): string { - return label.replace(/"/g, '#quot;').replace(/\[/g, '#91;').replace(/\]/g, '#93;'); -} - /** * Format YAML string values * @@ -575,526 +400,18 @@ function generateSlashCommandFile(workflow: Workflow): string { frontmatterLines.push('---', ''); const frontmatter = frontmatterLines.join('\n'); - // Mermaid flowchart + // Mermaid flowchart using shared module const mermaidFlowchart = generateMermaidFlowchart(workflow); - // Workflow execution logic - const executionLogic = generateWorkflowExecutionLogic(workflow); - - return `${frontmatter}${mermaidFlowchart}\n\n${executionLogic}`; -} - -/** - * Format MCP node in Manual Parameter Config Mode - * - * User explicitly configures server, tool, and all parameters. - * Export shows explicit parameter values for reproduction. - * - * @param node - MCP node - * @returns Markdown sections for this node - */ -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(`${translate('mcpNode.description')}: ${node.data.toolDescription || ''}`); - sections.push(''); - sections.push(`${translate('mcpNode.server')}: ${node.data.serverId}`); - sections.push(''); - sections.push(`${translate('mcpNode.toolName')}: ${node.data.toolName || ''}`); - sections.push(''); - sections.push(`${translate('mcpNode.validationStatus')}: ${node.data.validationStatus}`); - sections.push(''); - - // Show configured parameters - const parameterValues = node.data.parameterValues || {}; - if (Object.keys(parameterValues).length > 0) { - sections.push(`${translate('mcpNode.configuredParameters')}:`); - 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(''); - } - - // Show parameter schema - const parameters = node.data.parameters || []; - if (parameters.length > 0) { - sections.push(`${translate('mcpNode.availableParameters')}:`); - sections.push(''); - for (const param of parameters) { - const requiredLabel = param.required - ? ` (${translate('mcpNode.required')})` - : ` (${translate('mcpNode.optional')})`; - const description = param.description || translate('mcpNode.noDescription'); - sections.push(`- \`${param.name}\` (${param.type})${requiredLabel}: ${description}`); - } - sections.push(''); - } - - sections.push(translate('mcpNode.executionMethod')); - sections.push(''); - - return sections; -} - -/** - * Format MCP node in AI Parameter Config Mode - * - * User selects server and tool, describes parameters in natural language. - * Export includes tool name, parameter schema, and natural language description - * for Claude Code to interpret and set appropriate parameter values. - * - * @param node - MCP node - * @returns Markdown sections for this node - */ -function formatAiParameterConfigMode(node: McpNode): string[] { - const sections: string[] = []; - const nodeId = sanitizeNodeId(node.id); - - sections.push(`#### ${nodeId}(${node.data.toolName || 'MCP Tool'}) - AI Parameter Config Mode`); - sections.push(''); - - // Add metadata HTML comment for Claude Code (T056) - 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(`${translate('mcpNode.description')}: ${node.data.toolDescription || ''}`); - sections.push(''); - sections.push(`${translate('mcpNode.server')}: ${node.data.serverId}`); - sections.push(''); - sections.push(`${translate('mcpNode.toolName')}: ${node.data.toolName || ''}`); - sections.push(''); - sections.push(`${translate('mcpNode.validationStatus')}: ${node.data.validationStatus}`); - sections.push(''); - - // Show natural language parameter description - 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(''); - } - - // Show parameter schema for Claude Code interpretation - 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 - ? ` (${translate('mcpNode.required')})` - : ` (${translate('mcpNode.optional')})`; - const description = param.description || translate('mcpNode.noDescription'); - sections.push(`- \`${param.name}\` (${param.type})${requiredLabel}: ${description}`); - - // Show validation constraints if any - 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(''); - } - - // Instructions for Claude Code - sections.push('**Execution Method**:'); - sections.push(''); - sections.push( - 'Claude Code 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 - * - * User selects server only, describes entire task in natural language. - * Export includes server ID, task description, and available tools list - * for Claude Code to select the most appropriate tool and configure parameters. - * - * @param node - MCP node - * @returns Markdown sections for this node - */ -function formatAiToolSelectionMode(node: McpNode): string[] { - const sections: string[] = []; - const nodeId = sanitizeNodeId(node.id); - - sections.push(`#### ${nodeId}(MCP Auto-Selection) - AI Tool Selection Mode`); - sections.push(''); - - // Add metadata HTML comment for Claude Code (T057) - const availableToolsRaw = node.data.aiToolSelectionConfig?.availableTools || []; - const availableTools = availableToolsRaw.map((tool: unknown) => { - // Handle both string format (legacy) and object format (current) - if (typeof tool === 'object' && tool !== null && 'name' in tool) { - const toolObj = tool as { name: string; description?: string }; - return { - name: toolObj.name, - description: toolObj.description || '', - }; - } - return { - name: String(tool), - description: '', - }; + // Workflow execution logic using shared module + const workflowBaseName = nodeNameToFileName(workflow.name); + const executionLogic = generateExecutionInstructions(workflow, { + parentWorkflowName: workflowBaseName, + subAgentFlows: workflow.subAgentFlows, }); - const metadata = { - mode: 'aiToolSelection', - serverId: node.data.serverId, - userIntent: node.data.aiToolSelectionConfig?.taskDescription || '', - availableTools, - }; - sections.push(``); - sections.push(''); - - sections.push(`${translate('mcpNode.server')}: ${node.data.serverId}`); - sections.push(''); - sections.push(`${translate('mcpNode.validationStatus')}: ${node.data.validationStatus}`); - sections.push(''); - - // Show natural language task description - 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(''); - } - - // Show available tools for Claude Code to choose from - if (availableTools.length > 0) { - sections.push( - `**Available Tools** (${availableTools.length} tools from ${node.data.serverId}):` - ); - sections.push(''); - for (const tool of availableTools) { - sections.push(`- **${tool.name}**: ${tool.description || 'No description'}`); - } - sections.push(''); - } else { - sections.push('**Available Tools**: (snapshot not available, query server at runtime)'); - sections.push(''); - } - - // Instructions for Claude Code - sections.push('**Execution Method**:'); - sections.push(''); - sections.push( - 'Claude Code should analyze the task description above and select the most appropriate tool from the available tools list. Then, determine the appropriate parameter values for the selected tool based on the task requirements. If the available tools list is empty, query the MCP server at runtime to get the current list of tools.' - ); - sections.push(''); - - return sections; + return `${frontmatter}${mermaidFlowchart}\n\n${executionLogic}`; } -/** - * Generate workflow execution logic - * - * @param workflow - Workflow definition - * @returns Markdown text with execution instructions - */ -function generateWorkflowExecutionLogic(workflow: Workflow): string { - const { nodes } = workflow; - const sections: string[] = []; - - // Introduction - sections.push(translate('guide.title')); - sections.push(''); - sections.push(translate('guide.intro')); - sections.push(''); - - // Node type explanations - sections.push(translate('guide.nodeTypesTitle')); - sections.push(''); - sections.push(translate('guide.nodeTypes.subAgent')); - sections.push(translate('guide.nodeTypes.askUserQuestion')); - sections.push(translate('guide.nodeTypes.branch')); - sections.push(translate('guide.nodeTypes.prompt')); - sections.push(''); - - // Collect node details by type - const promptNodes = nodes.filter((n) => (n.type as string) === 'prompt') as PromptNode[]; - const skillNodes = nodes.filter((n) => (n.type as string) === 'skill') as SkillNode[]; - const mcpNodes = nodes.filter((n) => (n.type as string) === 'mcp') as McpNode[]; - const askUserQuestionNodes = nodes.filter( - (n) => (n.type as string) === 'askUserQuestion' - ) as AskUserQuestionNode[]; - const branchNodes = nodes.filter((n) => (n.type as string) === 'branch') as BranchNode[]; - const ifElseNodes = nodes.filter((n) => (n.type as string) === 'ifElse') as IfElseNode[]; - const switchNodes = nodes.filter((n) => (n.type as string) === 'switch') as SwitchNode[]; - const subAgentFlowNodes = nodes.filter( - (n) => (n.type as string) === 'subAgentFlow' - ) as SubAgentFlowNode[]; - - // Skill node details - if (skillNodes.length > 0) { - sections.push('## Skill Nodes'); - sections.push(''); - for (const node of skillNodes) { - const nodeId = sanitizeNodeId(node.id); - sections.push(`#### ${nodeId}(${node.data.name})`); - sections.push(''); - sections.push(`**Description**: ${node.data.description}`); - sections.push(''); - sections.push(`**Scope**: ${node.data.scope}`); - sections.push(''); - sections.push(`**Validation Status**: ${node.data.validationStatus}`); - sections.push(''); - if (node.data.allowedTools) { - sections.push(`**Allowed Tools**: ${node.data.allowedTools}`); - sections.push(''); - } - sections.push(`**Skill Path**: \`${node.data.skillPath}\``); - sections.push(''); - sections.push( - 'This node executes a Claude Code Skill. The Skill definition is stored in the SKILL.md file at the path shown above.' - ); - sections.push(''); - } - } - - // MCP node details - if (mcpNodes.length > 0) { - sections.push(translate('mcpNode.title')); - sections.push(''); - for (const node of mcpNodes) { - // Detect mode and use appropriate formatter (T055) - const mode = node.data.mode || 'manualParameterConfig'; - let nodeSections: string[] = []; - - switch (mode) { - case 'manualParameterConfig': - nodeSections = formatManualParameterConfigMode(node); - break; - case 'aiParameterConfig': - nodeSections = formatAiParameterConfigMode(node); - break; - case 'aiToolSelection': - nodeSections = formatAiToolSelectionMode(node); - break; - default: - // Fallback to manual mode for unknown modes - nodeSections = formatManualParameterConfigMode(node); - } - - sections.push(...nodeSections); - } - } - - // SubAgentFlow node details (Feature: 089-subworkflow) - // Keep it simple like SubAgent - just reference the agent name - const parentWorkflowBaseName = nodeNameToFileName(workflow.name); - if (subAgentFlowNodes.length > 0) { - sections.push('## Sub-Agent Flow Nodes'); - sections.push(''); - for (const node of subAgentFlowNodes) { - const nodeId = sanitizeNodeId(node.id); - const label = node.data.label || node.name || 'Sub-Agent Flow'; - - // Find linked sub-agent flow - const linkedSubAgentFlow = workflow.subAgentFlows?.find( - (sf) => sf.id === node.data.subAgentFlowId - ); - - if (linkedSubAgentFlow) { - const subAgentFlowFileName = nodeNameToFileName(linkedSubAgentFlow.name); - const agentFileName = `${parentWorkflowBaseName}_${subAgentFlowFileName}`; - - sections.push(`#### ${nodeId}(${label})`); - sections.push(''); - sections.push(`@Sub-Agent: ${agentFileName}`); - sections.push(''); - } - } - } - - // Prompt node details - if (promptNodes.length > 0) { - sections.push(translate('promptNode.title')); - 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(''); - - // Show variables if any - if (node.data.variables && Object.keys(node.data.variables).length > 0) { - sections.push(translate('promptNode.availableVariables')); - for (const [key, value] of Object.entries(node.data.variables)) { - sections.push(`- \`{{${key}}}\`: ${value || translate('promptNode.variableNotSet')}`); - } - sections.push(''); - } - } - } - - // AskUserQuestion node details - if (askUserQuestionNodes.length > 0) { - sections.push(translate('askNode.title')); - sections.push(''); - for (const node of askUserQuestionNodes) { - const nodeId = sanitizeNodeId(node.id); - sections.push(`#### ${nodeId}(${node.data.questionText})`); - sections.push(''); - - // Show selection mode - if (node.data.useAiSuggestions) { - sections.push( - `${translate('askNode.selectionMode')} ${translate('askNode.aiSuggestions')}` - ); - sections.push(''); - if (node.data.multiSelect) { - sections.push(translate('askNode.multiSelect')); - sections.push(''); - } - } else if (node.data.multiSelect) { - sections.push( - `${translate('askNode.selectionMode')} ${translate('askNode.multiSelectExplanation')}` - ); - sections.push(''); - sections.push(translate('askNode.options')); - for (const option of node.data.options) { - sections.push( - `- **${option.label}**: ${option.description || translate('askNode.noDescription')}` - ); - } - sections.push(''); - } else { - sections.push(`${translate('askNode.selectionMode')} ${translate('askNode.singleSelect')}`); - sections.push(''); - sections.push(translate('askNode.options')); - for (const option of node.data.options) { - sections.push( - `- **${option.label}**: ${option.description || translate('askNode.noDescription')}` - ); - } - sections.push(''); - } - } - } - - // Branch node details (Legacy) - if (branchNodes.length > 0) { - sections.push(translate('branchNode.title')); - sections.push(''); - for (const node of branchNodes) { - const nodeId = sanitizeNodeId(node.id); - const branchTypeName = - node.data.branchType === 'conditional' - ? translate('branchNode.binary') - : translate('branchNode.multiple'); - sections.push(`#### ${nodeId}(${branchTypeName})`); - sections.push(''); - sections.push(translate('branchNode.conditions')); - for (const branch of node.data.branches) { - sections.push(`- **${branch.label}**: ${branch.condition}`); - } - sections.push(''); - sections.push(translate('branchNode.executionMethod')); - sections.push(''); - } - } - - // IfElse node details - if (ifElseNodes.length > 0) { - sections.push(translate('ifElseNode.title')); - sections.push(''); - for (const node of ifElseNodes) { - const nodeId = sanitizeNodeId(node.id); - sections.push(`#### ${nodeId}(${translate('ifElseNode.binary')})`); - sections.push(''); - if (node.data.evaluationTarget) { - sections.push( - `**${translate('ifElseNode.evaluationTarget')}**: ${node.data.evaluationTarget}` - ); - sections.push(''); - } - sections.push(translate('branchNode.conditions')); - for (const branch of node.data.branches) { - sections.push(`- **${branch.label}**: ${branch.condition}`); - } - sections.push(''); - sections.push(translate('branchNode.executionMethod')); - sections.push(''); - } - } - - // Switch node details - if (switchNodes.length > 0) { - sections.push(translate('switchNode.title')); - sections.push(''); - for (const node of switchNodes) { - const nodeId = sanitizeNodeId(node.id); - sections.push(`#### ${nodeId}(${translate('switchNode.multiple')})`); - sections.push(''); - if (node.data.evaluationTarget) { - sections.push( - `**${translate('switchNode.evaluationTarget')}**: ${node.data.evaluationTarget}` - ); - sections.push(''); - } - sections.push(translate('branchNode.conditions')); - for (const branch of node.data.branches) { - sections.push(`- **${branch.label}**: ${branch.condition}`); - } - sections.push(''); - sections.push(translate('branchNode.executionMethod')); - sections.push(''); - } - } - - return sections.join('\n'); -} +// Re-export sanitizeNodeId for use by other modules that may need it +export { sanitizeNodeId }; diff --git a/src/extension/services/terminal-execution-service.ts b/src/extension/services/terminal-execution-service.ts index db787c1d..3d7ac60a 100644 --- a/src/extension/services/terminal-execution-service.ts +++ b/src/extension/services/terminal-execution-service.ts @@ -59,3 +59,45 @@ export function executeSlashCommandInTerminal( 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, + }; +} diff --git a/src/extension/services/workflow-prompt-generator.ts b/src/extension/services/workflow-prompt-generator.ts new file mode 100644 index 00000000..5d7dc68d --- /dev/null +++ b/src/extension/services/workflow-prompt-generator.ts @@ -0,0 +1,653 @@ +/** + * 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, + IfElseNode, + McpNode, + PromptNode, + SkillNode, + 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, '#quot;').replace(/\[/g, '#91;').replace(/\]/g, '#93;'); +} + +/** + * 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(agentName)}]`); + } else if (nodeType === 'askUserQuestion') { + const askNode = node as AskUserQuestionNode; + const questionText = askNode.data.questionText || 'Question'; + lines.push(` ${nodeId}{${escapeLabel(`AskUserQuestion:
${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}{${escapeLabel('If/Else:
Conditional Branch')}}`); + } else if (nodeType === 'switch') { + lines.push(` ${nodeId}{${escapeLabel('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)}"]]`); + } + } + + 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): 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(''); + sections.push( + 'Claude Code 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): string[] { + const sections: string[] = []; + const nodeId = sanitizeNodeId(node.id); + + sections.push(`#### ${nodeId}(MCP Auto-Selection) - AI Tool Selection Mode`); + sections.push(''); + + const availableToolsRaw = node.data.aiToolSelectionConfig?.availableTools || []; + const availableTools = availableToolsRaw.map((tool: unknown) => { + if (typeof tool === 'object' && tool !== null && 'name' in tool) { + const toolObj = tool as { name: string; description?: string }; + return { name: toolObj.name, description: toolObj.description || '' }; + } + return { name: String(tool), description: '' }; + }); + + const metadata = { + mode: 'aiToolSelection', + serverId: node.data.serverId, + userIntent: node.data.aiToolSelectionConfig?.taskDescription || '', + availableTools, + }; + 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(''); + } + + if (availableTools.length > 0) { + sections.push( + `**Available Tools** (${availableTools.length} tools from ${node.data.serverId}):` + ); + sections.push(''); + for (const tool of availableTools) { + sections.push(`- **${tool.name}**: ${tool.description || 'No description'}`); + } + sections.push(''); + } else { + sections.push('**Available Tools**: (snapshot not available, query server at runtime)'); + sections.push(''); + } + + sections.push('**Execution Method**:'); + sections.push(''); + sections.push( + 'Claude Code should analyze the task description above and select the most appropriate tool from the available tools list. Then, determine the appropriate parameter values for the selected tool based on the task requirements. If the available tools list is empty, query the MCP server at runtime to get the current list of tools.' + ); + sections.push(''); + + return sections; +} + +/** + * 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']; +} + +/** + * Generate workflow execution instructions + */ +export function generateExecutionInstructions( + workflow: Workflow, + options: ExecutionInstructionsOptions = {} +): string { + const { nodes } = workflow; + 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('- **Rectangle nodes**: Execute Sub-Agents using the Task tool'); + sections.push( + '- **Diamond nodes (AskUserQuestion:...)**: Use the AskUserQuestion tool to prompt the user and branch based on their response' + ); + 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 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 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'); + + // Skill node details + if (skillNodes.length > 0) { + sections.push('## Skill Nodes'); + sections.push(''); + for (const node of skillNodes) { + const nodeId = sanitizeNodeId(node.id); + sections.push(`#### ${nodeId}(${node.data.name})`); + sections.push(''); + sections.push(`**Description**: ${node.data.description}`); + sections.push(''); + sections.push(`**Scope**: ${node.data.scope}`); + sections.push(''); + sections.push(`**Validation Status**: ${node.data.validationStatus}`); + sections.push(''); + if (node.data.allowedTools) { + sections.push(`**Allowed Tools**: ${node.data.allowedTools}`); + sections.push(''); + } + sections.push(`**Skill Path**: \`${node.data.skillPath}\``); + sections.push(''); + sections.push( + 'This node executes a Claude Code Skill. The Skill definition is stored in the SKILL.md file at the path shown above.' + ); + 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); + break; + case 'aiToolSelection': + nodeSections = formatAiToolSelectionMode(node); + break; + default: + nodeSections = formatManualParameterConfigMode(node); + } + + sections.push(...nodeSections); + } + } + + // 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/shared/types/messages.ts b/src/shared/types/messages.ts index 6bbd745a..063abc6b 100644 --- a/src/shared/types/messages.ts +++ b/src/shared/types/messages.ts @@ -701,7 +701,14 @@ export type ExtensionMessage = | Message | Message | Message - | Message; + | Message + | Message + | Message + | Message + | Message + | Message + | Message + | Message; // ============================================================================ // AI Slack Description Generation Payloads @@ -1097,6 +1104,13 @@ export interface ShareWorkflowFailedPayload { // Copilot Integration Payloads (Beta) // ============================================================================ +/** + * Copilot execution mode selection + * - vscode: Opens VSCode Copilot Chat panel and sends command + * - cli: Uses Claude Code terminal with copilot-cli-slash-command skill + */ +export type CopilotExecutionMode = 'vscode' | 'cli'; + /** * Export workflow for Copilot payload */ @@ -1147,6 +1161,47 @@ export interface CopilotOperationFailedPayload { 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 +} + // ============================================================================ // Edit in VSCode Editor Payloads // ============================================================================ @@ -1251,7 +1306,9 @@ export type WebviewMessage = | Message | Message | Message - | Message; + | Message + | Message + | Message; // ============================================================================ // Error Codes diff --git a/src/webview/package-lock.json b/src/webview/package-lock.json index 21f5a7bc..7d831685 100644 --- a/src/webview/package-lock.json +++ b/src/webview/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-wf-studio-webview", - "version": "3.15.1", + "version": "3.15.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-wf-studio-webview", - "version": "3.15.1", + "version": "3.15.2", "license": "AGPL-3.0-or-later", "dependencies": { "@radix-ui/react-collapsible": "^1.1.12", diff --git a/src/webview/package.json b/src/webview/package.json index 8b68d4a8..39929295 100644 --- a/src/webview/package.json +++ b/src/webview/package.json @@ -1,6 +1,6 @@ { "name": "cc-wf-studio-webview", - "version": "3.15.1", + "version": "3.15.2", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/src/webview/src/components/Toolbar.tsx b/src/webview/src/components/Toolbar.tsx index fbecad26..96d339d7 100644 --- a/src/webview/src/components/Toolbar.tsx +++ b/src/webview/src/components/Toolbar.tsx @@ -17,8 +17,10 @@ import { } from '../services/ai-generation-service'; import { exportForCopilot, + exportForCopilotCli, runAsSlashCommand, runForCopilot, + runForCopilotCli, saveWorkflow, } from '../services/vscode-bridge'; import { @@ -32,6 +34,10 @@ import { EditableNameField } from './common/EditableNameField'; import { ProcessingOverlay } from './common/ProcessingOverlay'; import { StyledTooltipProvider } from './common/StyledTooltip'; import { ConfirmDialog } from './dialogs/ConfirmDialog'; +import { + type CopilotExecutionMode, + CopilotExecutionModeDropdown, +} from './toolbar/CopilotExecutionModeDropdown'; import { MoreActionsDropdown } from './toolbar/MoreActionsDropdown'; import { SlashCommandOptionsDropdown } from './toolbar/SlashCommandOptionsDropdown'; @@ -102,6 +108,11 @@ export const Toolbar: React.FC = ({ const stored = localStorage.getItem('cc-wf-studio:copilot-beta-enabled'); return stored === 'true'; }); + // Copilot execution mode (persisted in localStorage, default: 'cli') + const [copilotExecutionMode, setCopilotExecutionMode] = useState(() => { + const stored = localStorage.getItem('cc-wf-studio.copilotExecutionMode'); + return (stored as CopilotExecutionMode) || 'cli'; + }); const generationNameRequestIdRef = useRef(null); // Workflow name validation pattern (lowercase, numbers, hyphens, underscores only) @@ -136,6 +147,12 @@ export const Toolbar: React.FC = ({ }); }, []); + // Handle Copilot execution mode change + const handleCopilotExecutionModeChange = useCallback((mode: CopilotExecutionMode) => { + setCopilotExecutionMode(mode); + localStorage.setItem('cc-wf-studio.copilotExecutionMode', mode); + }, []); + const handleSave = async () => { if (!workflowName.trim()) { onError({ @@ -433,8 +450,14 @@ export const Toolbar: React.FC = ({ validateWorkflow(workflow); - const result = await exportForCopilot(workflow); - console.log('Workflow exported for Copilot:', result.exportedFiles); + // Export based on execution mode + if (copilotExecutionMode === 'cli') { + const result = await exportForCopilotCli(workflow); + console.log('Workflow exported as skill for Copilot CLI:', result.skillPath); + } else { + const result = await exportForCopilot(workflow); + console.log('Workflow exported for Copilot:', result.exportedFiles); + } } catch (error) { onError({ code: 'EXPORT_FAILED', @@ -480,8 +503,14 @@ export const Toolbar: React.FC = ({ validateWorkflow(workflow); - const result = await runForCopilot(workflow); - console.log('Workflow run for Copilot:', result.workflowName); + // Run based on execution mode + if (copilotExecutionMode === 'cli') { + const result = await runForCopilotCli(workflow); + console.log('Workflow run for Copilot CLI:', result.workflowName); + } else { + const result = await runForCopilot(workflow); + console.log('Workflow run for Copilot:', result.workflowName); + } } catch (error) { onError({ code: 'RUN_FAILED', @@ -895,62 +924,68 @@ export const Toolbar: React.FC = ({ > Copilot -
- - +
+
+ + +
+
diff --git a/src/webview/src/components/toolbar/CopilotExecutionModeDropdown.tsx b/src/webview/src/components/toolbar/CopilotExecutionModeDropdown.tsx new file mode 100644 index 00000000..32fb5df3 --- /dev/null +++ b/src/webview/src/components/toolbar/CopilotExecutionModeDropdown.tsx @@ -0,0 +1,180 @@ +/** + * Copilot Execution Mode Dropdown Component + * + * Provides submenu for selecting execution mode: + * - Copilot CLI (default): Uses terminal with copilot command + * - VSCode Copilot: Opens Copilot Chat panel + */ + +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import type { CopilotExecutionMode } from '@shared/types/messages'; +import { Check, ChevronDown, ChevronLeft, MessageSquare, Terminal } from 'lucide-react'; +import { useTranslation } from '../../i18n/i18n-context'; +import type { WebviewTranslationKeys } from '../../i18n/translation-keys'; + +// Re-export CopilotExecutionMode for use by other components +export type { CopilotExecutionMode } from '@shared/types/messages'; + +// Fixed font sizes for dropdown menu (not responsive) +const FONT_SIZES = { + small: 11, +} as const; + +const EXECUTION_MODE_OPTIONS: { + value: CopilotExecutionMode; + labelKey: keyof WebviewTranslationKeys; + icon: React.ReactNode; +}[] = [ + { + value: 'cli', + labelKey: 'copilot.mode.cli', + icon: , + }, + { + value: 'vscode', + labelKey: 'copilot.mode.vscode', + icon: , + }, +]; + +interface CopilotExecutionModeDropdownProps { + mode: CopilotExecutionMode; + onModeChange: (mode: CopilotExecutionMode) => void; +} + +export function CopilotExecutionModeDropdown({ + mode, + onModeChange, +}: CopilotExecutionModeDropdownProps) { + const { t } = useTranslation(); + + const currentModeOption = EXECUTION_MODE_OPTIONS.find((opt) => opt.value === mode); + const currentModeLabel = t(currentModeOption?.labelKey || 'copilot.mode.cli'); + const currentModeIcon = currentModeOption?.icon || ; + + return ( + + + + + + + + {/* Run Mode Sub-menu */} + + +
+ + + {currentModeLabel} + +
+
+ {currentModeIcon} + Mode +
+
+ + + + onModeChange(value as CopilotExecutionMode)} + > + {EXECUTION_MODE_OPTIONS.map((option) => ( + event.preventDefault()} + style={{ + padding: '6px 12px', + fontSize: `${FONT_SIZES.small}px`, + color: 'var(--vscode-foreground)', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: '8px', + outline: 'none', + borderRadius: '2px', + }} + > +
+ + + +
+ {option.icon} + {t(option.labelKey)} +
+ ))} +
+
+
+
+
+
+
+ ); +} diff --git a/src/webview/src/i18n/translation-keys.ts b/src/webview/src/i18n/translation-keys.ts index a9eec03e..b4dbdcb7 100644 --- a/src/webview/src/i18n/translation-keys.ts +++ b/src/webview/src/i18n/translation-keys.ts @@ -86,6 +86,11 @@ export interface WebviewTranslationKeys { 'toolbar.moreActions': string; 'toolbar.help': string; + // Copilot Execution Mode + 'copilot.mode.tooltip': string; + 'copilot.mode.cli': string; + 'copilot.mode.vscode': string; + // Node Palette 'palette.title': string; 'palette.basicNodes': string; diff --git a/src/webview/src/i18n/translations/en.ts b/src/webview/src/i18n/translations/en.ts index 21d50239..53799031 100644 --- a/src/webview/src/i18n/translations/en.ts +++ b/src/webview/src/i18n/translations/en.ts @@ -92,6 +92,11 @@ export const enWebviewTranslations: WebviewTranslationKeys = { 'toolbar.moreActions': 'More', 'toolbar.help': 'Help', + // Copilot Execution Mode + 'copilot.mode.tooltip': 'Select Copilot execution mode', + 'copilot.mode.cli': 'Copilot CLI', + 'copilot.mode.vscode': 'VSCode Copilot', + // Node Palette 'palette.title': 'Node Palette', 'palette.basicNodes': 'Basic Nodes', diff --git a/src/webview/src/i18n/translations/ja.ts b/src/webview/src/i18n/translations/ja.ts index 21a0616d..db448828 100644 --- a/src/webview/src/i18n/translations/ja.ts +++ b/src/webview/src/i18n/translations/ja.ts @@ -92,6 +92,11 @@ export const jaWebviewTranslations: WebviewTranslationKeys = { 'toolbar.moreActions': 'その他', 'toolbar.help': 'ヘルプ', + // Copilot Execution Mode + 'copilot.mode.tooltip': 'Copilot実行モードを選択', + 'copilot.mode.cli': 'Copilot CLI', + 'copilot.mode.vscode': 'VSCode Copilot', + // Node Palette 'palette.title': 'ノードパレット', 'palette.basicNodes': '基本ノード', diff --git a/src/webview/src/i18n/translations/ko.ts b/src/webview/src/i18n/translations/ko.ts index 0fcbe99a..33fb993a 100644 --- a/src/webview/src/i18n/translations/ko.ts +++ b/src/webview/src/i18n/translations/ko.ts @@ -91,6 +91,11 @@ export const koWebviewTranslations: WebviewTranslationKeys = { 'toolbar.moreActions': '더보기', 'toolbar.help': '도움말', + // Copilot Execution Mode + 'copilot.mode.tooltip': 'Copilot 실행 모드 선택', + 'copilot.mode.cli': 'Copilot CLI', + 'copilot.mode.vscode': 'VSCode Copilot', + // Node Palette 'palette.title': '노드 팔레트', 'palette.basicNodes': '기본 노드', diff --git a/src/webview/src/i18n/translations/zh-CN.ts b/src/webview/src/i18n/translations/zh-CN.ts index 7513cfd3..c7e67b13 100644 --- a/src/webview/src/i18n/translations/zh-CN.ts +++ b/src/webview/src/i18n/translations/zh-CN.ts @@ -89,6 +89,11 @@ export const zhCNWebviewTranslations: WebviewTranslationKeys = { 'toolbar.moreActions': '更多', 'toolbar.help': '帮助', + // Copilot Execution Mode + 'copilot.mode.tooltip': '选择 Copilot 执行模式', + 'copilot.mode.cli': 'Copilot CLI', + 'copilot.mode.vscode': 'VSCode Copilot', + // Node Palette 'palette.title': '节点面板', 'palette.basicNodes': '基本节点', diff --git a/src/webview/src/i18n/translations/zh-TW.ts b/src/webview/src/i18n/translations/zh-TW.ts index 062de1bf..62c73057 100644 --- a/src/webview/src/i18n/translations/zh-TW.ts +++ b/src/webview/src/i18n/translations/zh-TW.ts @@ -89,6 +89,11 @@ export const zhTWWebviewTranslations: WebviewTranslationKeys = { 'toolbar.moreActions': '更多', 'toolbar.help': '說明', + // Copilot Execution Mode + 'copilot.mode.tooltip': '選擇 Copilot 執行模式', + 'copilot.mode.cli': 'Copilot CLI', + 'copilot.mode.vscode': 'VSCode Copilot', + // Node Palette 'palette.title': '節點面板', 'palette.basicNodes': '基本節點', diff --git a/src/webview/src/services/vscode-bridge.ts b/src/webview/src/services/vscode-bridge.ts index c4fe102b..3630ad7a 100644 --- a/src/webview/src/services/vscode-bridge.ts +++ b/src/webview/src/services/vscode-bridge.ts @@ -7,12 +7,16 @@ import type { EditorContentUpdatedPayload, + ExportForCopilotCliPayload, + ExportForCopilotCliSuccessPayload, ExportForCopilotPayload, ExportForCopilotSuccessPayload, ExportWorkflowPayload, ExtensionMessage, OpenInEditorPayload, RunAsSlashCommandPayload, + RunForCopilotCliPayload, + RunForCopilotCliSuccessPayload, RunForCopilotPayload, RunForCopilotSuccessPayload, SaveWorkflowPayload, @@ -323,6 +327,58 @@ export function exportForCopilot(workflow: Workflow): Promise { + return new Promise((resolve, reject) => { + const requestId = `req-${Date.now()}-${Math.random()}`; + + const handler = (event: MessageEvent) => { + const message: ExtensionMessage = event.data; + + if (message.requestId === requestId) { + window.removeEventListener('message', handler); + + if (message.type === 'EXPORT_FOR_COPILOT_CLI_SUCCESS') { + resolve(message.payload as ExportForCopilotCliSuccessPayload); + } else if (message.type === 'EXPORT_FOR_COPILOT_CLI_CANCELLED') { + // User cancelled - resolve with empty result + resolve({ + skillName: '', + skillPath: '', + timestamp: new Date().toISOString(), + }); + } else if (message.type === 'EXPORT_FOR_COPILOT_CLI_FAILED') { + reject(new Error(message.payload?.errorMessage || 'Failed to export for Copilot CLI')); + } + } + }; + + window.addEventListener('message', handler); + + const payload: ExportForCopilotCliPayload = { workflow }; + vscode.postMessage({ + type: 'EXPORT_FOR_COPILOT_CLI', + requestId, + payload, + }); + + // Timeout after 30 seconds + setTimeout(() => { + window.removeEventListener('message', handler); + reject(new Error('Request timed out')); + }, 30000); + }); +} + /** * Run workflow for Copilot (Beta) * @@ -366,3 +422,47 @@ export function runForCopilot(workflow: Workflow): Promise { + return new Promise((resolve, reject) => { + const requestId = `req-${Date.now()}-${Math.random()}`; + + const handler = (event: MessageEvent) => { + const message: ExtensionMessage = event.data; + + if (message.requestId === requestId) { + window.removeEventListener('message', handler); + + if (message.type === 'RUN_FOR_COPILOT_CLI_SUCCESS') { + resolve(message.payload as RunForCopilotCliSuccessPayload); + } else if (message.type === 'RUN_FOR_COPILOT_CLI_FAILED') { + reject(new Error(message.payload?.errorMessage || 'Failed to run for Copilot CLI')); + } + } + }; + + window.addEventListener('message', handler); + + const payload: RunForCopilotCliPayload = { workflow }; + vscode.postMessage({ + type: 'RUN_FOR_COPILOT_CLI', + requestId, + payload, + }); + + // Timeout after 30 seconds + setTimeout(() => { + window.removeEventListener('message', handler); + reject(new Error('Request timed out')); + }, 30000); + }); +}