UNPKG

@ruby-mcp/gems-mcp

Version:

MCP server for interacting with RubyGems.org API, Gemfiles, and gemspecs - search gems, get versions, and manage dependencies

1,664 lines (1,652 loc) 114 kB
#!/usr/bin/env node // src/index.ts import { readFileSync } from "fs"; import { dirname, join as join2 } from "path"; import { fileURLToPath } from "url"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { Command } from "commander"; // src/api/cache.ts var ApiCache = class { constructor(defaultTtl = 5 * 60 * 1e3) { this.cache = /* @__PURE__ */ new Map(); this.defaultTtl = defaultTtl; } get(key) { const entry = this.cache.get(key); if (!entry) { return null; } const now = Date.now(); if (now > entry.timestamp + entry.ttl) { this.cache.delete(key); return null; } return entry.data; } set(key, data, ttl) { const entry = { data, timestamp: Date.now(), ttl: ttl ?? this.defaultTtl }; this.cache.set(key, entry); } has(key) { return this.get(key) !== null; } delete(key) { return this.cache.delete(key); } clear() { this.cache.clear(); } cleanup() { const now = Date.now(); let removedCount = 0; for (const [key, entry] of this.cache.entries()) { if (now > entry.timestamp + entry.ttl) { this.cache.delete(key); removedCount++; } } return removedCount; } getStats() { return { size: this.cache.size, keys: Array.from(this.cache.keys()) }; } static generateKey(endpoint, params) { const baseKey = endpoint.toLowerCase(); if (!params || Object.keys(params).length === 0) { return baseKey; } const sortedParams = Object.keys(params).sort().map((key) => `${key}=${encodeURIComponent(String(params[key]))}`).join("&"); return `${baseKey}?${sortedParams}`; } }; // src/api/client.ts var RubyGemsClient = class { constructor(options = {}) { this.lastRequestTime = 0; this.baseUrl = options.baseUrl ?? "https://rubygems.org"; this.timeout = options.timeout ?? 1e4; this.userAgent = options.userAgent ?? "@ruby-mcp/gems-mcp/0.1.0"; this.cacheEnabled = options.cacheEnabled ?? true; this.rateLimitDelay = options.rateLimitDelay ?? 100; this.cache = new ApiCache(options.cacheTtl); } async searchGems(query, limit = 10) { const cacheKey = ApiCache.generateKey("search", { query, limit }); if (this.cacheEnabled) { const cached = this.cache.get(cacheKey); if (cached) { return { data: cached, success: true }; } } try { const url = `${this.baseUrl}/api/v1/search.json?query=${encodeURIComponent(query)}`; const response = await this.makeRequest(url); if (!response.success) { return response; } let gems = response.data; if (limit > 0 && gems.length > limit) { gems = gems.slice(0, limit); } if (this.cacheEnabled) { this.cache.set(cacheKey, gems); } return { data: gems, success: true }; } catch (error) { return { data: [], success: false, error: error instanceof Error ? error.message : "Unknown error occurred" }; } } async getGemDetails(gemName) { const cacheKey = ApiCache.generateKey("gem", { name: gemName }); if (this.cacheEnabled) { const cached = this.cache.get(cacheKey); if (cached) { return { data: cached, success: true }; } } try { const url = `${this.baseUrl}/api/v1/gems/${encodeURIComponent(gemName)}.json`; const response = await this.makeRequest(url); if (!response.success) { return response; } const gemData = response.data; if (this.cacheEnabled) { this.cache.set(cacheKey, gemData); } return { data: gemData, success: true }; } catch (error) { return { data: {}, success: false, error: error instanceof Error ? error.message : "Unknown error occurred" }; } } async getGemVersions(gemName) { const cacheKey = ApiCache.generateKey("versions", { name: gemName }); if (this.cacheEnabled) { const cached = this.cache.get(cacheKey); if (cached) { return { data: cached, success: true }; } } try { const url = `${this.baseUrl}/api/v1/versions/${encodeURIComponent(gemName)}.json`; const response = await this.makeRequest(url); if (!response.success) { return response; } const versions = response.data; if (this.cacheEnabled) { this.cache.set(cacheKey, versions); } return { data: versions, success: true }; } catch (error) { return { data: [], success: false, error: error instanceof Error ? error.message : "Unknown error occurred" }; } } async getLatestVersion(gemName) { const cacheKey = ApiCache.generateKey("latest", { name: gemName }); if (this.cacheEnabled) { const cached = this.cache.get(cacheKey); if (cached) { return { data: cached, success: true }; } } try { const versionsResponse = await this.getGemVersions(gemName); if (!versionsResponse.success) { return { data: {}, success: false, error: versionsResponse.error }; } const versions = versionsResponse.data; if (versions.length === 0) { return { data: {}, success: false, error: `No versions found for gem: ${gemName}` }; } const sortedVersions = versions.sort( (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() ); const latestVersion = sortedVersions[0]; if (this.cacheEnabled) { this.cache.set(cacheKey, latestVersion); } return { data: latestVersion, success: true }; } catch (error) { return { data: {}, success: false, error: error instanceof Error ? error.message : "Unknown error occurred" }; } } async getReverseDependencies(gemName) { const cacheKey = ApiCache.generateKey("reverse_deps", { name: gemName }); if (this.cacheEnabled) { const cached = this.cache.get(cacheKey); if (cached) { return { data: cached, success: true }; } } try { const url = `${this.baseUrl}/api/v1/gems/${encodeURIComponent(gemName)}/reverse_dependencies.json`; const response = await this.makeRequest(url); if (!response.success) { return response; } const dependencies = response.data.map((name) => ({ name })); if (this.cacheEnabled) { this.cache.set(cacheKey, dependencies); } return { data: dependencies, success: true }; } catch (error) { return { data: [], success: false, error: error instanceof Error ? error.message : "Unknown error occurred" }; } } async makeRequest(url) { const now = Date.now(); const timeSinceLastRequest = now - this.lastRequestTime; if (timeSinceLastRequest < this.rateLimitDelay) { await new Promise( (resolve2) => setTimeout(resolve2, this.rateLimitDelay - timeSinceLastRequest) ); } this.lastRequestTime = Date.now(); try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); const response = await fetch(url, { method: "GET", headers: { "User-Agent": this.userAgent, Accept: "application/json" }, signal: controller.signal }); clearTimeout(timeoutId); if (response.status === 404) { return { data: null, success: false, error: "Resource not found" }; } if (response.status === 429) { return { data: null, success: false, error: "Rate limit exceeded" }; } if (response.status >= 400) { return { data: null, success: false, error: `HTTP ${response.status}` }; } const body = await response.json(); return { data: body, success: true }; } catch (error) { return { data: null, success: false, error: error instanceof Error ? error.message : "Network request failed" }; } } clearCache() { this.cache.clear(); } getCacheStats() { return this.cache.getStats(); } cleanupCache() { return this.cache.cleanup(); } }; // src/changelog/cache.ts var ChangelogCache = class { constructor(defaultTtl = 24 * 60 * 60 * 1e3) { this.cache = /* @__PURE__ */ new Map(); this.defaultTtl = defaultTtl; } get(key) { const entry = this.cache.get(key); if (!entry) { return null; } const now = Date.now(); if (now > entry.timestamp + entry.ttl) { this.cache.delete(key); return null; } return entry.data; } set(key, data, ttl) { const entry = { data, timestamp: Date.now(), ttl: ttl ?? this.defaultTtl }; this.cache.set(key, entry); } has(key) { return this.get(key) !== null; } delete(key) { return this.cache.delete(key); } clear() { this.cache.clear(); } cleanup() { const now = Date.now(); let removedCount = 0; for (const [key, entry] of this.cache.entries()) { if (now > entry.timestamp + entry.ttl) { this.cache.delete(key); removedCount++; } } return removedCount; } getStats() { return { size: this.cache.size, keys: Array.from(this.cache.keys()) }; } static generateKey(gemName, version) { return version ? `${gemName}@${version}` : gemName; } }; // src/changelog/fetcher.ts var ChangelogFetcher = class { constructor(options) { this.client = options.client; this.cacheEnabled = options.cacheEnabled ?? true; this.cache = new ChangelogCache(options.cacheTtl); this.timeout = options.timeout ?? 1e4; } async fetchChangelog(gemName, version) { const cacheKey = ChangelogCache.generateKey(gemName, version); if (this.cacheEnabled) { const cached = this.cache.get(cacheKey); if (cached) { return { success: true, content: cached.content, source: cached.source }; } } try { const gemResponse = await this.client.getGemDetails(gemName); if (!gemResponse.success) { return { success: false, error: `Failed to fetch gem details: ${gemResponse.error}` }; } const gem = gemResponse.data; const sources = this.getChangelogSources(gem, version); for (const source of sources) { const result = await this.fetchFromSource(source); if (result.success && result.content) { if (this.cacheEnabled) { this.cache.set(cacheKey, { content: result.content, source: result.source || source.url }); } return result; } } return { success: false, error: "No changelog found from any source" }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Unknown error" }; } } getChangelogSources(gem, version) { const sources = []; if (gem.changelog_uri) { sources.push({ type: "changelog_uri", url: gem.changelog_uri, version }); } if (gem.source_code_uri && this.isGitHubUrl(gem.source_code_uri)) { const { owner, repo } = this.parseGitHubUrl(gem.source_code_uri); if (owner && repo) { if (version) { sources.push({ type: "github_release", url: `https://github.com/${owner}/${repo}/releases/tag/v${version}`, version }); sources.push({ type: "github_release", url: `https://github.com/${owner}/${repo}/releases/tag/${version}`, version }); } sources.push({ type: "github_releases", url: `https://github.com/${owner}/${repo}/releases` }); const changelogFiles = [ "CHANGELOG.md", "CHANGELOG", "HISTORY.md", "HISTORY", "CHANGES.md", "CHANGES", "NEWS.md", "NEWS" ]; for (const file of changelogFiles) { sources.push({ type: "raw_file", url: `https://raw.githubusercontent.com/${owner}/${repo}/main/${file}` }); sources.push({ type: "raw_file", url: `https://raw.githubusercontent.com/${owner}/${repo}/master/${file}` }); } } } if (gem.documentation_uri) { sources.push({ type: "documentation", url: gem.documentation_uri }); } return sources; } async fetchFromSource(source) { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); const response = await fetch(source.url, { method: "GET", headers: { "User-Agent": "@ruby-mcp/gems-mcp", Accept: "text/html,text/markdown,text/plain,*/*" }, signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { return { success: false }; } const contentType = response.headers.get("content-type") || ""; let content = await response.text(); if (contentType.includes("text/html")) { content = this.htmlToMarkdown(content); } if (source.version && source.type !== "raw_file") { content = this.extractVersionContent(content, source.version); } return { success: true, content: content.trim(), source: source.url }; } catch (_error) { return { success: false }; } } isGitHubUrl(url) { return url.includes("github.com"); } parseGitHubUrl(url) { const match = url.match(/github\.com\/([^/]+)\/([^/]+)/); if (!match) { return { owner: "", repo: "" }; } let repo = match[2]; if (repo.endsWith(".git")) { repo = repo.slice(0, -4); } return { owner: match[1], repo }; } htmlToMarkdown(html) { let markdown = html; markdown = markdown.replace( /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "" ); markdown = markdown.replace( /<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, "" ); markdown = markdown.replace(/<h1[^>]*>(.*?)<\/h1>/gi, "# $1\n"); markdown = markdown.replace(/<h2[^>]*>(.*?)<\/h2>/gi, "## $1\n"); markdown = markdown.replace(/<h3[^>]*>(.*?)<\/h3>/gi, "### $1\n"); markdown = markdown.replace(/<h4[^>]*>(.*?)<\/h4>/gi, "#### $1\n"); markdown = markdown.replace(/<h5[^>]*>(.*?)<\/h5>/gi, "##### $1\n"); markdown = markdown.replace(/<h6[^>]*>(.*?)<\/h6>/gi, "###### $1\n"); markdown = markdown.replace( /<a[^>]*href=["']([^"']*)["'][^>]*>(.*?)<\/a>/gi, "[$2]($1)" ); markdown = markdown.replace(/<li[^>]*>(.*?)<\/li>/gi, "- $1\n"); markdown = markdown.replace(/<ul[^>]*>/gi, "\n"); markdown = markdown.replace(/<\/ul>/gi, "\n"); markdown = markdown.replace(/<ol[^>]*>/gi, "\n"); markdown = markdown.replace(/<\/ol>/gi, "\n"); markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/gi, "**$1**"); markdown = markdown.replace(/<b[^>]*>(.*?)<\/b>/gi, "**$1**"); markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/gi, "*$1*"); markdown = markdown.replace(/<i[^>]*>(.*?)<\/i>/gi, "*$1*"); markdown = markdown.replace(/<code[^>]*>(.*?)<\/code>/gi, "`$1`"); markdown = markdown.replace( /<pre[^>]*><code[^>]*>(.*?)<\/code><\/pre>/gi, "```\n$1\n```" ); markdown = markdown.replace(/<pre[^>]*>(.*?)<\/pre>/gi, "```\n$1\n```"); markdown = markdown.replace(/<p[^>]*>/gi, "\n"); markdown = markdown.replace(/<\/p>/gi, "\n"); markdown = markdown.replace(/<br\s*\/?>/gi, "\n"); markdown = markdown.replace(/<[^>]+>/g, ""); markdown = markdown.replace(/&lt;/g, "<"); markdown = markdown.replace(/&gt;/g, ">"); markdown = markdown.replace(/&amp;/g, "&"); markdown = markdown.replace(/&quot;/g, '"'); markdown = markdown.replace(/&#39;/g, "'"); markdown = markdown.replace(/\n{3,}/g, "\n\n"); markdown = markdown.trim(); return markdown; } extractVersionContent(content, version) { const versionPatterns = [ new RegExp( `##?\\s*\\[?v?${this.escapeRegex(version)}\\]?[^\\n]*\\n([\\s\\S]*?)(?=##|$)`, "i" ), new RegExp( `^v?${this.escapeRegex(version)}[^\\n]*\\n([\\s\\S]*?)(?=^\\d+\\.\\d+|$)`, "im" ) ]; for (const pattern of versionPatterns) { const match = content.match(pattern); if (match?.[1]) { return match[1].trim(); } } return content; } escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } clearCache() { this.cache.clear(); } getCacheStats() { return this.cache.getStats(); } cleanupCache() { return this.cache.cleanup(); } }; // src/project-manager.ts import { promises as fs } from "fs"; import { join, resolve } from "path"; var ProjectManager = class { constructor(projects = [], defaultPath) { this.projects = /* @__PURE__ */ new Map(); this.defaultProject = defaultPath || process.cwd(); if (!projects.some((p) => p.name === "default")) { this.projects.set("default", this.defaultProject); } for (const project of projects) { this.projects.set(project.name, resolve(project.path)); } } /** * Add a project to the manager */ async addProject(name, path) { const resolvedPath = resolve(path); try { const stats = await fs.stat(resolvedPath); if (!stats.isDirectory()) { throw new Error(`Project path is not a directory: ${resolvedPath}`); } await fs.access(resolvedPath, fs.constants.R_OK); } catch (error) { if (error instanceof Error) { if (error.message.includes("ENOENT")) { throw new Error(`Project directory does not exist: ${resolvedPath}`); } if (error.message.includes("EACCES")) { throw new Error( `Permission denied accessing project directory: ${resolvedPath}` ); } } throw error; } this.projects.set(name, resolvedPath); } /** * Get project path by name, or return default if not found */ getProjectPath(name) { if (!name) { return this.defaultProject; } const path = this.projects.get(name); if (!path) { throw new Error( `Project not found: ${name}. Available projects: ${Array.from(this.projects.keys()).join(", ")}` ); } return path; } /** * Resolve a file path within a project */ resolveFilePath(filePath, projectName) { const projectPath = this.getProjectPath(projectName); if (resolve(filePath) === filePath) { return filePath; } return join(projectPath, filePath); } /** * Get list of all project names */ getProjectNames() { return Array.from(this.projects.keys()); } /** * Check if a project exists */ hasProject(name) { return this.projects.has(name); } /** * Get the default project path */ getDefaultProjectPath() { return this.defaultProject; } /** * Validate that all configured projects are accessible */ async validateProjects() { const errors = []; for (const [name, path] of this.projects.entries()) { try { const stats = await fs.stat(path); if (!stats.isDirectory()) { errors.push(`Project '${name}' path is not a directory: ${path}`); continue; } await fs.access(path, fs.constants.R_OK); } catch (error) { if (error instanceof Error) { if (error.message.includes("ENOENT")) { errors.push(`Project '${name}' directory does not exist: ${path}`); } else if (error.message.includes("EACCES")) { errors.push( `Permission denied accessing project '${name}' directory: ${path}` ); } else { errors.push( `Error accessing project '${name}' at ${path}: ${error.message}` ); } } } } if (errors.length > 0) { throw new Error(`Project validation failed: ${errors.join("\n")}`); } } }; // src/schemas.ts import { z } from "zod"; var SearchGemsSchema = z.object({ query: z.string().min(1, "Query cannot be empty").max(100, "Query too long"), limit: z.number().int().min(1).max(100).optional().default(10) }); var GemDetailsSchema = z.object({ gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format") }); var GemVersionsSchema = z.object({ gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format"), include_prerelease: z.boolean().optional().default(false) }); var LatestVersionSchema = z.object({ gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format"), include_prerelease: z.boolean().optional().default(false) }); var GemDependenciesSchema = z.object({ gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format") }); var ChangelogSchema = z.object({ gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format"), version: z.string().min(1, "Version cannot be empty").max(50, "Version too long").regex( /^[0-9]+(?:\.[0-9]+)*(?:\.(?:pre|rc|alpha|beta)\d*)?$/, "Invalid version format" ).optional() }); var GemfileParserSchema = z.object({ file_path: z.string().min(1, "File path cannot be empty").max(500, "File path too long"), project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional() }); var GemPinSchema = z.object({ gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format"), version: z.string().min(1, "Version cannot be empty").max(50, "Version too long").regex( /^[0-9]+(?:\.[0-9]+)*(?:\.(?:pre|rc|alpha|beta)\d*)?$/, "Invalid version format" ), pin_type: z.enum(["~>", ">=", ">", "<", "<=", "="]).default("~>"), quote_style: z.enum(["single", "double"]).optional(), file_path: z.string().min(1, "File path cannot be empty").max(500, "File path too long").default("Gemfile"), project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional() }); var GemUnpinSchema = z.object({ gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format"), quote_style: z.enum(["single", "double"]).optional(), file_path: z.string().min(1, "File path cannot be empty").max(500, "File path too long").default("Gemfile"), project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional() }); var GemAddToGemfileSchema = z.object({ gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format"), version: z.string().min(1, "Version cannot be empty").max(50, "Version too long").regex( /^[0-9]+(?:\.[0-9]+)*(?:\.(?:pre|rc|alpha|beta)\d*)?$/, "Invalid version format" ).optional(), pin_type: z.enum(["~>", ">=", ">", "<", "<=", "="]).default("~>"), group: z.array(z.string().min(1).max(50)).optional(), source: z.string().min(1, "Source cannot be empty").max(500, "Source too long").optional(), require: z.union([z.literal(false), z.string().min(1).max(100)]).optional(), quote_style: z.enum(["single", "double"]).optional(), file_path: z.string().min(1, "File path cannot be empty").max(500, "File path too long").default("Gemfile"), project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional() }); var GemAddToGemspecSchema = z.object({ gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format"), version: z.string().min(1, "Version cannot be empty").max(50, "Version too long").regex( /^[0-9]+(?:\.[0-9]+)*(?:\.(?:pre|rc|alpha|beta)\d*)?$/, "Invalid version format" ).optional(), pin_type: z.enum(["~>", ">=", ">", "<", "<=", "="]).default("~>"), dependency_type: z.enum(["runtime", "development"]).default("runtime"), quote_style: z.enum(["single", "double"]).optional(), file_path: z.string().min(1, "File path cannot be empty").max(500, "File path too long"), project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional() }); var BundleInstallSchema = z.object({ project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional(), deployment: z.boolean().optional().default(false), without: z.array(z.string().min(1).max(50)).optional().describe("Groups to exclude during installation"), gemfile: z.string().min(1, "Gemfile path cannot be empty").max(500, "Gemfile path too long").optional(), clean: z.boolean().optional().default(false), frozen: z.boolean().optional().default(false), quiet: z.boolean().optional().default(false) }); var BundleCheckSchema = z.object({ project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional(), gemfile: z.string().min(1, "Gemfile path cannot be empty").max(500, "Gemfile path too long").optional() }); var BundleShowSchema = z.object({ gem_name: z.string().min(1, "Gem name cannot be empty").max(50, "Gem name too long").regex(/^[a-zA-Z0-9_-]+$/, "Invalid gem name format").optional(), project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional(), paths: z.boolean().optional().default(false), outdated: z.boolean().optional().default(false) }); var BundleAuditSchema = z.object({ project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional(), update: z.boolean().optional().default(false), verbose: z.boolean().optional().default(false), format: z.enum(["text", "json"]).optional().default("text"), gemfile_lock: z.string().min(1, "Gemfile.lock path cannot be empty").max(500, "Gemfile.lock path too long").optional() }); var BundleCleanSchema = z.object({ project: z.string().min(1, "Project name cannot be empty").max(100, "Project name too long").optional(), dry_run: z.boolean().optional().default(false), force: z.boolean().optional().default(false) }); var GemDependencySchema = z.object({ name: z.string(), requirements: z.string() }); var GemVersionResponseSchema = z.object({ authors: z.string().optional(), built_at: z.string(), created_at: z.string(), description: z.string().optional(), downloads_count: z.number(), metadata: z.record(z.string()), number: z.string(), summary: z.string().optional(), platform: z.string(), ruby_version: z.string().optional(), rubygems_version: z.string().optional(), prerelease: z.boolean(), licenses: z.array(z.string()).optional(), requirements: z.array(z.string()).optional(), sha: z.string().optional() }); var GemDetailsResponseSchema = z.object({ name: z.string(), downloads: z.number(), version: z.string(), version_created_at: z.string(), version_downloads: z.number(), platform: z.string(), authors: z.string().optional(), info: z.string().optional(), licenses: z.array(z.string()).optional(), metadata: z.record(z.string()), yanked: z.boolean(), sha: z.string().optional(), project_uri: z.string(), gem_uri: z.string(), homepage_uri: z.string().optional(), wiki_uri: z.string().optional(), documentation_uri: z.string().optional(), mailing_list_uri: z.string().optional(), source_code_uri: z.string().optional(), bug_tracker_uri: z.string().optional(), changelog_uri: z.string().optional(), funding_uri: z.string().optional(), dependencies: z.object({ development: z.array(GemDependencySchema), runtime: z.array(GemDependencySchema) }) }); var GemSearchResultSchema = z.object({ name: z.string(), downloads: z.number(), version: z.string(), version_created_at: z.string(), version_downloads: z.number(), platform: z.string(), authors: z.string().optional(), info: z.string().optional(), licenses: z.array(z.string()).optional(), metadata: z.record(z.string()), yanked: z.boolean(), sha: z.string().optional(), project_uri: z.string(), gem_uri: z.string(), homepage_uri: z.string().optional(), wiki_uri: z.string().optional(), documentation_uri: z.string().optional(), mailing_list_uri: z.string().optional(), source_code_uri: z.string().optional(), bug_tracker_uri: z.string().optional(), changelog_uri: z.string().optional(), funding_uri: z.string().optional() }); var ReverseDependencySchema = z.object({ name: z.string() }); var searchGemsInputSchema = { type: "object", properties: { query: { type: "string", description: "Search query for gems (name or keywords)", minLength: 1, maxLength: 100 }, limit: { type: "number", description: "Maximum number of results to return (1-100)", minimum: 1, maximum: 100, default: 10 } }, required: ["query"], additionalProperties: false }; var gemDetailsInputSchema = { type: "object", properties: { gem_name: { type: "string", description: "Name of the gem to get details for", minLength: 1, maxLength: 50, pattern: "^[a-zA-Z0-9_-]+$" } }, required: ["gem_name"], additionalProperties: false }; var gemVersionsInputSchema = { type: "object", properties: { gem_name: { type: "string", description: "Name of the gem to get versions for", minLength: 1, maxLength: 50, pattern: "^[a-zA-Z0-9_-]+$" }, include_prerelease: { type: "boolean", description: "Include prerelease versions in results", default: false } }, required: ["gem_name"], additionalProperties: false }; var latestVersionInputSchema = { type: "object", properties: { gem_name: { type: "string", description: "Name of the gem to get latest version for", minLength: 1, maxLength: 50, pattern: "^[a-zA-Z0-9_-]+$" }, include_prerelease: { type: "boolean", description: "Include prerelease versions when determining latest", default: false } }, required: ["gem_name"], additionalProperties: false }; var gemDependenciesInputSchema = { type: "object", properties: { gem_name: { type: "string", description: "Name of the gem to get reverse dependencies for", minLength: 1, maxLength: 50, pattern: "^[a-zA-Z0-9_-]+$" } }, required: ["gem_name"], additionalProperties: false }; var changelogInputSchema = { type: "object", properties: { gem_name: { type: "string", description: "Name of the gem to fetch changelog for", minLength: 1, maxLength: 50, pattern: "^[a-zA-Z0-9_-]+$" }, version: { type: "string", description: "Specific version to fetch changelog for (optional)", minLength: 1, maxLength: 50, pattern: "^[0-9]+(?:\\.[0-9]+)*(?:\\.(?:pre|rc|alpha|beta)\\d*)?$" } }, required: ["gem_name"], additionalProperties: false }; var gemfileParserInputSchema = { type: "object", properties: { file_path: { type: "string", description: "Path to the Gemfile or .gemspec file to parse", minLength: 1, maxLength: 500 }, project: { type: "string", description: "Optional project name to resolve file path within", minLength: 1, maxLength: 100 } }, required: ["file_path"], additionalProperties: false }; var gemPinInputSchema = { type: "object", properties: { gem_name: { type: "string", description: "Name of the gem to pin", minLength: 1, maxLength: 50, pattern: "^[a-zA-Z0-9_-]+$" }, version: { type: "string", description: "Version to pin the gem to", minLength: 1, maxLength: 50, pattern: "^[0-9]+(?:\\.[0-9]+)*(?:\\.(?:pre|rc|alpha|beta)\\d*)?$" }, pin_type: { type: "string", description: "Type of version pinning (~>, >=, >, <, <=, =)", enum: ["~>", ">=", ">", "<", "<=", "="], default: "~>" }, quote_style: { type: "string", description: "Quote style to use for gem declaration (single or double)", enum: ["single", "double"] }, file_path: { type: "string", description: "Path to the Gemfile to modify", minLength: 1, maxLength: 500, default: "Gemfile" }, project: { type: "string", description: "Optional project name to resolve file path within", minLength: 1, maxLength: 100 } }, required: ["gem_name", "version"], additionalProperties: false }; var gemUnpinInputSchema = { type: "object", properties: { gem_name: { type: "string", description: "Name of the gem to unpin (remove version constraints)", minLength: 1, maxLength: 50, pattern: "^[a-zA-Z0-9_-]+$" }, quote_style: { type: "string", description: "Quote style to use for gem declaration (single or double)", enum: ["single", "double"] }, file_path: { type: "string", description: "Path to the Gemfile to modify", minLength: 1, maxLength: 500, default: "Gemfile" }, project: { type: "string", description: "Optional project name to resolve file path within", minLength: 1, maxLength: 100 } }, required: ["gem_name"], additionalProperties: false }; var gemAddToGemfileInputSchema = { type: "object", properties: { gem_name: { type: "string", description: "Name of the gem to add", minLength: 1, maxLength: 50, pattern: "^[a-zA-Z0-9_-]+$" }, version: { type: "string", description: "Version to constrain the gem to", minLength: 1, maxLength: 50, pattern: "^[0-9]+(?:\\.[0-9]+)*(?:\\.(?:pre|rc|alpha|beta)\\d*)?$" }, pin_type: { type: "string", description: "Type of version constraint (~>, >=, >, <, <=, =)", enum: ["~>", ">=", ">", "<", "<=", "="], default: "~>" }, group: { type: "array", description: "Groups to add the gem to (e.g., development, test)", items: { type: "string", minLength: 1, maxLength: 50 } }, source: { type: "string", description: "Alternative source for the gem (git URL, path, or custom source)", minLength: 1, maxLength: 500 }, require: { oneOf: [ { type: "boolean", description: "Set to false to not require the gem on load" }, { type: "string", description: "Custom require path for the gem", minLength: 1, maxLength: 100 } ], description: "Require option for the gem (false or custom path)" }, quote_style: { type: "string", description: "Quote style to use for gem declaration (single or double)", enum: ["single", "double"] }, file_path: { type: "string", description: "Path to the Gemfile to modify", minLength: 1, maxLength: 500, default: "Gemfile" }, project: { type: "string", description: "Optional project name to resolve file path within", minLength: 1, maxLength: 100 } }, required: ["gem_name"], additionalProperties: false }; var gemAddToGemspecInputSchema = { type: "object", properties: { gem_name: { type: "string", description: "Name of the gem to add as dependency", minLength: 1, maxLength: 50, pattern: "^[a-zA-Z0-9_-]+$" }, version: { type: "string", description: "Version constraint for the dependency", minLength: 1, maxLength: 50, pattern: "^[0-9]+(?:\\.[0-9]+)*(?:\\.(?:pre|rc|alpha|beta)\\d*)?$" }, pin_type: { type: "string", description: "Type of version constraint (~>, >=, >, <, <=, =)", enum: ["~>", ">=", ">", "<", "<=", "="], default: "~>" }, dependency_type: { type: "string", description: "Type of dependency (runtime or development)", enum: ["runtime", "development"], default: "runtime" }, quote_style: { type: "string", description: "Quote style to use for dependency declaration (single or double)", enum: ["single", "double"] }, file_path: { type: "string", description: "Path to the .gemspec file to modify", minLength: 1, maxLength: 500 }, project: { type: "string", description: "Optional project name to resolve file path within", minLength: 1, maxLength: 100 } }, required: ["gem_name", "file_path"], additionalProperties: false }; var bundleInstallInputSchema = { type: "object", properties: { project: { type: "string", description: "Optional project name to run bundle install within", minLength: 1, maxLength: 100 }, deployment: { type: "boolean", description: "Install gems in deployment mode (production install)", default: false }, without: { type: "array", description: "Groups to exclude during installation (e.g., development, test)", items: { type: "string", minLength: 1, maxLength: 50 } }, gemfile: { type: "string", description: "Path to specific Gemfile to use (relative to project)", minLength: 1, maxLength: 500 }, clean: { type: "boolean", description: "Clean up old gems after installation", default: false }, frozen: { type: "boolean", description: "Do not allow Gemfile.lock to be updated", default: false }, quiet: { type: "boolean", description: "Suppress output during installation", default: false } }, required: [], additionalProperties: false }; var bundleCheckInputSchema = { type: "object", properties: { project: { type: "string", description: "Optional project name to run bundle check within", minLength: 1, maxLength: 100 }, gemfile: { type: "string", description: "Path to specific Gemfile to use (relative to project)", minLength: 1, maxLength: 500 } }, required: [], additionalProperties: false }; var bundleShowInputSchema = { type: "object", properties: { gem_name: { type: "string", description: "Name of gem to show (omit to show all gems)", minLength: 1, maxLength: 50, pattern: "^[a-zA-Z0-9_-]+$" }, project: { type: "string", description: "Optional project name to run bundle show within", minLength: 1, maxLength: 100 }, paths: { type: "boolean", description: "Show gem installation paths", default: false }, outdated: { type: "boolean", description: "Show outdated gems only", default: false } }, required: [], additionalProperties: false }; var bundleAuditInputSchema = { type: "object", properties: { project: { type: "string", description: "Optional project name to run bundle audit within", minLength: 1, maxLength: 100 }, update: { type: "boolean", description: "Update vulnerability database before auditing", default: false }, verbose: { type: "boolean", description: "Show verbose output", default: false }, format: { type: "string", description: "Output format for audit results", enum: ["text", "json"], default: "text" }, gemfile_lock: { type: "string", description: "Path to specific Gemfile.lock to audit (relative to project)", minLength: 1, maxLength: 500 } }, required: [], additionalProperties: false }; var bundleCleanInputSchema = { type: "object", properties: { project: { type: "string", description: "Optional project name to run bundle clean within", minLength: 1, maxLength: 100 }, dry_run: { type: "boolean", description: "Show what would be cleaned without actually cleaning", default: false }, force: { type: "boolean", description: "Force clean even if bundle is not frozen", default: false } }, required: [], additionalProperties: false }; // src/tools/add.ts import { promises as fs2 } from "fs"; // src/utils/quotes.ts var DEFAULT_QUOTE_CONFIG = { gemfile: "single", gemspec: "double" }; function getQuoteChar(style) { return style === "single" ? "'" : '"'; } function formatGemDeclaration(gemName, options) { const quote = getQuoteChar(options.quoteStyle); let declaration = `gem ${quote}${gemName}${quote}`; if (options.version && options.pinType) { declaration += `, ${quote}${options.pinType} ${options.version}${quote}`; } if (options.source) { if (options.source.startsWith("http") || options.source.startsWith("git")) { declaration += `, git: ${quote}${options.source}${quote}`; } else if (options.source.startsWith("/") || options.source.startsWith("./") || options.source.startsWith("../")) { declaration += `, path: ${quote}${options.source}${quote}`; } else { declaration += `, source: ${quote}${options.source}${quote}`; } } if (options.require !== void 0) { if (options.require === false) { declaration += ", require: false"; } else { declaration += `, require: ${quote}${options.require}${quote}`; } } return declaration; } function formatDependencyDeclaration(gemName, options) { const quote = getQuoteChar(options.quoteStyle); const methodName = options.dependencyType === "development" ? "add_development_dependency" : "add_dependency"; let declaration = ` spec.${methodName} ${quote}${gemName}${quote}`; if (options.version && options.pinType) { declaration += `, ${quote}${options.pinType} ${options.version}${quote}`; } return declaration; } function parseQuoteStyle(value) { const normalized = value.toLowerCase().trim(); if (normalized === "single" || normalized === "'") { return "single"; } if (normalized === "double" || normalized === '"') { return "double"; } throw new Error( `Invalid quote style: ${value}. Must be 'single' or 'double'` ); } function formatVersionRequirement(version, pinType, quoteStyle) { const quote = getQuoteChar(quoteStyle); return `${quote}${pinType} ${version}${quote}`; } function detectQuoteStyle(gemLine) { const match = gemLine.match(/gem\s+(['"])/); if (match) { return match[1] === "'" ? "single" : "double"; } return "single"; } // src/utils/validation.ts import { ZodError } from "zod"; function validateInput(schema, data) { try { const validated = schema.parse(data); return { success: true, data: validated }; } catch (error) { if (error instanceof ZodError) { const issues = error.issues.map((issue) => { const path = issue.path.length > 0 ? issue.path.join(".") : "root"; return `${path}: ${issue.message}`; }); return { success: false, error: `Validation failed: ${issues[0]}`, issues }; } return { success: false, error: error instanceof Error ? error.message : "Unknown validation error" }; } } // src/tools/add.ts var GemAddTool = class { constructor(options) { this.projectManager = options?.projectManager; this.quoteConfig = options?.quoteConfig; } async executeAddToGemfile(args) { const validation = validateInput(GemAddToGemfileSchema, args); if (!validation.success) { return { content: [ { type: "text", text: `Error: ${validation.error}` } ], isError: true }; } const { gem_name, version, pin_type, group, source, require: requireOption, quote_style, file_path, project } = validation.data; let resolvedFilePath; try { resolvedFilePath = this.projectManager ? this.projectManager.resolveFilePath(file_path, project) : file_path; } catch (error) { return { content: [ { type: "text", text: `Error: ${error instanceof Error ? error.message : "Unknown error"}` } ], isError: true }; } try { await fs2.access(resolvedFilePath, fs2.constants.R_OK | fs2.constants.W_OK); const fileStats = await fs2.stat(resolvedFilePath); if (!fileStats.isFile()) { return { content: [ { type: "text", text: `Error: ${resolvedFilePath} is not a file` } ], isError: true }; } const content = await fs2.readFile(resolvedFilePath, "utf-8"); const lines = content.split("\n"); for (const line of lines) { const trimmedLine = line.trim(); if (trimmedLine.startsWith("#") || !trimmedLine) continue; const gemMatch = line.match(/gem\s+['"]([^'"]+)['"]/); if (gemMatch && gemMatch[1] === gem_name) { return { content: [ { type: "text", text: `Gem '${gem_name}' already exists in ${resolvedFilePath}` } ], isError: true }; } } const effectiveQuoteStyle = quote_style || this.quoteConfig?.gemfile || "single"; const gemDeclaration = formatGemDeclaration(gem_name, { version, pinType: pin_type, source, require: requireOption, quoteStyle: effectiveQuoteStyle }); if (group && group.length > 0) { const groupNames = group.join(", :"); const groupPattern = new RegExp( `^\\s*group\\s+:${groupNames.replace(", :", ",\\s*:")}\\s+do\\s*$` ); let groupStartIndex = -1; let groupEndIndex = -1; let indentLevel = ""; for (let i = 0; i < lines.length; i++) { if (groupPattern.test(lines[i])) { groupStartIndex = i; indentLevel = lines[i].match(/^(\s*)/)?.[1] || ""; let blockLevel = 1; for (let j = i + 1; j < lines.length; j++) { if (/^\s*group\s+.*do\s*$/.test(lines[j])) { blockLevel++; } else if (/^\s*end\s*$/.test(lines[j])) { blockLevel--; if (blockLevel === 0) { groupEndIndex = j; break; } } } break; } } if (groupStartIndex !== -1 && groupEndIndex !== -1) { lines.splice(groupEndIndex, 0, `${indentLevel} ${gemDeclaration}`); } else { const newGroup = [ "", `group :${groupNames} do`, ` ${gemDeclaration}`, "end" ]; lines.push(...newGroup); } } else { let insertIndex = lines.length; while (insertIndex > 0 && lines[insertIndex - 1].trim() === "") { insertIndex--; } lines.splice(insertIndex, 0, gemDeclaration); } await fs2.writeFile(resolvedFilePath, lines.join("\n"), "utf-8"); const groupInfo = group && group.length > 0 ? ` in group [:${group.join(", :")}]` : ""; const versionInfo = version ? ` with version '${pin_type} ${version}'` : ""; return { content: [ { type: "text", text: `Successfully added '${gem_name}'${versionInfo}${groupInfo} to ${resolvedFilePath}` } ] }; } catch (error) { if (error instanceof Error) { if (error.message.includes("ENOENT")) { return { content: [ { type: "text", text: `Error: File not found: ${resolvedFilePath}` } ], isError: true }; } if (error.message.includes("EACCES")) { return { content: [ { type: "text", text: `Error: Permission denied accessing file: ${resolvedFilePath}` } ], isError: true }; } } return { content: [ { type: "text", text: `Unexpected error adding gem: ${error instanceof Error ? error.message : "Unknown error"}` } ], isError: true }; } } async executeAddToGemspec(args) { const validation = validateInput(GemAddToGemspecSchema, args); if (!validation.success) { return { content: [ { type: "text", text: `Error: ${validation.error}`