From 0a63798ae3775bcccad72fcf3070439808b8e7df Mon Sep 17 00:00:00 2001 From: Kody <72270156+kody-bot@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:57:46 -0600 Subject: [PATCH 1/2] feat: add kody install for running local MCP clients Detect currently running local agents, write host MCP config through add-mcp (plus leftover writers and vendor CLIs), skip web clients, and print a copy-paste onboarding prompt. Claude Desktop stays Connectors-UI only. Help points at kody.codes/onboarding for ChatGPT, Claude.ai, and Grok. --- README.md | 12 ++ package.json | 5 +- skills/kody/SKILL.md | 10 + src/add-mcp-install.ts | 73 +++++++ src/cli.ts | 19 ++ src/defaults.ts | 6 + src/detect-running-hosts.ts | 18 ++ src/help.ts | 10 +- src/host-catalog.ts | 413 ++++++++++++++++++++++++++++++++++++ src/host-config.ts | 102 +++++++++ src/install-hosts.ts | 405 +++++++++++++++++++++++++++++++++++ src/install.ts | 154 ++++++++++++++ src/onboarding-prompt.ts | 16 ++ src/process-info.ts | 21 ++ src/which.ts | 21 ++ test/cli.test.ts | 1 + test/host-catalog.test.ts | 94 ++++++++ test/host-config.test.ts | 42 ++++ test/install.test.ts | 170 +++++++++++++++ 19 files changed, 1590 insertions(+), 2 deletions(-) create mode 100644 src/add-mcp-install.ts create mode 100644 src/detect-running-hosts.ts create mode 100644 src/host-catalog.ts create mode 100644 src/host-config.ts create mode 100644 src/install-hosts.ts create mode 100644 src/install.ts create mode 100644 src/onboarding-prompt.ts create mode 100644 src/process-info.ts create mode 100644 src/which.ts create mode 100644 test/host-catalog.test.ts create mode 100644 test/host-config.test.ts create mode 100644 test/install.test.ts diff --git a/README.md b/README.md index dc29bd5..b8fd991 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Turn one-off agent work into something you can rerun: a local MCP client for with Client ID Metadata Documents (SEP-991). ```bash +npx @kodycodes/cli install npx @kodycodes/cli login npx @kodycodes/cli search "what can you do" npx @kodycodes/cli skill install @@ -26,6 +27,7 @@ Or run via `npx @kodycodes/cli` without a global install. | Command | Purpose | | --- | --- | +| `kody install` | Detect running local MCP clients, write their config, and start host OAuth. | | `kody login` | Browser OAuth (CIMD + PKCE). Stores access and refresh tokens. | | `kody logout` | Deletes stored credentials. | | `kody status` | Shows login state without printing secrets. | @@ -34,6 +36,16 @@ Or run via `npx @kodycodes/cli` without a global install. | `kody execute` | Calls Kody `execute` (`--code`, `--file`, or stdin via `--file -`). | | `kody skill install` | Copies the getting-started skill into Claude Code / Cursor / Agents. | +`kody install` lists **running local** agents (Cursor, Claude Desktop, VS Code, +Goose, Claude Code, Codex, Windsurf, Zed, and similar) and writes each host's +remote MCP entry for `https://kody.codes/mcp`. Common host formats go through +[`add-mcp`](https://www.npmjs.com/package/add-mcp). It does not list web clients. +For ChatGPT, Claude.ai, and Grok, use [kody.codes/onboarding](https://kody.codes/onboarding). + +After install, the CLI prints a prompt you can paste into the configured agent +to continue onboarding. Host OAuth stays in that client — `kody login` is only +for the CLI itself. + `--mcp-url` or `KODY_MCP_URL` overrides the default `https://kody.codes/mcp`. `--json` prints structured MCP results. diff --git a/package.json b/package.json index f345ea1..2e09d8c 100644 --- a/package.json +++ b/package.json @@ -47,8 +47,11 @@ "access": "public" }, "dependencies": { + "@inquirer/checkbox": "^5.2.2", "@modelcontextprotocol/client": "2.0.0", - "@napi-rs/keyring": "^1.3.0" + "@napi-rs/keyring": "^1.3.0", + "add-mcp": "^2.0.0", + "ps-list": "^9.0.0" }, "devDependencies": { "@types/node": "^24.5.2", diff --git a/skills/kody/SKILL.md b/skills/kody/SKILL.md index 7b910b6..4599199 100644 --- a/skills/kody/SKILL.md +++ b/skills/kody/SKILL.md @@ -25,6 +25,16 @@ Then install this skill into the current host if it is not already present: npx @kodycodes/cli skill install ``` +To add Kody as a remote MCP server in running local agents: + +```bash +npx @kodycodes/cli install +``` + +That command only lists local clients that are currently running. For web-based +clients (ChatGPT, Claude.ai, Grok), point the user at +https://kody.codes/onboarding. + ## Login ```bash diff --git a/src/add-mcp-install.ts b/src/add-mcp-install.ts new file mode 100644 index 0000000..a7f7ee1 --- /dev/null +++ b/src/add-mcp-install.ts @@ -0,0 +1,73 @@ +import { + upsertServer, + type AgentType, + type InstallResult, + type McpServerConfig, +} from 'add-mcp' +import type { HostId } from './host-catalog.js' + +/** + * Hosts whose on-disk MCP config `add-mcp` already knows how to merge. + * Claude Desktop is intentionally omitted: remote MCP lives in Connectors, not + * `claude_desktop_config.json`. + */ +export const addMcpAgentByHostId = { + antigravity: 'antigravity', + cline: 'cline', + 'cline-cli': 'cline-cli', + 'claude-code': 'claude-code', + codex: 'codex', + cursor: 'cursor', + 'gemini-cli': 'gemini-cli', + goose: 'goose', + 'copilot-cli': 'github-copilot-cli', + 'grok-build': 'grok-build', + mcporter: 'mcporter', + opencode: 'opencode', + vscode: 'vscode', + windsurf: 'windsurf', + zed: 'zed', +} as const satisfies Partial> + +export type AddMcpHostId = keyof typeof addMcpAgentByHostId + +const addMcpProjectHosts = new Set([ + 'claude-code', + 'codex', + 'cursor', + 'gemini-cli', + 'copilot-cli', + 'grok-build', + 'mcporter', + 'opencode', + 'vscode', + 'zed', +]) + +export type UpsertServerFn = ( + agentType: AgentType, + serverName: string, + serverConfig: McpServerConfig, + options?: { local?: boolean; cwd?: string }, +) => InstallResult + +export function isAddMcpHostId(id: HostId): id is AddMcpHostId { + return Object.hasOwn(addMcpAgentByHostId, id) +} + +export function addMcpUsesProjectScope(id: AddMcpHostId, project: boolean): boolean { + return project && addMcpProjectHosts.has(id) +} + +export function kodyRemoteConfig(mcpUrl: string): McpServerConfig { + return { type: 'http', url: mcpUrl } +} + +export function defaultUpsertServer( + agentType: AgentType, + serverName: string, + serverConfig: McpServerConfig, + options?: { local?: boolean; cwd?: string }, +): InstallResult { + return upsertServer(agentType, serverName, serverConfig, options) +} diff --git a/src/cli.ts b/src/cli.ts index b2aa578..3d5808e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,7 @@ import { usage } from './help.js' import { ensureFreshCredentials, login } from './auth.js' import { deleteCredentials, loadCredentials } from './store.js' import { callKodyTool, formatToolResult, listKodyTools } from './mcp.js' +import { runInstall } from './install.js' import { installSkill } from './skill.js' import { readPackageVersion } from './package-info.js' import { redactError } from './redact.js' @@ -16,6 +17,7 @@ export type CommandName = | 'whoami' | 'search' | 'execute' + | 'install' | 'skill' | 'help' | 'version' @@ -43,6 +45,8 @@ function parseKnown(args: Array) { 'conversation-id': { type: 'string' }, project: { type: 'boolean' }, 'no-browser': { type: 'boolean' }, + clients: { type: 'string' }, + yes: { type: 'boolean', short: 'y' }, }, }) } @@ -68,6 +72,7 @@ export function resolveCommand(argv: Array): { case 'whoami': case 'search': case 'execute': + case 'install': case 'skill': case 'help': case 'version': @@ -214,6 +219,20 @@ async function dispatch( write(formatToolResult(result, json)) return result.isError ? 1 : 0 } + case 'install': { + const result = await runInstall( + { + mcpUrl, + clients: + typeof parsed.values.clients === 'string' ? parsed.values.clients : undefined, + yes: parsed.values.yes === true, + project: parsed.values.project === true, + json, + }, + { stdout: write }, + ) + return result.code + } case 'skill': { const action = parsed.positionals[0] ?? 'install' if (action !== 'install') { diff --git a/src/defaults.ts b/src/defaults.ts index e858878..7160f92 100644 --- a/src/defaults.ts +++ b/src/defaults.ts @@ -18,3 +18,9 @@ export function cliClientMetadataUrl(mcpUrl: string): string { export function cliRedirectUrl(port: number = oauthCallbackPort): URL { return new URL(`http://127.0.0.1:${port}/callback`) } + +export const onboardingPath = '/onboarding' + +export function onboardingUrl(mcpUrl: string): string { + return new URL(onboardingPath, mcpUrl).href +} diff --git a/src/detect-running-hosts.ts b/src/detect-running-hosts.ts new file mode 100644 index 0000000..7571192 --- /dev/null +++ b/src/detect-running-hosts.ts @@ -0,0 +1,18 @@ +import psList from 'ps-list' +import { hostCatalog, type HostDefinition } from './host-catalog.js' +import type { ProcessInfo } from './process-info.js' + +export async function listRunningProcesses(): Promise> { + const processes = await psList() + return processes.map((process) => ({ + name: process.name, + cmd: process.cmd, + })) +} + +export function detectRunningHosts( + processes: Array, + catalog: ReadonlyArray = hostCatalog, +): Array { + return catalog.filter((host) => processes.some((process) => host.matches(process))) +} diff --git a/src/help.ts b/src/help.ts index d31334d..8cced59 100644 --- a/src/help.ts +++ b/src/help.ts @@ -1,4 +1,5 @@ -import { defaultMcpUrl } from './defaults.js' +import { defaultMcpUrl, onboardingUrl } from './defaults.js' +import { hostIds } from './host-catalog.js' import { readPackageVersion } from './package-info.js' export const usage = `Kody CLI ${readPackageVersion()} @@ -12,8 +13,15 @@ Usage: kody whoami [--mcp-url ] [--json] kody search [query] [--entity ] [--domain ] [--limit ] [--json] kody execute [--code ] [--file ] [--params ] [--conversation-id ] [--json] + kody install [--mcp-url ] [--clients ] [--yes] [--project] [--json] kody skill install [--project] + kody install configures running local MCP clients (Cursor, Claude Desktop, + VS Code, Goose, and others). For web-based clients (ChatGPT, Claude.ai, Grok), + see ${onboardingUrl(defaultMcpUrl)} + + --clients Comma-separated ids: ${hostIds.join(', ')} + Environment: KODY_MCP_URL Override the default MCP URL (${defaultMcpUrl}) ` diff --git a/src/host-catalog.ts b/src/host-catalog.ts new file mode 100644 index 0000000..d0fb183 --- /dev/null +++ b/src/host-catalog.ts @@ -0,0 +1,413 @@ +import { join } from 'node:path' +import { haystackMatches, nameEquals, type ProcessInfo } from './process-info.js' + +export const hostIds = [ + 'amazon-q', + 'antigravity', + 'claude-code', + 'claude-desktop', + 'cline', + 'cline-cli', + 'codex', + 'copilot-cli', + 'cursor', + 'gemini-cli', + 'goose', + 'grok-build', + 'jetbrains', + 'mcporter', + 'opencode', + 'qwen-code', + 'visual-studio', + 'vscode', + 'vscode-insiders', + 'windsurf', + 'zed', +] as const + +export type HostId = (typeof hostIds)[number] + +export type HostKind = 'file' | 'command' | 'manual' + +export type HostDefinition = { + id: HostId + label: string + kind: HostKind + matches: (process: ProcessInfo) => boolean +} + +const hostAliases: Record = { + 'cline-vscode': 'cline', + codeium: 'windsurf', + cascade: 'windsurf', + gemini: 'gemini-cli', + 'github-copilot-cli': 'copilot-cli', +} + +function isClaudeDesktop(process: ProcessInfo): boolean { + if (haystackMatches(process, /Claude\.app|claude-desktop|Claude Helper/iu)) { + return true + } + const raw = process.name.replace(/\.exe$/iu, '').trim() + return raw === 'Claude' || raw.startsWith('Claude ') +} + +export function isHostId(value: string): value is HostId { + return (hostIds as ReadonlyArray).includes(value) +} + +export function parseHostIds(raw: string): Array { + const ids = raw + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + if (ids.length === 0) { + throw new Error(`Provide at least one client id. Known: ${hostIds.join(', ')}`) + } + const known: Array = [] + for (const id of ids) { + if (id === 'grok' || id === 'chatgpt' || id === 'claude.ai') { + throw new Error( + `"${id}" is a web client. For the local Grok Build CLI use grok-build. For web-based clients see https://kody.codes/onboarding`, + ) + } + const resolved = hostAliases[id] ?? id + if (!isHostId(resolved)) { + throw new Error(`Unknown client "${id}". Known: ${hostIds.join(', ')}`) + } + known.push(resolved) + } + return known +} + +const jetbrainsNames = [ + 'clion', + 'datagrip', + 'goland', + 'idea', + 'idea64', + 'intellij', + 'phpstorm', + 'pycharm', + 'pycharm64', + 'rider', + 'rubymine', + 'rustrover', + 'webstorm', + 'webstorm64', +] + +export const hostCatalog: ReadonlyArray = [ + { + id: 'cursor', + label: 'Cursor', + kind: 'file', + matches: (process) => + nameEquals(process, 'cursor') || haystackMatches(process, /Cursor\.app/iu), + }, + { + id: 'vscode-insiders', + label: 'VS Code Insiders', + kind: 'file', + matches: (process) => + /insiders/iu.test(process.name) && + (/code/iu.test(process.name) || haystackMatches(process, /code/iu)), + }, + { + id: 'vscode', + label: 'VS Code', + kind: 'file', + matches: (process) => { + if (haystackMatches(process, /insiders|cursor|windsurf|antigravity/iu)) return false + const base = process.name.replace(/\.exe$/iu, '').trim().toLowerCase() + return ( + base === 'code' || + base === 'code - oss' || + base.startsWith('code helper') || + haystackMatches(process, /Visual Studio Code\.app/iu) + ) + }, + }, + { + id: 'claude-desktop', + label: 'Claude Desktop', + kind: 'manual', + matches: isClaudeDesktop, + }, + { + id: 'claude-code', + label: 'Claude Code', + kind: 'command', + matches: (process) => nameEquals(process, 'claude') && !isClaudeDesktop(process), + }, + { + id: 'codex', + label: 'Codex', + kind: 'file', + matches: (process) => + nameEquals(process, 'codex') || haystackMatches(process, /Codex\.app/iu), + }, + { + id: 'opencode', + label: 'OpenCode', + kind: 'file', + matches: (process) => nameEquals(process, 'opencode'), + }, + { + id: 'goose', + label: 'Goose', + kind: 'file', + matches: (process) => + nameEquals(process, 'goose', 'goosed') || haystackMatches(process, /Goose\.app/iu), + }, + { + id: 'windsurf', + label: 'Windsurf', + kind: 'file', + matches: (process) => + nameEquals(process, 'windsurf') || haystackMatches(process, /Windsurf\.app/iu), + }, + { + id: 'zed', + label: 'Zed', + kind: 'file', + matches: (process) => nameEquals(process, 'zed') || haystackMatches(process, /Zed\.app/iu), + }, + { + id: 'copilot-cli', + label: 'Copilot CLI', + kind: 'command', + matches: (process) => nameEquals(process, 'copilot'), + }, + { + id: 'gemini-cli', + label: 'Gemini CLI', + kind: 'command', + matches: (process) => nameEquals(process, 'gemini'), + }, + { + id: 'qwen-code', + label: 'Qwen Code', + kind: 'file', + matches: (process) => nameEquals(process, 'qwen'), + }, + { + id: 'amazon-q', + label: 'Amazon Q', + kind: 'file', + matches: (process) => + haystackMatches(process, /amazon.?q/iu) || + (nameEquals(process, 'q') && haystackMatches(process, /amazon/iu)), + }, + { + id: 'antigravity', + label: 'Antigravity', + kind: 'file', + matches: (process) => + nameEquals(process, 'antigravity') || haystackMatches(process, /Antigravity\.app/iu), + }, + { + id: 'cline', + label: 'Cline', + kind: 'file', + matches: (process) => nameEquals(process, 'cline-vscode') || haystackMatches(process, /cline-vscode/iu), + }, + { + id: 'cline-cli', + label: 'Cline CLI', + kind: 'command', + matches: (process) => nameEquals(process, 'cline'), + }, + { + id: 'grok-build', + label: 'Grok Build', + kind: 'file', + matches: (process) => nameEquals(process, 'grok'), + }, + { + id: 'mcporter', + label: 'MCPorter', + kind: 'file', + matches: (process) => nameEquals(process, 'mcporter'), + }, + { + id: 'visual-studio', + label: 'Visual Studio', + kind: 'file', + matches: (process) => nameEquals(process, 'devenv'), + }, + { + id: 'jetbrains', + label: 'JetBrains IDE', + kind: 'manual', + matches: (process) => + nameEquals(process, ...jetbrainsNames) || + haystackMatches( + process, + /IntelliJ IDEA|PyCharm|WebStorm|GoLand|RustRover|PhpStorm|CLion|Rider|RubyMine|DataGrip/u, + ), + }, +] + +export function hostById(id: HostId): HostDefinition { + const host = hostCatalog.find((candidate) => candidate.id === id) + if (!host) throw new Error(`Unknown host: ${id}`) + return host +} + +export function configHome(home: string, platform: NodeJS.Platform = process.platform): string { + return platform === 'win32' ? join(home, 'AppData', 'Roaming') : join(home, '.config') +} + +export function applicationSupport( + home: string, + app: string, + platform: NodeJS.Platform = process.platform, +): string { + if (platform === 'darwin') return join(home, 'Library', 'Application Support', app) + if (platform === 'win32') return join(home, 'AppData', 'Roaming', app) + return join(home, '.config', app) +} + +export function hostConfigPath(input: { + id: HostId + home: string + cwd: string + project: boolean + platform?: NodeJS.Platform +}): string | null { + const platform = input.platform ?? process.platform + if (input.project) { + switch (input.id) { + case 'cursor': + return join(input.cwd, '.cursor', 'mcp.json') + case 'vscode': + case 'vscode-insiders': + case 'copilot-cli': + return join(input.cwd, '.vscode', 'mcp.json') + case 'claude-code': + return join(input.cwd, '.mcp.json') + case 'opencode': + return join(input.cwd, 'opencode.json') + case 'codex': + return join(input.cwd, '.codex', 'config.toml') + case 'gemini-cli': + return join(input.cwd, '.gemini', 'settings.json') + case 'qwen-code': + return join(input.cwd, '.qwen', 'settings.json') + case 'amazon-q': + return join(input.cwd, '.amazonq', 'mcp.json') + case 'zed': + return join(input.cwd, '.zed', 'settings.json') + case 'grok-build': + return join(input.cwd, '.grok', 'config.toml') + case 'mcporter': + return join(input.cwd, 'config', 'mcporter.json') + case 'antigravity': + case 'claude-desktop': + case 'cline': + case 'cline-cli': + case 'goose': + case 'windsurf': + case 'visual-studio': + case 'jetbrains': + return null + default: { + const exhaustive: never = input.id + return exhaustive + } + } + } + switch (input.id) { + case 'cursor': + return join(input.home, '.cursor', 'mcp.json') + case 'vscode': + return join(applicationSupport(input.home, 'Code', platform), 'User', 'mcp.json') + case 'vscode-insiders': + return join(applicationSupport(input.home, 'Code - Insiders', platform), 'User', 'mcp.json') + case 'claude-code': + return join(input.home, '.claude.json') + case 'codex': + return join(input.home, '.codex', 'config.toml') + case 'opencode': + return join(configHome(input.home, platform), 'opencode', 'opencode.json') + case 'goose': + return join(configHome(input.home, platform), 'goose', 'config.yaml') + case 'windsurf': + return join(input.home, '.codeium', 'windsurf', 'mcp_config.json') + case 'zed': + return join(configHome(input.home, platform), 'zed', 'settings.json') + case 'copilot-cli': + return join(input.home, '.copilot', 'mcp-config.json') + case 'gemini-cli': + return join(input.home, '.gemini', 'settings.json') + case 'qwen-code': + return join(input.home, '.qwen', 'settings.json') + case 'amazon-q': + return join(input.home, '.aws', 'amazonq', 'mcp.json') + case 'antigravity': + return join(input.home, '.gemini', 'config', 'mcp_config.json') + case 'cline': + return join( + applicationSupport(input.home, 'Code', platform), + 'User', + 'globalStorage', + 'saoudrizwan.claude-dev', + 'settings', + 'cline_mcp_settings.json', + ) + case 'cline-cli': + return join(input.home, '.cline', 'data', 'settings', 'cline_mcp_settings.json') + case 'grok-build': + return join(input.home, '.grok', 'config.toml') + case 'mcporter': + return join(input.home, '.mcporter', 'mcporter.json') + case 'visual-studio': + return join(input.home, '.mcp.json') + case 'claude-desktop': + case 'jetbrains': + return null + default: { + const exhaustive: never = input.id + return exhaustive + } + } +} + +export function encodeBase64Url(value: string): string { + return Buffer.from(value) + .toString('base64') + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/u, '') +} + +export function buildCursorInstallUrl(mcpServerUrl: string): string { + const config = encodeBase64Url(JSON.stringify({ url: mcpServerUrl })) + return `cursor://anysphere.cursor-deeplink/mcp/install?name=kody&config=${config}` +} + +export function buildVsCodeInstallUrl( + mcpServerUrl: string, + scheme: 'vscode' | 'vscode-insiders' = 'vscode', +): string { + const config = encodeURIComponent( + JSON.stringify({ + name: 'kody', + type: 'http', + url: mcpServerUrl, + }), + ) + return `${scheme}:mcp/install?${config}` +} + +export function buildGooseInstallUrl(mcpServerUrl: string): string { + const params = new URLSearchParams({ + url: mcpServerUrl, + type: 'streamable_http', + id: 'kody', + name: 'Kody', + description: 'Kody personal assistant', + }) + return `goose://extension?${params.toString()}` +} diff --git a/src/host-config.ts b/src/host-config.ts new file mode 100644 index 0000000..f1b38f5 --- /dev/null +++ b/src/host-config.ts @@ -0,0 +1,102 @@ +import { dirname } from 'node:path' + +export const leftoverHostIds = [ + 'amazon-q', + 'qwen-code', + 'visual-studio', + 'vscode-insiders', +] as const + +export type LeftoverHostId = (typeof leftoverHostIds)[number] + +function asObject(value: unknown, label: string): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record + } + throw new Error(`${label} must be a JSON object.`) +} + +function nestedObject(parent: Record, key: string): Record { + const current = parent[key] + if (current == null) { + const next = {} + parent[key] = next + return next + } + return asObject(current, key) +} + +export function leftoverRemoteEntry( + id: LeftoverHostId, + mcpUrl: string, +): Record { + switch (id) { + case 'vscode-insiders': + case 'visual-studio': + case 'amazon-q': + return { type: 'http', url: mcpUrl } + case 'qwen-code': + return { httpUrl: mcpUrl } + default: { + const exhaustive: never = id + return exhaustive + } + } +} + +function jsonRootKey(id: LeftoverHostId): 'mcpServers' | 'servers' { + switch (id) { + case 'vscode-insiders': + case 'visual-studio': + return 'servers' + case 'amazon-q': + case 'qwen-code': + return 'mcpServers' + default: { + const exhaustive: never = id + return exhaustive + } + } +} + +export function sameRemoteUrl(entry: unknown, mcpUrl: string): boolean { + if (!entry || typeof entry !== 'object') return false + const record = entry as Record + const candidates = [record.url, record.serverUrl, record.httpUrl, record.uri] + return candidates.some((value) => value === mcpUrl) +} + +export function mergeHostDocument(input: { + id: LeftoverHostId + existing: string | null + mcpUrl: string +}): { body: string; changed: boolean } { + const entry = leftoverRemoteEntry(input.id, input.mcpUrl) + const parsed = input.existing?.trim() ? JSON.parse(input.existing) : {} + const root = asObject(parsed, 'MCP config') + const bucket = nestedObject(root, jsonRootKey(input.id)) + const changed = !sameRemoteUrl(bucket.kody, input.mcpUrl) + bucket.kody = entry + return { body: `${JSON.stringify(root, null, 2)}\n`, changed } +} + +export async function writeMergedConfig(input: { + path: string + id: LeftoverHostId + mcpUrl: string + readFile: (path: string) => Promise + writeFile: (path: string, body: string) => Promise + mkdir: (path: string) => Promise +}): Promise<{ path: string; changed: boolean }> { + const existing = await input.readFile(input.path) + const merged = mergeHostDocument({ + id: input.id, + existing, + mcpUrl: input.mcpUrl, + }) + if (merged.changed) { + await input.mkdir(dirname(input.path)) + await input.writeFile(input.path, merged.body) + } + return { path: input.path, changed: merged.changed } +} diff --git a/src/install-hosts.ts b/src/install-hosts.ts new file mode 100644 index 0000000..09a0434 --- /dev/null +++ b/src/install-hosts.ts @@ -0,0 +1,405 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { spawn } from 'node:child_process' +import { + addMcpAgentByHostId, + addMcpUsesProjectScope, + defaultUpsertServer, + isAddMcpHostId, + kodyRemoteConfig, + type AddMcpHostId, + type UpsertServerFn, +} from './add-mcp-install.js' +import { + buildCursorInstallUrl, + buildGooseInstallUrl, + buildVsCodeInstallUrl, + hostById, + hostConfigPath, + type HostId, +} from './host-catalog.js' +import { writeMergedConfig, type LeftoverHostId } from './host-config.js' +import { openUrl } from './open-url.js' +import { which as whichOnPath } from './which.js' + +export type ApplyStatus = 'wrote' | 'unchanged' | 'manual' | 'command' + +export type ApplyResult = { + id: HostId + label: string + status: ApplyStatus + path?: string + opened?: string + command?: string + instructions: Array +} + +export type InstallRuntime = { + home: string + cwd: string + project: boolean + platform?: NodeJS.Platform + which?: (command: string) => string | undefined + openUrl?: (url: string) => Promise + openApp?: (name: string) => Promise + runCommand?: (command: string, args: Array) => Promise + readFile?: (path: string) => Promise + writeFile?: (path: string, body: string) => Promise + mkdir?: (path: string) => Promise + upsertServer?: UpsertServerFn +} + +const oauthNote = 'Approve Kody in the browser when the client starts OAuth.' + +async function defaultReadFile(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch { + return null + } +} + +async function defaultRunCommand(command: string, args: Array): Promise { + return await new Promise((resolve) => { + const child = spawn(command, args, { stdio: 'ignore' }) + child.on('error', () => resolve(false)) + child.on('exit', (code) => resolve(code === 0)) + }) +} + +async function defaultOpenApp(name: string): Promise { + if (process.platform === 'darwin') return defaultRunCommand('open', ['-a', name]) + if (process.platform === 'win32') return defaultRunCommand('cmd', ['/c', 'start', '', name]) + return defaultRunCommand(name, []) +} + +function runtimeMethods(runtime: InstallRuntime) { + return { + which: runtime.which ?? whichOnPath, + openUrl: runtime.openUrl ?? openUrl, + openApp: runtime.openApp ?? defaultOpenApp, + runCommand: runtime.runCommand ?? defaultRunCommand, + readFile: runtime.readFile ?? defaultReadFile, + writeFile: + runtime.writeFile ?? ((path: string, body: string) => writeFile(path, body, 'utf8')), + mkdir: runtime.mkdir ?? ((path: string) => mkdir(path, { recursive: true }).then(() => undefined)), + upsertServer: runtime.upsertServer ?? defaultUpsertServer, + } +} + +async function writeLeftoverFile( + id: LeftoverHostId, + mcpUrl: string, + runtime: InstallRuntime, +): Promise<{ path: string; changed: boolean } | null> { + const path = hostConfigPath({ + id, + home: runtime.home, + cwd: runtime.cwd, + project: runtime.project, + platform: runtime.platform, + }) + if (!path) return null + const io = runtimeMethods(runtime) + return await writeMergedConfig({ + path, + id, + mcpUrl, + readFile: io.readFile, + writeFile: io.writeFile, + mkdir: io.mkdir, + }) +} + +function writeAddMcpHost( + id: AddMcpHostId, + mcpUrl: string, + runtime: InstallRuntime, +): { path?: string; changed: boolean; error?: string } { + const result = runtimeMethods(runtime).upsertServer( + addMcpAgentByHostId[id], + 'kody', + kodyRemoteConfig(mcpUrl), + { + local: addMcpUsesProjectScope(id, runtime.project), + cwd: runtime.cwd, + }, + ) + if (!result.success) { + return { changed: false, error: result.error ?? `add-mcp could not configure ${id}.` } + } + return { path: result.path, changed: true } +} + +async function tryCommand( + runtime: InstallRuntime, + command: string, + args: Array, +): Promise<{ ran: boolean; command: string }> { + const resolved = runtimeMethods(runtime).which(command) + const line = `${command} ${args.join(' ')}` + if (!resolved) return { ran: false, command: line } + const ok = await runtimeMethods(runtime).runCommand(resolved, args) + return { ran: ok, command: line } +} + +export async function applyHost(input: { + id: HostId + mcpUrl: string + runtime: InstallRuntime +}): Promise { + const host = hostById(input.id) + const io = runtimeMethods(input.runtime) + switch (input.id) { + case 'claude-desktop': { + const opened = await io.openApp('Claude') + return { + id: input.id, + label: host.label, + status: 'manual', + opened: opened ? 'Claude' : undefined, + instructions: [ + 'Open Settings → Connectors and add a custom connector with this MCP URL.', + 'Do not put the remote URL in claude_desktop_config.json.', + 'After connecting, start a new chat and ask Claude to list Kody tools.', + oauthNote, + ], + } + } + case 'jetbrains': + return { + id: input.id, + label: host.label, + status: 'manual', + instructions: [ + 'Open Settings → Tools → AI Assistant → MCP and add a remote HTTP server named kody.', + oauthNote, + ], + } + case 'claude-code': { + const added = await tryCommand(input.runtime, 'claude', [ + 'mcp', + 'add', + '--transport', + 'http', + '-s', + input.runtime.project ? 'project' : 'user', + 'kody', + input.mcpUrl, + ]) + if (added.ran) { + return { + id: input.id, + label: host.label, + status: 'command', + command: added.command, + instructions: [oauthNote], + } + } + return addMcpResult(input, [ + `Or run: claude mcp add --transport http -s user kody ${input.mcpUrl}`, + oauthNote, + ]) + } + case 'copilot-cli': { + const added = await tryCommand(input.runtime, 'copilot', [ + 'mcp', + 'add', + '--transport', + 'http', + 'kody', + input.mcpUrl, + ]) + if (added.ran) { + return { + id: input.id, + label: host.label, + status: 'command', + command: added.command, + instructions: [oauthNote], + } + } + return addMcpResult(input, [ + `Or run: copilot mcp add --transport http kody ${input.mcpUrl}`, + oauthNote, + ]) + } + case 'gemini-cli': { + const added = await tryCommand(input.runtime, 'gemini', [ + 'mcp', + 'add', + '--transport', + 'http', + '--scope', + input.runtime.project ? 'project' : 'user', + 'kody', + input.mcpUrl, + ]) + if (added.ran) { + return { + id: input.id, + label: host.label, + status: 'command', + command: added.command, + instructions: [oauthNote], + } + } + return addMcpResult(input, [ + `Or run: gemini mcp add --transport http --scope user kody ${input.mcpUrl}`, + oauthNote, + ]) + } + case 'cursor': { + const written = writeMappedHost(input) + const url = buildCursorInstallUrl(input.mcpUrl) + const opened = written.status === 'wrote' ? await io.openUrl(url) : false + return { + ...written, + opened: opened ? url : undefined, + instructions: [ + 'Reload MCP servers in Cursor if the new server does not appear.', + oauthNote, + ], + } + } + case 'vscode': { + const written = writeMappedHost(input) + const url = buildVsCodeInstallUrl(input.mcpUrl) + const opened = written.status === 'wrote' ? await io.openUrl(url) : false + return { + ...written, + opened: opened ? url : undefined, + instructions: [ + 'Use Agent mode in Copilot Chat after the server is added.', + oauthNote, + ], + } + } + case 'vscode-insiders': { + const written = await writeLeftoverFile(input.id, input.mcpUrl, input.runtime) + const url = buildVsCodeInstallUrl(input.mcpUrl, 'vscode-insiders') + const opened = written?.changed ? await io.openUrl(url) : false + return leftoverResult(host.label, input.id, written, [ + 'Use Agent mode in Copilot Chat after the server is added.', + oauthNote, + ], opened ? url : undefined) + } + case 'goose': { + const written = writeMappedHost(input) + const url = buildGooseInstallUrl(input.mcpUrl) + const opened = written.status === 'wrote' ? await io.openUrl(url) : false + return { + ...written, + opened: opened ? url : undefined, + instructions: ['Restart Goose if the extension does not appear.', oauthNote], + } + } + case 'codex': { + const written = writeMappedHost(input) + if (written.status === 'wrote') { + await tryCommand(input.runtime, 'codex', ['mcp', 'login', 'kody']) + } + return { + ...written, + instructions: ['If OAuth does not start, run: codex mcp login kody', oauthNote], + } + } + case 'opencode': { + const written = writeMappedHost(input) + if (written.status === 'wrote') { + await tryCommand(input.runtime, 'opencode', ['mcp', 'auth', 'kody']) + } + return { + ...written, + instructions: ['If OAuth does not start, run: opencode mcp auth kody', oauthNote], + } + } + case 'windsurf': + case 'zed': + case 'antigravity': + case 'cline': + case 'cline-cli': + case 'grok-build': + case 'mcporter': + return addMcpResult(input, [ + `Restart ${host.label} if the new server does not appear.`, + oauthNote, + ]) + case 'qwen-code': + case 'amazon-q': + case 'visual-studio': { + const written = await writeLeftoverFile(input.id, input.mcpUrl, input.runtime) + return leftoverResult(host.label, input.id, written, [ + `Restart ${host.label} if the new server does not appear.`, + oauthNote, + ]) + } + default: { + const exhaustive: never = input.id + throw new Error(`Unhandled host: ${String(exhaustive)}`) + } + } +} + +function writeMappedHost(input: { + id: HostId + mcpUrl: string + runtime: InstallRuntime +}): ApplyResult { + const host = hostById(input.id) + if (!isAddMcpHostId(input.id)) { + return { + id: input.id, + label: host.label, + status: 'manual', + instructions: [`${host.label} has no add-mcp writer.`], + } + } + const written = writeAddMcpHost(input.id, input.mcpUrl, input.runtime) + if (written.error) { + return { + id: input.id, + label: host.label, + status: 'manual', + instructions: [written.error, oauthNote], + } + } + return { + id: input.id, + label: host.label, + status: written.changed ? 'wrote' : 'unchanged', + path: written.path, + instructions: [], + } +} + +function addMcpResult( + input: { id: HostId; mcpUrl: string; runtime: InstallRuntime }, + instructions: Array, +): ApplyResult { + return { ...writeMappedHost(input), instructions } +} + +function leftoverResult( + label: string, + id: LeftoverHostId, + written: { path: string; changed: boolean } | null, + instructions: Array, + opened?: string, +): ApplyResult { + if (!written) { + return { + id, + label, + status: 'manual', + instructions: [`${label} has no automatic config path for this scope.`, ...instructions], + } + } + return { + id, + label, + status: written.changed ? 'wrote' : 'unchanged', + path: written.path, + opened, + instructions, + } +} diff --git a/src/install.ts b/src/install.ts new file mode 100644 index 0000000..8fe7337 --- /dev/null +++ b/src/install.ts @@ -0,0 +1,154 @@ +import { homedir } from 'node:os' +import checkbox from '@inquirer/checkbox' +import { detectRunningHosts, listRunningProcesses } from './detect-running-hosts.js' +import { hostById, parseHostIds, type HostId } from './host-catalog.js' +import { applyHost, type ApplyResult, type InstallRuntime } from './install-hosts.js' +import { + buildOnboardingContinuationPrompt, + webClientsOnboardingNote, +} from './onboarding-prompt.js' +import type { ProcessInfo } from './process-info.js' + +export type InstallOptions = { + mcpUrl: string + clients?: string + yes?: boolean + project?: boolean + json?: boolean +} + +export type InstallIo = { + stdout?: (text: string) => void + stderr?: (text: string) => void + isTTY?: boolean + home?: string + cwd?: string + listProcesses?: () => Promise> + chooseHosts?: (hosts: Array<{ id: HostId; label: string }>) => Promise> + runtime?: Partial +} + +async function defaultChooseHosts( + hosts: Array<{ id: HostId; label: string }>, +): Promise> { + return await checkbox({ + message: 'Configure Kody in these running agents', + choices: hosts.map((host) => ({ + name: host.label, + value: host.id, + checked: true, + })), + required: false, + }) +} + +export async function runInstall( + options: InstallOptions, + io: InstallIo = {}, +): Promise<{ code: number; results: Array }> { + const write = io.stdout ?? ((text: string) => process.stdout.write(text)) + const mcpUrl = options.mcpUrl + const home = io.home ?? homedir() + const cwd = io.cwd ?? process.cwd() + const isTTY = io.isTTY ?? Boolean(process.stdin.isTTY) + const listProcesses = io.listProcesses ?? listRunningProcesses + const detected = detectRunningHosts(await listProcesses()) + const requested = options.clients ? parseHostIds(options.clients) : null + + let selected: Array + if (requested) { + selected = requested + } else if (detected.length === 0) { + write(noRunningAgentsMessage(mcpUrl)) + return { code: 1, results: [] } + } else if (options.yes || (!isTTY && detected.length === 1)) { + selected = detected.map((host) => host.id) + } else if (!isTTY && detected.length > 1) { + write( + [ + 'Multiple local agents are running. Re-run with --yes or --clients .', + +`Detected: ${detected.map((host) => host.id).join(', ')}`, + '', + ].join('\n'), + ) + return { code: 1, results: [] } + } else { + const choose = io.chooseHosts ?? defaultChooseHosts + selected = await choose(detected.map((host) => ({ id: host.id, label: host.label }))) + if (selected.length === 0) { + write(`No clients selected.\n${webClientsOnboardingNote(mcpUrl)}\n`) + return { code: 1, results: [] } + } + } + + const runtime: InstallRuntime = { + home, + cwd, + project: options.project === true, + ...io.runtime, + } + const results: Array = [] + for (const id of selected) { + results.push(await applyHost({ id, mcpUrl, runtime })) + } + + if (options.json) { + write(`${JSON.stringify({ mcpUrl, results }, null, 2)}\n`) + } else { + write(formatInstallReport({ mcpUrl, results })) + } + return { code: 0, results } +} + +export function noRunningAgentsMessage(mcpUrl: string { + return [ + 'No local MCP agents are running.', + 'Start Cursor, Claude Desktop, VS Code, Goose, or another local client, then run kody install again.', + 'Or pass --clients cursor,vscode to configure a client that is not running.', + webClientsOnboardingNote(mcpUrl), + '', + ].join('\n') +} + +export function formatInstallReport(input: { + mcpUrl: string + results: Array +}): string { + const lines: Array = [] + for (const result of input.results) { + const host = hostById(result.id) + const headline = statusHeadline(result) + lines.push(`${host.label}: ${headline}`) + if (result.path) lines.push(` ${result.path}`) + if (result.command) lines.push(` ${result.command}`) + for (const instruction of result.instructions) { + lines.push(` ${instruction}`) + } + lines.push('') + } + lines.push('Paste this into your agent to continue onboarding:') + lines.push('') + lines.push(buildOnboardingContinuationPrompt()) + lines.push('') + lines.push(webClientsOnboardingNote(input.mcpUrl)) + lines.push('') + return lines.join('\n') +} + +function statusHeadline(result: ApplyResult): string { + switch (result.status) { + case 'wrote': + return 'configured' + case 'unchanged': + return 'already configured' + case 'command': + return 'added via CLI' + case 'manual': + return 'needs a manual step' + default: { + const exhaustive: never = result.status + return exhaustive + } + } +} diff --git a/src/onboarding-prompt.ts b/src/onboarding-prompt.ts new file mode 100644 index 0000000..a40e35c --- /dev/null +++ b/src/onboarding-prompt.ts @@ -0,0 +1,16 @@ +import { onboardingUrl } from './defaults.js' + +export function buildOnboardingContinuationPrompt(): string { + return [ + 'Kody is connected as an MCP server in this agent.', + 'Help me get started with Kody.', + 'First, briefly explain what Kody can do for me in plain language.', + 'Then help me connect one integration I care about: check coding_guide_get for a matching provider guide (for example provider_github or provider_google) and follow it; otherwise use search and the official guides to find the right setup steps, walk me through the connect or secrets flow, and verify the connection with a small ad hoc execute smoke test.', + 'Do not create any packages until the integration works — start with ad hoc execute calls.', + 'Once the integration works, check community_search for a trusted community package that is close to what I want, fork or adapt it (community_fork, or point me at one-click install on /onboarding or the listing detail), and only create a new package if nothing suitable exists.', + ].join(' ') +} + +export function webClientsOnboardingNote(mcpUrl: string): string { + return `For web-based clients (ChatGPT, Claude.ai, Grok), see ${onboardingUrl(mcpUrl)}` +} diff --git a/src/process-info.ts b/src/process-info.ts new file mode 100644 index 0000000..364c3c0 --- /dev/null +++ b/src/process-info.ts @@ -0,0 +1,21 @@ +export type ProcessInfo = { + name: string + cmd?: string +} + +export function processHaystack(process: ProcessInfo): string { + return `${process.name} ${process.cmd ?? ''}` +} + +export function processBaseName(process: ProcessInfo): string { + return process.name.replace(/\.exe$/iu, '').trim().toLowerCase() +} + +export function nameEquals(process: ProcessInfo, ...names: Array): boolean { + const base = processBaseName(process) + return names.some((name) => base === name.toLowerCase()) +} + +export function haystackMatches(process: ProcessInfo, pattern: RegExp): boolean { + return pattern.test(processHaystack(process)) +} diff --git a/src/which.ts b/src/which.ts new file mode 100644 index 0000000..cae13f3 --- /dev/null +++ b/src/which.ts @@ -0,0 +1,21 @@ +import { accessSync, constants } from 'node:fs' +import { delimiter, join } from 'node:path' + +export function which(command: string, env: NodeJS.ProcessEnv = process.env): string | undefined { + const pathValue = env.PATH ?? env.Path + if (!pathValue) return undefined + const extensions = + process.platform === 'win32' ? (env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';') : [''] + for (const directory of pathValue.split(delimiter)) { + for (const extension of extensions) { + const candidate = join(directory, `${command}${extension}`) + try { + accessSync(candidate, constants.X_OK) + return candidate + } catch { + // keep looking + } + } + } + return undefined +} diff --git a/test/cli.test.ts b/test/cli.test.ts index 178c3dd..08b76b6 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -11,6 +11,7 @@ import { createFileBackend, saveCredentials } from '../src/store.js' test('resolveCommand maps subcommands and flags', () => { assert.equal(resolveCommand(['search', 'what can you do']).command, 'search') + assert.equal(resolveCommand(['install', '--yes']).command, 'install') assert.equal(resolveCommand(['--help']).command, 'help') assert.equal(resolveCommand(['--version']).command, 'version') assert.equal( diff --git a/test/host-catalog.test.ts b/test/host-catalog.test.ts new file mode 100644 index 0000000..5bfa70a --- /dev/null +++ b/test/host-catalog.test.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { getAgentTypes } from 'add-mcp' +import { addMcpAgentByHostId } from '../src/add-mcp-install.js' +import { detectRunningHosts } from '../src/detect-running-hosts.js' +import { + buildCursorInstallUrl, + buildGooseInstallUrl, + hostConfigPath, + parseHostIds, +} from '../src/host-catalog.js' + +test('parseHostIds rejects web clients and accepts local aliases', () => { + assert.deepEqual(parseHostIds('cursor,vscode'), ['cursor', 'vscode']) + assert.deepEqual(parseHostIds('github-copilot-cli,gemini'), ['copilot-cli', 'gemini-cli']) + assert.throws(() => parseHostIds('chatgpt'), /web client/) + assert.throws(() => parseHostIds('grok'), /grok-build/) + assert.throws(() => parseHostIds('claude.ai'), /web client/) +}) + +test('detectRunningHosts only matches local process names', () => { + const detected = detectRunningHosts([ + { name: 'Cursor', cmd: '/Applications/Cursor.app/Contents/MacOS/Cursor' }, + { name: 'Claude', cmd: '/Applications/Claude.app/Contents/MacOS/Claude' }, + { name: 'claude', cmd: '/usr/local/bin/claude' }, + { name: 'Code Helper', cmd: '/Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper.app' }, + { name: 'Goose', cmd: '/Applications/Goose.app/Contents/MacOS/Goose' }, + { name: 'grok', cmd: '/usr/local/bin/grok' }, + { name: 'Safari', cmd: '/Applications/Safari.app/Contents/MacOS/Safari' }, + { name: 'chrome', cmd: 'Google Chrome claude.ai chatgpt.com grok.com' }, + ]) + assert.deepEqual( + detected.map((host) => host.id).sort(), + ['claude-code', 'claude-desktop', 'cursor', 'goose', 'grok-build', 'vscode'], + ) +}) + +test('Cursor is not classified as VS Code', () => { + const detected = detectRunningHosts([ + { name: 'Cursor Helper', cmd: '/Applications/Cursor.app/Contents/Frameworks/Cursor Helper.app' }, + ]) + assert.deepEqual( + detected.map((host) => host.id), + ['cursor'], + ) +}) + +test('hostConfigPath uses user-level files by default', () => { + assert.equal( + hostConfigPath({ id: 'cursor', home: '/home/me', cwd: '/proj', project: false }), + '/home/me/.cursor/mcp.json', + ) + assert.equal( + hostConfigPath({ + id: 'vscode', + home: '/home/me', + cwd: '/proj', + project: false, + platform: 'linux', + }), + '/home/me/.config/Code/User/mcp.json', + ) + assert.equal( + hostConfigPath({ + id: 'claude-desktop', + home: '/home/me', + cwd: '/proj', + project: false, + }), + null, + ) + assert.equal( + hostConfigPath({ id: 'goose', home: '/home/me', cwd: '/proj', project: true }), + null, + ) + assert.equal( + hostConfigPath({ id: 'cursor', home: '/home/me', cwd: '/proj', project: true }), + '/proj/.cursor/mcp.json', + ) +}) + +test('install deeplinks encode the MCP URL', () => { + assert.match(buildCursorInstallUrl('https://kody.codes/mcp'), /^cursor:\/\//) + assert.match(buildGooseInstallUrl('https://kody.codes/mcp'), /streamable_http/) +}) + +test('every add-mcp agent is mapped or intentionally skipped', () => { + const skipped = new Set(['claude-desktop']) + const mapped = new Set(Object.values(addMcpAgentByHostId)) + for (const agent of getAgentTypes()) { + if (skipped.has(agent)) continue + assert.equal(mapped.has(agent), true, `missing add-mcp mapping for ${agent}`) + } +}) diff --git a/test/host-config.test.ts b/test/host-config.test.ts new file mode 100644 index 0000000..50c7f8b --- /dev/null +++ b/test/host-config.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { mergeHostDocument } from '../src/host-config.js' + +const mcpUrl = 'https://kody.codes/mcp' + +test('mergeHostDocument writes leftover-host remote entries and keeps siblings', () => { + const insiders = mergeHostDocument({ + id: 'vscode-insiders', + existing: JSON.stringify({ servers: { other: { url: 'https://example.test' } } }), + mcpUrl, + }) + assert.equal(insiders.changed, true) + assert.deepEqual(JSON.parse(insiders.body), { + servers: { + other: { url: 'https://example.test' }, + kody: { type: 'http', url: mcpUrl }, + }, + }) + + const visualStudio = mergeHostDocument({ id: 'visual-studio', existing: null, mcpUrl }) + assert.deepEqual(JSON.parse(visualStudio.body), { + servers: { kody: { type: 'http', url: mcpUrl } }, + }) + + const amazonQ = mergeHostDocument({ id: 'amazon-q', existing: null, mcpUrl }) + assert.deepEqual(JSON.parse(amazonQ.body), { + mcpServers: { kody: { type: 'http', url: mcpUrl } }, + }) + + const qwen = mergeHostDocument({ id: 'qwen-code', existing: '{"theme":"dark"}', mcpUrl }) + assert.deepEqual(JSON.parse(qwen.body), { + theme: 'dark', + mcpServers: { kody: { httpUrl: mcpUrl } }, + }) +}) + +test('mergeHostDocument is idempotent when the URL already matches', () => { + const first = mergeHostDocument({ id: 'vscode-insiders', existing: null, mcpUrl }) + const second = mergeHostDocument({ id: 'vscode-insiders', existing: first.body, mcpUrl }) + assert.equal(second.changed, false) +}) diff --git a/test/install.test.ts b/test/install.test.ts new file mode 100644 index 0000000..0302d96 --- /dev/null +++ b/test/install.test.ts @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { resolveCommand } from '../src/cli.js' +import { onboardingUrl } from '../src/defaults.js' +import { + formatInstallReport, + noRunningAgentsMessage, + runInstall, +} from '../src/install.js' +import { + buildOnboardingContinuationPrompt, + webClientsOnboardingNote, +} from '../src/onboarding-prompt.js' + +const mcpUrl = 'https://kody.codes/mcp' + +test('resolveCommand maps install flags', () => { + const parsed = resolveCommand(['install', '--clients', 'cursor,goose', '--yes']) + assert.equal(parsed.command, 'install') + assert.equal(parsed.values.clients, 'cursor,goose') + assert.equal(parsed.values.yes, true) +}) + +test('runInstall writes Cursor config for an explicit client', async () => { + const upserts: Array<{ agent: string; name: string; url?: string; local?: boolean }> = [] + const opened: Array = [] + let output = '' + const result = await runInstall( + { mcpUrl, clients: 'cursor' }, + { + stdout: (text) => { + output += text + }, + home: '/home/me', + cwd: '/proj', + isTTY: false, + listProcesses: async () => [], + runtime: { + openUrl: async (url) => { + opened.push(url) + return true + }, + upsertServer: (agent, name, config, options) => { + upserts.push({ agent, name, url: config.url, local: options?.local }) + return { success: true, path: '/home/me/.cursor/mcp.json' } + }, + }, + }, + ) + assert.equal(result.code, 0) + assert.equal(result.results[0]?.status, 'wrote') + assert.deepEqual(upserts, [ + { agent: 'cursor', name: 'kody', url: mcpUrl, local: false }, + ]) + assert.equal(opened.length, 1) + assert.match(opened[0] ?? '', /^cursor:\/\//) + assert.match(output, /Paste this into your agent/) + assert.match(output, /kody\.codes\/onboarding/) +}) + +test('runInstall does not list web clients when nothing local is running', async () => { + let output = '' + const result = await runInstall( + { mcpUrl }, + { + stdout: (text) => { + output += text + }, + isTTY: false, + listProcesses: async () => [{ name: 'Safari' }, { name: 'chrome' }], + }, + ) + assert.equal(result.code, 1) + assert.equal(result.results.length, 0) + assert.match(output, /No local MCP agents are running/) + assert.match(output, /ChatGPT, Claude\.ai, Grok/) + assert.doesNotMatch(output, /chatgpt/) +}) + +test('runInstall writes leftover VS Code Insiders config without add-mcp', async () => { + const files = new Map() + const upserts: Array = [] + const result = await runInstall( + { mcpUrl, clients: 'vscode-insiders' }, + { + stdout: () => undefined, + home: '/home/me', + cwd: '/proj', + isTTY: false, + listProcesses: async () => [], + runtime: { + platform: 'linux', + openUrl: async () => true, + upsertServer: (agent) => { + upserts.push(agent) + return { success: true, path: '/unused' } + }, + readFile: async (path) => files.get(path) ?? null, + writeFile: async (path, body) => { + files.set(path, body) + }, + mkdir: async () => undefined, + }, + }, + ) + assert.equal(result.results[0]?.status, 'wrote') + assert.deepEqual(upserts, []) + assert.deepEqual( + JSON.parse(files.get('/home/me/.config/Code - Insiders/User/mcp.json') ?? '{}'), + { servers: { kody: { type: 'http', url: mcpUrl } } }, + ) +}) + +test('Claude Desktop stays instruction-only', async () => { + const files = new Map() + const apps: Array = [] + const result = await runInstall( + { mcpUrl, clients: 'claude-desktop' }, + { + stdout: () => undefined, + home: '/home/me', + isTTY: false, + listProcesses: async () => [], + runtime: { + openApp: async (name) => { + apps.push(name) + return true + }, + writeFile: async (path, body) => { + files.set(path, body) + }, + mkdir: async () => undefined, + }, + }, + ) + assert.equal(result.results[0]?.status, 'manual') + assert.equal(apps[0], 'Claude') + assert.equal(files.size, 0) + assert.match(result.results[0]?.instructions.join('\n') ?? '', /Connectors/) + assert.match( + result.results[0]?.instructions.join('\n') ?? '', + /claude_desktop_config\.json/, + ) +}) + +test('onboarding prompt and help note stay copyable', () => { + assert.match(buildOnboardingContinuationPrompt(), /Kody is connected/) + assert.match(buildOnboardingContinuationPrompt(), /coding_guide_get/) + assert.equal(onboardingUrl(mcpUrl), 'https://kody.codes/onboarding') + assert.equal( + webClientsOnboardingNote(mcpUrl), + 'For web-based clients (ChatGPT, Claude.ai, Grok), see https://kody.codes/onboarding', + ) + assert.match(noRunningAgentsMessage(mcpUrl), /kody\.codes\/onboarding/) + assert.match( + formatInstallReport({ + mcpUrl, + results: [ + { + id: 'cursor', + label: 'Cursor', + status: 'wrote', + path: '/home/me/.cursor/mcp.json', + instructions: ['Approve Kody in the browser when the client starts OAuth.'], + }, + ], + }), + /Paste this into your agent to continue onboarding/, + ) +}) From 1e77f4f549ed54f43c6c05b0fa64a1707844b323 Mon Sep 17 00:00:00 2001 From: Kody <72270156+kody-bot@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:59:24 -0600 Subject: [PATCH 2/2] fix: restore noRunningAgentsMessage type signature --- src/install.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/install.ts b/src/install.ts index 8fe7337..2ae1fbf 100644 --- a/src/install.ts +++ b/src/install.ts @@ -67,8 +67,7 @@ export async function runInstall( write( [ 'Multiple local agents are running. Re-run with --yes or --clients .', - -`Detected: ${detected.map((host) => host.id).join(', ')}`, + `Detected: ${detected.map((host) => host.id).join(', ')}`, '', ].join('\n'), ) @@ -101,7 +100,7 @@ export async function runInstall( return { code: 0, results } } -export function noRunningAgentsMessage(mcpUrl: string { +export function noRunningAgentsMessage(mcpUrl: string): string { return [ 'No local MCP agents are running.', 'Start Cursor, Claude Desktop, VS Code, Goose, or another local client, then run kody install again.',