diff --git a/CHANGELOG.md b/CHANGELOG.md index e53b4bf9..bba0bbad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [3.29.2](https://github.com/breaking-brake/cc-wf-studio/compare/v3.29.1...v3.29.2) (2026-03-17) + +### Bug Fixes + +* add GroupNode/CodexNode and icons to diff preview ([#658](https://github.com/breaking-brake/cc-wf-studio/issues/658)) ([75ba2bb](https://github.com/breaking-brake/cc-wf-studio/commit/75ba2bbe149e3f6cc7d1d99efef22db6a406f657)) +* support plugin skills in workflow detection and export ([#651](https://github.com/breaking-brake/cc-wf-studio/issues/651)) ([#659](https://github.com/breaking-brake/cc-wf-studio/issues/659)) ([68d7aec](https://github.com/breaking-brake/cc-wf-studio/commit/68d7aec6c12670f1f5836a9be8edaad48e968777)) + ## [3.29.1](https://github.com/breaking-brake/cc-wf-studio/compare/v3.29.0...v3.29.1) (2026-03-17) ### Improvements diff --git a/package-lock.json b/package-lock.json index d0404d43..cd676613 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-wf-studio", - "version": "3.29.1", + "version": "3.29.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-wf-studio", - "version": "3.29.1", + "version": "3.29.2", "license": "AGPL-3.0-or-later", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/package.json b/package.json index e34bf7ca..f793e14d 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, GitHub Copilot, and more AI agents", - "version": "3.29.1", + "version": "3.29.2", "publisher": "breaking-brake", "icon": "resources/icon.png", "repository": { diff --git a/src/extension/services/skill-normalization-service.ts b/src/extension/services/skill-normalization-service.ts index 7c9198cf..1d24c9c0 100644 --- a/src/extension/services/skill-normalization-service.ts +++ b/src/extension/services/skill-normalization-service.ts @@ -108,8 +108,25 @@ function getStandardSkillPatterns(targetCli: TargetCli): string[] { * @param targetCli - Target CLI for execution * @returns True if the skill is from a standard directory for this CLI */ -function isSkillFromStandardDir(skillPath: string, targetCli: TargetCli): boolean { +function isSkillFromStandardDir( + skillPath: string, + targetCli: TargetCli, + scope?: 'user' | 'project' | 'local' +): boolean { + // Plugin skills and user-scope skills are resolved by name, + // not by path - no need to copy them to .claude/skills/ + if (scope === 'local' || scope === 'user') { + return true; + } + const normalizedPath = skillPath.replace(/\\/g, '/'); + + // Plugin skills (from .claude/plugins/) are always resolved by name + if (normalizedPath.includes('.claude/plugins/')) { + return true; + } + + // Path-based check for project scope const standardPatterns = getStandardSkillPatterns(targetCli); return standardPatterns.some((pattern) => normalizedPath.includes(pattern)); @@ -305,7 +322,7 @@ export async function checkSkillsToNormalize( processedNames.add(skillName); // Skip skills from standard directories for the target CLI - if (isSkillFromStandardDir(skillPath, targetCli)) { + if (isSkillFromStandardDir(skillPath, targetCli, skillNode.data.scope)) { skippedSkills.push(skillName); continue; } @@ -513,7 +530,9 @@ export async function normalizeSkillsWithoutPrompt( */ export function hasNonStandardSkills(workflow: Workflow, targetCli: TargetCli = 'claude'): boolean { const skillNodes = extractSkillNodes(workflow); - return skillNodes.some((node) => !isSkillFromStandardDir(node.data.skillPath, targetCli)); + return skillNodes.some( + (node) => !isSkillFromStandardDir(node.data.skillPath, targetCli, node.data.scope) + ); } // ============================================================================ diff --git a/src/extension/services/skill-service.ts b/src/extension/services/skill-service.ts index 7b906cc1..022294a6 100644 --- a/src/extension/services/skill-service.ts +++ b/src/extension/services/skill-service.ts @@ -71,10 +71,12 @@ export async function scanSkills( if (metadata) { // Convert to relative path for project Skills (T020) const pathToStore = scope === 'project' ? toRelativePath(skillPath, scope) : skillPath; + // Fall back to directory name if frontmatter has no name field + const skillName = metadata.name || dirent.name; skills.push({ skillPath: pathToStore, - name: metadata.name, + name: skillName, description: metadata.description, scope, validationStatus: 'valid', @@ -280,7 +282,8 @@ export async function scanPluginSkills(): Promise { marketplace.installLocation, parsed.pluginName, skillScope, - skills + skills, + selectedInstallation.installPath ); } } catch (_err) { @@ -290,6 +293,58 @@ export async function scanPluginSkills(): Promise { return skills; } +/** + * Scan a skills directory and add found skills to the array + * + * @returns true if any skills were found + */ +async function scanSkillsDirectory( + skillsDir: string, + scope: 'user' | 'project' | 'local', + skills: SkillReference[], + pluginName?: string +): Promise { + try { + const skillDirs = await fs.readdir(skillsDir, { withFileTypes: true }); + let found = false; + + for (const skillDir of skillDirs) { + if (!skillDir.isDirectory()) continue; + + const skillPath = path.join(skillsDir, skillDir.name, 'SKILL.md'); + + try { + const content = await fs.readFile(skillPath, 'utf-8'); + const metadata = parseSkillFrontmatter(content); + + if (metadata) { + // Fall back to directory name if frontmatter has no name field + const skillName = metadata.name || skillDir.name; + if (skills.some((s) => s.name === skillName)) continue; + + skills.push({ + skillPath, + name: skillName, + description: metadata.description, + scope, + validationStatus: 'valid', + allowedTools: metadata.allowedTools, + pluginName, + }); + found = true; + } + } catch { + // Skill file not found or invalid - skip + } + } + + return found; + } catch { + // Directory doesn't exist + return false; + } +} + /** * Scan skills from a specific plugin within a marketplace */ @@ -297,7 +352,8 @@ async function scanMarketplacePlugin( marketplaceLocation: string, pluginName: string, scope: 'user' | 'project' | 'local', - skills: SkillReference[] + skills: SkillReference[], + installPath?: string ): Promise { const marketplaceJsonPath = path.join(marketplaceLocation, '.claude-plugin', 'marketplace.json'); @@ -319,16 +375,19 @@ async function scanMarketplacePlugin( const metadata = parseSkillFrontmatter(content); if (metadata) { + // Fall back to directory name if frontmatter has no name field + const skillName = metadata.name || path.basename(skillDir); // Skip if skill with same name already exists (name-based dedup) - if (skills.some((s) => s.name === metadata.name)) continue; + if (skills.some((s) => s.name === skillName)) continue; skills.push({ skillPath, - name: metadata.name, + name: skillName, description: metadata.description, scope, validationStatus: 'valid', allowedTools: metadata.allowedTools, + pluginName, }); } } catch { @@ -336,37 +395,17 @@ async function scanMarketplacePlugin( } } } else { - // Fallback: scan default 'skills/' directory + // Fallback 1: scan default 'skills/' directory in marketplace const defaultSkillsDir = path.join(marketplaceLocation, 'skills'); - try { - const skillDirs = await fs.readdir(defaultSkillsDir, { withFileTypes: true }); - for (const skillDir of skillDirs) { - if (!skillDir.isDirectory()) continue; - - const skillPath = path.join(defaultSkillsDir, skillDir.name, 'SKILL.md'); - - try { - const content = await fs.readFile(skillPath, 'utf-8'); - const metadata = parseSkillFrontmatter(content); - - if (metadata) { - if (skills.some((s) => s.name === metadata.name)) continue; - - skills.push({ - skillPath, - name: metadata.name, - description: metadata.description, - scope, - validationStatus: 'valid', - allowedTools: metadata.allowedTools, - }); - } - } catch { - // Skill file not found or invalid - skip - } + const foundSkills = await scanSkillsDirectory(defaultSkillsDir, scope, skills, pluginName); + + // Fallback 2: scan 'skills/' directory in plugin installPath + if (!foundSkills && installPath) { + const installPathSkillsDir = path.join(installPath, 'skills'); + // Avoid scanning same directory twice + if (path.normalize(installPathSkillsDir) !== path.normalize(defaultSkillsDir)) { + await scanSkillsDirectory(installPathSkillsDir, scope, skills, pluginName); } - } catch { - // No default skills directory } } } catch { diff --git a/src/extension/services/workflow-prompt-generator.ts b/src/extension/services/workflow-prompt-generator.ts index eebdde37..9e00b4f8 100644 --- a/src/extension/services/workflow-prompt-generator.ts +++ b/src/extension/services/workflow-prompt-generator.ts @@ -644,7 +644,10 @@ export function generateExecutionInstructions( for (const node of skillNodes) { const nodeId = sanitizeNodeId(node.id); const executionMode = node.data.executionMode || 'execute'; - const skillName = node.data.name; + // Plugin skills use 'pluginName:skillName' format for Claude Code resolution + const skillName = node.data.pluginName + ? `${node.data.pluginName}:${node.data.name}` + : node.data.name; sections.push(`#### ${nodeId}(${skillName})`); sections.push(''); diff --git a/src/extension/services/yaml-parser.ts b/src/extension/services/yaml-parser.ts index 87902d61..ab223077 100644 --- a/src/extension/services/yaml-parser.ts +++ b/src/extension/services/yaml-parser.ts @@ -59,13 +59,13 @@ export function parseSkillFrontmatter(content: string): SkillMetadata | null { // Extract optional field const allowedTools = yaml.match(/^allowed-tools:\s*(.+)$/m)?.[1]?.trim(); - // Validate required fields - if (!name || !description) { - return null; // Required fields missing + // Validate: at least description is required + if (!description) { + return null; // Description is required } return { - name, + name: name || '', // May be empty; callers can fall back to directory name description, allowedTools, }; diff --git a/src/shared/types/messages.ts b/src/shared/types/messages.ts index 935f9aad..bb444db8 100644 --- a/src/shared/types/messages.ts +++ b/src/shared/types/messages.ts @@ -224,6 +224,8 @@ export interface SkillReference { * - undefined: for local scope or legacy data */ source?: 'claude' | 'copilot' | 'codex' | 'roo' | 'gemini' | 'antigravity' | 'cursor'; + /** Plugin name for plugin-provided skills (e.g., 'with-me' for 'with-me:skill-name') */ + pluginName?: string; } // ============================================================================ diff --git a/src/shared/types/workflow-definition.ts b/src/shared/types/workflow-definition.ts index 59d08813..ab9065d0 100644 --- a/src/shared/types/workflow-definition.ts +++ b/src/shared/types/workflow-definition.ts @@ -232,6 +232,8 @@ export interface SkillNodeData { * Only used when executionMode is 'execute' (or undefined). */ executionPrompt?: string; + /** Plugin name for plugin-provided skills (e.g., 'with-me' for 'with-me:skill-name') */ + pluginName?: string; } /** diff --git a/src/webview/package-lock.json b/src/webview/package-lock.json index 9114cbcb..a414df7b 100644 --- a/src/webview/package-lock.json +++ b/src/webview/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-wf-studio-webview", - "version": "3.29.1", + "version": "3.29.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-wf-studio-webview", - "version": "3.29.1", + "version": "3.29.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 ea695141..b89dfb1b 100644 --- a/src/webview/package.json +++ b/src/webview/package.json @@ -1,6 +1,6 @@ { "name": "cc-wf-studio-webview", - "version": "3.29.1", + "version": "3.29.2", "private": true, "license": "AGPL-3.0-or-later", "type": "module", diff --git a/src/webview/src/components/dialogs/DiffPreviewDialog.tsx b/src/webview/src/components/dialogs/DiffPreviewDialog.tsx index 99523df6..28803ab3 100644 --- a/src/webview/src/components/dialogs/DiffPreviewDialog.tsx +++ b/src/webview/src/components/dialogs/DiffPreviewDialog.tsx @@ -2,6 +2,7 @@ import * as Dialog from '@radix-ui/react-dialog'; import type { PlannedSubAgentFile } from '@shared/types/messages'; import { FileText } from 'lucide-react'; import type React from 'react'; +import { getNodeTypeIcon } from '../../constants/node-type-icons'; import { useTranslation } from '../../i18n/i18n-context'; import type { WorkflowDiffSummary } from '../../utils/workflow-diff'; @@ -147,54 +148,133 @@ export const DiffPreviewDialog: React.FC = ({ > {t('dialog.diffPreview.nodes')}: - {diffSummary.addedNodes.map((node) => ( -
- { + const Icon = getNodeTypeIcon(node.type); + return ( +
- + {node.name} - - - ({node.type}) - -
- ))} - {diffSummary.removedNodes.map((node) => ( -
- + + + + {Icon && ( + + )} + + {node.name} + + + ({node.type}) + +
+ ); + })} + {diffSummary.removedNodes.map((node) => { + const Icon = getNodeTypeIcon(node.type); + return ( +
- - {node.name} - - - ({node.type}) - -
- ))} - {diffSummary.modifiedNodes.map((node) => ( -
- + - + + {Icon && ( + + )} + + {node.name} + + + ({node.type}) + +
+ ); + })} + {diffSummary.modifiedNodes.map((node) => { + const Icon = getNodeTypeIcon(node.type); + return ( +
- ~ {node.name} - - - ({node.type}) - -
- ))} + + ~ + + {Icon && ( + + )} + + {node.name} + + + ({node.type}) + +
+ ); + })} )} diff --git a/src/webview/src/components/dialogs/SkillBrowserDialog.tsx b/src/webview/src/components/dialogs/SkillBrowserDialog.tsx index 33c0e11c..5fed57e8 100644 --- a/src/webview/src/components/dialogs/SkillBrowserDialog.tsx +++ b/src/webview/src/components/dialogs/SkillBrowserDialog.tsx @@ -212,6 +212,7 @@ export function SkillBrowserDialog({ isOpen, onClose }: SkillBrowserDialogProps) allowedTools: selectedSkill.allowedTools, outputPorts: 1, source: selectedSkill.source, + pluginName: selectedSkill.pluginName, executionMode: pendingExecutionMode, executionPrompt: pendingExecutionMode === 'execute' ? pendingExecutionPrompt || undefined : undefined, @@ -251,16 +252,21 @@ export function SkillBrowserDialog({ isOpen, onClose }: SkillBrowserDialogProps) // Compute filtered skills for ALL tabs simultaneously const filterLower = filterText.toLowerCase().trim(); + const getSkillDisplayName = (skill: SkillReference): string => + skill.pluginName ? `${skill.pluginName}:${skill.name}` : skill.name; + const filteredUserSkills = filterLower - ? userSkills.filter((skill) => skill.name.toLowerCase().includes(filterLower)) + ? userSkills.filter((skill) => getSkillDisplayName(skill).toLowerCase().includes(filterLower)) : userSkills; const filteredProjectSkills = filterLower - ? projectSkills.filter((skill) => skill.name.toLowerCase().includes(filterLower)) + ? projectSkills.filter((skill) => + getSkillDisplayName(skill).toLowerCase().includes(filterLower) + ) : projectSkills; const filteredLocalSkills = filterLower - ? localSkills.filter((skill) => skill.name.toLowerCase().includes(filterLower)) + ? localSkills.filter((skill) => getSkillDisplayName(skill).toLowerCase().includes(filterLower)) : localSkills; // Get skills for current tab (for list rendering) @@ -665,7 +671,7 @@ export function SkillBrowserDialog({ isOpen, onClose }: SkillBrowserDialogProps) color: 'var(--vscode-foreground)', }} > - {skill.name} + {getSkillDisplayName(skill)} - {selectedSkill.name} + {getSkillDisplayName(selectedSkill)}
> = React.memo fontWeight: 500, }} > - {data.name || 'Untitled Skill'} + {data.pluginName ? `${data.pluginName}:${data.name}` : data.name || 'Untitled Skill'}
{/* Description or Execution Prompt */} @@ -161,9 +161,9 @@ export const SkillNodeComponent: React.FC> = React.memo > {data.scope} - {/* Source Badge for project and user skills */} - {(data.scope === 'project' || data.scope === 'user') && data.source && ( - + {/* Source Badge - show provider badge for skills with source, default to 'claude' for plugin skills */} + {(data.source || data.pluginName) && ( + )} {/* Execution Mode Badge (only show for 'load' mode) */} {data.executionMode === 'load' && ( diff --git a/src/webview/src/components/preview/PreviewCanvas.tsx b/src/webview/src/components/preview/PreviewCanvas.tsx index 4ef4fbf0..24a3aebc 100644 --- a/src/webview/src/components/preview/PreviewCanvas.tsx +++ b/src/webview/src/components/preview/PreviewCanvas.tsx @@ -28,7 +28,9 @@ import { StyledTooltip } from '../common/StyledTooltip'; import { DeletableEdge } from '../edges/DeletableEdge'; import { AskUserQuestionNodeComponent } from '../nodes/AskUserQuestionNode'; import { BranchNodeComponent } from '../nodes/BranchNode'; +import { CodexNodeComponent } from '../nodes/CodexNode'; import { EndNode } from '../nodes/EndNode'; +import { GroupNodeComponent } from '../nodes/GroupNode'; import { IfElseNodeComponent } from '../nodes/IfElseNode'; import { McpNodeComponent } from '../nodes/McpNode/McpNode'; import { PromptNode } from '../nodes/PromptNode'; @@ -53,6 +55,8 @@ const nodeTypes: NodeTypes = { skill: SkillNodeComponent, mcp: McpNodeComponent, subAgentFlow: SubAgentFlowNodeComponent, + codex: CodexNodeComponent, + group: GroupNodeComponent, }; /**