From 578e6eef88f6b229c14168b87e0d978733c2a6b7 Mon Sep 17 00:00:00 2001 From: im10furry <1936409761@qq.com> Date: Tue, 9 Jun 2026 15:17:12 +0800 Subject: [PATCH] refactor(cli): split entrypoint commands into modular registration files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract mainCommand.tsx (541 lines) from cli.tsx for the main program command - Add commands/ directory with per-feature registration files: config, models, agents, pluginSkills, approvedTools, mcp, doctorUpdate, session, context - Add commandContext.ts with shared registration context type and omitKeys utility - Add untyped-deps.d.ts for shell-quote, turndown, semver, and debug module types - Fix regex in session.tsx: \\\\d → \\d for legacy number detection - Add tests: anthropic-helpers (6 cases) and cli-command-registration (4 cases) --- src/entrypoints/cli/commandContext.ts | 18 + src/entrypoints/cli/commands/agents.ts | 71 ++ src/entrypoints/cli/commands/approvedTools.ts | 30 + src/entrypoints/cli/commands/config.ts | 70 ++ src/entrypoints/cli/commands/context.ts | 71 ++ src/entrypoints/cli/commands/doctorUpdate.tsx | 53 + src/entrypoints/cli/commands/index.ts | 26 + src/entrypoints/cli/commands/mcp.tsx | 974 ++++++++++++++++++ src/entrypoints/cli/commands/models.ts | 163 +++ src/entrypoints/cli/commands/pluginSkills.ts | 465 +++++++++ src/entrypoints/cli/commands/session.tsx | 259 +++++ src/entrypoints/cli/mainCommand.tsx | 541 ++++++++++ src/types/untyped-deps.d.ts | 89 ++ tests/unit/anthropic-helpers.test.ts | 62 ++ tests/unit/cli-command-registration.test.ts | 87 ++ 15 files changed, 2979 insertions(+) create mode 100644 src/entrypoints/cli/commandContext.ts create mode 100644 src/entrypoints/cli/commands/agents.ts create mode 100644 src/entrypoints/cli/commands/approvedTools.ts create mode 100644 src/entrypoints/cli/commands/config.ts create mode 100644 src/entrypoints/cli/commands/context.ts create mode 100644 src/entrypoints/cli/commands/doctorUpdate.tsx create mode 100644 src/entrypoints/cli/commands/index.ts create mode 100644 src/entrypoints/cli/commands/mcp.tsx create mode 100644 src/entrypoints/cli/commands/models.ts create mode 100644 src/entrypoints/cli/commands/pluginSkills.ts create mode 100644 src/entrypoints/cli/commands/session.tsx create mode 100644 src/entrypoints/cli/mainCommand.tsx create mode 100644 src/types/untyped-deps.d.ts create mode 100644 tests/unit/anthropic-helpers.test.ts create mode 100644 tests/unit/cli-command-registration.test.ts diff --git a/src/entrypoints/cli/commandContext.ts b/src/entrypoints/cli/commandContext.ts new file mode 100644 index 00000000..86941c20 --- /dev/null +++ b/src/entrypoints/cli/commandContext.ts @@ -0,0 +1,18 @@ +import type { RenderOptions } from 'ink' + +export type CliCommandRegistrationContext = { + stdinContent: string + renderContext: RenderOptions | undefined + renderContextWithExitOnCtrlC: RenderOptions +} + +export function omitKeys>( + input: T, + ...keys: (keyof T | string)[] +): Partial { + const result = { ...input } as Partial + for (const key of keys) { + delete (result as any)[key as any] + } + return result +} diff --git a/src/entrypoints/cli/commands/agents.ts b/src/entrypoints/cli/commands/agents.ts new file mode 100644 index 00000000..2ab953cb --- /dev/null +++ b/src/entrypoints/cli/commands/agents.ts @@ -0,0 +1,71 @@ +import type { Command } from '@commander-js/extra-typings' +import { cwd } from 'process' +import { setup } from '../setup' + +export function registerAgentsCommands(program: Command): void { + const agentsCmd = program + .command('agents') + .description('Agent utilities (validate templates, etc.)') + + agentsCmd + .command('validate [paths...]') + .description( + 'Validate agent markdown files (defaults to user+project agent dirs)', + ) + .option('--cwd ', 'The current working directory', String, cwd()) + .option('--json', 'Output as JSON') + .option( + '--no-tools-check', + 'Skip validating tool names against the tool registry', + ) + .action(async (paths: string[] | undefined, options: any) => { + try { + const workingDir = + typeof options?.cwd === 'string' ? options.cwd : cwd() + await setup(workingDir, false) + const { validateAgentTemplates } = await import('../agentsValidate') + const report = await validateAgentTemplates({ + cwd: workingDir, + paths: Array.isArray(paths) ? paths : [], + checkTools: options.toolsCheck !== false, + }) + + if (options.json) { + console.log(JSON.stringify(report, null, 2)) + process.exitCode = report.ok ? 0 : 1 + return + } + + console.log( + `Validated ${report.results.length} agent file(s): ${report.errorCount} error(s), ${report.warningCount} warning(s)\n`, + ) + + for (const r of report.results) { + const rel = r.filePath + const title = r.agentType ? `${r.agentType}` : '(unknown agent)' + console.log(`${title} — ${rel}`) + if (r.model) { + const normalized = r.normalizedModel + ? ` (normalized: ${r.normalizedModel})` + : '' + console.log(` model: ${r.model}${normalized}`) + } + if (r.issues.length === 0) { + console.log(` OK`) + } else { + for (const issue of r.issues) { + console.log(` - ${issue.level}: ${issue.message}`) + } + } + console.log('') + } + + process.exitCode = report.ok ? 0 : 1 + return + } catch (error) { + console.error((error as Error).message) + process.exitCode = 1 + return + } + }) +} diff --git a/src/entrypoints/cli/commands/approvedTools.ts b/src/entrypoints/cli/commands/approvedTools.ts new file mode 100644 index 00000000..08df395e --- /dev/null +++ b/src/entrypoints/cli/commands/approvedTools.ts @@ -0,0 +1,30 @@ +import type { Command } from '@commander-js/extra-typings' +import { getCwd } from '@utils/state' +import { + handleListApprovedTools, + handleRemoveApprovedTool, +} from '@commands/approved-tools' + +export function registerApprovedToolsCommands(program: Command): void { + const allowedTools = program + .command('approved-tools') + .description('Manage approved tools') + + allowedTools + .command('list') + .description('List all approved tools') + .action(async () => { + const result = handleListApprovedTools(getCwd()) + console.log(result) + process.exit(0) + }) + + allowedTools + .command('remove ') + .description('Remove a tool from the list of approved tools') + .action(async (tool: string) => { + const result = handleRemoveApprovedTool(tool) + console.log(result.message) + process.exit(result.success ? 0 : 1) + }) +} diff --git a/src/entrypoints/cli/commands/config.ts b/src/entrypoints/cli/commands/config.ts new file mode 100644 index 00000000..75cea428 --- /dev/null +++ b/src/entrypoints/cli/commands/config.ts @@ -0,0 +1,70 @@ +import type { Command } from '@commander-js/extra-typings' +import { cwd } from 'process' +import { PRODUCT_COMMAND } from '@constants/product' +import { setup } from '../setup' +import { + deleteConfigForCLI, + getConfigForCLI, + listConfigForCLI, + setConfigForCLI, +} from '@utils/config' + +export function registerConfigCommands(program: Command): void { + const config = program + .command('config') + .description( + `Manage configuration (eg. ${PRODUCT_COMMAND} config set -g theme dark)`, + ) + + config + .command('get ') + .description('Get a config value') + .option('--cwd ', 'The current working directory', String, cwd()) + .option('-g, --global', 'Use global config') + .action(async (key, { cwd, global }) => { + await setup(cwd, false) + console.log(getConfigForCLI(key, global ?? false)) + process.exit(0) + }) + + config + .command('set ') + .description('Set a config value') + .option('--cwd ', 'The current working directory', String, cwd()) + .option('-g, --global', 'Use global config') + .action(async (key, value, { cwd, global }) => { + await setup(cwd, false) + setConfigForCLI(key, value, global ?? false) + console.log(`Set ${key} to ${value}`) + process.exit(0) + }) + + config + .command('remove ') + .description('Remove a config value') + .option('--cwd ', 'The current working directory', String, cwd()) + .option('-g, --global', 'Use global config') + .action(async (key, { cwd, global }) => { + await setup(cwd, false) + deleteConfigForCLI(key, global ?? false) + console.log(`Removed ${key}`) + process.exit(0) + }) + + config + .command('list') + .description('List all config values') + .option('--cwd ', 'The current working directory', String, cwd()) + .option('-g, --global', 'Use global config', false) + .action(async ({ cwd, global }) => { + await setup(cwd, false) + console.log( + JSON.stringify( + global ? listConfigForCLI(true) : listConfigForCLI(false), + null, + 2, + ), + ) + process.exit(0) + }) +} diff --git a/src/entrypoints/cli/commands/context.ts b/src/entrypoints/cli/commands/context.ts new file mode 100644 index 00000000..ce9e2630 --- /dev/null +++ b/src/entrypoints/cli/commands/context.ts @@ -0,0 +1,71 @@ +import type { Command } from '@commander-js/extra-typings' +import { cwd } from 'process' +import { PRODUCT_COMMAND } from '@constants/product' +import { getContext, removeContext, setContext } from '@context' +import { setup } from '../setup' +import { omitKeys } from '../commandContext' + +export function registerContextCommands(program: Command): void { + const contextCmd = program + .command('context') + .description( + `Set static context (eg. ${PRODUCT_COMMAND} context add-file ./src/*.py)`, + ) + + contextCmd + .command('get ') + .option('--cwd ', 'The current working directory', String, cwd()) + .description('Get a value from context') + .action(async (key, { cwd }) => { + await setup(cwd, false) + + const context = omitKeys( + await getContext(), + 'codeStyle', + 'directoryStructure', + ) + console.log(context[key]) + process.exit(0) + }) + + contextCmd + .command('set ') + .description('Set a value in context') + .option('--cwd ', 'The current working directory', String, cwd()) + .action(async (key, value, { cwd }) => { + await setup(cwd, false) + + setContext(key, value) + console.log(`Set context.${key} to "${value}"`) + process.exit(0) + }) + + contextCmd + .command('list') + .description('List all context values') + .option('--cwd ', 'The current working directory', String, cwd()) + .action(async ({ cwd }) => { + await setup(cwd, false) + + const context = omitKeys( + await getContext(), + 'codeStyle', + 'directoryStructure', + 'gitStatus', + ) + console.log(JSON.stringify(context, null, 2)) + process.exit(0) + }) + + contextCmd + .command('remove ') + .description('Remove a value from context') + .option('--cwd ', 'The current working directory', String, cwd()) + .action(async (key, { cwd }) => { + await setup(cwd, false) + + removeContext(key) + console.log(`Removed context.${key}`) + process.exit(0) + }) +} diff --git a/src/entrypoints/cli/commands/doctorUpdate.tsx b/src/entrypoints/cli/commands/doctorUpdate.tsx new file mode 100644 index 00000000..38d832f4 --- /dev/null +++ b/src/entrypoints/cli/commands/doctorUpdate.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import type { Command } from '@commander-js/extra-typings' +import { PRODUCT_NAME } from '@constants/product' +import { Doctor } from '@screens/Doctor' +import { MACRO } from '@constants/macros' + +export function registerDoctorAndUpdateCommands(program: Command): void { + program + .command('doctor') + .description(`Check the health of your ${PRODUCT_NAME} installation`) + .action(async () => { + await new Promise(resolve => { + ;(async () => { + const { render } = await import('ink') + render( resolve()} doctorMode={true} />) + })() + }) + process.exit(0) + }) + + program + .command('update') + .description('Show manual upgrade commands (no auto-install)') + .action(async () => { + console.log(`Current version: ${MACRO.VERSION}`) + console.log('Checking for updates...') + + const { getLatestVersion, getUpdateCommandSuggestions } = + await import('@utils/session/autoUpdater') + const latestVersion = await getLatestVersion() + + if (!latestVersion) { + console.error('Failed to check for updates') + process.exit(1) + } + + if (latestVersion === MACRO.VERSION) { + console.log(`${PRODUCT_NAME} is up to date`) + process.exit(0) + } + + console.log(`New version available: ${latestVersion}`) + const cmds = await getUpdateCommandSuggestions() + console.log('\nRun one of the following commands to update:') + for (const c of cmds) console.log(` ${c}`) + if (process.platform !== 'win32') { + console.log( + '\nNote: you may need to prefix with "sudo" on macOS/Linux.', + ) + } + process.exit(0) + }) +} diff --git a/src/entrypoints/cli/commands/index.ts b/src/entrypoints/cli/commands/index.ts new file mode 100644 index 00000000..676f2608 --- /dev/null +++ b/src/entrypoints/cli/commands/index.ts @@ -0,0 +1,26 @@ +import type { Command } from '@commander-js/extra-typings' +import type { CliCommandRegistrationContext } from '../commandContext' +import { registerAgentsCommands } from './agents' +import { registerApprovedToolsCommands } from './approvedTools' +import { registerConfigCommands } from './config' +import { registerContextCommands } from './context' +import { registerDoctorAndUpdateCommands } from './doctorUpdate' +import { registerMcpCommands } from './mcp' +import { registerModelsCommands } from './models' +import { registerPluginAndSkillsCommands } from './pluginSkills' +import { registerSessionLogAndErrorCommands } from './session' + +export function registerCliCommands( + program: Command, + context: CliCommandRegistrationContext, +): void { + registerConfigCommands(program) + registerModelsCommands(program) + registerAgentsCommands(program) + registerPluginAndSkillsCommands(program) + registerApprovedToolsCommands(program) + registerMcpCommands(program) + registerDoctorAndUpdateCommands(program) + registerSessionLogAndErrorCommands(program, context) + registerContextCommands(program) +} diff --git a/src/entrypoints/cli/commands/mcp.tsx b/src/entrypoints/cli/commands/mcp.tsx new file mode 100644 index 00000000..725864dd --- /dev/null +++ b/src/entrypoints/cli/commands/mcp.tsx @@ -0,0 +1,974 @@ +import React from 'react' +import type { Command } from '@commander-js/extra-typings' +import { existsSync } from 'node:fs' +import { cwd } from 'process' +import { PRODUCT_COMMAND, PRODUCT_NAME } from '@constants/product' +import { setup } from '../setup' +import { startMCPServer } from '../../mcp' +import { + getCurrentProjectConfig, + getGlobalConfig, + getProjectMcpServerDefinitions, + saveCurrentProjectConfig, + type McpServerConfig, +} from '@utils/config' +import { + addMcpServer, + ensureConfigScope, + getClients, + getMcpServer, + getMcprcServerStatus, + listMCPServers, + parseEnvVars, + removeMcpServer, +} from '@services/mcpClient' +import { + looksLikeMcpUrl, + normalizeMcpScopeForCli, + normalizeMcpTransport, + parseMcpHeaders, +} from '@services/mcpCliUtils' + +export function registerMcpCommands(program: Command): void { + const mcp = program + .command('mcp') + .description('Configure and manage MCP servers') + + mcp + .command('serve') + .description(`Start the ${PRODUCT_NAME} MCP server`) + .action(async () => { + const providedCwd = (program.opts() as { cwd?: string }).cwd ?? cwd() + + if (!existsSync(providedCwd)) { + console.error(`Error: Directory ${providedCwd} does not exist`) + process.exit(1) + } + + try { + await setup(providedCwd, false) + await startMCPServer(providedCwd) + } catch (error) { + console.error('Error: Failed to start MCP server:', error) + process.exit(1) + } + }) + + mcp + .command('add-sse ') + .description('Add an SSE server') + .option( + '-s, --scope ', + 'Configuration scope (local, user, or project)', + 'local', + ) + .option( + '-H, --header ', + 'Set headers (e.g. -H "X-Api-Key: abc123" -H "X-Custom: value")', + ) + .action(async (name, url, options) => { + try { + const scopeInfo = normalizeMcpScopeForCli(options.scope) + const headers = parseMcpHeaders(options.header) + + addMcpServer( + name, + { type: 'sse', url, ...(headers ? { headers } : {}) }, + scopeInfo.scope, + ) + console.log( + `Added SSE MCP server ${name} with URL: ${url} to ${scopeInfo.display} config`, + ) + if (headers) { + console.log(`Headers: ${JSON.stringify(headers, null, 2)}`) + } + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + mcp + .command('add-http ') + .description('Add a Streamable HTTP MCP server') + .option( + '-s, --scope ', + 'Configuration scope (local, user, or project)', + 'local', + ) + .option( + '-H, --header ', + 'Set headers (e.g. -H "X-Api-Key: abc123" -H "X-Custom: value")', + ) + .action(async (name, url, options) => { + try { + const scopeInfo = normalizeMcpScopeForCli(options.scope) + const headers = parseMcpHeaders(options.header) + addMcpServer( + name, + { type: 'http', url, ...(headers ? { headers } : {}) }, + scopeInfo.scope, + ) + console.log( + `Added HTTP MCP server ${name} with URL: ${url} to ${scopeInfo.display} config`, + ) + if (headers) { + console.log(`Headers: ${JSON.stringify(headers, null, 2)}`) + } + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + mcp + .command('add-ws ') + .description('Add a WebSocket MCP server') + .option( + '-s, --scope ', + 'Configuration scope (local, user, or project)', + 'local', + ) + .action(async (name, url, options) => { + try { + const scopeInfo = normalizeMcpScopeForCli(options.scope) + addMcpServer(name, { type: 'ws', url }, scopeInfo.scope) + console.log( + `Added WebSocket MCP server ${name} with URL ${url} to ${scopeInfo.display} config`, + ) + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + mcp + .command('add [name] [commandOrUrl] [args...]') + .description('Add a server (run without arguments for interactive wizard)') + .option( + '-s, --scope ', + 'Configuration scope (local, user, or project)', + 'local', + ) + .option( + '-t, --transport ', + 'MCP transport (stdio, sse, or http)', + ) + .option( + '-H, --header ', + 'Set headers (e.g. -H "X-Api-Key: abc123" -H "X-Custom: value")', + ) + .option( + '-e, --env ', + 'Set environment variables (e.g. -e KEY=value)', + ) + .action(async (name, commandOrUrl, args, options) => { + try { + if (!name) { + console.log('Interactive wizard mode: Enter the server details') + const { createInterface } = await import('readline') + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }) + + const question = (query: string) => + new Promise(resolve => rl.question(query, resolve)) + + const serverName = await question('Server name: ') + if (!serverName) { + console.error('Error: Server name is required') + rl.close() + process.exit(1) + } + + const serverType = await question( + 'Server type (stdio, http, sse, ws) [stdio]: ', + ) + const type = + serverType && ['stdio', 'http', 'sse', 'ws'].includes(serverType) + ? serverType + : 'stdio' + + const prompt = type === 'stdio' ? 'Command: ' : 'URL: ' + const commandOrUrlValue = await question(prompt) + if (!commandOrUrlValue) { + console.error( + `Error: ${type === 'stdio' ? 'Command' : 'URL'} is required`, + ) + rl.close() + process.exit(1) + } + + let serverArgs: string[] = [] + let serverEnv: Record = {} + + if (type === 'stdio') { + const argsStr = await question( + 'Command arguments (space-separated): ', + ) + serverArgs = argsStr ? argsStr.split(' ').filter(Boolean) : [] + + const envStr = await question( + 'Environment variables (format: KEY1=value1,KEY2=value2): ', + ) + if (envStr) { + const envPairs = envStr.split(',').map(pair => pair.trim()) + serverEnv = parseEnvVars(envPairs.map(pair => pair)) + } + } + + const scopeStr = await question( + 'Configuration scope (local, user, or project) [local]: ', + ) + const scopeInfo = normalizeMcpScopeForCli(scopeStr) + const serverScope = scopeInfo.scope + + rl.close() + + if (type === 'http') { + addMcpServer( + serverName, + { type: 'http', url: commandOrUrlValue }, + serverScope, + ) + console.log( + `Added HTTP MCP server ${serverName} with URL ${commandOrUrlValue} to ${scopeInfo.display} config`, + ) + } else if (type === 'sse') { + addMcpServer( + serverName, + { type: 'sse', url: commandOrUrlValue }, + serverScope, + ) + console.log( + `Added SSE MCP server ${serverName} with URL ${commandOrUrlValue} to ${scopeInfo.display} config`, + ) + } else if (type === 'ws') { + addMcpServer( + serverName, + { type: 'ws', url: commandOrUrlValue }, + serverScope, + ) + console.log( + `Added WebSocket MCP server ${serverName} with URL ${commandOrUrlValue} to ${scopeInfo.display} config`, + ) + } else { + addMcpServer( + serverName, + { + type: 'stdio', + command: commandOrUrlValue, + args: serverArgs, + env: serverEnv, + }, + serverScope, + ) + + console.log( + `Added stdio MCP server ${serverName} with command: ${commandOrUrlValue} ${serverArgs.join(' ')} to ${scopeInfo.display} config`, + ) + } + } else if (name && commandOrUrl) { + const scopeInfo = normalizeMcpScopeForCli(options.scope) + const transportInfo = normalizeMcpTransport(options.transport) + + if (transportInfo.transport === 'stdio') { + if (options.header?.length) { + throw new Error( + '--header can only be used with --transport http or --transport sse', + ) + } + + const env = parseEnvVars(options.env) + if (!transportInfo.explicit && looksLikeMcpUrl(commandOrUrl)) { + console.warn( + `Warning: "${commandOrUrl}" looks like a URL. Default transport is stdio, so it will be treated as a command.`, + ) + console.warn( + `If you meant to add an HTTP MCP server, run: ${PRODUCT_COMMAND} mcp add ${name} ${commandOrUrl} --transport http`, + ) + console.warn( + `If you meant to add a legacy SSE MCP server, run: ${PRODUCT_COMMAND} mcp add ${name} ${commandOrUrl} --transport sse`, + ) + } + + addMcpServer( + name, + { type: 'stdio', command: commandOrUrl, args: args || [], env }, + scopeInfo.scope, + ) + + console.log( + `Added stdio MCP server ${name} with command: ${commandOrUrl} ${(args || []).join(' ')} to ${scopeInfo.display} config`, + ) + } else { + if (options.env?.length) { + throw new Error('--env is only supported for stdio MCP servers') + } + if (args?.length) { + throw new Error( + 'Unexpected arguments. URL-based MCP servers do not accept command args.', + ) + } + + const headers = parseMcpHeaders(options.header) + addMcpServer( + name, + { + type: transportInfo.transport, + url: commandOrUrl, + ...(headers ? { headers } : {}), + }, + scopeInfo.scope, + ) + + const kind = transportInfo.transport.toUpperCase() + console.log( + `Added ${kind} MCP server ${name} with URL: ${commandOrUrl} to ${scopeInfo.display} config`, + ) + if (headers) { + console.log(`Headers: ${JSON.stringify(headers, null, 2)}`) + } + } + } else { + console.error( + 'Error: Missing required arguments. Either provide no arguments for interactive mode or specify name and command/URL.', + ) + process.exit(1) + } + + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + mcp + .command('remove ') + .description('Remove an MCP server') + .option( + '-s, --scope ', + 'Configuration scope (local, user, or project)', + ) + .action(async (name: string, options: { scope?: string }) => { + try { + if (options.scope) { + const scopeInfo = normalizeMcpScopeForCli(options.scope) + removeMcpServer(name, scopeInfo.scope) + console.log( + `Removed MCP server ${name} from ${scopeInfo.display} config`, + ) + process.exit(0) + } + + const matches: Array<{ + scope: ReturnType + display: string + }> = [] + + const projectConfig = getCurrentProjectConfig() + if (projectConfig.mcpServers?.[name]) { + matches.push({ + scope: ensureConfigScope('project'), + display: 'local', + }) + } + + const globalConfig = getGlobalConfig() + if (globalConfig.mcpServers?.[name]) { + matches.push({ scope: ensureConfigScope('global'), display: 'user' }) + } + + const projectFileDefinitions = getProjectMcpServerDefinitions() + if (projectFileDefinitions.servers[name]) { + const source = projectFileDefinitions.sources[name] + if (source === '.mcp.json') { + matches.push({ + scope: ensureConfigScope('mcpjson'), + display: 'project', + }) + } else { + matches.push({ + scope: ensureConfigScope('mcprc'), + display: 'mcprc', + }) + } + } + + if (matches.length === 0) { + throw new Error(`No MCP server found with name: ${name}`) + } + + if (matches.length > 1) { + console.error( + `MCP server "${name}" exists in multiple scopes: ${matches + .map(m => m.display) + .join(', ')}`, + ) + console.error('Please specify which scope to remove from:') + for (const match of matches) { + console.error( + ` ${PRODUCT_COMMAND} mcp remove ${name} --scope ${match.display}`, + ) + } + process.exit(1) + } + + const match = matches[0]! + removeMcpServer(name, match.scope) + console.log(`Removed MCP server ${name} from ${match.display} config`) + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + mcp + .command('list') + .description('List configured MCP servers') + .action(async () => { + try { + const servers = listMCPServers() + if (Object.keys(servers).length === 0) { + console.log( + `No MCP servers configured. Use \`${PRODUCT_COMMAND} mcp add\` to add a server.`, + ) + process.exit(0) + } + + const projectFileServers = getProjectMcpServerDefinitions() + const clients = await getClients() + const clientByName = new Map() + for (const client of clients) { + clientByName.set(client.name, client) + } + + const names = Object.keys(servers).sort((a, b) => a.localeCompare(b)) + for (const name of names) { + const server = servers[name]! + + const client = clientByName.get(name) + const status = + client?.type === 'connected' + ? 'connected' + : client?.type === 'failed' + ? 'failed' + : projectFileServers.servers[name] + ? (() => { + const approval = getMcprcServerStatus(name) + if (approval === 'pending') return 'pending' + if (approval === 'rejected') return 'rejected' + return 'disconnected' + })() + : 'disconnected' + + const summary = (() => { + switch (server.type) { + case 'http': + return `${server.url} (http)` + case 'sse': + return `${server.url} (sse)` + case 'sse-ide': + return `${server.url} (sse-ide)` + case 'ws': + return `${server.url} (ws)` + case 'ws-ide': + return `${server.url} (ws-ide)` + case 'stdio': + default: + return `${server.command} ${(server.args || []).join(' ')} (stdio)` + } + })() + + console.log(`${name}: ${summary} [${status}]`) + } + + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + mcp + .command('add-json ') + .description('Add an MCP server with a JSON string') + .option( + '-s, --scope ', + 'Configuration scope (project, global, or mcprc)', + 'project', + ) + .action(async (name, jsonStr, options) => { + try { + const scope = ensureConfigScope(options.scope) + + let serverConfig + try { + serverConfig = JSON.parse(jsonStr) + } catch (e) { + console.error('Error: Invalid JSON string') + process.exit(1) + } + + if ( + !serverConfig.type || + !['stdio', 'sse', 'http', 'ws', 'sse-ide', 'ws-ide'].includes( + serverConfig.type, + ) + ) { + console.error( + 'Error: Server type must be one of: "stdio", "http", "sse", "ws", "sse-ide", "ws-ide"', + ) + process.exit(1) + } + + if ( + ['sse', 'http', 'ws', 'sse-ide', 'ws-ide'].includes( + serverConfig.type, + ) && + !serverConfig.url + ) { + console.error('Error: URL-based MCP servers must have a URL') + process.exit(1) + } + + if (serverConfig.type === 'stdio' && !serverConfig.command) { + console.error('Error: stdio server must have a command') + process.exit(1) + } + + if ( + ['sse-ide', 'ws-ide'].includes(serverConfig.type) && + !serverConfig.ideName + ) { + console.error('Error: IDE MCP servers must include ideName') + process.exit(1) + } + + addMcpServer(name, serverConfig, scope) + + switch (serverConfig.type) { + case 'http': + console.log( + `Added HTTP MCP server ${name} with URL ${serverConfig.url} to ${scope} config`, + ) + break + case 'sse': + console.log( + `Added SSE MCP server ${name} with URL ${serverConfig.url} to ${scope} config`, + ) + break + case 'sse-ide': + console.log( + `Added SSE-IDE MCP server ${name} with URL ${serverConfig.url} to ${scope} config`, + ) + break + case 'ws': + console.log( + `Added WS MCP server ${name} with URL ${serverConfig.url} to ${scope} config`, + ) + break + case 'ws-ide': + console.log( + `Added WS-IDE MCP server ${name} with URL ${serverConfig.url} to ${scope} config`, + ) + break + case 'stdio': + default: + console.log( + `Added stdio MCP server ${name} with command: ${serverConfig.command} ${( + serverConfig.args || [] + ).join(' ')} to ${scope} config`, + ) + break + } + + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + mcp + .command('get ') + .description('Get details about an MCP server') + .action(async (name: string) => { + try { + const server = getMcpServer(name) + if (!server) { + console.error(`No MCP server found with name: ${name}`) + process.exit(1) + } + + const projectFileServers = getProjectMcpServerDefinitions() + const clients = await getClients() + const client = clients.find(c => c.name === name) + + const status = + client?.type === 'connected' + ? 'connected' + : client?.type === 'failed' + ? 'failed' + : projectFileServers.servers[name] + ? (() => { + const approval = getMcprcServerStatus(name) + if (approval === 'pending') return 'pending' + if (approval === 'rejected') return 'rejected' + return 'disconnected' + })() + : 'disconnected' + + const scopeDisplay = (() => { + switch (server.scope) { + case 'project': + return 'local' + case 'global': + return 'user' + case 'mcpjson': + return 'project' + case 'mcprc': + return 'mcprc' + default: + return server.scope + } + })() + + console.log(`${name}:`) + console.log(` Status: ${status}`) + console.log(` Scope: ${scopeDisplay}`) + + const printHeaders = (headers: Record | undefined) => { + if (!headers || Object.keys(headers).length === 0) return + console.log(' Headers:') + for (const [key, value] of Object.entries(headers)) { + console.log(` ${key}: ${value}`) + } + } + + switch (server.type) { + case 'http': + console.log(` Type: http`) + console.log(` URL: ${server.url}`) + printHeaders(server.headers) + break + case 'sse': + console.log(` Type: sse`) + console.log(` URL: ${server.url}`) + printHeaders(server.headers) + break + case 'sse-ide': + console.log(` Type: sse-ide`) + console.log(` URL: ${server.url}`) + console.log(` IDE: ${server.ideName}`) + printHeaders(server.headers) + break + case 'ws': + console.log(` Type: ws`) + console.log(` URL: ${server.url}`) + break + case 'ws-ide': + console.log(` Type: ws-ide`) + console.log(` URL: ${server.url}`) + console.log(` IDE: ${server.ideName}`) + break + case 'stdio': + default: + console.log(` Type: stdio`) + console.log(` Command: ${server.command}`) + console.log(` Args: ${(server.args || []).join(' ')}`) + if (server.env) { + console.log(' Environment:') + for (const [key, value] of Object.entries(server.env)) { + console.log(` ${key}=${value}`) + } + } + break + } + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + mcp + .command('add-from-claude-desktop') + .description( + 'Import MCP servers from a desktop MCP host config (macOS, Windows and WSL)', + ) + .option( + '-s, --scope ', + 'Configuration scope (project, global, or mcprc)', + 'project', + ) + .action(async options => { + try { + const scope = ensureConfigScope(options.scope) + const platform = process.platform + + const { existsSync, readFileSync } = await import('fs') + const { join } = await import('path') + const { exec } = await import('child_process') + + const isWSL = + platform === 'linux' && + existsSync('/proc/version') && + readFileSync('/proc/version', 'utf-8') + .toLowerCase() + .includes('microsoft') + + if (platform !== 'darwin' && platform !== 'win32' && !isWSL) { + console.error( + 'Error: This command is only supported on macOS, Windows, and WSL', + ) + process.exit(1) + } + + let configPath = '' + if (platform === 'darwin') { + configPath = join( + process.env.HOME || '~', + 'Library/Application Support/Claude/claude_desktop_config.json', + ) + } else if (platform === 'win32') { + configPath = join( + process.env.APPDATA || '', + 'Claude/claude_desktop_config.json', + ) + } else if (isWSL) { + const whoamiCommand = await new Promise((resolve, reject) => { + exec( + 'powershell.exe -Command "whoami"', + (err: Error | null, stdout: string) => { + if (err) reject(err) + else resolve(stdout.trim().split('\\').pop() || '') + }, + ) + }) + + configPath = `/mnt/c/Users/${whoamiCommand}/AppData/Roaming/Claude/claude_desktop_config.json` + } + + if (!existsSync(configPath)) { + console.error(`Error: Config file not found at ${configPath}`) + process.exit(1) + } + + let config + try { + const configContent = readFileSync(configPath, 'utf-8') + config = JSON.parse(configContent) + } catch (err) { + console.error(`Error reading config file: ${err}`) + process.exit(1) + } + + const mcpServers = config.mcpServers || {} + const serverNames = Object.keys(mcpServers) + const numServers = serverNames.length + + if (numServers === 0) { + console.log('No MCP servers found in the desktop config') + process.exit(0) + } + + const ink = await import('ink') + const reactModule = await import('react') + const inkjsui = await import('@inkjs/ui') + const utilsTheme = await import('@utils/theme') + + const { render } = ink + const React = reactModule + const { MultiSelect } = inkjsui + const { Box, Text } = ink + const { getTheme } = utilsTheme + + await new Promise(resolve => { + function ClaudeDesktopImport() { + const { useState } = reactModule + const [isFinished, setIsFinished] = useState(false) + const [importResults, setImportResults] = useState( + [] as { name: string; success: boolean }[], + ) + const [, setIsImporting] = useState(false) + const theme = getTheme() + + const importServers = async (selectedServers: string[]) => { + setIsImporting(true) + const results = [] + + for (const name of selectedServers) { + try { + const server = mcpServers[name] + + const existingServer = getMcpServer(name) + if (existingServer) { + continue + } + + addMcpServer(name, server as McpServerConfig, scope) + results.push({ name, success: true }) + } catch (err) { + results.push({ name, success: false }) + } + } + + setImportResults(results) + setIsImporting(false) + setIsFinished(true) + + setTimeout(() => { + resolve() + }, 1000) + } + + const handleConfirm = async (selectedServers: string[]) => { + const existingServers = selectedServers.filter(name => + getMcpServer(name), + ) + + if (existingServers.length > 0) { + const results = [] + + const newServers = selectedServers.filter( + name => !getMcpServer(name), + ) + for (const name of newServers) { + try { + const server = mcpServers[name] + addMcpServer(name, server as McpServerConfig, scope) + results.push({ name, success: true }) + } catch (err) { + results.push({ name, success: false }) + } + } + + for (const name of existingServers) { + try { + const server = mcpServers[name] + addMcpServer(name, server as McpServerConfig, scope) + results.push({ name, success: true }) + } catch (err) { + results.push({ name, success: false }) + } + } + + setImportResults(results) + setIsImporting(false) + setIsFinished(true) + + setTimeout(() => { + resolve() + }, 1000) + } else { + await importServers(selectedServers) + } + } + + return ( + + + + Import MCP Servers from Desktop Config + + + + + Found {numServers} MCP servers in the desktop config. + + + + Please select the servers you want to import: + + + ({ + label: name, + value: name, + }))} + defaultValue={serverNames} + onSubmit={handleConfirm} + /> + + + + + + Space to select · Enter to confirm · Esc to cancel + + + + {isFinished && ( + + + Successfully imported{' '} + {importResults.filter(r => r.success).length} MCP server + to local config. + + + )} + + ) + } + + const { unmount } = render() + + setTimeout(() => { + unmount() + resolve() + }, 30000) + }) + + process.exit(0) + } catch (error) { + console.error(`Error: ${(error as Error).message}`) + process.exit(1) + } + }) + + const resetMcpChoices = () => { + const config = getCurrentProjectConfig() + saveCurrentProjectConfig({ + ...config, + approvedMcprcServers: [], + rejectedMcprcServers: [], + }) + console.log( + 'All project-file MCP server approvals/rejections (.mcp.json/.mcprc) have been reset.', + ) + console.log( + `You will be prompted for approval next time you start ${PRODUCT_NAME}.`, + ) + process.exit(0) + } + + mcp + .command('reset-project-choices') + .description( + 'Reset approvals for project-file MCP servers (.mcp.json/.mcprc) in this project', + ) + .action(() => { + resetMcpChoices() + }) + + mcp + .command('reset-mcprc-choices') + .description( + 'Reset approvals for project-file MCP servers (.mcp.json/.mcprc) in this project', + ) + .action(() => { + resetMcpChoices() + }) +} diff --git a/src/entrypoints/cli/commands/models.ts b/src/entrypoints/cli/commands/models.ts new file mode 100644 index 00000000..15c8066e --- /dev/null +++ b/src/entrypoints/cli/commands/models.ts @@ -0,0 +1,163 @@ +import type { Command } from '@commander-js/extra-typings' +import { readFileSync, writeFileSync } from 'node:fs' +import { cwd } from 'process' +import { setup } from '../setup' +import { getGlobalConfig, saveGlobalConfig } from '@utils/config' +import { + applyModelConfigYamlImport, + formatModelConfigYamlForSharing, +} from '@utils/model/modelConfigYaml' + +export function registerModelsCommands(program: Command): void { + const modelsCmd = program + .command('models') + .description('Import/export model profiles and pointers (YAML)') + + modelsCmd + .command('export') + .description( + 'Export shareable model config as YAML (does not include plaintext API keys)', + ) + .option('--cwd ', 'The current working directory', String, cwd()) + .option('-o, --output ', 'Write YAML to file instead of stdout') + .action(async ({ cwd, output }) => { + try { + await setup(cwd, false) + const yamlText = formatModelConfigYamlForSharing(getGlobalConfig()) + if (output) { + writeFileSync(output, yamlText, 'utf-8') + console.log(`Wrote model config YAML to ${output}`) + } else { + console.log(yamlText) + } + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + modelsCmd + .command('import ') + .description('Import model config YAML (merges by default)') + .option('--cwd ', 'The current working directory', String, cwd()) + .option('--replace', 'Replace existing model profiles instead of merging') + .action(async (file: string, { cwd, replace }) => { + try { + await setup(cwd, false) + + const yamlText = readFileSync(file, 'utf-8') + const { nextConfig, warnings } = applyModelConfigYamlImport( + getGlobalConfig(), + yamlText, + { replace: !!replace }, + ) + saveGlobalConfig(nextConfig) + + await import('@utils/model').then(({ reloadModelManager }) => { + reloadModelManager() + }) + + if (warnings.length > 0) { + console.error(warnings.join('\n')) + } + console.log(`Imported model config YAML from ${file}`) + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + modelsCmd + .command('list') + .description('List configured model profiles and pointers') + .option('--cwd ', 'The current working directory', String, cwd()) + .option('--json', 'Output as JSON') + .action(async (options: any) => { + try { + const workingDir = + typeof options?.cwd === 'string' ? options.cwd : cwd() + const asJson = options?.json === true + await setup(workingDir, false) + const { reloadModelManager, getModelManager } = + await import('@utils/model') + reloadModelManager() + const manager = getModelManager() + const config = getGlobalConfig() + + const pointers = (['main', 'task', 'compact', 'quick'] as const).map( + pointer => { + const pointerId = config.modelPointers?.[pointer] ?? null + const resolved = manager.resolveModelWithInfo(pointer) + const profile = resolved.success ? resolved.profile : null + return { + pointer, + pointerId, + resolved: profile + ? { + name: profile.name, + provider: profile.provider, + modelName: profile.modelName, + isActive: profile.isActive, + } + : null, + error: resolved.success ? null : (resolved.error ?? null), + } + }, + ) + + const profiles = (config.modelProfiles ?? []).map(p => ({ + name: p.name, + provider: p.provider, + modelName: p.modelName, + baseURL: p.baseURL ?? null, + maxTokens: p.maxTokens, + contextLength: p.contextLength, + reasoningEffort: p.reasoningEffort ?? null, + isActive: p.isActive, + createdAt: p.createdAt, + lastUsed: typeof p.lastUsed === 'number' ? p.lastUsed : null, + isGPT5: p.isGPT5 ?? null, + validationStatus: p.validationStatus ?? null, + lastValidation: + typeof p.lastValidation === 'number' ? p.lastValidation : null, + hasApiKey: Boolean(p.apiKey), + })) + + if (asJson) { + console.log(JSON.stringify({ pointers, profiles }, null, 2)) + process.exitCode = 0 + return + } + + console.log('Model pointers:\n') + for (const ptr of pointers) { + const resolvedLabel = ptr.resolved + ? `${ptr.resolved.name} (${ptr.resolved.provider}:${ptr.resolved.modelName})` + : '(unresolved)' + const configured = ptr.pointerId ? ` -> ${ptr.pointerId}` : '' + const err = ptr.error ? ` [${ptr.error}]` : '' + console.log(` - ${ptr.pointer}${configured}: ${resolvedLabel}${err}`) + } + + const active = profiles.filter(p => p.isActive) + console.log( + `\nModel profiles (${active.length}/${profiles.length} active):\n`, + ) + for (const p of profiles.sort((a, b) => a.name.localeCompare(b.name))) { + const status = p.isActive ? 'active' : 'inactive' + console.log(` - ${p.name} (${status})`) + console.log(` provider=${p.provider} modelName=${p.modelName}`) + if (p.baseURL) console.log(` baseURL=${p.baseURL}`) + } + + process.exitCode = 0 + return + } catch (error) { + console.error((error as Error).message) + process.exitCode = 1 + return + } + }) +} diff --git a/src/entrypoints/cli/commands/pluginSkills.ts b/src/entrypoints/cli/commands/pluginSkills.ts new file mode 100644 index 00000000..b3044d5a --- /dev/null +++ b/src/entrypoints/cli/commands/pluginSkills.ts @@ -0,0 +1,465 @@ +import type { Command } from '@commander-js/extra-typings' +import { cwd } from 'process' + +export function registerPluginAndSkillsCommands(program: Command): void { + const registerMarketplaceCommands = (marketplaceCmd: Command) => { + marketplaceCmd + .command('add ') + .description('Add a marketplace from a URL, path, or GitHub repo') + .action(async (source: string) => { + try { + const { addMarketplace } = await import('@services/skillMarketplace') + console.log('Adding marketplace...') + const { name } = await addMarketplace(source) + console.log(`Successfully added marketplace: ${name}`) + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + marketplaceCmd + .command('list') + .description('List all configured marketplaces') + .option('--json', 'Output as JSON') + .action(async (options: { json?: boolean }) => { + try { + const { listMarketplaces } = + await import('@services/skillMarketplace') + const marketplaces = listMarketplaces() + + if (options.json) { + console.log(JSON.stringify(marketplaces, null, 2)) + process.exit(0) + } + + const names = Object.keys(marketplaces).sort() + if (names.length === 0) { + console.log('No marketplaces configured') + process.exit(0) + } + + console.log('Configured marketplaces:\n') + for (const name of names) { + const entry = marketplaces[name] as any + console.log(` - ${name}`) + const src = entry?.source + if (src?.source === 'github') { + console.log(` Source: GitHub (${src.repo})`) + } else if (src?.source === 'git') { + console.log(` Source: Git (${src.url})`) + } else if (src?.source === 'url') { + console.log(` Source: URL (${src.url})`) + } else if (src?.source === 'directory') { + console.log(` Source: Directory (${src.path})`) + } else if (src?.source === 'file') { + console.log(` Source: File (${src.path})`) + } else if (src?.source === 'npm') { + console.log(` Source: NPM (${src.package})`) + } + console.log('') + } + + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + marketplaceCmd + .command('remove ') + .alias('rm') + .description('Remove a configured marketplace') + .action(async (name: string) => { + try { + const { removeMarketplace } = + await import('@services/skillMarketplace') + removeMarketplace(name) + console.log(`Successfully removed marketplace: ${name}`) + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + marketplaceCmd + .command('update [name]') + .description( + 'Update marketplace(s) from their source - updates all if no name specified', + ) + .action(async (name: string | undefined, _options: any) => { + try { + const { + listMarketplaces, + refreshAllMarketplacesAsync, + refreshMarketplaceAsync, + } = await import('@services/skillMarketplace') + + const trimmed = typeof name === 'string' ? name.trim() : '' + if (trimmed) { + console.log(`Updating marketplace: ${trimmed}...`) + await refreshMarketplaceAsync(trimmed) + console.log(`Successfully updated marketplace: ${trimmed}`) + process.exit(0) + } + + const marketplaces = listMarketplaces() + const names = Object.keys(marketplaces) + if (names.length === 0) { + console.log('No marketplaces configured') + process.exit(0) + } + + console.log(`Updating ${names.length} marketplace(s)...`) + await refreshAllMarketplacesAsync(message => { + console.log(message) + }) + console.log(`Successfully updated ${names.length} marketplace(s)`) + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + } + + const pluginCmd = program + .command('plugin') + .description('Manage plugins and marketplaces') + + const pluginMarketplaceCmd = pluginCmd + .command('marketplace') + .description( + 'Manage marketplaces (.kode-plugin/marketplace.json; legacy .claude-plugin supported)', + ) + + registerMarketplaceCommands(pluginMarketplaceCmd) + + const PLUGIN_SCOPES = ['user', 'project', 'local'] as const + type PluginScope = (typeof PLUGIN_SCOPES)[number] + + const parsePluginScope = (value: unknown): PluginScope | null => { + const normalized = String(value || 'user') as PluginScope + return PLUGIN_SCOPES.includes(normalized) ? normalized : null + } + + pluginCmd + .command('install ') + .alias('i') + .description( + 'Install a plugin from available marketplaces (use plugin@marketplace for specific marketplace)', + ) + .option('--cwd ', 'The current working directory', String, cwd()) + .option( + '-s, --scope ', + 'Installation scope: user, project, or local', + 'user', + ) + .option('--force', 'Overwrite existing installed files', () => true) + .action(async (plugin: string, options: any) => { + try { + const scope = parsePluginScope(options.scope) + if (!scope) { + console.error( + `Invalid scope: ${String(options.scope)}. Must be one of: ${PLUGIN_SCOPES.join(', ')}`, + ) + process.exit(1) + } + + const { setCwd } = await import('@utils/state') + await setCwd(options.cwd ?? cwd()) + + const { installSkillPlugin } = + await import('@services/skillMarketplace') + const result = installSkillPlugin(plugin, { + scope, + force: options.force === true, + }) + + const skillList = + result.installedSkills.length > 0 + ? `Skills: ${result.installedSkills.join(', ')}` + : 'Skills: (none)' + console.log(`Installed ${result.pluginSpec}\n${skillList}`) + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + pluginCmd + .command('uninstall ') + .alias('remove') + .alias('rm') + .description('Uninstall an installed plugin') + .option('--cwd ', 'The current working directory', String, cwd()) + .option( + '-s, --scope ', + `Uninstall from scope: ${PLUGIN_SCOPES.join(', ')} (default: user)`, + 'user', + ) + .action(async (plugin: string, options: any) => { + try { + const scope = parsePluginScope(options.scope) + if (!scope) { + console.error( + `Invalid scope: ${String(options.scope)}. Must be one of: ${PLUGIN_SCOPES.join(', ')}`, + ) + process.exit(1) + } + + const { setCwd } = await import('@utils/state') + await setCwd(options.cwd ?? cwd()) + + const { uninstallSkillPlugin } = + await import('@services/skillMarketplace') + const result = uninstallSkillPlugin(plugin, { scope }) + const skillList = + result.removedSkills.length > 0 + ? `Skills: ${result.removedSkills.join(', ')}` + : 'Skills: (none)' + console.log(`Uninstalled ${result.pluginSpec}\n${skillList}`) + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + pluginCmd + .command('list') + .description('List installed plugins') + .option('--cwd ', 'The current working directory', String, cwd()) + .option( + '-s, --scope ', + `Filter by scope: ${PLUGIN_SCOPES.join(', ')} (default: user)`, + 'user', + ) + .option('--json', 'Output as JSON') + .action(async (options: any) => { + try { + const scope = parsePluginScope(options.scope) + if (!scope) { + console.error( + `Invalid scope: ${String(options.scope)}. Must be one of: ${PLUGIN_SCOPES.join(', ')}`, + ) + process.exit(1) + } + + const { setCwd, getCwd } = await import('@utils/state') + await setCwd(options.cwd ?? cwd()) + + const { listInstalledSkillPlugins } = + await import('@services/skillMarketplace') + const all = listInstalledSkillPlugins() + const filtered = Object.fromEntries( + Object.entries(all).filter(([, record]) => { + if ((record as any)?.scope !== scope) return false + if (scope === 'user') return true + return (record as any)?.projectPath === getCwd() + }), + ) + + if (options.json) { + console.log(JSON.stringify(filtered, null, 2)) + process.exit(0) + } + + const names = Object.keys(filtered).sort() + if (names.length === 0) { + console.log('No plugins installed') + process.exit(0) + } + console.log(`Installed plugins (scope=${scope}):\n`) + for (const spec of names) { + const record = filtered[spec] as any + const enabled = record?.isEnabled === false ? 'disabled' : 'enabled' + console.log(` - ${spec} (${enabled})`) + } + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + pluginCmd + .command('enable ') + .description('Enable a disabled plugin') + .option('--cwd ', 'The current working directory', String, cwd()) + .option( + '-s, --scope ', + `Installation scope: ${PLUGIN_SCOPES.join(', ')} (default: user)`, + 'user', + ) + .action(async (plugin: string, options: any) => { + try { + const scope = parsePluginScope(options.scope) + if (!scope) { + console.error( + `Invalid scope: ${String(options.scope)}. Must be one of: ${PLUGIN_SCOPES.join(', ')}`, + ) + process.exit(1) + } + + const { setCwd } = await import('@utils/state') + await setCwd(options.cwd ?? cwd()) + + const { enableSkillPlugin } = await import('@services/skillMarketplace') + const result = enableSkillPlugin(plugin, { scope }) + console.log(`Enabled ${result.pluginSpec}`) + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + pluginCmd + .command('disable ') + .description('Disable an enabled plugin') + .option('--cwd ', 'The current working directory', String, cwd()) + .option( + '-s, --scope ', + `Installation scope: ${PLUGIN_SCOPES.join(', ')} (default: user)`, + 'user', + ) + .action(async (plugin: string, options: any) => { + try { + const scope = parsePluginScope(options.scope) + if (!scope) { + console.error( + `Invalid scope: ${String(options.scope)}. Must be one of: ${PLUGIN_SCOPES.join(', ')}`, + ) + process.exit(1) + } + + const { setCwd } = await import('@utils/state') + await setCwd(options.cwd ?? cwd()) + + const { disableSkillPlugin } = + await import('@services/skillMarketplace') + const result = disableSkillPlugin(plugin, { scope }) + console.log(`Disabled ${result.pluginSpec}`) + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + pluginCmd + .command('validate ') + .description('Validate a plugin or marketplace manifest') + .option('--cwd ', 'The current working directory', String, cwd()) + .action(async (path: string, options: any) => { + try { + const { setCwd } = await import('@utils/state') + await setCwd(options.cwd ?? cwd()) + + const { formatValidationResult, validatePluginOrMarketplacePath } = + await import('@services/pluginValidation') + + const result = validatePluginOrMarketplacePath(path) + console.log( + `Validating ${result.fileType} manifest: ${result.filePath}\n`, + ) + console.log(formatValidationResult(result)) + process.exit(result.success ? 0 : 1) + } catch (error) { + console.error( + `Unexpected error during validation: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + process.exit(2) + } + }) + + const skillsCmd = program + .command('skills') + .description('Manage skills and skill marketplaces') + + const marketplaceCmd = skillsCmd + .command('marketplace') + .description( + 'Manage skill marketplaces (.kode-plugin/marketplace.json; legacy .claude-plugin supported)', + ) + + registerMarketplaceCommands(marketplaceCmd) + + skillsCmd + .command('install ') + .description('Install a skill plugin pack (@)') + .option('--cwd ', 'The current working directory', String, cwd()) + .option('--project', 'Install into this project (.kode/...)', () => true) + .option('--force', 'Overwrite existing installed files', () => true) + .action(async (plugin: string, options: any) => { + try { + const { setCwd } = await import('@utils/state') + await setCwd(options.cwd ?? cwd()) + + const { installSkillPlugin } = + await import('@services/skillMarketplace') + const result = installSkillPlugin(plugin, { + project: options.project === true, + force: options.force === true, + }) + const skillList = + result.installedSkills.length > 0 + ? `Skills: ${result.installedSkills.join(', ')}` + : 'Skills: (none)' + console.log(`Installed ${plugin}\n${skillList}`) + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + skillsCmd + .command('uninstall ') + .description('Uninstall a skill plugin pack (@)') + .option('--cwd ', 'The current working directory', String, cwd()) + .option('--project', 'Uninstall from this project (.kode/...)', () => true) + .action(async (plugin: string, options: any) => { + try { + const { setCwd } = await import('@utils/state') + await setCwd(options.cwd ?? cwd()) + + const { uninstallSkillPlugin } = + await import('@services/skillMarketplace') + const result = uninstallSkillPlugin(plugin, { + project: options.project === true, + }) + const skillList = + result.removedSkills.length > 0 + ? `Skills: ${result.removedSkills.join(', ')}` + : 'Skills: (none)' + console.log(`Uninstalled ${plugin}\n${skillList}`) + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) + + skillsCmd + .command('list-installed') + .description('List installed skill plugins') + .action(async () => { + try { + const { listInstalledSkillPlugins } = + await import('@services/skillMarketplace') + console.log(JSON.stringify(listInstalledSkillPlugins(), null, 2)) + process.exit(0) + } catch (error) { + console.error((error as Error).message) + process.exit(1) + } + }) +} diff --git a/src/entrypoints/cli/commands/session.tsx b/src/entrypoints/cli/commands/session.tsx new file mode 100644 index 00000000..97db7522 --- /dev/null +++ b/src/entrypoints/cli/commands/session.tsx @@ -0,0 +1,259 @@ +import React from 'react' +import type { Command } from '@commander-js/extra-typings' +import { existsSync } from 'node:fs' +import { cwd } from 'process' +import { setup } from '../setup' +import { LogList } from '@screens/LogList' +import { ResumeConversation } from '@screens/ResumeConversation' +import { getCurrentProjectConfig } from '@utils/config' +import { + CACHE_PATHS, + dateToFilename, + getNextAvailableLogForkNumber, + loadLogList, + logError, + parseLogFilename, +} from '@utils/log' +import { loadMessagesFromLog } from '@utils/session/conversationRecovery' +import { assertMinVersion } from '@utils/session/autoUpdater' +import { isDefaultSlowAndCapableModel } from '@utils/model' +import { getClients } from '@services/mcpClient' +import type { CliCommandRegistrationContext } from '../commandContext' + +export function registerSessionLogAndErrorCommands( + program: Command, + context: CliCommandRegistrationContext, +): void { + const { renderContextWithExitOnCtrlC } = context + program + .command('log') + .description('Manage conversation logs.') + .argument( + '[number]', + 'A number (0, 1, 2, etc.) to display a specific log', + parseInt, + ) + .option('--cwd ', 'The current working directory', String, cwd()) + .action(async (number, { cwd }) => { + await setup(cwd, false) + + const context: { unmount?: () => void } = {} + ;(async () => { + const { render } = await import('ink') + const { unmount } = render( + , + renderContextWithExitOnCtrlC, + ) + context.unmount = unmount + })() + }) + + program + .command('resume') + .description( + 'Resume a previous conversation. Optionally provide a session ID or session name (legacy: log index or file path).', + ) + .argument( + '[identifier]', + 'A session ID or session name (legacy: log index or file path)', + ) + .option('--cwd ', 'The current working directory', String, cwd()) + .option('-e, --enable-architect', 'Enable the Architect tool', () => true) + .option('-v, --verbose', 'Do not truncate message output', () => true) + .option( + '--safe', + 'Enable strict permission checking mode (default is permissive)', + () => true, + ) + .option( + '--disable-slash-commands', + 'Disable slash commands (treat /... as plain text)', + () => true, + ) + .action( + async ( + identifier, + { cwd, enableArchitect, safe, verbose, disableSlashCommands }, + ) => { + await setup(cwd, safe) + assertMinVersion() + + const [{ getTools }, { getCommands }] = await Promise.all([ + import('@tools'), + import('@commands'), + ]) + const [allTools, commands, mcpClients] = await Promise.all([ + getTools( + enableArchitect ?? getCurrentProjectConfig().enableArchitectTool, + ), + getCommands(), + getClients(), + ]) + const tools = + disableSlashCommands === true + ? allTools.filter(t => t.name !== 'SlashCommand') + : allTools + + if (identifier !== undefined) { + const { loadKodeAgentSessionMessages } = + await import('@utils/protocol/kodeAgentSessionLoad') + const { resolveResumeSessionIdentifier } = + await import('@utils/protocol/kodeAgentSessionResume') + const { setKodeAgentSessionId } = + await import('@utils/protocol/kodeAgentSessionId') + + const rawIdentifier = String(identifier).trim() + const isLegacyNumber = /^-?\d+$/.test(rawIdentifier) + const isLegacyPath = !isLegacyNumber && existsSync(rawIdentifier) + + let messages: any[] | undefined + let messageLogName: string = dateToFilename(new Date()) + let initialForkNumber: number | undefined = undefined + + try { + if (isLegacyNumber || isLegacyPath) { + const logs = await loadLogList(CACHE_PATHS.messages()) + if (isLegacyNumber) { + const number = Math.abs(parseInt(rawIdentifier, 10)) + const log = logs[number] + if (!log) { + console.error('No conversation found at index', number) + process.exit(1) + } + messages = await loadMessagesFromLog(log.fullPath, tools) + messageLogName = log.date + initialForkNumber = getNextAvailableLogForkNumber( + log.date, + log.forkNumber ?? 1, + 0, + ) + } else { + messages = await loadMessagesFromLog(rawIdentifier, tools) + const pathSegments = rawIdentifier.split('/') + const filename = + pathSegments[pathSegments.length - 1] ?? 'unknown' + const { date, forkNumber } = parseLogFilename(filename) + messageLogName = date + initialForkNumber = getNextAvailableLogForkNumber( + date, + forkNumber ?? 1, + 0, + ) + } + } else { + const resolved = resolveResumeSessionIdentifier({ + cwd, + identifier: rawIdentifier, + }) + if (resolved.kind === 'ok') { + setKodeAgentSessionId(resolved.sessionId) + messages = loadKodeAgentSessionMessages({ + cwd, + sessionId: resolved.sessionId, + }) + } else if (resolved.kind === 'different_directory') { + console.error( + resolved.otherCwd + ? `Error: That session belongs to a different directory: ${resolved.otherCwd}` + : `Error: That session belongs to a different directory.`, + ) + process.exit(1) + } else if (resolved.kind === 'ambiguous') { + console.error( + `Error: Multiple sessions match "${rawIdentifier}": ${resolved.matchingSessionIds.join( + ', ', + )}`, + ) + process.exit(1) + } else { + console.error( + `No conversation found with session ID or name: ${rawIdentifier}`, + ) + process.exit(1) + } + } + + const isDefaultModel = await isDefaultSlowAndCapableModel() + { + const { render } = await import('ink') + const { REPL } = await import('@screens/REPL') + render( + , + { exitOnCtrlC: false }, + ) + } + } catch (error) { + logError(`Failed to load conversation: ${error}`) + process.exit(1) + } + } else { + const { listKodeAgentSessions } = + await import('@utils/protocol/kodeAgentSessionResume') + const sessions = listKodeAgentSessions({ cwd }) + if (sessions.length === 0) { + console.error('No conversation found to resume') + process.exit(1) + } + + const context: { unmount?: () => void } = {} + ;(async () => { + const { render } = await import('ink') + const { unmount } = render( + , + renderContextWithExitOnCtrlC, + ) + context.unmount = unmount + })() + } + }, + ) + + program + .command('error') + .description( + 'View error logs. Optionally provide a number (0, -1, -2, etc.) to display a specific log.', + ) + .argument( + '[number]', + 'A number (0, 1, 2, etc.) to display a specific log', + parseInt, + ) + .option('--cwd ', 'The current working directory', String, cwd()) + .action(async (number, { cwd }) => { + await setup(cwd, false) + + const context: { unmount?: () => void } = {} + ;(async () => { + const { render } = await import('ink') + const { unmount } = render( + , + renderContextWithExitOnCtrlC, + ) + context.unmount = unmount + })() + }) +} diff --git a/src/entrypoints/cli/mainCommand.tsx b/src/entrypoints/cli/mainCommand.tsx new file mode 100644 index 00000000..91f11cd9 --- /dev/null +++ b/src/entrypoints/cli/mainCommand.tsx @@ -0,0 +1,541 @@ +import React from 'react' +import type { Command } from '@commander-js/extra-typings' +import { cwd } from 'process' +import { PRODUCT_COMMAND, PRODUCT_NAME } from '@constants/product' +import { runPrintMode } from './printMode' +import { setup } from './setup' +import { showSetupScreens } from './setupScreens' +import { getCurrentProjectConfig } from '@utils/config' +import { clearAgentCache, setFlagAgentsFromCliJson } from '@utils/agent/loader' +import { setEnabledSettingSourcesFromCli } from '@utils/config/settingSources' +import { clearOutputStyleCache } from '@services/outputStyles' +import { assertMinVersion } from '@utils/session/autoUpdater' +import { getClients, getClientsForCliMcpConfig } from '@services/mcpClient' +import { isDefaultSlowAndCapableModel } from '@utils/model' +import { dateToFilename } from '@utils/log' +import { ResumeConversation } from '@screens/ResumeConversation' +import { MACRO } from '@constants/macros' +import type { CliCommandRegistrationContext } from './commandContext' + +export function registerMainCommand( + program: Command, + context: CliCommandRegistrationContext, +): void { + const { stdinContent, renderContext, renderContextWithExitOnCtrlC } = context + program + .name(PRODUCT_COMMAND) + .description( + `${PRODUCT_NAME} - starts an interactive session by default, use -p/--print for non-interactive output`, + ) + .argument('[prompt]', 'Your prompt', String) + .option('--cwd ', 'The current working directory', String, cwd()) + .option( + '-d, --debug [filter]', + 'Enable debug mode with optional category filtering (e.g., "api,hooks" or "!statsig,!file")', + ) + .option( + '--debug-verbose', + 'Enable verbose debug terminal output', + () => true, + ) + .option( + '--verbose', + 'Override verbose mode setting from config', + () => true, + ) + .option('-e, --enable-architect', 'Enable the Architect tool', () => true) + .option( + '-p, --print', + 'Print response and exit (useful for pipes)', + () => true, + ) + .option( + '--output-format ', + 'Output format (only works with --print): "text" (default), "json", or "stream-json"', + String, + 'text', + ) + .option( + '--json-schema ', + 'JSON Schema for structured output validation. Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}', + String, + ) + .option( + '--input-format ', + 'Input format (only works with --print): "text" (default) or "stream-json"', + String, + 'text', + ) + .option( + '--mcp-debug', + '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', + () => true, + ) + .option( + '--dangerously-skip-permissions', + 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', + () => true, + ) + .option( + '--allow-dangerously-skip-permissions', + 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', + () => true, + ) + .option( + '--max-budget-usd ', + 'Maximum dollar amount to spend on API calls (only works with --print)', + String, + ) + .option( + '--include-partial-messages', + 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', + () => true, + ) + .option( + '--replay-user-messages', + 'Re-emit user messages from stdin back on stdout for acknowledgment (only works with --input-format=stream-json and --output-format=stream-json)', + () => true, + ) + .option( + '--allowedTools, --allowed-tools ', + 'Comma or space-separated list of tool names to allow (e.g. "Bash(git:*) Edit")', + ) + .option( + '--tools ', + 'Specify the list of available tools from the built-in set. Use "" to disable all tools, "default" to use all tools, or specify tool names (e.g. "Bash,Edit,Read"). Only works with --print mode.', + ) + .option( + '--disallowedTools, --disallowed-tools ', + 'Comma or space-separated list of tool names to deny (e.g. "Bash(git:*) Edit")', + ) + .option( + '--mcp-config ', + 'Load MCP servers from JSON files or strings (space-separated)', + ) + .option('--system-prompt ', 'System prompt to use for the session') + .option( + '--append-system-prompt ', + 'Append a system prompt to the default system prompt', + ) + .option( + '--permission-mode ', + 'Permission mode to use for the session (choices: "acceptEdits", "bypassPermissions", "default", "delegate", "dontAsk", "plan")', + String, + ) + .option( + '--permission-prompt-tool ', + 'Permission prompt tool (only works with --print, --output-format=stream-json, and --input-format=stream-json): "stdio"', + String, + ) + .option( + '--safe', + 'Enable strict permission checking mode (default is permissive)', + () => true, + ) + .option( + '--disable-slash-commands', + 'Disable slash commands (treat /... as plain text)', + () => true, + ) + .option( + '--plugin-dir ', + 'Load plugins from directories for this session only (repeatable)', + (value, previous: string[] | undefined) => { + const prev = Array.isArray(previous) ? previous : [] + const next = Array.isArray(value) ? value : [value] + return [...prev, ...next].filter(Boolean) + }, + [], + ) + .option( + '--model ', + "Model for the current session. Provide an alias for the latest model (e.g. 'sonnet' or 'opus') or a model's full name.", + String, + ) + .option( + '--agent ', + "Agent for the current session. Overrides the 'agent' setting.", + String, + ) + .option( + '--betas ', + 'Beta headers to include in API requests (API key users only)', + ) + .option( + '--fallback-model ', + 'Enable automatic fallback to specified model when default model is overloaded (only works with --print)', + String, + ) + .option( + '--settings ', + 'Path to a settings JSON file or a JSON string to load additional settings from', + String, + ) + .option( + '--add-dir ', + 'Additional directories to allow tool access to', + ) + .option( + '--ide', + 'Automatically connect to IDE on startup if exactly one valid IDE is available', + () => true, + ) + .option( + '--strict-mcp-config', + 'Only use MCP servers from --mcp-config, ignoring all other MCP configurations', + () => true, + ) + .option( + '--agents ', + `JSON object defining custom agents (e.g. '{"reviewer": {"description": "Reviews code", "prompt": "You are a code reviewer"}}')`, + String, + ) + .option( + '--setting-sources ', + 'Comma-separated list of setting sources to load (user, project, local).', + String, + ) + .option( + '-r, --resume [value]', + 'Resume a conversation by session ID or session name (omit value to open selector)', + ) + .option( + '-c, --continue', + 'Continue the most recent conversation', + () => true, + ) + .option( + '--fork-session', + 'When resuming/continuing, create a new session ID instead of reusing the original (use with --resume or --continue)', + () => true, + ) + .option( + '--no-session-persistence', + 'Disable session persistence - sessions will not be saved to disk and cannot be resumed (only works with --print)', + ) + .option( + '--session-id ', + 'Use a specific session ID for the conversation (must be a valid UUID)', + String, + ) + .action( + async ( + prompt, + { + cwd, + debug, + verbose, + enableArchitect, + print, + outputFormat, + jsonSchema, + inputFormat, + dangerouslySkipPermissions, + allowDangerouslySkipPermissions, + replayUserMessages, + allowedTools, + tools: cliTools, + disallowedTools, + mcpConfig, + systemPrompt: systemPromptOverride, + appendSystemPrompt, + permissionMode, + permissionPromptTool, + safe, + disableSlashCommands, + pluginDir, + model, + addDir, + strictMcpConfig, + agents, + settingSources, + resume, + continue: continueConversation, + forkSession, + sessionId, + sessionPersistence, + }, + ) => { + try { + setEnabledSettingSourcesFromCli(settingSources) + } catch (err) { + process.stderr.write( + `Error processing --setting-sources: ${err instanceof Error ? err.message : String(err)}\n`, + ) + process.exit(1) + } + + setFlagAgentsFromCliJson(agents) + clearAgentCache() + clearOutputStyleCache() + + await setup(cwd, safe) + await showSetupScreens(safe, print) + + assertMinVersion() + + { + const requested = + Array.isArray(pluginDir) && pluginDir.length > 0 ? pluginDir : [] + const { listEnabledInstalledPluginPackRoots } = + await import('@services/skillMarketplace') + const installed = listEnabledInstalledPluginPackRoots() + + const all = [...installed, ...requested].filter(Boolean) + const deduped = Array.from(new Set(all)) + + if (deduped.length > 0) { + const { configureSessionPlugins } = + await import('@services/pluginRuntime') + const { errors } = await configureSessionPlugins({ + pluginDirs: deduped, + }) + for (const err of errors) { + console.warn(err) + } + } + } + + const [{ ask }, { getTools }, { getCommands }] = await Promise.all([ + import('@app/ask'), + import('@tools'), + import('@commands'), + ]) + const commands = await getCommands() + + const mcpClientsPromise = + (Array.isArray(mcpConfig) && mcpConfig.length > 0) || + strictMcpConfig === true + ? getClientsForCliMcpConfig({ + mcpConfig: Array.isArray(mcpConfig) ? mcpConfig : [], + strictMcpConfig: strictMcpConfig === true, + projectDir: cwd, + }) + : getClients() + + const [allTools, mcpClients] = await Promise.all([ + getTools( + enableArchitect ?? getCurrentProjectConfig().enableArchitectTool, + ), + mcpClientsPromise, + ]) + const tools = + disableSlashCommands === true + ? allTools.filter(t => t.name !== 'SlashCommand') + : allTools + const inputPrompt = [prompt, stdinContent].filter(Boolean).join('\n') + + const { + loadKodeAgentSessionMessages, + findMostRecentKodeAgentSessionId, + } = await import('@utils/protocol/kodeAgentSessionLoad') + const { listKodeAgentSessions, resolveResumeSessionIdentifier } = + await import('@utils/protocol/kodeAgentSessionResume') + const { isUuid } = await import('@utils/text/uuid') + const { setKodeAgentSessionId, getKodeAgentSessionId } = + await import('@utils/protocol/kodeAgentSessionId') + const { randomUUID } = await import('crypto') + + const wantsContinue = Boolean(continueConversation) + const wantsResume = resume !== undefined + const wantsFork = Boolean(forkSession) + + if (sessionId && !isUuid(String(sessionId))) { + console.error(`Error: --session-id must be a valid UUID`) + process.exit(1) + } + + if (sessionId && (wantsContinue || wantsResume) && !wantsFork) { + console.error( + `Error: --session-id can only be used with --continue or --resume if --fork-session is also specified.`, + ) + process.exit(1) + } + + let initialMessages: any[] | undefined + let resumedFromSessionId: string | null = null + let needsResumeSelector = false + + if (wantsContinue) { + const latest = findMostRecentKodeAgentSessionId(cwd) + if (!latest) { + console.error('No conversation found to continue') + process.exit(1) + } + initialMessages = loadKodeAgentSessionMessages({ + cwd, + sessionId: latest, + }) + resumedFromSessionId = latest + } else if (wantsResume) { + if (resume === true) { + needsResumeSelector = true + } else { + const identifier = String(resume) + const resolved = resolveResumeSessionIdentifier({ cwd, identifier }) + if (resolved.kind === 'ok') { + initialMessages = loadKodeAgentSessionMessages({ + cwd, + sessionId: resolved.sessionId, + }) + resumedFromSessionId = resolved.sessionId + } else if (resolved.kind === 'different_directory') { + console.error( + resolved.otherCwd + ? `Error: That session belongs to a different directory: ${resolved.otherCwd}` + : `Error: That session belongs to a different directory.`, + ) + process.exit(1) + } else if (resolved.kind === 'ambiguous') { + console.error( + `Error: Multiple sessions match "${identifier}": ${resolved.matchingSessionIds.join( + ', ', + )}`, + ) + process.exit(1) + } else { + console.error( + `No conversation found with session ID or name: ${identifier}`, + ) + process.exit(1) + } + } + } + + if (needsResumeSelector && print) { + console.error( + 'Error: --resume without a value requires interactive mode (no --print).', + ) + process.exit(1) + } + + if (!needsResumeSelector) { + const effectiveSessionId = (() => { + if (resumedFromSessionId) { + if (wantsFork) return sessionId ? String(sessionId) : randomUUID() + return resumedFromSessionId + } + if (sessionId) return String(sessionId) + return getKodeAgentSessionId() + })() + + setKodeAgentSessionId(effectiveSessionId) + } + + if (print) { + await runPrintMode({ + prompt, + stdinContent, + inputPrompt, + cwd, + safe, + verbose, + outputFormat, + inputFormat, + jsonSchema, + permissionPromptTool, + replayUserMessages, + cliTools, + tools, + commands, + ask, + initialMessages, + sessionPersistence, + systemPromptOverride, + appendSystemPrompt, + disableSlashCommands, + allowedTools, + disallowedTools, + addDir, + permissionMode, + dangerouslySkipPermissions, + allowDangerouslySkipPermissions, + model, + mcpClients, + }) + return + } else { + if (sessionPersistence === false) { + console.error( + 'Error: --no-session-persistence only works with --print', + ) + process.exit(1) + } + + // Start update check early, outside REPL render critical path + const updateCheckPromise = import('@utils/session/autoUpdater') + .then(({ getUpdateBannerInfo }) => getUpdateBannerInfo()) + .catch(() => ({ version: null, commands: null })) + + const updateInfo = { + version: null as string | null, + commands: null as string[] | null, + } + + if (needsResumeSelector) { + const sessions = listKodeAgentSessions({ cwd }) + if (sessions.length === 0) { + console.error('No conversation found to resume') + process.exit(1) + } + + const context: { unmount?: () => void } = {} + ;(async () => { + const { render } = await import('ink') + const { unmount } = render( + , + renderContextWithExitOnCtrlC, + ) + context.unmount = unmount + })() + return + } + + const isDefaultModel = await isDefaultSlowAndCapableModel() + + { + const { render } = await import('ink') + const { REPL } = await import('@screens/REPL') + render( + , + renderContext, + ) + } + } + }, + ) + .version(MACRO.VERSION, '-v, --version') +} diff --git a/src/types/untyped-deps.d.ts b/src/types/untyped-deps.d.ts new file mode 100644 index 00000000..41e203f4 --- /dev/null +++ b/src/types/untyped-deps.d.ts @@ -0,0 +1,89 @@ +declare module 'shell-quote' { + export type ControlOperator = + | '&&' + | '||' + | ';' + | ';;' + | '|' + | '&' + | '>&' + | '>' + | '>>' + | '<' + | '<<' + | '(' + | ')' + | 'glob' + + export type ParseEntry = + | string + | { op: ControlOperator; pattern?: string } + | { comment: string } + + export function parse( + command: string, + env?: Record | ((varName: string) => string | undefined), + ): ParseEntry[] + + export function quote(args: Array): string +} + +declare module 'turndown' { + type RuleFilter = + | string + | string[] + | ((node: { + nodeName: string + nodeType: number + getAttribute(name: string): string | null + }) => boolean) + + type ReplacementFunction = ( + content: string, + node: { + nodeName: string + nodeType: number + getAttribute(name: string): string | null + }, + ) => string + + type TurndownOptions = { + headingStyle?: 'setext' | 'atx' + hr?: string + bulletListMarker?: '-' | '+' | '*' + codeBlockStyle?: 'indented' | 'fenced' + fence?: '```' | '~~~' + emDelimiter?: '_' | '*' + strongDelimiter?: '**' | '__' + } + + export default class TurndownService { + constructor(options?: TurndownOptions) + addRule( + key: string, + rule: { filter: RuleFilter; replacement: ReplacementFunction }, + ): this + turndown(html: string): string + } +} + +declare module 'semver' { + export function gt(version: string, other: string): boolean + export function satisfies(version: string, range: string): boolean + + const semver: { + gt: typeof gt + satisfies: typeof satisfies + } + export default semver +} + +declare module 'debug' { + export interface Debugger { + (formatter: string, ...args: unknown[]): void + enabled: boolean + namespace: string + } + + export default function debug(namespace: string): Debugger +} diff --git a/tests/unit/anthropic-helpers.test.ts b/tests/unit/anthropic-helpers.test.ts new file mode 100644 index 00000000..82e4b15f --- /dev/null +++ b/tests/unit/anthropic-helpers.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test' +import { + createAnthropicUsage, + extractTextFromContent, + isToolUseLikeBlockParam, + normalizeAnthropicUsage, + normalizeImageMediaType, +} from '@utils/ai/anthropic' + +describe('Anthropic SDK compatibility helpers', () => { + test('createAnthropicUsage returns a complete zero usage shape', () => { + expect(createAnthropicUsage()).toMatchObject({ + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + input_tokens: 0, + output_tokens: 0, + }) + }) + + test('normalizeAnthropicUsage accepts SDK and legacy token aliases', () => { + expect( + normalizeAnthropicUsage({ + prompt_tokens: 12, + completion_tokens: 7, + prompt_token_details: { cached_tokens: 3 }, + cacheCreatedInputTokens: 2, + }), + ).toMatchObject({ + input_tokens: 12, + output_tokens: 7, + cache_read_input_tokens: 3, + cache_creation_input_tokens: 2, + }) + }) + + test('extractTextFromContent handles strings, text blocks, and missing text', () => { + expect(extractTextFromContent('plain text')).toBe('plain text') + expect( + extractTextFromContent([ + { type: 'image', source: { type: 'base64', data: 'x' } }, + { type: 'text', text: 'block text' }, + ]), + ).toBe('block text') + expect(extractTextFromContent([{ type: 'image' }])).toBeNull() + }) + + test('recognizes tool-use-like Anthropic content block params', () => { + expect(isToolUseLikeBlockParam({ type: 'tool_use' })).toBe(true) + expect(isToolUseLikeBlockParam({ type: 'server_tool_use' })).toBe(true) + expect(isToolUseLikeBlockParam({ type: 'mcp_tool_use' })).toBe(true) + expect(isToolUseLikeBlockParam({ type: 'text', text: 'nope' })).toBe(false) + }) + + test('normalizeImageMediaType keeps supported types and falls back to png', () => { + expect(normalizeImageMediaType('image/jpeg')).toBe('image/jpeg') + expect(normalizeImageMediaType('image/webp')).toBe('image/webp') + expect(normalizeImageMediaType(undefined)).toBe('image/png') + expect(normalizeImageMediaType('application/octet-stream')).toBe( + 'image/png', + ) + }) +}) diff --git a/tests/unit/cli-command-registration.test.ts b/tests/unit/cli-command-registration.test.ts new file mode 100644 index 00000000..99863878 --- /dev/null +++ b/tests/unit/cli-command-registration.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'bun:test' +import { Command } from '@commander-js/extra-typings' +import { registerMainCommand } from '../../src/entrypoints/cli/mainCommand' +import { registerCliCommands } from '../../src/entrypoints/cli/commands' +import type { CliCommandRegistrationContext } from '../../src/entrypoints/cli/commandContext' + +function buildProgram(): Command { + const program = new Command() + const renderContext = { exitOnCtrlC: false } as any + const context: CliCommandRegistrationContext = { + stdinContent: '', + renderContext, + renderContextWithExitOnCtrlC: { + ...renderContext, + exitOnCtrlC: true, + }, + } + + registerMainCommand(program, context) + registerCliCommands(program, context) + return program +} + +describe('CLI command registration', () => { + test('registers representative top-level commands after runCli split', () => { + const program = buildProgram() + const commandNames = program.commands.map(command => command.name()) + + for (const name of [ + 'config', + 'models', + 'agents', + 'plugin', + 'skills', + 'approved-tools', + 'mcp', + 'doctor', + 'update', + 'log', + 'resume', + 'error', + 'context', + ]) { + expect(commandNames).toContain(name) + } + }) + + test('keeps representative main command flags registered', () => { + const program = buildProgram() + const flags = program.options.map(option => option.flags) + + for (const flag of [ + '--cwd ', + '-p, --print', + '--output-format ', + '--input-format ', + '--allowedTools, --allowed-tools ', + '--mcp-config ', + '-r, --resume [value]', + '-c, --continue', + '--session-id ', + ]) { + expect(flags).toContain(flag) + } + }) + + test('help text for representative command groups remains wired', () => { + const program = buildProgram() + + const expectations: Array<[string, string]> = [ + ['config', 'Manage configuration'], + ['models', 'Import/export model profiles'], + ['agents', 'Agent utilities'], + ['plugin', 'Manage plugins'], + ['skills', 'Manage skills'], + ['approved-tools', 'Manage approved tools'], + ['mcp', 'Configure and manage MCP servers'], + ['context', 'Set static context'], + ['resume', 'Resume a previous conversation'], + ] + + for (const [name, expected] of expectations) { + const command = program.commands.find(command => command.name() === name) + expect(command?.helpInformation()).toContain(expected) + } + }) +})