diff --git a/src/commands/connect/add-impl.ts b/src/commands/connect/add-impl.ts index 811a28c..0b1f661 100644 --- a/src/commands/connect/add-impl.ts +++ b/src/commands/connect/add-impl.ts @@ -12,6 +12,7 @@ export async function addServer( metadata?: string headers?: string namespace?: string + force?: boolean }, ): Promise { try { @@ -24,17 +25,74 @@ export async function addServer( const normalizedUrl = normalizeMcpUrl(mcpUrl) const session = await ConnectSession.create(options.namespace) + + // Check for existing connections with the same URL + if (!options.force) { + const { connections: existing } = + await session.listConnectionsByUrl(normalizedUrl) + if (existing.length > 0) { + const match = existing[0] + const status = match.status?.state ?? "unknown" + console.error( + chalk.yellow( + `Connection already exists for this URL: ${match.name} (${match.connectionId}, status: ${status})`, + ), + ) + if (status === "auth_required") { + const authUrl = (match.status as { authorizationUrl?: string }) + ?.authorizationUrl + if (authUrl) { + console.error( + chalk.yellow(`Authorization required. Run: open "${authUrl}"`), + ) + } + } else if (status === "connected") { + console.error( + chalk.yellow( + `Use "smithery connect tools ${match.connectionId}" to interact with it.`, + ), + ) + } + console.error( + chalk.dim(`Use --force to create a new connection anyway.`), + ) + const output = formatConnectionOutput(match) + outputJson(output) + return + } + } + const connection = await session.createConnection(normalizedUrl, { name: options.name, metadata: parsedMetadata, headers: parsedHeaders, }) + if (connection.status?.state === "auth_required") { + const authUrl = (connection.status as { authorizationUrl?: string }) + ?.authorizationUrl + if (authUrl) { + console.error( + chalk.yellow(`Authorization required. Run: open "${authUrl}"`), + ) + } + } + const output = formatConnectionOutput(connection) outputJson(output) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) console.error(chalk.red(`Failed to add connection: ${errorMessage}`)) + if ( + errorMessage.includes("Missing required permission") || + errorMessage.includes("403") + ) { + console.error( + chalk.yellow( + `\nYour authentication token may be expired or missing required permissions. Run "smithery login" to re-authenticate.`, + ), + ) + } process.exit(1) } } diff --git a/src/commands/connect/api.ts b/src/commands/connect/api.ts index 517598e..2d69ba9 100644 --- a/src/commands/connect/api.ts +++ b/src/commands/connect/api.ts @@ -44,6 +44,17 @@ export class ConnectSession { return new ConnectSession(client, ns) } + async listConnectionsByUrl( + mcpUrl: string, + ): Promise<{ connections: Connection[] }> { + const data = + await this.smitheryClient.experimental.connect.connections.list( + this.namespace, + { mcpUrl }, + ) + return { connections: data.connections } + } + async listConnections(options?: { limit?: number cursor?: string diff --git a/src/commands/connect/list.ts b/src/commands/connect/list.ts index 6154dc2..f3126de 100644 --- a/src/commands/connect/list.ts +++ b/src/commands/connect/list.ts @@ -25,6 +25,7 @@ export async function listServers(options: { servers: connections.map((conn) => ({ id: conn.connectionId, name: conn.name, + mcpUrl: conn.mcpUrl, status: conn.status?.state ?? "unknown", })), help: "smithery connect tools - List tools for a specific server", diff --git a/src/commands/skills/search.ts b/src/commands/skills/search.ts index 5c9559d..c4af9de 100644 --- a/src/commands/skills/search.ts +++ b/src/commands/skills/search.ts @@ -6,6 +6,7 @@ import { installSkill } from "./install.js" export interface SearchOptions { json?: boolean + interactive?: boolean limit?: number page?: number namespace?: string @@ -65,223 +66,257 @@ export async function searchSkills( initialQuery?: string, options: SearchOptions = {}, ): Promise { - const { json = false, limit = 10, page = 1, namespace } = options + const { + json = false, + interactive = false, + limit = 10, + page = 1, + namespace, + } = options - // In json mode, require a query (unless filtering by namespace) - if (json && !initialQuery && !namespace) { + const searchTerm = interactive + ? await getSearchTerm(initialQuery) + : (initialQuery ?? "") + + try { + // Skills search is a public endpoint, no authentication required + const client = new Smithery({ apiKey: "" }) + + // Build query params + const queryParams: { + q?: string + pageSize: number + page?: number + namespace?: string + } = { + pageSize: limit, + page, + } + if (searchTerm) { + queryParams.q = searchTerm + } + if (namespace) { + queryParams.namespace = namespace + } + + if (interactive) { + return interactiveSearch(client, queryParams, limit, searchTerm) + } + + const response = await client.skills.list(queryParams) + const skills = response.skills + + if (json) { + const cleanedSkills = skills.map((skill) => { + const { + vector, + $dist, + score, + gitUrl, + totalActivations, + uniqueUsers, + externalStars, + ...rest + } = skill as SkillListResponse & { + vector?: unknown + $dist?: unknown + score?: unknown + } + return { + ...rest, + stars: externalStars, + url: getSkillUrl(skill.namespace, skill.slug), + } + }) + console.log(JSON.stringify(cleanedSkills, null, 2)) + return null + } + + if (skills.length === 0) { + console.log(chalk.yellow("No skills found.")) + return null + } + + const yaml = (await import("yaml")).default + const output = skills.map((skill) => ({ + name: skill.displayName || `${skill.namespace}/${skill.slug}`, + qualifiedName: `${skill.namespace}/${skill.slug}`, + ...(skill.description && { description: skill.description }), + ...(skill.externalStars && { stars: skill.externalStars }), + ...(skill.categories && + skill.categories.length > 0 && { categories: skill.categories }), + })) + console.log(yaml.stringify(output).replace(/\n\n/g, "\n").trimEnd()) + console.log() + console.log(chalk.dim("Tip: Use --json for machine-readable output")) + return null + } catch (error) { console.error( - chalk.red("Error: --json requires a search query or --namespace filter"), + chalk.red("Error searching skills:"), + error instanceof Error ? error.message : String(error), ) process.exit(1) } +} + +async function interactiveSearch( + client: Smithery, + queryParams: { + q?: string + pageSize: number + page?: number + namespace?: string + }, + limit: number, + initialSearchTerm: string, +): Promise { + let searchTerm = initialSearchTerm - let searchTerm = - json || namespace ? (initialQuery ?? "") : await getSearchTerm(initialQuery) + while (true) { + if (searchTerm) { + queryParams.q = searchTerm + } - try { - while (true) { - // Skills search is a public endpoint, no authentication required - const client = new Smithery({ apiKey: "" }) + const ora = (await import("ora")).default + const searchMsg = queryParams.namespace + ? `Searching in ${queryParams.namespace}${searchTerm ? ` for "${searchTerm}"` : ""}...` + : `Searching for "${searchTerm}"...` + const spinner = ora(searchMsg).start() - // Build query params - const queryParams: { - q?: string - pageSize: number - page?: number - namespace?: string - } = { - pageSize: limit, - page, - } - if (searchTerm) { - queryParams.q = searchTerm - } - if (namespace) { - queryParams.namespace = namespace - } + const response = await client.skills.list(queryParams) + const skills = response.skills - // JSON mode: fetch and output JSON without spinners - if (json) { - const response = await client.skills.list(queryParams) - // Filter out internal fields from JSON output - const cleanedSkills = response.skills.map((skill) => { - const { - vector, - $dist, - score, - gitUrl, - totalActivations, - uniqueUsers, - externalStars, - ...rest - } = skill as SkillListResponse & { - vector?: unknown - $dist?: unknown - score?: unknown - } - return { - ...rest, - stars: externalStars, - url: getSkillUrl(skill.namespace, skill.slug), - } - }) - console.log(JSON.stringify(cleanedSkills, null, 2)) - return null - } + if (skills.length === 0) { + spinner.fail(`No skills found for "${searchTerm}"`) + return null + } - const ora = (await import("ora")).default - const searchMsg = namespace - ? `Searching in ${namespace}${searchTerm ? ` for "${searchTerm}"` : ""}...` - : `Searching for "${searchTerm}"...` - const spinner = ora(searchMsg).start() + spinner.succeed( + `☀ ${skills.length < limit ? `Found ${skills.length} result${skills.length === 1 ? "" : "s"}:` : `Showing top ${skills.length} results:`}`, + ) + console.log( + chalk.dim( + `${chalk.cyan("→ View more")} at smithery.ai/skills?q=${searchTerm.replace(/\s+/g, "+")}`, + ), + ) + console.log() - const response = await client.skills.list(queryParams) + const inquirer = (await import("inquirer")).default + const autocompletePrompt = (await import("inquirer-autocomplete-prompt")) + .default + inquirer.registerPrompt("autocomplete", autocompletePrompt) - const skills = response.skills + const { selectedSkill } = await inquirer.prompt([ + { + type: "autocomplete", + name: "selectedSkill", + message: "Select skill for details (or search again):", + source: (_: unknown, input: string) => { + const options = [ + { name: chalk.dim("← Search again"), value: "__SEARCH_AGAIN__" }, + { name: chalk.dim("Exit"), value: "__EXIT__" }, + ] - if (skills.length === 0) { - spinner.fail(`No skills found for "${searchTerm}"`) - return null - } + const filtered = skills + .filter((s) => { + const searchStr = (input || "").toLowerCase() + return ( + s.displayName?.toLowerCase().includes(searchStr) || + s.slug.toLowerCase().includes(searchStr) || + s.namespace.toLowerCase().includes(searchStr) || + s.description?.toLowerCase().includes(searchStr) + ) + }) + .map((s) => ({ + name: formatSkillDisplay(s), + value: s.id, + })) + + return Promise.resolve([...options, ...filtered]) + }, + }, + ]) + + if (selectedSkill === "__EXIT__") { + return null + } else if (selectedSkill === "__SEARCH_AGAIN__") { + searchTerm = await getSearchTerm() + continue + } - spinner.succeed( - `☀ ${skills.length < limit ? `Found ${skills.length} result${skills.length === 1 ? "" : "s"}:` : `Showing top ${skills.length} results:`}`, + console.log() + const selectedSkillData = skills.find((s) => s.id === selectedSkill) + if (selectedSkillData) { + const displayName = + selectedSkillData.displayName || + `${selectedSkillData.namespace}/${selectedSkillData.slug}` + console.log(`${chalk.bold.cyan(displayName)}`) + console.log( + `${chalk.dim("Qualified name:")} ${selectedSkillData.namespace}/${selectedSkillData.slug}`, ) + if (selectedSkillData.description) { + console.log( + `${chalk.dim("Description:")} ${selectedSkillData.description}`, + ) + } + if ( + selectedSkillData.categories && + selectedSkillData.categories.length > 0 + ) { + console.log( + `${chalk.dim("Categories:")} ${selectedSkillData.categories.join(", ")}`, + ) + } + if (selectedSkillData.externalStars) { + console.log( + `${chalk.dim("Stars:")} ${selectedSkillData.externalStars.toLocaleString()}`, + ) + } + console.log() + + const skillIdentifier = `${selectedSkillData.namespace}/${selectedSkillData.slug}` + console.log(chalk.bold("To install this skill, run:")) + console.log() console.log( - chalk.dim( - `${chalk.cyan("→ View more")} at smithery.ai/skills?q=${searchTerm.replace(/\s+/g, "+")}`, + chalk.cyan( + ` smithery skills install ${skillIdentifier} --agent `, ), ) console.log() - // Show interactive selection - const inquirer = (await import("inquirer")).default - const autocompletePrompt = (await import("inquirer-autocomplete-prompt")) - .default - inquirer.registerPrompt("autocomplete", autocompletePrompt) - - const { selectedSkill } = await inquirer.prompt([ + const { action } = await inquirer.prompt([ { - type: "autocomplete", - name: "selectedSkill", - message: "Select skill for details (or search again):", - source: (_: unknown, input: string) => { - const options = [ - { name: chalk.dim("← Search again"), value: "__SEARCH_AGAIN__" }, - { name: chalk.dim("Exit"), value: "__EXIT__" }, - ] - - const filtered = skills - .filter((s) => { - const searchStr = (input || "").toLowerCase() - return ( - s.displayName?.toLowerCase().includes(searchStr) || - s.slug.toLowerCase().includes(searchStr) || - s.namespace.toLowerCase().includes(searchStr) || - s.description?.toLowerCase().includes(searchStr) - ) - }) - .map((s) => ({ - name: formatSkillDisplay(s), - value: s.id, - })) - - return Promise.resolve([...options, ...filtered]) - }, + type: "list", + name: "action", + message: "What would you like to do?", + choices: [ + { name: "↓ Install", value: "install" }, + { name: "← Back to skill list", value: "back" }, + { name: "← Search again", value: "search" }, + { name: "Exit", value: "exit" }, + ], }, ]) - if (selectedSkill === "__EXIT__") { - return null - } else if (selectedSkill === "__SEARCH_AGAIN__") { - searchTerm = await getSearchTerm() - continue - } - - // Show detailed view of selected skill - console.log() - const selectedSkillData = skills.find((s) => s.id === selectedSkill) - if (selectedSkillData) { - const displayName = - selectedSkillData.displayName || - `${selectedSkillData.namespace}/${selectedSkillData.slug}` - console.log(`${chalk.bold.cyan(displayName)}`) - console.log( - `${chalk.dim("Qualified name:")} ${selectedSkillData.namespace}/${selectedSkillData.slug}`, - ) - if (selectedSkillData.description) { - console.log( - `${chalk.dim("Description:")} ${selectedSkillData.description}`, - ) - } - if ( - selectedSkillData.categories && - selectedSkillData.categories.length > 0 - ) { - console.log( - `${chalk.dim("Categories:")} ${selectedSkillData.categories.join(", ")}`, - ) - } - if (selectedSkillData.externalStars) { - console.log( - `${chalk.dim("Stars:")} ${selectedSkillData.externalStars.toLocaleString()}`, - ) - } - console.log() - - // Show install command - const skillIdentifier = `${selectedSkillData.namespace}/${selectedSkillData.slug}` - console.log(chalk.bold("To install this skill, run:")) - console.log() - console.log( - chalk.cyan( - ` smithery skills install ${skillIdentifier} --agent `, - ), - ) - console.log() - - // Ask what to do next - const { action } = await inquirer.prompt([ + if (action === "install") { + const { agent } = await inquirer.prompt([ { type: "list", - name: "action", - message: "What would you like to do?", - choices: [ - { name: "↓ Install", value: "install" }, - { name: "← Back to skill list", value: "back" }, - { name: "← Search again", value: "search" }, - { name: "Exit", value: "exit" }, - ], + name: "agent", + message: "Select the agent to install to:", + choices: SKILL_AGENTS.map((a) => ({ name: a, value: a })), }, ]) - if (action === "install") { - // Prompt for agent selection - const { agent } = await inquirer.prompt([ - { - type: "list", - name: "agent", - message: "Select the agent to install to:", - choices: SKILL_AGENTS.map((a) => ({ name: a, value: a })), - }, - ]) - - await installSkill(skillIdentifier, agent) - return null - } else if (action === "back") { - console.log() - } else if (action === "search") { - searchTerm = await getSearchTerm() - } else { - return null // Exit - } + await installSkill(skillIdentifier, agent) + return null + } else if (action === "back") { + console.log() + } else if (action === "search") { + searchTerm = await getSearchTerm() + } else { + return null } } - } catch (error) { - console.error( - chalk.red("Error searching skills:"), - error instanceof Error ? error.message : String(error), - ) - process.exit(1) } } diff --git a/src/index.ts b/src/index.ts index 7cdc142..288a8d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -388,28 +388,65 @@ program program .command("search [term]") .description("Search for servers in the Smithery registry") - .option("--json", "Output results as JSON (non-interactive)") + .option("--json", "Output results as JSON") + .option("-i, --interactive", "Interactive search mode") + .option("--verified", "Only show verified servers") + .option("--limit ", "Max results per page", "10") + .option("--page ", "Page number", "1") .action(async (term, options) => { // API key is optional for search - use if available, don't prompt const apiKey = await getApiKey() + if (options.interactive) { + const { interactiveServerSearch } = await import( + "./utils/command-prompts" + ) + await interactiveServerSearch(apiKey, term) + return + } + + const { searchServers } = await import("./lib/registry") + const searchTerm = term ?? "" + const servers = await searchServers(searchTerm, apiKey, { + verified: options.verified, + pageSize: parseInt(options.limit, 10), + page: parseInt(options.page, 10), + }) + if (options.json) { - const { searchServers } = await import("./lib/registry") - // Non-interactive JSON output - if (!term) { - console.error( - chalk.red("Error: Search term is required when using --json"), - ) - process.exit(1) - } - const servers = await searchServers(term, apiKey) - console.log(JSON.stringify({ servers }, null, 2)) + const serversWithUrl = servers.map((server) => ({ + ...server, + connectionUrl: `https://server.smithery.ai/${server.qualifiedName}`, + })) + console.log(JSON.stringify({ servers: serversWithUrl }, null, 2)) return } - const { interactiveServerSearch } = await import("./utils/command-prompts") - await interactiveServerSearch(apiKey, term) - // @TODO: add install flow + if (servers.length === 0) { + console.log(chalk.yellow("No servers found.")) + return + } + + if (!term) { + console.log(chalk.bold("Most popular servers:\n")) + } + + const yaml = (await import("yaml")).default + const output = servers.map((server) => ({ + name: server.displayName || server.qualifiedName, + qualifiedName: server.qualifiedName, + ...(server.description && { description: server.description }), + ...(server.verified && { verified: true }), + useCount: server.useCount, + connectionUrl: `https://server.smithery.ai/${server.qualifiedName}`, + })) + console.log(yaml.stringify(output).replace(/\n\n/g, "\n").trimEnd()) + console.log() + console.log( + chalk.dim( + "Tip: Use --json for machine-readable output. Use smithery connect add to connect a server.", + ), + ) }) // Login command @@ -628,6 +665,10 @@ connect .option("--metadata ", "Custom metadata as JSON object") .option("--headers ", "Custom headers as JSON object (stored securely)") .option("--namespace ", "Target namespace") + .option( + "--force", + "Create a new connection even if one already exists for this URL", + ) .action(async (mcpUrl, options) => { const { addServer } = await import("./commands/connect") await addServer(mcpUrl, options) @@ -755,12 +796,10 @@ skills }) skills - .command("search [query]") + .command("search ") .description("Search for skills in the Smithery registry") - .option( - "--json", - "Print search results as JSON without interactive selection", - ) + .option("--json", "Output results as JSON") + .option("-i, --interactive", "Interactive search mode") .option("--limit ", "Maximum number of results to show", "10") .option("--page ", "Page number", "1") .option("--namespace ", "Filter by namespace") @@ -768,6 +807,7 @@ skills const { searchSkills } = await import("./commands/skills") await searchSkills(query, { json: options.json, + interactive: options.interactive, limit: Number.parseInt(options.limit, 10), page: Number.parseInt(options.page, 10), namespace: options.namespace, diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 74f01c5..f3ecbde 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -129,6 +129,7 @@ export const resolveServer = async ( export const searchServers = async ( searchTerm: string, apiKey?: string, + filters?: { verified?: boolean; pageSize?: number; page?: number }, ): Promise< Array<{ qualifiedName: string @@ -145,7 +146,9 @@ export const searchServers = async ( try { const response = await smithery.servers.list({ q: searchTerm, - pageSize: 10, + pageSize: filters?.pageSize ?? 10, + page: filters?.page, + ...(filters?.verified && { verified: "true" }), }) const servers = (response.servers || []).map((server) => ({