From f00f76456cc5709bcc21bdf522374abc09d338a6 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 15 Jan 2026 20:00:09 +0800 Subject: [PATCH] feat: claude subagents transform --- packages/cli/src/ui/AppContainer.tsx | 2 +- .../subagents/manage/ActionSelectionStep.tsx | 6 +- .../subagents/manage/AgentSelectionStep.tsx | 130 +++++- .../subagents/manage/AgentsManagerDialog.tsx | 6 +- packages/core/src/config/config.ts | 17 +- .../core/src/extension/claude-converter.ts | 409 ++++++++++++++---- .../src/extension/extensionManager.test.ts | 4 + .../core/src/extension/extensionManager.ts | 141 ++++-- .../core/src/extension/gemini-converter.ts | 2 +- packages/core/src/extension/github.ts | 17 +- packages/core/src/extension/marketplace.ts | 198 --------- packages/core/src/skills/skill-load.ts | 148 +++++++ packages/core/src/skills/skill-manager.ts | 56 +-- .../core/src/subagents/subagent-manager.ts | 210 +++++---- 14 files changed, 891 insertions(+), 455 deletions(-) create mode 100644 packages/core/src/skills/skill-load.ts diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 117e3b9ee..1bd40baa4 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -165,7 +165,7 @@ export const AppContainer = (props: AppContainerProps) => { const extensionManager = config.getExtensionManager(); - extensionManager.setRequestConsent((description) => + extensionManager.setRequestConsent(async (description) => requestConsentInteractive(description, addConfirmUpdateExtensionRequest), ); diff --git a/packages/cli/src/ui/components/subagents/manage/ActionSelectionStep.tsx b/packages/cli/src/ui/components/subagents/manage/ActionSelectionStep.tsx index 28393d08a..3cf453814 100644 --- a/packages/cli/src/ui/components/subagents/manage/ActionSelectionStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/ActionSelectionStep.tsx @@ -58,7 +58,11 @@ export const ActionSelectionStep = ({ }, ]; - const actions = selectedAgent?.isBuiltin + // Extension-level agents are also read-only (like builtin) + const isReadOnly = + selectedAgent?.isBuiltin || selectedAgent?.level === 'extension'; + + const actions = isReadOnly ? allActions.filter( (action) => action.value === 'view' || action.value === 'back', ) diff --git a/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx index a3a0c9af4..71d813fe8 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx @@ -12,10 +12,11 @@ import { type SubagentConfig } from '@qwen-code/qwen-code-core'; import { t } from '../../../../i18n/index.js'; interface NavigationState { - currentBlock: 'project' | 'user' | 'builtin'; + currentBlock: 'project' | 'user' | 'builtin' | 'extension'; projectIndex: number; userIndex: number; builtinIndex: number; + extensionIndex: number; } interface AgentSelectionStepProps { @@ -32,6 +33,7 @@ export const AgentSelectionStep = ({ projectIndex: 0, userIndex: 0, builtinIndex: 0, + extensionIndex: 0, }); // Group agents by level @@ -47,6 +49,10 @@ export const AgentSelectionStep = ({ () => availableAgents.filter((agent) => agent.level === 'builtin'), [availableAgents], ); + const extensionAgents = useMemo( + () => availableAgents.filter((agent) => agent.level === 'extension'), + [availableAgents], + ); const projectNames = useMemo( () => new Set(projectAgents.map((agent) => agent.name)), [projectAgents], @@ -60,8 +66,10 @@ export const AgentSelectionStep = ({ setNavigation((prev) => ({ ...prev, currentBlock: 'user' })); } else if (builtinAgents.length > 0) { setNavigation((prev) => ({ ...prev, currentBlock: 'builtin' })); + } else if (extensionAgents.length > 0) { + setNavigation((prev) => ({ ...prev, currentBlock: 'extension' })); } - }, [projectAgents, userAgents, builtinAgents]); + }, [projectAgents, userAgents, builtinAgents, extensionAgents]); // Custom keyboard navigation useKeypress( @@ -87,6 +95,13 @@ export const AgentSelectionStep = ({ currentBlock: 'user', userIndex: userAgents.length - 1, }; + } else if (extensionAgents.length > 0) { + // Move to last item in extension block + return { + ...prev, + currentBlock: 'extension', + extensionIndex: extensionAgents.length - 1, + }; } else { // Wrap to last item in project block return { ...prev, projectIndex: projectAgents.length - 1 }; @@ -108,11 +123,18 @@ export const AgentSelectionStep = ({ currentBlock: 'builtin', builtinIndex: builtinAgents.length - 1, }; + } else if (extensionAgents.length > 0) { + // Move to last item in extension block + return { + ...prev, + currentBlock: 'extension', + extensionIndex: extensionAgents.length - 1, + }; } else { // Wrap to last item in user block return { ...prev, userIndex: userAgents.length - 1 }; } - } else { + } else if (prev.currentBlock === 'builtin') { // builtin block if (prev.builtinIndex > 0) { return { ...prev, builtinIndex: prev.builtinIndex - 1 }; @@ -130,10 +152,46 @@ export const AgentSelectionStep = ({ currentBlock: 'project', projectIndex: projectAgents.length - 1, }; + } else if (extensionAgents.length > 0) { + // Move to last item in extension block + return { + ...prev, + currentBlock: 'extension', + extensionIndex: extensionAgents.length - 1, + }; } else { // Wrap to last item in builtin block return { ...prev, builtinIndex: builtinAgents.length - 1 }; } + } else { + // extension block + if (prev.extensionIndex > 0) { + return { ...prev, extensionIndex: prev.extensionIndex - 1 }; + } else if (userAgents.length > 0) { + // Move to last item in user block + return { + ...prev, + currentBlock: 'user', + userIndex: userAgents.length - 1, + }; + } else if (projectAgents.length > 0) { + // Move to last item in project block + return { + ...prev, + currentBlock: 'project', + projectIndex: projectAgents.length - 1, + }; + } else if (builtinAgents.length > 0) { + // Move to last item in builtin block + return { + ...prev, + currentBlock: 'builtin', + builtinIndex: builtinAgents.length - 1, + }; + } else { + // Wrap to last item in extension block + return { ...prev, extensionIndex: extensionAgents.length - 1 }; + } } }); } else if (name === 'down' || name === 'j') { @@ -147,6 +205,9 @@ export const AgentSelectionStep = ({ } else if (builtinAgents.length > 0) { // Move to first item in builtin block return { ...prev, currentBlock: 'builtin', builtinIndex: 0 }; + } else if (extensionAgents.length > 0) { + // Move to first item in extension block + return { ...prev, currentBlock: 'extension', extensionIndex: 0 }; } else { // Wrap to first item in project block return { ...prev, projectIndex: 0 }; @@ -157,6 +218,9 @@ export const AgentSelectionStep = ({ } else if (builtinAgents.length > 0) { // Move to first item in builtin block return { ...prev, currentBlock: 'builtin', builtinIndex: 0 }; + } else if (extensionAgents.length > 0) { + // Move to first item in extension block + return { ...prev, currentBlock: 'extension', extensionIndex: 0 }; } else if (projectAgents.length > 0) { // Move to first item in project block return { ...prev, currentBlock: 'project', projectIndex: 0 }; @@ -164,10 +228,13 @@ export const AgentSelectionStep = ({ // Wrap to first item in user block return { ...prev, userIndex: 0 }; } - } else { + } else if (prev.currentBlock === 'builtin') { // builtin block if (prev.builtinIndex < builtinAgents.length - 1) { return { ...prev, builtinIndex: prev.builtinIndex + 1 }; + } else if (extensionAgents.length > 0) { + // Move to first item in extension block + return { ...prev, currentBlock: 'extension', extensionIndex: 0 }; } else if (projectAgents.length > 0) { // Move to first item in project block return { ...prev, currentBlock: 'project', projectIndex: 0 }; @@ -178,6 +245,23 @@ export const AgentSelectionStep = ({ // Wrap to first item in builtin block return { ...prev, builtinIndex: 0 }; } + } else { + // extension block + if (prev.extensionIndex < extensionAgents.length - 1) { + return { ...prev, extensionIndex: prev.extensionIndex + 1 }; + } else if (projectAgents.length > 0) { + // Move to first item in project block + return { ...prev, currentBlock: 'project', projectIndex: 0 }; + } else if (userAgents.length > 0) { + // Move to first item in user block + return { ...prev, currentBlock: 'user', userIndex: 0 }; + } else if (builtinAgents.length > 0) { + // Move to first item in builtin block + return { ...prev, currentBlock: 'builtin', builtinIndex: 0 }; + } else { + // Wrap to first item in extension block + return { ...prev, extensionIndex: 0 }; + } } }); } else if (name === 'return' || name === 'space') { @@ -188,11 +272,17 @@ export const AgentSelectionStep = ({ } else if (navigation.currentBlock === 'user') { // User agents come after project agents in the availableAgents array globalIndex = projectAgents.length + navigation.userIndex; - } else { - // builtin block + } else if (navigation.currentBlock === 'builtin') { // Builtin agents come after project and user agents in the availableAgents array globalIndex = projectAgents.length + userAgents.length + navigation.builtinIndex; + } else { + // Extension agents come after project, user, and builtin agents + globalIndex = + projectAgents.length + + userAgents.length + + builtinAgents.length + + navigation.extensionIndex; } if (globalIndex >= 0 && globalIndex < availableAgents.length) { @@ -258,7 +348,8 @@ export const AgentSelectionStep = ({ const enabledAgentsCount = projectAgents.length + userAgents.filter((agent) => !projectNames.has(agent.name)).length + - builtinAgents.length; + builtinAgents.length + + extensionAgents.length; return ( @@ -305,7 +396,10 @@ export const AgentSelectionStep = ({ {/* Built-in Agents */} {builtinAgents.length > 0 && ( - + 0 ? 1 : 0} + > {t('Built-in Agents')} @@ -320,10 +414,28 @@ export const AgentSelectionStep = ({ )} + {/* Extension Agents */} + {extensionAgents.length > 0 && ( + + + {t('Extension Agents')} + + + {extensionAgents.map((agent, index) => { + const isSelected = + navigation.currentBlock === 'extension' && + navigation.extensionIndex === index; + return renderAgentItem(agent, index, isSelected); + })} + + + )} + {/* Agent count summary */} {(projectAgents.length > 0 || userAgents.length > 0 || - builtinAgents.length > 0) && ( + builtinAgents.length > 0 || + extensionAgents.length > 0) && ( {t('Using: {{count}} agents', { diff --git a/packages/cli/src/ui/components/subagents/manage/AgentsManagerDialog.tsx b/packages/cli/src/ui/components/subagents/manage/AgentsManagerDialog.tsx index f496d6bc5..f2a5f02e2 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentsManagerDialog.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentsManagerDialog.tsx @@ -95,7 +95,11 @@ export function AgentsManagerDialog({ try { const subagentManager = config.getSubagentManager(); - await subagentManager.deleteSubagent(agent.name, agent.level); + await subagentManager.deleteSubagent( + agent.name, + agent.level, + agent.extensionName, + ); // Reload agents to get updated state await loadAgents(); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 59f6dde76..ee1c56078 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -474,8 +474,6 @@ export class Config { private readonly listExtensions: boolean; private readonly overrideExtensions?: string[]; - fallbackModelHandler?: FallbackModelHandler; - private quotaErrorOccurred: boolean = false; private readonly summarizeToolOutput: | Record | undefined; @@ -663,6 +661,11 @@ export class Config { this.chatRecordingService = this.chatRecordingEnabled ? new ChatRecordingService(this) : undefined; + this.extensionManager = new ExtensionManager({ + workspaceDir: this.targetDir, + enabledExtensionOverrides: this.overrideExtensions, + isWorkspaceTrusted: this.isTrustedFolder(), + }); } /** @@ -681,6 +684,9 @@ export class Config { await this.getGitService(); } this.promptRegistry = new PromptRegistry(); + this.extensionManager.setConfig(this); + await this.extensionManager.refreshCache(); + this.subagentManager = new SubagentManager(this); this.skillManager = new SkillManager(this); await this.skillManager.startWatching(); @@ -690,13 +696,6 @@ export class Config { this.subagentManager.loadSessionSubagents(this.sessionSubagents); } - this.extensionManager = new ExtensionManager({ - workspaceDir: this.targetDir, - enabledExtensionOverrides: this.overrideExtensions, - config: this, - isWorkspaceTrusted: this.isTrustedFolder(), - }); - await this.extensionManager.refreshCache(); const activeExtensions = await this.extensionManager .getLoadedExtensions() diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index c5833286c..1cbc726c6 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -12,7 +12,17 @@ import * as path from 'node:path'; import { glob } from 'glob'; import type { ExtensionConfig } from './extensionManager.js'; import { ExtensionStorage } from './storage.js'; -import type { MCPServerConfig } from '../config/config.js'; +import type { + ExtensionInstallMetadata, + MCPServerConfig, +} from '../config/config.js'; +import { cloneFromGit, downloadFromGitHubRelease } from './github.js'; +import { createHash } from 'node:crypto'; +import { copyDirectory } from './gemini-converter.js'; +import { + parse as parseYaml, + stringify as stringifyYaml, +} from '../utils/yaml-parser.js'; export interface ClaudePluginConfig { name: string; @@ -32,6 +42,33 @@ export interface ClaudePluginConfig { lspServers?: string; } +/** + * Claude Code subagent configuration format. + * Based on https://code.claude.com/docs/en/sub-agents + */ +export interface ClaudeAgentConfig { + /** Unique identifier using lowercase letters and hyphens */ + name: string; + /** When Claude should delegate to this subagent */ + description: string; + /** Tools the subagent can use. Inherits all tools if omitted */ + tools?: string[]; + /** Tools to deny, removed from inherited or specified list */ + disallowedTools?: string[]; + /** Model to use: sonnet, opus, haiku, or inherit */ + model?: string; + /** Permission mode: default, acceptEdits, dontAsk, bypassPermissions, or plan */ + permissionMode?: string; + /** Skills to load into the subagent's context at startup */ + skills?: string[]; + /** Hooks configuration */ + hooks?: unknown; + /** System prompt content */ + systemPrompt?: string; + /** subagent color */ + color?: string; +} + export type ClaudePluginSource = | { source: 'github'; repo: string } | { source: 'url'; url: string }; @@ -50,6 +87,200 @@ export interface ClaudeMarketplaceConfig { metadata?: { description?: string; version?: string; pluginRoot?: string }; } +const CLAUDE_TOOLS_MAPPING: Record = { + AskUserQuestion: 'None', + Bash: 'Shell', + BashOutput: 'None', + Edit: 'Edit', + ExitPlanMode: 'ExitPlanMode', + Glob: 'Glob', + Grep: 'Grep', + KillShell: 'None', + NotebookEdit: 'None', + Read: ['ReadFile', 'ReadManyFiles'], + Skill: 'Skill', + Task: 'Task', + TodoWrite: 'TodoWrite', + WebFetch: 'WebFetch', + WebSearch: 'WebSearch', + Write: 'WriteFile', + LS: 'ListFiles', +}; + +const claudeBuildInToolsTransform = (tools: string[]): string[] => { + const transformedTools: string[] = []; + tools.forEach((tool) => { + if (!CLAUDE_TOOLS_MAPPING[tool]) { + transformedTools.push(tool); + } else { + if (CLAUDE_TOOLS_MAPPING[tool] === 'None') { + return; + } else if (Array.isArray(CLAUDE_TOOLS_MAPPING[tool])) { + transformedTools.push(...CLAUDE_TOOLS_MAPPING[tool]); + } else { + transformedTools.push(CLAUDE_TOOLS_MAPPING[tool]); + } + } + }); + return transformedTools; +}; + +/** + * Parses a value that can be either a comma-separated string or an array. + * Claude agent config can have tools like 'Glob, Grep, Read' or ['Glob', 'Grep', 'Read'] + * @param value The value to parse + * @returns Array of strings or undefined + */ +function parseStringOrArray(value: unknown): string[] | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (Array.isArray(value)) { + return value.map(String); + } + if (typeof value === 'string') { + // Split by comma and trim whitespace + return value + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + } + return undefined; +} + +/** + * Converts a Claude agent config to Qwen Code subagent format. + * @param claudeAgent Claude agent configuration + * @returns Converted agent config compatible with Qwen Code SubagentConfig + */ +export function convertClaudeAgentConfig( + claudeAgent: ClaudeAgentConfig, +): Record { + // Base config with required fields + const qwenAgent: Record = { + name: claudeAgent.name, + description: claudeAgent.description, + }; + + if (claudeAgent.color) { + qwenAgent['color'] = claudeAgent.color; + } + + // Convert system prompt if present + if (claudeAgent.systemPrompt) { + qwenAgent['systemPrompt'] = claudeAgent.systemPrompt; + } + + // Convert tools using claudeBuildInToolsTransform + if (claudeAgent.tools && claudeAgent.tools.length > 0) { + qwenAgent['tools'] = claudeBuildInToolsTransform(claudeAgent.tools); + } + + // Convert model to modelConfig + if (claudeAgent.model) { + // Map Claude model names to Qwen model config + // Claude uses: sonnet, opus, haiku, inherit + // We preserve the model name for now, the actual mapping will be handled at runtime + qwenAgent['modelConfig'] = { + model: claudeAgent.model === 'inherit' ? undefined : claudeAgent.model, + }; + } + + // Preserve unsupported fields as-is for potential future compatibility + // These fields are not supported by Qwen Code SubagentConfig but we keep them + if (claudeAgent.permissionMode) { + qwenAgent['permissionMode'] = claudeAgent.permissionMode; + } + if (claudeAgent.hooks) { + qwenAgent['hooks'] = claudeAgent.hooks; + } + if (claudeAgent.skills && claudeAgent.skills.length > 0) { + qwenAgent['skills'] = claudeAgent.skills; + } + if (claudeAgent.disallowedTools && claudeAgent.disallowedTools.length > 0) { + qwenAgent['disallowedTools'] = claudeAgent.disallowedTools; + } + + return qwenAgent; +} + +/** + * Converts all agent files in a directory from Claude format to Qwen format. + * Parses the YAML frontmatter, converts the configuration, and writes back. + * @param agentsDir Directory containing agent markdown files + */ +async function convertAgentFiles(agentsDir: string): Promise { + if (!fs.existsSync(agentsDir)) { + return; + } + + const files = await fs.promises.readdir(agentsDir); + + for (const file of files) { + if (!file.endsWith('.md')) continue; + + const filePath = path.join(agentsDir, file); + + try { + const content = await fs.promises.readFile(filePath, 'utf-8'); + + // Parse frontmatter + const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; + const match = content.match(frontmatterRegex); + + if (!match) { + // No frontmatter, skip this file + continue; + } + + const [, frontmatterYaml, body] = match; + const frontmatter = parseYaml(frontmatterYaml) as Record; + + // Build Claude agent config from frontmatter + // Note: Claude tools/disallowedTools/skills can be comma-separated strings like 'Glob, Grep, Read' + const claudeAgent: ClaudeAgentConfig = { + name: String(frontmatter['name'] || ''), + description: String(frontmatter['description'] || ''), + tools: parseStringOrArray(frontmatter['tools']), + disallowedTools: parseStringOrArray(frontmatter['disallowedTools']), + model: frontmatter['model'] as string | undefined, + permissionMode: frontmatter['permissionMode'] as string | undefined, + skills: parseStringOrArray(frontmatter['skills']), + hooks: frontmatter['hooks'], + color: frontmatter['color'] as string | undefined, + systemPrompt: body.trim(), + }; + + // Convert to Qwen format + const qwenAgent = convertClaudeAgentConfig(claudeAgent); + + // Build new frontmatter (excluding systemPrompt as it goes in body) + const newFrontmatter: Record = {}; + for (const [key, value] of Object.entries(qwenAgent)) { + if (key !== 'systemPrompt' && value !== undefined) { + newFrontmatter[key] = value; + } + } + + // Write converted content back + const newYaml = stringifyYaml(newFrontmatter); + const systemPrompt = (qwenAgent['systemPrompt'] as string) || body.trim(); + const newContent = `--- +${newYaml} +--- + +${systemPrompt} +`; + + await fs.promises.writeFile(filePath, newContent, 'utf-8'); + } catch (error) { + console.warn( + `[Claude Converter] Failed to convert agent file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} + /** * Converts a Claude plugin config to Qwen Code format. * @param claudeConfig Claude plugin configuration @@ -108,14 +339,10 @@ export function convertClaudeToQwenConfig( * 2. Commands, skills, and agents collected to respective folders * 3. MCP servers resolved from JSON files if needed * 4. All other files preserved - * - * @param extensionDir Path to the Claude plugin directory - * @param marketplace Marketplace information for loading marketplace.json - * @returns Object containing converted config and the temporary directory path */ export async function convertClaudePluginPackage( extensionDir: string, - marketplace: { marketplaceSource: string; pluginName: string }, + pluginName: string, ): Promise<{ config: ExtensionConfig; convertedDir: string }> { // Step 1: Load marketplace.json const marketplaceJsonPath = path.join( @@ -135,46 +362,27 @@ export async function convertClaudePluginPackage( // Find the target plugin in marketplace const marketplacePlugin = marketplaceConfig.plugins.find( - (p) => p.name === marketplace.pluginName, + (p) => p.name === pluginName, ); if (!marketplacePlugin) { - throw new Error( - `Plugin ${marketplace.pluginName} not found in marketplace.json`, - ); + throw new Error(`Plugin ${pluginName} not found in marketplace.json`); } // Step 2: Resolve plugin source directory based on source field - const source = marketplacePlugin.source; - let pluginSourceDir: string; + const pluginDir = path.join( + extensionDir, + `plugin${createHash('sha256').update(`${extensionDir}/${pluginName}`).digest('hex')}`, + ); + await fs.promises.mkdir(pluginDir, { recursive: true }); - if (typeof source === 'string') { - // Check if it's a URL (online path) - if (source.startsWith('http://') || source.startsWith('https://')) { - throw new Error( - `Online plugin sources are not supported in convertClaudePluginPackage. ` + - `Plugin ${marketplace.pluginName} has source: ${source}. ` + - `This should be downloaded and resolved before calling this function.`, - ); - } - // Relative path within marketplace directory - const marketplaceDir = marketplaceConfig.metadata?.pluginRoot - ? path.join(extensionDir, marketplaceConfig.metadata.pluginRoot) - : extensionDir; - pluginSourceDir = path.join(marketplaceDir, source); - } else if (source.source === 'github' || source.source === 'url') { - throw new Error( - `Online plugin sources (github/url) are not supported in convertClaudePluginPackage. ` + - `Plugin ${marketplace.pluginName} has source type: ${source.source}. ` + - `This should be downloaded and resolved before calling this function.`, - ); - } else { - throw new Error( - `Unsupported plugin source type for ${marketplace.pluginName}: ${JSON.stringify(source)}`, - ); - } + const pluginSource = await resolvePluginSource( + marketplacePlugin, + extensionDir, + pluginDir, + ); - if (!fs.existsSync(pluginSourceDir)) { - throw new Error(`Plugin source directory not found: ${pluginSourceDir}`); + if (!fs.existsSync(pluginSource)) { + throw new Error(`Plugin source directory not found: ${pluginSource}`); } // Step 3: Load and merge plugin.json if exists (based on strict mode) @@ -183,7 +391,7 @@ export async function convertClaudePluginPackage( if (strict) { const pluginJsonPath = path.join( - pluginSourceDir, + pluginSource, '.claude-plugin', 'plugin.json', ); @@ -201,7 +409,7 @@ export async function convertClaudePluginPackage( if (mergedConfig.mcpServers && typeof mergedConfig.mcpServers === 'string') { const mcpServersPath = path.isAbsolute(mergedConfig.mcpServers) ? mergedConfig.mcpServers - : path.join(pluginSourceDir, mergedConfig.mcpServers); + : path.join(pluginSource, mergedConfig.mcpServers); if (fs.existsSync(mcpServersPath)) { try { @@ -223,14 +431,14 @@ export async function convertClaudePluginPackage( try { // Step 6: Copy plugin files to temporary directory - await copyDirectory(pluginSourceDir, tmpDir); + await copyDirectory(pluginSource, tmpDir); // Step 7: Collect commands to commands folder if (mergedConfig.commands) { const commandsDestDir = path.join(tmpDir, 'commands'); await collectResources( mergedConfig.commands, - pluginSourceDir, + pluginSource, commandsDestDir, ); } @@ -238,22 +446,16 @@ export async function convertClaudePluginPackage( // Step 8: Collect skills to skills folder if (mergedConfig.skills) { const skillsDestDir = path.join(tmpDir, 'skills'); - await collectResources( - mergedConfig.skills, - pluginSourceDir, - skillsDestDir, - ); + await collectResources(mergedConfig.skills, pluginSource, skillsDestDir); } // Step 9: Collect agents to agents folder + const agentsDestDir = path.join(tmpDir, 'agents'); if (mergedConfig.agents) { - const agentsDestDir = path.join(tmpDir, 'agents'); - await collectResources( - mergedConfig.agents, - pluginSourceDir, - agentsDestDir, - ); + await collectResources(mergedConfig.agents, pluginSource, agentsDestDir); } + // Step 9.1: Convert collected agent files from Claude format to Qwen format + await convertAgentFiles(agentsDestDir); // Step 10: Convert to Qwen format config const qwenConfig = convertClaudeToQwenConfig(mergedConfig); @@ -281,34 +483,6 @@ export async function convertClaudePluginPackage( } } -/** - * Recursively copies a directory and its contents. - * @param source Source directory path - * @param destination Destination directory path - */ -async function copyDirectory( - source: string, - destination: string, -): Promise { - // Create destination directory if it doesn't exist - if (!fs.existsSync(destination)) { - fs.mkdirSync(destination, { recursive: true }); - } - - const entries = fs.readdirSync(source, { withFileTypes: true }); - - for (const entry of entries) { - const sourcePath = path.join(source, entry.name); - const destPath = path.join(destination, entry.name); - - if (entry.isDirectory()) { - await copyDirectory(sourcePath, destPath); - } else { - fs.copyFileSync(sourcePath, destPath); - } - } -} - /** * Collects resources (commands, skills, agents) to a destination folder. * If a resource is already in the destination folder, it will be skipped. @@ -498,3 +672,74 @@ export function isClaudePluginConfig( return true; } + +/** + * Resolve plugin source from marketplace plugin configuration. + * Returns the absolute path to the plugin source directory. + */ +async function resolvePluginSource( + pluginConfig: ClaudeMarketplacePluginConfig, + marketplaceDir: string, + pluginDir: string, +): Promise { + const source = pluginConfig.source; + + // Handle string source (relative path or URL) + if (typeof source === 'string') { + // Check if it's a URL + if (source.startsWith('http://') || source.startsWith('https://')) { + // Download from URL + const installMetadata: ExtensionInstallMetadata = { + source, + type: 'git', + }; + try { + await downloadFromGitHubRelease(installMetadata, pluginDir); + } catch { + await cloneFromGit(installMetadata, pluginDir); + } + return pluginDir; + } + + // Relative path within marketplace + const pluginRoot = marketplaceDir; + const sourcePath = path.join(pluginRoot, source); + + if (!fs.existsSync(sourcePath)) { + throw new Error(`Plugin source not found at ${sourcePath}`); + } + + // Copy to plugin directory + await fs.promises.cp(sourcePath, pluginDir, { recursive: true }); + return pluginDir; + } + + // Handle object source (github or url) + if (source.source === 'github') { + const installMetadata: ExtensionInstallMetadata = { + source: `https://github.com/${source.repo}`, + type: 'git', + }; + try { + await downloadFromGitHubRelease(installMetadata, pluginDir); + } catch { + await cloneFromGit(installMetadata, pluginDir); + } + return pluginDir; + } + + if (source.source === 'url') { + const installMetadata: ExtensionInstallMetadata = { + source: source.url, + type: 'git', + }; + try { + await downloadFromGitHubRelease(installMetadata, pluginDir); + } catch { + await cloneFromGit(installMetadata, pluginDir); + } + return pluginDir; + } + + throw new Error(`Unsupported plugin source type: ${JSON.stringify(source)}`); +} diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index 72197cda7..3d33a30f8 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -764,6 +764,8 @@ describe('extensionManager utility functions', () => { config, requestConsent, [], + [], + [], previousConfig, ); @@ -786,6 +788,8 @@ describe('extensionManager utility functions', () => { config, requestConsent, [], + [], + [], previousConfig, ); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 1fab09498..d33a4ec67 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -37,10 +37,7 @@ import { import type { LoadExtensionContext } from './variableSchema.js'; import { Override, type AllExtensionsEnablementConfig } from './override.js'; import chalk from 'chalk'; -import { - installFromMarketplace, - parseMarketplaceSource, -} from './marketplace.js'; +import { parseMarketplaceSource } from './marketplace.js'; import { isGeminiExtensionConfig, convertGeminiExtensionPackage, @@ -63,6 +60,9 @@ import { ExtensionUpdateEvent, } from '../telemetry/types.js'; import { stat } from 'node:fs/promises'; +import { loadSkillsFromDir } from '../skills/skill-load.js'; +import { convertClaudePluginPackage } from './claude-converter.js'; +import { loadSubagentFromDir } from '../subagents/subagent-manager.js'; // ============================================================================ // Types and Interfaces @@ -82,13 +82,14 @@ export interface Extension { isActive: boolean; path: string; config: ExtensionConfig; - installMetadata?: ExtensionInstallMetadata; + mcpServers?: Record; contextFiles: string[]; excludeTools?: string[]; settings?: ExtensionSetting[]; resolvedSettings?: ResolvedExtensionSetting[]; + commands?: string[]; skills?: SkillConfig[]; agents?: SubagentConfig[]; } @@ -243,7 +244,10 @@ async function loadCommandsFromDir( } } -async function convertGeminiOrClaudeExtension(extensionDir: string) { +async function convertGeminiOrClaudeExtension( + extensionDir: string, + pluginName?: string, +) { let newExtensionDir = extensionDir; const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); if (fs.existsSync(configFilePath)) { @@ -251,6 +255,10 @@ async function convertGeminiOrClaudeExtension(extensionDir: string) { } else if (isGeminiExtensionConfig(extensionDir)) { newExtensionDir = (await convertGeminiExtensionPackage(extensionDir)) .convertedDir; + } else if (pluginName) { + newExtensionDir = ( + await convertClaudePluginPackage(extensionDir, pluginName) + ).convertedDir; } // Claude plugin conversion not yet implemented return newExtensionDir; @@ -298,6 +306,10 @@ export class ExtensionManager { this.isWorkspaceTrusted = options.isWorkspaceTrusted; } + setConfig(config: Config): void { + this.config = config; + } + setRequestConsent( requestConsent: (consent: string) => Promise, ): void { @@ -622,20 +634,23 @@ export class ExtensionManager { ); } + extension.commands = await loadCommandsFromDir( + `${effectiveExtensionPath}/commands`, + extension.name, + ); + extension.contextFiles = getContextFileNames(config) .map((contextFileName) => path.join(effectiveExtensionPath, contextFileName), ) .filter((contextFilePath) => fs.existsSync(contextFilePath)); - if (this.config) { - extension.skills = await this.config - .getSkillManager() - .loadSkillsFromDir(effectiveExtensionPath, 'extension'); - extension.agents = await this.config - .getSubagentManager() - .loadSubagentFromDir(effectiveExtensionPath, 'extension'); - } + extension.skills = await loadSkillsFromDir( + `${effectiveExtensionPath}/skills`, + ); + extension.agents = await loadSubagentFromDir( + `${effectiveExtensionPath}/agents`, + ); return extension; } catch (e) { @@ -738,6 +753,7 @@ export class ExtensionManager { } let tempDir: string | undefined; + let claudePluginName: string | undefined; // Handle marketplace installation if (installMetadata.type === 'marketplace') { @@ -751,16 +767,20 @@ export class ExtensionManager { } tempDir = await ExtensionStorage.createTmpDir(); - const marketplaceResult = await installFromMarketplace({ - marketplaceUrl: marketplaceParsed.marketplaceSource, - pluginName: marketplaceParsed.pluginName, - tempDir, - requestConsent: requestConsent || this.requestConsent, - }); - - newExtensionConfig = marketplaceResult.config; - localSourcePath = marketplaceResult.sourcePath; - installMetadata = marketplaceResult.installMetadata; + try { + await downloadFromGitHubRelease( + { + source: marketplaceParsed.marketplaceSource, + type: 'git', + }, + tempDir, + ); + } catch (_error) { + await cloneFromGit(installMetadata, tempDir); + installMetadata.type = 'git'; + } + localSourcePath = tempDir; + claudePluginName = marketplaceParsed.pluginName; } else if ( installMetadata.type === 'git' || installMetadata.type === 'github-release' @@ -788,13 +808,14 @@ export class ExtensionManager { } try { - localSourcePath = await convertGeminiOrClaudeExtension(localSourcePath); - if (!newExtensionConfig) { - newExtensionConfig = this.loadExtensionConfig({ - extensionDir: localSourcePath, - workspaceDir: currentDir, - }); - } + localSourcePath = await convertGeminiOrClaudeExtension( + localSourcePath, + claudePluginName, + ); + newExtensionConfig = this.loadExtensionConfig({ + extensionDir: localSourcePath, + workspaceDir: currentDir, + }); if (isUpdate && installMetadata.autoUpdate) { const oldSettings = new Set( @@ -833,12 +854,24 @@ export class ExtensionManager { `${localSourcePath}/commands`, newExtensionConfig.name, ); + const previousCommands = previous?.commands ?? []; + + const skills = await loadSkillsFromDir(`${localSourcePath}/skills`); + const previousSkills = previous?.skills ?? []; + + const agents = await loadSubagentFromDir(`${localSourcePath}/agents`); + const previousAgents = previous?.agents ?? []; await maybeRequestConsentOrFail( newExtensionConfig, requestConsent || this.requestConsent, commands, + skills, + agents, previousExtensionConfig, + previousCommands, + previousSkills, + previousAgents, ); const extensionStorage = new ExtensionStorage(newExtensionName); @@ -873,7 +906,8 @@ export class ExtensionManager { if ( installMetadata.type === 'local' || installMetadata.type === 'git' || - installMetadata.type === 'github-release' + installMetadata.type === 'github-release' || + installMetadata.type === 'marketplace' ) { await copyExtension(localSourcePath, destinationPath); } @@ -1041,6 +1075,12 @@ export class ExtensionManager { output += `\n ${contextFile}`; }); } + if (extension.commands && extension.commands.length > 0) { + output += `\n Commands:`; + extension.commands.forEach((command) => { + output += `\n /${command}`; + }); + } if (extension.config.mcpServers) { output += `\n MCP servers:`; Object.keys(extension.config.mcpServers).forEach((key) => { @@ -1188,6 +1228,12 @@ export class ExtensionManager { ) ).filter((updateInfo) => !!updateInfo); } + + async refreshMemory(): Promise { + if (!this.config) return; + this.config.getSkillManager().refreshCache(); + this.config.getSubagentManager().refreshCache(); + } } export async function copyExtension( @@ -1234,7 +1280,9 @@ export function validateName(name: string) { */ export function extensionConsentString( extensionConfig: ExtensionConfig, - commands?: string[], + commands: string[] = [], + skills: SkillConfig[] = [], + subagents: SubagentConfig[] = [], ): string { const output: string[] = []; const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {}); @@ -1268,6 +1316,18 @@ export function extensionConsentString( `This extension will exclude the following core tools: ${extensionConfig.excludeTools}`, ); } + if (skills.length > 0) { + output.push('This extension will install the following skills:'); + for (const skill of skills) { + output.push(` * ${chalk.bold(skill.name)}: ${skill.description}`); + } + } + if (subagents.length > 0) { + output.push('This extension will install the following subagents:'); + for (const subagent of subagents) { + output.push(` * ${chalk.bold(subagent.name)}: ${subagent.description}`); + } + } return output.join('\n'); } @@ -1284,12 +1344,25 @@ export async function maybeRequestConsentOrFail( extensionConfig: ExtensionConfig, requestConsent: (consent: string) => Promise, commands: string[], + skills: SkillConfig[] = [], + subagents: SubagentConfig[] = [], previousExtensionConfig?: ExtensionConfig, + previousCommands: string[] = [], + previousSkills: SkillConfig[] = [], + previousSubagents: SubagentConfig[] = [], ) { - const extensionConsent = extensionConsentString(extensionConfig, commands); + const extensionConsent = extensionConsentString( + extensionConfig, + commands, + skills, + subagents, + ); if (previousExtensionConfig) { const previousExtensionConsent = extensionConsentString( previousExtensionConfig, + previousCommands, + previousSkills, + previousSubagents, ); if (previousExtensionConsent === extensionConsent) { return; diff --git a/packages/core/src/extension/gemini-converter.ts b/packages/core/src/extension/gemini-converter.ts index 7d3c44883..c3dee4966 100644 --- a/packages/core/src/extension/gemini-converter.ts +++ b/packages/core/src/extension/gemini-converter.ts @@ -111,7 +111,7 @@ export async function convertGeminiExtensionPackage( * @param source Source directory path * @param destination Destination directory path */ -async function copyDirectory( +export async function copyDirectory( source: string, destination: string, ): Promise { diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index 7cbf796c8..a89c565b8 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -303,7 +303,22 @@ export async function downloadFromGitHubRelease( const hasGeminiConfig = fs.existsSync( path.join(destination, lonelyDir.name, 'gemini-extension.json'), ); - if (hasQwenConfig || hasGeminiConfig) { + const hasMarketplaceConfig = fs.existsSync( + path.join( + destination, + lonelyDir.name, + '.claude-plugin/marketplace.json', + ), + ); + const hasClaudePluginConfig = fs.existsSync( + path.join(destination, lonelyDir.name, '.claude-plugin/plugin.json'), + ); + if ( + hasQwenConfig || + hasGeminiConfig || + hasMarketplaceConfig || + hasClaudePluginConfig + ) { const dirPathToExtract = path.join(destination, lonelyDir.name); const extractedDirFiles = await fs.promises.readdir(dirPathToExtract); for (const file of extractedDirFiles) { diff --git a/packages/core/src/extension/marketplace.ts b/packages/core/src/extension/marketplace.ts index ab3351546..35b472683 100644 --- a/packages/core/src/extension/marketplace.ts +++ b/packages/core/src/extension/marketplace.ts @@ -11,17 +11,7 @@ * Example: https://github.com/example/marketplace:my-plugin */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; import type { ExtensionConfig } from './extensionManager.js'; -import { - convertClaudeToQwenConfig, - mergeClaudeConfigs, - type ClaudeMarketplaceConfig, - type ClaudeMarketplacePluginConfig, - type ClaudePluginConfig, -} from './claude-converter.js'; -import { cloneFromGit, downloadFromGitHubRelease } from './github.js'; import type { ExtensionInstallMetadata } from '../config/config.js'; export interface MarketplaceInstallOptions { @@ -69,191 +59,3 @@ export function parseMarketplaceSource(source: string): { return { marketplaceSource, pluginName }; } - -/** - * Install an extension from a Claude marketplace. - * - * Process: - * 1. Download marketplace repository - * 2. Parse marketplace.json - * 3. Find the specified plugin - * 4. Download/copy plugin source - * 5. Merge configurations (if strict mode) - * 6. Convert to Qwen format - */ -export async function installFromMarketplace( - options: MarketplaceInstallOptions, -): Promise { - const { - marketplaceUrl, - pluginName, - tempDir, - requestConsent: _requestConsent, - } = options; - - // Step 1: Download marketplace repository - const marketplaceDir = path.join(tempDir, 'marketplace'); - await fs.promises.mkdir(marketplaceDir, { recursive: true }); - - console.log(`Downloading marketplace from ${marketplaceUrl}...`); - const installMetadata: ExtensionInstallMetadata = { - source: marketplaceUrl, - type: 'git', - }; - - try { - await downloadFromGitHubRelease(installMetadata, marketplaceDir); - } catch { - await cloneFromGit(installMetadata, marketplaceDir); - } - - // Step 2: Parse marketplace.json - const marketplaceConfigPath = path.join(marketplaceDir, 'marketplace.json'); - if (!fs.existsSync(marketplaceConfigPath)) { - throw new Error( - `Marketplace configuration not found at ${marketplaceConfigPath}`, - ); - } - - const marketplaceConfigContent = await fs.promises.readFile( - marketplaceConfigPath, - 'utf-8', - ); - const marketplaceConfig: ClaudeMarketplaceConfig = JSON.parse( - marketplaceConfigContent, - ); - - // Step 3: Find the plugin - const pluginConfig = marketplaceConfig.plugins.find( - (p) => p.name.toLowerCase() === pluginName.toLowerCase(), - ); - - if (!pluginConfig) { - throw new Error( - `Plugin "${pluginName}" not found in marketplace. Available plugins: ${marketplaceConfig.plugins.map((p) => p.name).join(', ')}`, - ); - } - - // Step 4: Download/copy plugin source - const pluginDir = path.join(tempDir, 'plugin'); - await fs.promises.mkdir(pluginDir, { recursive: true }); - - const pluginSource = await resolvePluginSource( - pluginConfig, - marketplaceDir, - pluginDir, - ); - - // Step 5: Merge configurations (if strict mode) - let finalPluginConfig: ClaudePluginConfig; - const strict = pluginConfig.strict ?? true; - - if (strict) { - // Read plugin.json from plugin source - const pluginJsonPath = path.join( - pluginSource, - '.claude-plugin', - 'plugin.json', - ); - if (!fs.existsSync(pluginJsonPath)) { - throw new Error( - `Strict mode requires plugin.json at ${pluginJsonPath}, but file not found`, - ); - } - - const pluginJsonContent = await fs.promises.readFile( - pluginJsonPath, - 'utf-8', - ); - const basePluginConfig: ClaudePluginConfig = JSON.parse(pluginJsonContent); - - // Merge marketplace config with plugin config - finalPluginConfig = mergeClaudeConfigs(pluginConfig, basePluginConfig); - } else { - // Use marketplace config directly - finalPluginConfig = pluginConfig; - } - - // Step 6: Convert to Qwen format - const qwenConfig = convertClaudeToQwenConfig(finalPluginConfig); - - return { - config: qwenConfig, - sourcePath: pluginSource, - installMetadata: { - source: `${marketplaceUrl}:${pluginName}`, - type: 'git', // Marketplace installs are treated as git installs - }, - }; -} - -/** - * Resolve plugin source from marketplace plugin configuration. - * Returns the absolute path to the plugin source directory. - */ -async function resolvePluginSource( - pluginConfig: ClaudeMarketplacePluginConfig, - marketplaceDir: string, - pluginDir: string, -): Promise { - const source = pluginConfig.source; - - // Handle string source (relative path or URL) - if (typeof source === 'string') { - // Check if it's a URL - if (source.startsWith('http://') || source.startsWith('https://')) { - // Download from URL - const installMetadata: ExtensionInstallMetadata = { - source, - type: 'git', - }; - try { - await downloadFromGitHubRelease(installMetadata, pluginDir); - } catch { - await cloneFromGit(installMetadata, pluginDir); - } - return pluginDir; - } - - // Relative path within marketplace - const pluginRoot = marketplaceDir; - const sourcePath = path.join(pluginRoot, source); - - if (!fs.existsSync(sourcePath)) { - throw new Error(`Plugin source not found at ${sourcePath}`); - } - - // Copy to plugin directory - await fs.promises.cp(sourcePath, pluginDir, { recursive: true }); - return pluginDir; - } - - // Handle object source (github or url) - if (source.source === 'github') { - const installMetadata: ExtensionInstallMetadata = { - source: `https://github.com/${source.repo}`, - type: 'git', - }; - try { - await downloadFromGitHubRelease(installMetadata, pluginDir); - } catch { - await cloneFromGit(installMetadata, pluginDir); - } - return pluginDir; - } - - if (source.source === 'url') { - const installMetadata: ExtensionInstallMetadata = { - source: source.url, - type: 'git', - }; - try { - await downloadFromGitHubRelease(installMetadata, pluginDir); - } catch { - await cloneFromGit(installMetadata, pluginDir); - } - return pluginDir; - } - - throw new Error(`Unsupported plugin source type: ${JSON.stringify(source)}`); -} diff --git a/packages/core/src/skills/skill-load.ts b/packages/core/src/skills/skill-load.ts new file mode 100644 index 000000000..ed88eb907 --- /dev/null +++ b/packages/core/src/skills/skill-load.ts @@ -0,0 +1,148 @@ +import type { SkillConfig, SkillValidationResult } from './types.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { parse as parseYaml } from '../utils/yaml-parser.js'; + +const SKILL_MANIFEST_FILE = 'SKILL.md'; + +export async function loadSkillsFromDir( + baseDir: string, +): Promise { + try { + const entries = await fs.readdir(baseDir, { withFileTypes: true }); + const skills: SkillConfig[] = []; + for (const entry of entries) { + // Only process directories (each skill is a directory) + if (!entry.isDirectory()) continue; + + const skillDir = path.join(baseDir, entry.name); + const skillManifest = path.join(skillDir, SKILL_MANIFEST_FILE); + + try { + // Check if SKILL.md exists + await fs.access(skillManifest); + + const content = await fs.readFile(skillManifest, 'utf8'); + const config = parseSkillContent(content, skillManifest); + skills.push(config); + } catch (error) { + console.warn( + `Failed to parse skill at ${skillDir}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + continue; + } + } + return skills; + } catch (_error) { + // Directory doesn't exist or can't be read + return []; + } +} + +export function parseSkillContent( + content: string, + filePath: string, +): SkillConfig { + // Split frontmatter and content + const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; + const match = content.match(frontmatterRegex); + + if (!match) { + throw new Error('Invalid format: missing YAML frontmatter'); + } + + const [, frontmatterYaml, body] = match; + + // Parse YAML frontmatter + const frontmatter = parseYaml(frontmatterYaml) as Record; + + // Extract required fields + const nameRaw = frontmatter['name']; + const descriptionRaw = frontmatter['description']; + + if (nameRaw == null || nameRaw === '') { + throw new Error('Missing "name" in frontmatter'); + } + + if (descriptionRaw == null || descriptionRaw === '') { + throw new Error('Missing "description" in frontmatter'); + } + + // Convert to strings + const name = String(nameRaw); + const description = String(descriptionRaw); + + // Extract optional fields + const allowedToolsRaw = frontmatter['allowedTools'] as unknown[] | undefined; + let allowedTools: string[] | undefined; + + if (allowedToolsRaw !== undefined) { + if (Array.isArray(allowedToolsRaw)) { + allowedTools = allowedToolsRaw.map(String); + } else { + throw new Error('"allowedTools" must be an array'); + } + } + + const config: SkillConfig = { + name, + description, + allowedTools, + filePath, + body: body.trim(), + level: 'extension', + }; + + // Validate the parsed configuration + const validation = validateConfig(config); + if (!validation.isValid) { + throw new Error(`Validation failed: ${validation.errors.join(', ')}`); + } + + return config; +} + +export function validateConfig( + config: Partial, +): SkillValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Check required fields + if (typeof config.name !== 'string') { + errors.push('Missing or invalid "name" field'); + } else if (config.name.trim() === '') { + errors.push('"name" cannot be empty'); + } + + if (typeof config.description !== 'string') { + errors.push('Missing or invalid "description" field'); + } else if (config.description.trim() === '') { + errors.push('"description" cannot be empty'); + } + + // Validate allowedTools if present + if (config.allowedTools !== undefined) { + if (!Array.isArray(config.allowedTools)) { + errors.push('"allowedTools" must be an array'); + } else { + for (const tool of config.allowedTools) { + if (typeof tool !== 'string') { + errors.push('"allowedTools" must contain only strings'); + break; + } + } + } + } + + // Warn if body is empty + if (!config.body || config.body.trim() === '') { + warnings.push('Skill body is empty'); + } + + return { + isValid: errors.length === 0, + errors, + warnings, + }; +} diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index 471d46dfd..9fc527339 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -18,6 +18,7 @@ import type { } from './types.js'; import { SkillError, SkillErrorCode } from './types.js'; import type { Config } from '../config/config.js'; +import { validateConfig } from './skill-load.js'; const QWEN_CONFIG_DIR = '.qwen'; const SKILLS_CONFIG_DIR = 'skills'; @@ -166,46 +167,7 @@ export class SkillManager { * @returns Validation result */ validateConfig(config: Partial): SkillValidationResult { - const errors: string[] = []; - const warnings: string[] = []; - - // Check required fields - if (typeof config.name !== 'string') { - errors.push('Missing or invalid "name" field'); - } else if (config.name.trim() === '') { - errors.push('"name" cannot be empty'); - } - - if (typeof config.description !== 'string') { - errors.push('Missing or invalid "description" field'); - } else if (config.description.trim() === '') { - errors.push('"description" cannot be empty'); - } - - // Validate allowedTools if present - if (config.allowedTools !== undefined) { - if (!Array.isArray(config.allowedTools)) { - errors.push('"allowedTools" must be an array'); - } else { - for (const tool of config.allowedTools) { - if (typeof tool !== 'string') { - errors.push('"allowedTools" must contain only strings'); - break; - } - } - } - } - - // Warn if body is empty - if (!config.body || config.body.trim() === '') { - warnings.push('Skill body is empty'); - } - - return { - isValid: errors.length === 0, - errors, - warnings, - }; + return validateConfig(config); } /** @@ -411,9 +373,17 @@ export class SkillManager { return []; } - // if (level === 'extension') { - // const extensions = this.config.getExtensions(); - // } + if (level === 'extension') { + const extensions = this.config.getExtensions(); + const skills: SkillConfig[] = []; + for (const extension of extensions) { + extension.skills?.forEach((skill) => { + skills.push(skill); + }); + } + + return skills; + } const baseDir = this.getSkillsBaseDir(level); const skills = await this.loadSkillsFromDir(baseDir, level); diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index 303292e89..443ff1e7c 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -178,6 +178,15 @@ export class SubagentManager { return userConfig; } + // Try extension level + const extensionConfig = await this.findSubagentByNameAtLevel( + name, + 'extension', + ); + if (extensionConfig) { + return extensionConfig; + } + // Try built-in agents as fallback return BuiltinAgentRegistry.getBuiltinAgent(name); } @@ -259,7 +268,11 @@ export class SubagentManager { * @param level - Specific level to delete from, or undefined to delete from both * @throws SubagentError if deletion fails */ - async deleteSubagent(name: string, level?: SubagentLevel): Promise { + async deleteSubagent( + name: string, + level?: SubagentLevel, + extensionName?: string, + ): Promise { // Check if it's a built-in agent first if (BuiltinAgentRegistry.isBuiltinAgent(name)) { throw new SubagentError( @@ -268,6 +281,13 @@ export class SubagentManager { name, ); } + if (level === 'extension') { + throw new SubagentError( + `Cannot delete subagent "${name}" in extension "${extensionName}", If needed, you can directly uninstall extension.`, + SubagentErrorCode.INVALID_CONFIG, + name, + ); + } const levelsToCheck: SubagentLevel[] = level ? [level] @@ -440,7 +460,7 @@ export class SubagentManager { * * @private */ - private async refreshCache(): Promise { + async refreshCache(): Promise { const subagentsCache = new Map(); const levels: SubagentLevel[] = ['project', 'user', 'builtin', 'extension']; @@ -510,71 +530,7 @@ export class SubagentManager { filePath: string, level: SubagentLevel, ): SubagentConfig { - try { - // Split frontmatter and content - const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; - const match = content.match(frontmatterRegex); - - if (!match) { - throw new Error('Invalid format: missing YAML frontmatter'); - } - - const [, frontmatterYaml, systemPrompt] = match; - - // Parse YAML frontmatter - const frontmatter = parseYaml(frontmatterYaml) as Record; - - // Extract required fields and convert to strings - const nameRaw = frontmatter['name']; - const descriptionRaw = frontmatter['description']; - - if (nameRaw == null || nameRaw === '') { - throw new Error('Missing "name" in frontmatter'); - } - - if (descriptionRaw == null || descriptionRaw === '') { - throw new Error('Missing "description" in frontmatter'); - } - - // Convert to strings (handles numbers, booleans, etc.) - const name = String(nameRaw); - const description = String(descriptionRaw); - - // Extract optional fields - const tools = frontmatter['tools'] as string[] | undefined; - const modelConfig = frontmatter['modelConfig'] as - | Record - | undefined; - const runConfig = frontmatter['runConfig'] as - | Record - | undefined; - const color = frontmatter['color'] as string | undefined; - - const config: SubagentConfig = { - name, - description, - tools, - systemPrompt: systemPrompt.trim(), - filePath, - modelConfig: modelConfig as Partial, - runConfig: runConfig as Partial, - color, - level, - }; - - // Validate the parsed configuration - const validation = this.validator.validateConfig(config); - if (!validation.isValid) { - throw new Error(`Validation failed: ${validation.errors.join(', ')}`); - } - - return config; - } catch (error) { - throw new SubagentError( - `Failed to parse subagent file: ${error instanceof Error ? error.message : 'Unknown error'}`, - SubagentErrorCode.INVALID_CONFIG, - ); - } + return parseSubagentContent(content, filePath, level, this.validator); } /** @@ -819,6 +775,11 @@ export class SubagentManager { return BuiltinAgentRegistry.getBuiltinAgents(); } + if (level === 'extension') { + const extensions = this.config.getExtensions(); + return extensions.flatMap((extension) => extension.agents || []); + } + const projectRoot = this.config.getProjectRoot(); const homeDir = os.homedir(); const isHomeDirectory = path.resolve(projectRoot) === path.resolve(homeDir); @@ -832,14 +793,6 @@ export class SubagentManager { let baseDir = level === 'project' ? projectRoot : homeDir; baseDir = path.join(baseDir, QWEN_CONFIG_DIR, AGENT_CONFIG_DIR); - const subagents = await this.loadSubagentFromDir(baseDir, level); - return subagents; - } - - async loadSubagentFromDir( - baseDir: string, - level: SubagentLevel, - ): Promise { try { const files = await fs.readdir(baseDir); const subagents: SubagentConfig[] = []; @@ -910,3 +863,110 @@ export class SubagentManager { return false; // Name is already in use } } + +export async function loadSubagentFromDir( + baseDir: string, +): Promise { + try { + const files = await fs.readdir(baseDir); + const subagents: SubagentConfig[] = []; + + for (const file of files) { + if (!file.endsWith('.md')) continue; + + const filePath = path.join(baseDir, file); + + try { + const content = await fs.readFile(filePath, 'utf8'); + const config = parseSubagentContent( + content, + filePath, + 'extension', + new SubagentValidator(), + ); + subagents.push(config); + } catch (_error) { + // Ignore invalid files + continue; + } + } + + return subagents; + } catch (_error) { + // Directory doesn't exist or can't be read + return []; + } +} + +function parseSubagentContent( + content: string, + filePath: string, + level: SubagentLevel, + validator: SubagentValidator, +): SubagentConfig { + try { + // Split frontmatter and content + const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; + const match = content.match(frontmatterRegex); + + if (!match) { + throw new Error('Invalid format: missing YAML frontmatter'); + } + + const [, frontmatterYaml, systemPrompt] = match; + + // Parse YAML frontmatter + const frontmatter = parseYaml(frontmatterYaml) as Record; + + // Extract required fields and convert to strings + const nameRaw = frontmatter['name']; + const descriptionRaw = frontmatter['description']; + + if (nameRaw == null || nameRaw === '') { + throw new Error('Missing "name" in frontmatter'); + } + + if (descriptionRaw == null || descriptionRaw === '') { + throw new Error('Missing "description" in frontmatter'); + } + + // Convert to strings (handles numbers, booleans, etc.) + const name = String(nameRaw); + const description = String(descriptionRaw); + + // Extract optional fields + const tools = frontmatter['tools'] as string[] | undefined; + const modelConfig = frontmatter['modelConfig'] as + | Record + | undefined; + const runConfig = frontmatter['runConfig'] as + | Record + | undefined; + const color = frontmatter['color'] as string | undefined; + + const config: SubagentConfig = { + name, + description, + tools, + systemPrompt: systemPrompt.trim(), + filePath, + modelConfig: modelConfig as Partial, + runConfig: runConfig as Partial, + color, + level, + }; + + // Validate the parsed configuration + const validation = validator.validateConfig(config); + if (!validation.isValid) { + throw new Error(`Validation failed: ${validation.errors.join(', ')}`); + } + + return config; + } catch (error) { + throw new SubagentError( + `Failed to parse subagent file: ${error instanceof Error ? error.message : 'Unknown error'}`, + SubagentErrorCode.INVALID_CONFIG, + ); + } +}