diff --git a/README.md b/README.md index 603f23b..b62aeaf 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,40 @@ Coderrr is an AI-powered coding agent that analyzes tasks, creates actionable plans, performs file operations, and executes commands with user permission. Built for developers who want automated assistance without sacrificing control. +--- + +## 🧩 Skills Marketplace + +**Extend Coderrr's capabilities with installable skills!** + +Browse and install skills from the [coderrr-skills](https://github.com/Akash-nath29/coderrr-skills) marketplace: + +```bash +# Browse available skills +coderrr market + +# Install a skill +coderrr install web-scraper + +# List installed skills +coderrr skills +``` + +| Skill | Description | +|-------|-------------| +| **web-scraper** | Fetch and extract content from web pages | +| **pdf** | Create, merge, split, and extract PDFs | +| **code-analyzer** | Lint code, count lines, find TODOs | +| **docx/xlsx/pptx** | Work with Office documents | +| **api-client** | Make HTTP requests | + +šŸ‘‰ **[Browse all skills →](https://github.com/Akash-nath29/coderrr-skills)** + +--- + ## Table of Contents +- [🧩 Skills Marketplace](#-skills-marketplace) - [See Coderrr in Action](#see-coderrr-in-action) - [Features](#features) - [Core Capabilities](#core-capabilities) diff --git a/backend/main.py b/backend/main.py index 6135706..a3609fc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -93,7 +93,7 @@ class PlanStep(BaseModel): action: Literal[ "create_file", "update_file", "patch_file", "delete_file", "read_file", "run_command", "create_dir", "delete_dir", - "list_dir", "rename_dir" + "list_dir", "rename_dir", "invoke_skill" ] path: Optional[str] = None content: Optional[str] = None @@ -102,6 +102,9 @@ class PlanStep(BaseModel): old_path: Optional[str] = Field(default=None, alias="oldPath") new_path: Optional[str] = Field(default=None, alias="newPath") command: Optional[str] = None + skill: Optional[str] = None # Skill name for invoke_skill + tool: Optional[str] = None # Tool name for invoke_skill + args: Optional[dict] = None # Arguments for invoke_skill summary: str diff --git a/bin/coderrr.js b/bin/coderrr.js index 49f5717..8ae1342 100644 --- a/bin/coderrr.js +++ b/bin/coderrr.js @@ -26,6 +26,10 @@ program const { displayRecipeList } = require('../src/recipeUI'); const recipeManager = require('../src/recipeManager'); const { displayInsights } = require('../src/insightsUI'); +const { registerSkillCommands } = require('../src/skillsUI'); + +// Register skill management commands +registerSkillCommands(program); // Optional: Load .env from user's home directory (for advanced users who want custom backend) const homeConfigPath = path.join(os.homedir(), '.coderrr', '.env'); diff --git a/src/agent.js b/src/agent.js index 03c4d78..39e35ee 100644 --- a/src/agent.js +++ b/src/agent.js @@ -10,6 +10,8 @@ const GitOperations = require('./gitOps'); const { sanitizeAxiosError, formatUserError, createSafeError, isNetworkError } = require('./errorHandler'); const configManager = require('./configManager'); const { getProvider } = require('./providers'); +const skillRegistry = require('./skillRegistry'); +const skillRunner = require('./skillRunner'); /** * Core AI Agent that communicates with backend and executes plans @@ -59,6 +61,10 @@ class Agent { // Track running processes spawned in separate terminals this.runningProcesses = []; + // Load installed agent skills for tool invocation + this.installedSkills = skillRegistry.loadAllSkills(); + this.toolManifest = skillRegistry.generateToolManifest(); + // Register cleanup handler for when Coderrr exits this.registerExitCleanup(); } @@ -251,6 +257,16 @@ When editing existing files, use EXACT filenames from the list above. When creat For command execution on ${osType}, use appropriate command separators (${osType === 'Windows' ? 'semicolon (;)' : 'ampersand (&&)'}).`; } + // Inject available skill tools into context (if any are installed) + if (this.toolManifest) { + enhancedPrompt = `${enhancedPrompt} + +${this.toolManifest} + +To invoke a skill tool, use the action: "invoke_skill" with "skill", "tool", and "args" properties. +Example: {"action": "invoke_skill", "skill": "web-scraper", "tool": "fetch_page", "args": {"url": "..."}, "summary": "Fetching page"}`; + } + const spinner = ui.spinner('Thinking...'); spinner.start(); @@ -451,6 +467,10 @@ For command execution on ${osType}, use appropriate command separators (${osType // Store the process handle for potential cleanup later if (!this.runningProcesses) { this.runningProcesses = []; + + // Load installed agent skills for tool invocation + this.installedSkills = skillRegistry.loadAllSkills(); + this.toolManifest = skillRegistry.generateToolManifest(); } this.runningProcesses.push(result); @@ -487,6 +507,24 @@ For command execution on ${osType}, use appropriate command separators (${osType break; } } + } else if (step.action === 'invoke_skill') { + // Execute a skill tool + ui.info(`Invoking skill tool: ${step.skill}/${step.tool}`); + + const result = await skillRunner.executeTool( + step.skill, + step.tool, + step.args || {}, + { cwd: this.workingDir } + ); + + if (result.success) { + stepResult = `Skill ${step.skill}/${step.tool} executed successfully`; + stepSuccess = true; + ui.success(`Tool output:\n${result.output}`); + } else { + throw new Error(result.error || 'Skill tool execution failed'); + } } else { // File operation const result = await this.fileOps.execute(step); diff --git a/src/executor.js b/src/executor.js index 98a4f0a..9a77601 100644 --- a/src/executor.js +++ b/src/executor.js @@ -9,6 +9,7 @@ const fsSync = require('fs'); const path = require('path'); const os = require('os'); const ui = require('./ui'); +const skillRunner = require('./skillRunner'); class CommandExecutor { constructor() { diff --git a/src/skillMarketplace.js b/src/skillMarketplace.js new file mode 100644 index 0000000..3f15030 --- /dev/null +++ b/src/skillMarketplace.js @@ -0,0 +1,249 @@ +/** + * Skill Marketplace - Remote Registry Client + * + * Connects Coderrr to the remote skill marketplace hosted on GitHub. + * Handles fetching registry, downloading skills, and caching. + */ + +const axios = require('axios'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const skillRegistry = require('./skillRegistry'); +const { installSkillDependencies } = require('./skillRunner'); + +// Registry configuration +const REGISTRY_URL = 'https://raw.githubusercontent.com/Akash-nath29/coderrr-skills/main/registry.json'; +const CACHE_DIR = path.join(os.homedir(), '.coderrr'); +const CACHE_FILE = path.join(CACHE_DIR, 'registry-cache.json'); +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +/** + * Ensure cache directory exists + */ +function ensureCacheDir() { + if (!fs.existsSync(CACHE_DIR)) { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + } +} + +/** + * Load cached registry if valid + * @returns {Object|null} Cached registry or null if expired/missing + */ +function loadCache() { + try { + if (!fs.existsSync(CACHE_FILE)) return null; + + const cache = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); + const age = Date.now() - cache.timestamp; + + if (age < CACHE_TTL) { + return cache.data; + } + return null; + } catch (e) { + return null; + } +} + +/** + * Save registry to cache + * @param {Object} data - Registry data + */ +function saveCache(data) { + ensureCacheDir(); + const cache = { + timestamp: Date.now(), + data: data + }; + fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2), 'utf8'); +} + +/** + * Fetch the remote registry with caching + * @returns {Promise} Registry object + */ +async function fetchRegistry() { + // Check cache first + const cached = loadCache(); + if (cached) { + return cached; + } + + try { + const response = await axios.get(REGISTRY_URL, { timeout: 10000 }); + const registry = response.data; + + // Cache the result + saveCache(registry); + + return registry; + } catch (error) { + if (error.code === 'ENOTFOUND' || error.code === 'ETIMEDOUT') { + throw new Error('Could not connect to skill registry. Check your internet connection.'); + } + throw new Error(`Failed to fetch registry: ${error.message}`); + } +} + +/** + * Search skills by query + * @param {string} query - Search query + * @returns {Promise} Matching skills + */ +async function searchSkills(query) { + const registry = await fetchRegistry(); + const q = query.toLowerCase(); + + return Object.values(registry.skills).filter(skill => + skill.name.toLowerCase().includes(q) || + skill.description.toLowerCase().includes(q) || + (skill.tags && skill.tags.some(tag => tag.toLowerCase().includes(q))) + ); +} + +/** + * Get skill info by name + * @param {string} name - Skill name + * @returns {Promise} Skill info or null + */ +async function getSkillInfo(name) { + const registry = await fetchRegistry(); + return registry.skills[name] || null; +} + +/** + * List all available skills in the marketplace + * @returns {Promise} All skills + */ +async function listAvailableSkills() { + const registry = await fetchRegistry(); + return Object.values(registry.skills); +} + +/** + * Download a file from URL + * @param {string} url - File URL + * @returns {Promise} File content + */ +async function downloadFile(url) { + const response = await axios.get(url, { + timeout: 30000, + responseType: 'text' + }); + return response.data; +} + +/** + * Download and install a skill from the marketplace + * @param {string} skillName - Name of the skill to install + * @returns {Promise} Installation result + */ +async function downloadSkill(skillName) { + const skillInfo = await getSkillInfo(skillName); + + if (!skillInfo) { + return { success: false, error: `Skill not found: ${skillName}` }; + } + + // Check if already installed + if (skillRegistry.isSkillInstalled(skillName)) { + return { success: false, error: `Skill "${skillName}" is already installed.` }; + } + + const skillDir = path.join(skillRegistry.SKILLS_DIR, skillName); + const toolsDir = path.join(skillDir, 'tools'); + const baseUrl = skillInfo.download_url; + + try { + // Create directories + skillRegistry.ensureSkillsDir(); + fs.mkdirSync(toolsDir, { recursive: true }); + + // Download Skills.md + console.log(` Downloading Skills.md...`); + const skillsMd = await downloadFile(`${baseUrl}/Skills.md`); + fs.writeFileSync(path.join(skillDir, 'Skills.md'), skillsMd, 'utf8'); + + // Try to download requirements.txt (optional) + try { + const requirements = await downloadFile(`${baseUrl}/requirements.txt`); + fs.writeFileSync(path.join(skillDir, 'requirements.txt'), requirements, 'utf8'); + console.log(` Found requirements.txt`); + } catch (e) { + // requirements.txt is optional, ignore + } + + // Download each tool + for (const tool of skillInfo.tools) { + console.log(` Downloading ${tool}.py...`); + try { + const toolContent = await downloadFile(`${baseUrl}/tools/${tool}.py`); + fs.writeFileSync(path.join(toolsDir, `${tool}.py`), toolContent, 'utf8'); + } catch (e) { + console.warn(` Warning: Could not download ${tool}.py`); + } + } + + // Install Python dependencies if requirements.txt exists + const depsResult = await installSkillDependencies(skillName); + if (!depsResult.success && depsResult.error) { + console.log(` Note: ${depsResult.error}`); + } + + return { + success: true, + skill: skillInfo, + message: `Skill "${skillName}" installed successfully` + }; + + } catch (error) { + // Cleanup on failure + try { + if (fs.existsSync(skillDir)) { + fs.rmSync(skillDir, { recursive: true }); + } + } catch (e) { + // Ignore cleanup errors + } + return { success: false, error: `Installation failed: ${error.message}` }; + } +} + +/** + * Check if a source is a local path or remote skill name + * @param {string} source - Source string + * @returns {boolean} True if local path + */ +function isLocalPath(source) { + // Local if starts with ./, ../, /, or contains drive letter (Windows) + return source.startsWith('./') || + source.startsWith('../') || + source.startsWith('/') || + /^[a-zA-Z]:/.test(source); +} + +/** + * Clear the registry cache + */ +function clearCache() { + try { + if (fs.existsSync(CACHE_FILE)) { + fs.unlinkSync(CACHE_FILE); + } + } catch (e) { + // Ignore + } +} + +module.exports = { + REGISTRY_URL, + fetchRegistry, + searchSkills, + getSkillInfo, + listAvailableSkills, + downloadSkill, + isLocalPath, + clearCache +}; diff --git a/src/skillRegistry.js b/src/skillRegistry.js new file mode 100644 index 0000000..4148042 --- /dev/null +++ b/src/skillRegistry.js @@ -0,0 +1,308 @@ +/** + * Skill Registry for Coderrr + * + * Discovers, loads, and manages installed agent skills from ~/.coderrr/skills/ + * Each skill contains a Skills.md description and Python tools in tools/ directory. + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const CODERRR_DIR = path.join(os.homedir(), '.coderrr'); +const SKILLS_DIR = path.join(CODERRR_DIR, 'skills'); + +/** + * Ensure the skills directory exists + */ +function ensureSkillsDir() { + if (!fs.existsSync(SKILLS_DIR)) { + fs.mkdirSync(SKILLS_DIR, { recursive: true }); + } + return SKILLS_DIR; +} + +/** + * Get the skills directory path + * @returns {string} Path to ~/.coderrr/skills/ + */ +function getSkillsDir() { + return SKILLS_DIR; +} + +/** + * Validate that a skill folder has the required structure + * @param {string} skillPath - Full path to the skill folder + * @returns {Object} { valid: boolean, error?: string } + */ +function validateSkillStructure(skillPath) { + const skillsMarkdown = path.join(skillPath, 'Skills.md'); + const toolsDir = path.join(skillPath, 'tools'); + + if (!fs.existsSync(skillsMarkdown)) { + return { valid: false, error: 'Missing Skills.md file' }; + } + + if (!fs.existsSync(toolsDir)) { + return { valid: false, error: 'Missing tools/ directory' }; + } + + const stats = fs.statSync(toolsDir); + if (!stats.isDirectory()) { + return { valid: false, error: 'tools/ is not a directory' }; + } + + // Check for at least one .py file in tools/ + const toolFiles = fs.readdirSync(toolsDir).filter(f => f.endsWith('.py')); + if (toolFiles.length === 0) { + return { valid: false, error: 'No Python tools found in tools/' }; + } + + return { valid: true }; +} + +/** + * Parse the Skills.md file to extract skill metadata + * @param {string} skillsMarkdownPath - Path to Skills.md + * @returns {Object} { name: string, description: string, rawContent: string } + */ +function parseSkillsMarkdown(skillsMarkdownPath) { + const content = fs.readFileSync(skillsMarkdownPath, 'utf-8'); + const lines = content.split('\n'); + + let name = ''; + let description = ''; + + // Extract name from first # header + for (const line of lines) { + const headerMatch = line.match(/^#\s+(.+)/); + if (headerMatch) { + name = headerMatch[1].trim(); + break; + } + } + + // Extract description - first non-empty line after header + let foundHeader = false; + for (const line of lines) { + if (line.startsWith('#')) { + foundHeader = true; + continue; + } + if (foundHeader && line.trim()) { + description = line.trim(); + break; + } + } + + return { + name: name || path.basename(path.dirname(skillsMarkdownPath)), + description: description || 'No description provided', + rawContent: content + }; +} + +/** + * Extract tool metadata from a Python file by parsing docstrings + * @param {string} toolPath - Path to the .py file + * @returns {Object} { name: string, description: string, parameters: string[] } + */ +function parseToolMetadata(toolPath) { + const content = fs.readFileSync(toolPath, 'utf-8'); + const toolName = path.basename(toolPath, '.py'); + + let description = ''; + let parameters = []; + + // Try to extract docstring (simple pattern for triple-quoted strings) + const docstringMatch = content.match(/^"""([\s\S]*?)"""|^'''([\s\S]*?)'''/m); + if (docstringMatch) { + description = (docstringMatch[1] || docstringMatch[2] || '').trim().split('\n')[0]; + } + + // Try to extract function parameters from main() or first def + const funcMatch = content.match(/def\s+(?:main|run|\w+)\s*\(([^)]*)\)/); + if (funcMatch && funcMatch[1]) { + parameters = funcMatch[1] + .split(',') + .map(p => p.trim().split('=')[0].split(':')[0].trim()) + .filter(p => p && p !== 'self'); + } + + return { + name: toolName, + description: description || `Tool: ${toolName}`, + parameters + }; +} + +/** + * Load a single skill from its directory + * @param {string} skillName - Name of the skill (folder name) + * @returns {Object|null} Skill object or null if invalid + */ +function loadSkill(skillName) { + const skillPath = path.join(SKILLS_DIR, skillName); + + if (!fs.existsSync(skillPath)) { + return null; + } + + const validation = validateSkillStructure(skillPath); + if (!validation.valid) { + console.warn(`Skill "${skillName}" is invalid: ${validation.error}`); + return null; + } + + // Parse Skills.md + const skillsMarkdownPath = path.join(skillPath, 'Skills.md'); + const metadata = parseSkillsMarkdown(skillsMarkdownPath); + + // Load all tools + const toolsDir = path.join(skillPath, 'tools'); + const toolFiles = fs.readdirSync(toolsDir).filter(f => f.endsWith('.py')); + + const tools = toolFiles.map(toolFile => { + const toolPath = path.join(toolsDir, toolFile); + return parseToolMetadata(toolPath); + }); + + return { + name: skillName, + displayName: metadata.name, + description: metadata.description, + path: skillPath, + tools, + rawSkillsContent: metadata.rawContent + }; +} + +/** + * List all installed skills (folder names in ~/.coderrr/skills/) + * @returns {string[]} Array of skill names + */ +function listInstalledSkills() { + ensureSkillsDir(); + + if (!fs.existsSync(SKILLS_DIR)) { + return []; + } + + return fs.readdirSync(SKILLS_DIR).filter(name => { + const skillPath = path.join(SKILLS_DIR, name); + return fs.statSync(skillPath).isDirectory(); + }); +} + +/** + * Load all valid installed skills + * @returns {Object[]} Array of skill objects + */ +function loadAllSkills() { + const skillNames = listInstalledSkills(); + const skills = []; + + for (const name of skillNames) { + const skill = loadSkill(name); + if (skill) { + skills.push(skill); + } + } + + return skills; +} + +/** + * Get all available tools across all installed skills + * @returns {Object[]} Array of { skill, tool } objects + */ +function getAvailableTools() { + const skills = loadAllSkills(); + const tools = []; + + for (const skill of skills) { + for (const tool of skill.tools) { + tools.push({ + skillName: skill.name, + skillDescription: skill.description, + toolName: tool.name, + toolDescription: tool.description, + toolParameters: tool.parameters + }); + } + } + + return tools; +} + +/** + * Generate a tool manifest string for injection into LLM context + * @returns {string} Formatted manifest of all available skills and tools + */ +function generateToolManifest() { + const skills = loadAllSkills(); + + if (skills.length === 0) { + return ''; + } + + let manifest = 'AVAILABLE SKILLS & TOOLS:\n\n'; + + for (const skill of skills) { + manifest += `[${skill.name}] - ${skill.description}\n`; + + for (const tool of skill.tools) { + const params = tool.parameters.length > 0 + ? `(${tool.parameters.join(', ')})` + : '()'; + manifest += ` • ${tool.name}${params}: ${tool.description}\n`; + } + manifest += '\n'; + } + + return manifest.trim(); +} + +/** + * Check if a specific skill is installed + * @param {string} skillName - Name of the skill + * @returns {boolean} + */ +function isSkillInstalled(skillName) { + const skillPath = path.join(SKILLS_DIR, skillName); + return fs.existsSync(skillPath) && validateSkillStructure(skillPath).valid; +} + +/** + * Remove an installed skill + * @param {string} skillName - Name of the skill to remove + * @returns {boolean} True if removed successfully + */ +function removeSkill(skillName) { + const skillPath = path.join(SKILLS_DIR, skillName); + + if (!fs.existsSync(skillPath)) { + return false; + } + + // Recursively delete the skill folder + fs.rmSync(skillPath, { recursive: true, force: true }); + return true; +} + +module.exports = { + CODERRR_DIR, + SKILLS_DIR, + ensureSkillsDir, + getSkillsDir, + validateSkillStructure, + parseSkillsMarkdown, + parseToolMetadata, + loadSkill, + listInstalledSkills, + loadAllSkills, + getAvailableTools, + generateToolManifest, + isSkillInstalled, + removeSkill +}; diff --git a/src/skillRunner.js b/src/skillRunner.js new file mode 100644 index 0000000..2cb6bb8 --- /dev/null +++ b/src/skillRunner.js @@ -0,0 +1,250 @@ +/** + * Skill Runner for Coderrr + * + * Executes Python tools from installed skills in isolated subprocess. + * Captures output and returns structured results to the agent. + */ + +const { spawn } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const { SKILLS_DIR } = require('./skillRegistry'); + +/** + * Execute a Python tool from a skill + * @param {string} skillName - Name of the skill + * @param {string} toolName - Name of the tool (without .py extension) + * @param {Object} args - Arguments to pass to the tool + * @param {Object} options - Execution options + * @param {string} options.cwd - Working directory for the tool + * @param {number} options.timeout - Timeout in milliseconds (default: 30000) + * @returns {Promise} { success, output, error, exitCode } + */ +async function executeTool(skillName, toolName, args = {}, options = {}) { + const { cwd = process.cwd(), timeout = 30000 } = options; + + const toolPath = path.join(SKILLS_DIR, skillName, 'tools', `${toolName}.py`); + + // Validate tool exists + if (!fs.existsSync(toolPath)) { + return { + success: false, + output: '', + error: `Tool not found: ${skillName}/${toolName}`, + exitCode: 1 + }; + } + + // Convert args object to command line arguments + const argsList = buildArgsList(args); + + return new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + let resolved = false; + + const pythonCommand = process.platform === 'win32' ? 'python' : 'python3'; + + const proc = spawn(pythonCommand, [toolPath, ...argsList], { + cwd, + env: { + ...process.env, + CODERRR_SKILL: skillName, + CODERRR_TOOL: toolName, + CODERRR_CWD: cwd + }, + shell: true, + timeout + }); + + proc.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + proc.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('close', (code) => { + if (resolved) return; + resolved = true; + + resolve({ + success: code === 0, + output: stdout.trim(), + error: stderr.trim(), + exitCode: code + }); + }); + + proc.on('error', (err) => { + if (resolved) return; + resolved = true; + + resolve({ + success: false, + output: '', + error: `Failed to execute tool: ${err.message}`, + exitCode: 1 + }); + }); + + // Timeout handler + setTimeout(() => { + if (resolved) return; + resolved = true; + + proc.kill('SIGTERM'); + resolve({ + success: false, + output: stdout.trim(), + error: `Tool execution timed out after ${timeout}ms`, + exitCode: 124 + }); + }, timeout); + }); +} + +/** + * Convert an arguments object to a list of command line arguments + * @param {Object} args - Arguments object + * @returns {string[]} List of arguments + */ +function buildArgsList(args) { + const argsList = []; + + for (const [key, value] of Object.entries(args)) { + if (value === true) { + // Boolean flag: --flag + argsList.push(`--${key}`); + } else if (value === false) { + // Skip false booleans + continue; + } else if (Array.isArray(value)) { + // Array: --key value1 --key value2 + for (const v of value) { + argsList.push(`--${key}`, String(v)); + } + } else if (value !== null && value !== undefined) { + // Key-value: --key value + argsList.push(`--${key}`, String(value)); + } + } + + return argsList; +} + +/** + * Parse tool output as JSON if possible + * @param {string} output - Raw output string + * @returns {Object|string} Parsed JSON or original string + */ +function parseToolOutput(output) { + try { + return JSON.parse(output); + } catch { + return output; + } +} + +/** + * Check if Python is available on the system + * @returns {Promise} { available, version, command } + */ +async function checkPythonAvailable() { + return new Promise((resolve) => { + const pythonCommand = process.platform === 'win32' ? 'python' : 'python3'; + + const proc = spawn(pythonCommand, ['--version'], { shell: true }); + + let output = ''; + proc.stdout.on('data', (data) => { + output += data.toString(); + }); + proc.stderr.on('data', (data) => { + output += data.toString(); + }); + + proc.on('close', (code) => { + if (code === 0) { + const versionMatch = output.match(/Python\s+(\d+\.\d+\.\d+)/); + resolve({ + available: true, + version: versionMatch ? versionMatch[1] : 'unknown', + command: pythonCommand + }); + } else { + resolve({ + available: false, + version: null, + command: null + }); + } + }); + + proc.on('error', () => { + resolve({ + available: false, + version: null, + command: null + }); + }); + }); +} + +/** + * Install Python dependencies for a skill (if requirements.txt exists) + * @param {string} skillName - Name of the skill + * @returns {Promise} { success, output, error } + */ +async function installSkillDependencies(skillName) { + const requirementsPath = path.join(SKILLS_DIR, skillName, 'requirements.txt'); + + if (!fs.existsSync(requirementsPath)) { + return { success: true, output: 'No requirements.txt found', error: '' }; + } + + return new Promise((resolve) => { + const pythonCommand = process.platform === 'win32' ? 'python' : 'python3'; + + const proc = spawn(pythonCommand, ['-m', 'pip', 'install', '-r', requirementsPath], { + shell: true, + timeout: 120000 // 2 minute timeout for pip install + }); + + let stdout = ''; + let stderr = ''; + + proc.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + proc.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('close', (code) => { + resolve({ + success: code === 0, + output: stdout.trim(), + error: stderr.trim() + }); + }); + + proc.on('error', (err) => { + resolve({ + success: false, + output: '', + error: err.message + }); + }); + }); +} + +module.exports = { + executeTool, + buildArgsList, + parseToolOutput, + checkPythonAvailable, + installSkillDependencies +}; diff --git a/src/skillsUI.js b/src/skillsUI.js new file mode 100644 index 0000000..909cdcc --- /dev/null +++ b/src/skillsUI.js @@ -0,0 +1,315 @@ +/** + * Skills UI for Coderrr CLI + * + * Provides CLI commands for managing agent skills. + * Supports both local and remote (marketplace) installation. + */ + +const path = require('path'); +const fs = require('fs'); +const chalk = require('chalk'); +const inquirer = require('inquirer'); +const skillRegistry = require('./skillRegistry'); +const marketplace = require('./skillMarketplace'); +const { checkPythonAvailable, installSkillDependencies } = require('./skillRunner'); + +/** + * Display list of installed skills + */ +function displaySkillsList() { + const skills = skillRegistry.loadAllSkills(); + + if (skills.length === 0) { + console.log(chalk.yellow('\nā–² No skills installed.')); + console.log(chalk.gray(' Install skills with: coderrr install ')); + console.log(chalk.gray(' Browse marketplace with: coderrr market\n')); + return; + } + + console.log(chalk.cyan.bold('\nā”œā”€ Installed Skills\n')); + + for (const skill of skills) { + console.log(` ${chalk.white.bold(skill.name)} - ${chalk.gray(skill.description)}`); + for (const tool of skill.tools) { + const params = tool.parameters.length > 0 ? `(${tool.parameters.join(', ')})` : '()'; + console.log(` ${chalk.green('•')} ${tool.name}${chalk.gray(params)}`); + } + console.log(); + } +} + +/** + * Install a skill from local path + * @param {string} sourcePath - Resolved path to skill folder + */ +async function installLocalSkill(sourcePath) { + if (!fs.existsSync(sourcePath)) { + console.log(chalk.red(`\nāœ— Source not found: ${sourcePath}\n`)); + return false; + } + + const validation = skillRegistry.validateSkillStructure(sourcePath); + if (!validation.valid) { + console.log(chalk.red(`\nāœ— Invalid skill: ${validation.error}`)); + console.log(chalk.gray('\n Required structure:')); + console.log(chalk.gray(' /')); + console.log(chalk.gray(' ā”œā”€ā”€ Skills.md')); + console.log(chalk.gray(' └── tools/')); + console.log(chalk.gray(' └── *.py\n')); + return false; + } + + const skillName = path.basename(sourcePath); + const targetPath = path.join(skillRegistry.SKILLS_DIR, skillName); + + if (fs.existsSync(targetPath)) { + console.log(chalk.yellow(`\nā–² Skill "${skillName}" already installed.`)); + console.log(chalk.gray(` Use: coderrr uninstall ${skillName}\n`)); + return false; + } + + skillRegistry.ensureSkillsDir(); + fs.cpSync(sourcePath, targetPath, { recursive: true }); + + const depsResult = await installSkillDependencies(skillName); + if (!depsResult.success && depsResult.error) { + console.log(chalk.yellow(`\nā–² Warning: ${depsResult.error}`)); + } + + const skill = skillRegistry.loadSkill(skillName); + console.log(chalk.green(`\nā–  Skill "${skillName}" installed!`)); + console.log(` Tools: ${skill.tools.map(t => t.name).join(', ')}\n`); + return true; +} + +/** + * Install a skill from marketplace + * @param {string} skillName - Name of skill in registry + */ +async function installRemoteSkill(skillName) { + console.log(` Fetching "${skillName}" from marketplace...`); + + const result = await marketplace.downloadSkill(skillName); + + if (!result.success) { + console.log(chalk.red(`\nāœ— ${result.error}\n`)); + return false; + } + + console.log(chalk.green(`\nā–  Skill "${skillName}" installed!`)); + console.log(` Tools: ${result.skill.tools.join(', ')}\n`); + return true; +} + +/** + * Install a skill (auto-detect local vs remote) + * @param {string} source - Local path or skill name + */ +async function installSkill(source) { + console.log(chalk.cyan.bold('\nā”œā”€ Installing Skill\n')); + + const python = await checkPythonAvailable(); + if (!python.available) { + console.log(chalk.red('āœ— Python not available. Skills require Python 3.8+.\n')); + return false; + } + console.log(chalk.green(` ā–  Python ${python.version} found`)); + + if (marketplace.isLocalPath(source)) { + return await installLocalSkill(path.resolve(source)); + } else { + return await installRemoteSkill(source); + } +} + +/** + * Uninstall a skill + * @param {string} skillName - Name of the skill + */ +async function uninstallSkill(skillName) { + if (!skillRegistry.isSkillInstalled(skillName)) { + console.log(chalk.yellow(`\nā–² Skill "${skillName}" not installed.\n`)); + return false; + } + + const { confirm } = await inquirer.prompt([{ + type: 'confirm', + name: 'confirm', + message: `Remove skill "${skillName}"?`, + default: false + }]); + + if (!confirm) { + console.log(chalk.yellow('\nā–² Cancelled.\n')); + return false; + } + + if (skillRegistry.removeSkill(skillName)) { + console.log(chalk.green(`\nā–  Skill "${skillName}" uninstalled.\n`)); + return true; + } else { + console.log(chalk.red(`\nāœ— Failed to uninstall.\n`)); + return false; + } +} + +/** + * Search marketplace for skills + * @param {string} query - Search query + */ +async function searchMarketplace(query) { + console.log(chalk.cyan.bold('\nā”œā”€ Searching Marketplace\n')); + + try { + const results = await marketplace.searchSkills(query); + + if (results.length === 0) { + console.log(chalk.yellow(` No skills found for: "${query}"\n`)); + return; + } + + console.log(` Found ${results.length} skill(s):\n`); + + for (const skill of results) { + const installed = skillRegistry.isSkillInstalled(skill.name); + const status = installed ? chalk.green(' [installed]') : ''; + + console.log(` ${chalk.cyan.bold(skill.name)}${status}`); + console.log(` ${skill.description}`); + if (skill.tags && skill.tags.length > 0) { + console.log(` ${chalk.gray('Tags: ' + skill.tags.join(', '))}`); + } + console.log(); + } + } catch (error) { + console.log(chalk.red(` āœ— ${error.message}\n`)); + } +} + +/** + * List all available skills in marketplace + */ +async function listMarketplace() { + console.log(chalk.cyan.bold('\nā”œā”€ Available Skills (Marketplace)\n')); + + try { + const skills = await marketplace.listAvailableSkills(); + + if (skills.length === 0) { + console.log(chalk.yellow(' No skills available in marketplace.\n')); + return; + } + + for (const skill of skills) { + const installed = skillRegistry.isSkillInstalled(skill.name); + const status = installed ? chalk.green(' āœ“') : ''; + + console.log(` ${chalk.cyan(skill.name)}${status} - ${skill.description}`); + } + console.log(); + console.log(chalk.gray(` Install with: coderrr install \n`)); + } catch (error) { + console.log(chalk.red(` āœ— ${error.message}\n`)); + } +} + +/** + * Show detailed info about a skill + * @param {string} skillName - Name of skill + */ +async function showSkillInfo(skillName) { + console.log(chalk.cyan.bold('\nā”œā”€ Skill Info\n')); + + try { + const skill = await marketplace.getSkillInfo(skillName); + + if (!skill) { + console.log(chalk.red(` Skill not found: ${skillName}\n`)); + return; + } + + const installed = skillRegistry.isSkillInstalled(skillName); + + console.log(` ${chalk.white.bold(skill.displayName || skill.name)}\n`); + console.log(` Name: ${skill.name}`); + console.log(` Status: ${installed ? chalk.green('Installed') : chalk.yellow('Not installed')}`); + console.log(` Version: ${skill.version}`); + console.log(` Author: ${skill.author}`); + console.log(` Description: ${skill.description}`); + console.log(` Tools: ${skill.tools.join(', ')}`); + if (skill.tags && skill.tags.length > 0) { + console.log(` Tags: ${skill.tags.join(', ')}`); + } + console.log(); + + if (!installed) { + console.log(chalk.gray(` Install with: coderrr install ${skillName}\n`)); + } + } catch (error) { + console.log(chalk.red(` āœ— ${error.message}\n`)); + } +} + +/** + * Register skill commands with commander program + * @param {Command} program - Commander program instance + */ +function registerSkillCommands(program) { + // List installed skills + program + .command('skills') + .description('List all installed agent skills') + .action(() => { + displaySkillsList(); + }); + + // Install a skill (local or from marketplace) + program + .command('install ') + .description('Install a skill (name from marketplace or local path)') + .action(async (source) => { + await installSkill(source); + }); + + // Uninstall a skill + program + .command('uninstall ') + .description('Uninstall an installed skill') + .action(async (skillName) => { + await uninstallSkill(skillName); + }); + + // Search marketplace + program + .command('search ') + .description('Search for skills in the marketplace') + .action(async (query) => { + await searchMarketplace(query); + }); + + // List all available skills in marketplace + program + .command('market') + .description('Browse all available skills in the marketplace') + .action(async () => { + await listMarketplace(); + }); + + // Show skill info + program + .command('info ') + .description('Show detailed information about a skill') + .action(async (skillName) => { + await showSkillInfo(skillName); + }); +} + +module.exports = { + displaySkillsList, + installSkill, + uninstallSkill, + searchMarketplace, + listMarketplace, + showSkillInfo, + registerSkillCommands +};