UNPKG

@lenne.tech/cli

Version:

lenne.Tech CLI: lt

462 lines (461 loc) 19.1 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.PLUGIN_POST_INSTALL = exports.MAX_COMMANDS_TO_SHOW = exports.EMPTY_PLUGIN_CONTENTS = void 0; exports.findMarkdownFiles = findMarkdownFiles; exports.handleMissingEnvVars = handleMissingEnvVars; exports.printPluginSummary = printPluginSummary; exports.processPostInstall = processPostInstall; exports.readPluginContents = readPluginContents; exports.safeExecCommand = safeExecCommand; exports.setupPermissions = setupPermissions; /** * Plugin utilities for Claude Code plugin management * Handles reading plugin contents, permissions, and post-installation setup */ const child_process_1 = require("child_process"); const fs_1 = require("fs"); const os_1 = require("os"); const path_1 = require("path"); const claude_cli_1 = require("./claude-cli"); const json_utils_1 = require("./json-utils"); const shell_config_1 = require("./shell-config"); /** * Empty plugin contents constant (used for failed installations) */ exports.EMPTY_PLUGIN_CONTENTS = { agents: [], commands: [], hooks: 0, mcpServers: [], permissions: [], skills: [], }; /** * Maximum number of commands to show in plugin summary before truncating */ exports.MAX_COMMANDS_TO_SHOW = 5; /** * Post-installation configurations for specific plugins * These define additional setup steps needed after plugin installation */ exports.PLUGIN_POST_INSTALL = { 'lt-offers': { // No envVars needed — OAuth via browser login // No requirements — MCP connection is remote HTTP }, 'typescript-lsp': { envVars: [ { description: 'Enable LSP tools in Claude Code (required for LSP features)', name: 'ENABLE_LSP_TOOL', value: '1', }, ], requirements: [ { checkCommand: 'which typescript-language-server', description: 'TypeScript language server', installCommand: 'npm install -g typescript-language-server typescript', }, ], }, }; /** * Recursively find all .md files in a directory and convert to command names * @param dir - Directory to search * @param basePath - Base path for relative path calculation * @returns Array of command names (e.g., '/git:commit-message') */ function findMarkdownFiles(dir, basePath = '') { const results = []; if (!(0, fs_1.existsSync)(dir)) return results; try { const entries = (0, fs_1.readdirSync)(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = (0, path_1.join)(dir, entry.name); const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name; if (entry.isDirectory()) { results.push(...findMarkdownFiles(fullPath, relativePath)); } else if (entry.isFile() && entry.name.endsWith('.md')) { // Convert path to command name (e.g., "git/commit-message.md" -> "/git:commit-message") const commandName = relativePath.replace(/\.md$/, '').replace(/\//g, ':'); results.push(`/${commandName}`); } } } catch (_a) { // Directory read failed, return empty results } return results; } /** * Handle missing environment variables from plugin installations * Checks shell config, prompts user to add missing vars, and provides manual instructions * @param missingEnvVars - Map of env var name to PluginEnvVar * @param toolbox - Toolbox with print and prompt functions * @returns Result indicating if vars were needed and if they were configured */ function handleMissingEnvVars(missingEnvVars, toolbox) { return __awaiter(this, void 0, void 0, function* () { const { print: { error, info, success, warning }, prompt, } = toolbox; const result = { configured: false, needed: false, }; if (missingEnvVars.size === 0) { return result; } // Get preferred shell config const targetConfig = (0, shell_config_1.getPreferredShellConfig)(); // Check which env vars are truly missing (not in file either) const envVarsToAdd = []; const envVarsAlreadyInFile = []; for (const envVar of missingEnvVars.values()) { if (targetConfig && (0, shell_config_1.checkEnvVarInFile)(targetConfig.path, envVar.name, envVar.value)) { envVarsAlreadyInFile.push(envVar); } else { envVarsToAdd.push(envVar); } } // Show already configured vars if (envVarsAlreadyInFile.length > 0) { info(''); for (const envVar of envVarsAlreadyInFile) { info(`${envVar.name} already configured in ${targetConfig === null || targetConfig === void 0 ? void 0 : targetConfig.path}`); } info(`Run 'source ${targetConfig === null || targetConfig === void 0 ? void 0 : targetConfig.path}' or restart your terminal to apply.`); } // Handle vars that need to be added if (envVarsToAdd.length > 0) { result.needed = true; info(''); warning('Environment variables required:'); for (const envVar of envVarsToAdd) { info(` ${envVar.name}=${envVar.value}`); info(` ${envVar.description}`); } if (targetConfig) { // Ask user if they want to add the env vars automatically const shouldAdd = yield prompt.confirm(`Add ${envVarsToAdd.length > 1 ? 'these variables' : envVarsToAdd[0].name} to ${targetConfig.path}?`, true); if (shouldAdd) { let allAdded = true; for (const envVar of envVarsToAdd) { const added = (0, shell_config_1.addEnvVarToShellConfig)(targetConfig.path, envVar.name, envVar.value); if (added) { success(` Added ${envVar.name}=${envVar.value} to ${targetConfig.path}`); } else { error(` Failed to add ${envVar.name} to ${targetConfig.path}`); allAdded = false; } } if (allAdded) { result.configured = true; info(''); info(`Run 'source ${targetConfig.path}' or restart your terminal to apply changes.`); } } else { info(''); info('To add manually, run:'); for (const envVar of envVarsToAdd) { info(` echo 'export ${envVar.name}=${envVar.value}' >> ${targetConfig.path}`); } } } else { info(''); info('Add manually to your shell profile:'); for (const envVar of envVarsToAdd) { info(` export ${envVar.name}=${envVar.value}`); } } } return result; }); } /** * Print plugin summary with skills, commands, agents, etc. * @param pluginName - Name of the plugin * @param contents - Plugin contents * @param info - Info print function from toolbox */ function printPluginSummary(pluginName, contents, info) { const isLspPlugin = pluginName.endsWith('-lsp'); const hasContent = contents.skills.length > 0 || contents.commands.length > 0 || contents.agents.length > 0; if (hasContent || isLspPlugin) { info(''); info(`${pluginName}:`); // Show LSP indicator for LSP plugins if (isLspPlugin) { info(` Type: Language Server (LSP)`); } // Alphabetical order: Agents, Commands, Hooks, MCP Servers, Skills if (contents.agents.length > 0) { info(` Agents (${contents.agents.length}): ${contents.agents.join(', ')}`); } if (contents.commands.length > 0) { const shown = contents.commands.slice(0, exports.MAX_COMMANDS_TO_SHOW); const remaining = contents.commands.length - exports.MAX_COMMANDS_TO_SHOW; const commandsStr = remaining > 0 ? `${shown.join(', ')} and ${remaining} more` : shown.join(', '); info(` Commands (${contents.commands.length}): ${commandsStr}`); } if (contents.hooks > 0) { info(` Hooks: ${contents.hooks} auto-detection hooks`); } if (contents.mcpServers.length > 0) { info(` MCP Servers (${contents.mcpServers.length}): ${contents.mcpServers.join(', ')}`); } if (contents.skills.length > 0) { info(` Skills (${contents.skills.length}): ${contents.skills.join(', ')}`); } } } /** * Process post-installation requirements for a plugin * Checks and installs required dependencies, reports missing env vars * @param pluginName - Name of the plugin * @param toolbox - Extended Gluegun toolbox * @returns Post-install result with success status and details */ function processPostInstall(pluginName, toolbox) { const { print: { spin, warning }, } = toolbox; const result = { envVarsMissing: [], requirementsInstalled: [], requirementsMissing: [], success: true, }; const postInstall = exports.PLUGIN_POST_INSTALL[pluginName]; if (!postInstall) { return result; } // Check and install requirements if (postInstall.requirements) { for (const req of postInstall.requirements) { // If checkCommand is provided, verify if requirement is already met if (req.checkCommand) { const checkSpinner = spin(`Checking ${req.description}`); if ((0, claude_cli_1.checkCommandExists)(req.checkCommand)) { checkSpinner.succeed(`${req.description} already installed`); continue; } // Try to install checkSpinner.text = `Installing ${req.description}`; try { safeExecCommand(req.installCommand); // Verify installation if ((0, claude_cli_1.checkCommandExists)(req.checkCommand)) { checkSpinner.succeed(`${req.description} installed`); result.requirementsInstalled.push(req.description); } else { checkSpinner.fail(`${req.description} installation may have failed`); result.requirementsMissing.push(req.description); result.success = false; } } catch (err) { checkSpinner.fail(`Failed to install ${req.description}`); warning(` Command: ${req.installCommand}`); warning(` Error: ${err.message}`); result.requirementsMissing.push(req.description); result.success = false; } } else { // No checkCommand provided - always run installCommand const installSpinner = spin(`Running ${req.description}`); try { safeExecCommand(req.installCommand); installSpinner.succeed(`${req.description} completed`); result.requirementsInstalled.push(req.description); } catch (err) { installSpinner.fail(`Failed: ${req.description}`); warning(` Command: ${req.installCommand}`); warning(` Error: ${err.message}`); result.requirementsMissing.push(req.description); result.success = false; } } } } // Check environment variables if (postInstall.envVars) { for (const envVar of postInstall.envVars) { const currentValue = process.env[envVar.name]; if (currentValue !== envVar.value) { result.envVarsMissing.push(envVar); } } } return result; } /** * Read plugin contents from installed plugin directory * @param marketplaceName - Name of the marketplace * @param pluginName - Name of the plugin * @returns Plugin contents with skills, commands, agents, hooks, etc. */ function readPluginContents(marketplaceName, pluginName) { const pluginDir = (0, path_1.join)((0, os_1.homedir)(), '.claude', 'plugins', 'marketplaces', marketplaceName, 'plugins', pluginName); const result = { agents: [], commands: [], hooks: 0, mcpServers: [], permissions: [], skills: [], }; // Read skills const skillsDir = (0, path_1.join)(pluginDir, 'skills'); if ((0, fs_1.existsSync)(skillsDir)) { try { result.skills = (0, fs_1.readdirSync)(skillsDir, { withFileTypes: true }) .filter((dirent) => dirent.isDirectory()) .map((dirent) => dirent.name); } catch (_a) { // Skills directory read failed } } // Read agents const agentsDir = (0, path_1.join)(pluginDir, 'agents'); if ((0, fs_1.existsSync)(agentsDir)) { try { result.agents = (0, fs_1.readdirSync)(agentsDir, { withFileTypes: true }) .filter((dirent) => dirent.isFile() && dirent.name.endsWith('.md')) .map((dirent) => dirent.name.replace(/\.md$/, '')); } catch (_b) { // Agents directory read failed } } // Read commands const commandsDir = (0, path_1.join)(pluginDir, 'commands'); result.commands = findMarkdownFiles(commandsDir); // Read hooks const hooksPath = (0, path_1.join)(pluginDir, 'hooks', 'hooks.json'); const hooksContent = safeReadJson(hooksPath); if (hooksContent === null || hooksContent === void 0 ? void 0 : hooksContent.hooks) { // Count hooks across all event types for (const eventHooks of Object.values(hooksContent.hooks)) { if (Array.isArray(eventHooks)) { for (const hookGroup of eventHooks) { if (hookGroup.hooks && Array.isArray(hookGroup.hooks)) { result.hooks += hookGroup.hooks.length; } } } } } // Read MCP servers const mcpPath = (0, path_1.join)(pluginDir, '.mcp.json'); const mcpContent = safeReadJson(mcpPath); if (mcpContent === null || mcpContent === void 0 ? void 0 : mcpContent.mcpServers) { result.mcpServers = Object.keys(mcpContent.mcpServers); } // Read permissions const permissionsPath = (0, path_1.join)(pluginDir, 'permissions.json'); const pluginPerms = safeReadJson(permissionsPath); if (pluginPerms === null || pluginPerms === void 0 ? void 0 : pluginPerms.permissions) { result.permissions = pluginPerms.permissions.map((p) => p.pattern); } return result; } /** * Execute a shell command safely using spawnSync (no shell interpretation) * Splits the command string into executable and arguments to prevent command injection * @param command - Command string to execute (e.g., 'npm install -g typescript') * @throws Error if command fails (non-zero exit code or signal) */ function safeExecCommand(command) { var _a; const [cmd, ...args] = command.trim().split(/\s+/); const result = (0, child_process_1.spawnSync)(cmd, args, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); if (result.status !== 0 || result.error) { throw new Error(result.stderr || ((_a = result.error) === null || _a === void 0 ? void 0 : _a.message) || `Command failed: ${command}`); } } /** * Setup permissions in settings.json * Only adds new permissions, does not remove existing ones * (User may have customized permissions that should be preserved) * @param requiredPermissions - Array of permission patterns to add * @param error - Error print function from toolbox * @returns Result with added and existing permissions */ function setupPermissions(requiredPermissions, error) { const settingsPath = (0, path_1.join)((0, os_1.homedir)(), '.claude', 'settings.json'); try { // Read existing settings let settings = {}; if ((0, fs_1.existsSync)(settingsPath)) { const content = (0, fs_1.readFileSync)(settingsPath, 'utf-8'); const parsed = (0, json_utils_1.safeJsonParse)(content); if (parsed) { settings = parsed; } } // Ensure permissions.allow exists if (!settings.permissions) { settings.permissions = {}; } const perms = settings.permissions; if (!perms.allow) { perms.allow = []; } const currentAllowList = perms.allow; // Determine what to add const added = []; const existing = []; requiredPermissions.forEach((perm) => { if (currentAllowList.includes(perm)) { existing.push(perm); } else { added.push(perm); } }); // Add new permissions if (added.length > 0) { perms.allow = [...currentAllowList, ...added]; (0, fs_1.writeFileSync)(settingsPath, JSON.stringify(settings, null, 2)); } return { added, existing, success: true }; } catch (err) { error(`Could not configure permissions: ${err.message}`); return { added: [], existing: [], success: false }; } } /** * Safely read and parse a JSON file * @param filePath - Path to the JSON file * @returns Parsed object or null if reading/parsing fails */ function safeReadJson(filePath) { try { if (!(0, fs_1.existsSync)(filePath)) { return null; } const content = (0, fs_1.readFileSync)(filePath, 'utf-8'); return (0, json_utils_1.safeJsonParse)(content); } catch (_a) { return null; } }