- Complete architectural overhaul of useUnifiedCompletion hook - Unified state management: 8 separate states → single CompletionState interface - Simplified core logic: getWordAtCursor 194 lines → 42 lines (78% reduction) - Fixed infinite React update loops with ref-based input tracking - Smart triggering mechanism replacing aggressive auto-completion - Integrated @agent and @file mention system with system reminders - Added comprehensive agent loading and mention processing - Enhanced Tab/Arrow/Enter key handling with clean event management - Maintained 100% functional compatibility across all completion types Key improvements: • File path completion (relative, absolute, ~expansion, @references) • Slash command completion (/help, /model, etc.) • Agent completion (@agent-xxx with intelligent descriptions) • System command completion (PATH scanning with fallback) • Terminal-style Tab cycling, Enter confirmation, Escape cancellation • Preview mode with boundary calculation • History navigation compatibility • Empty directory handling with user feedback Architecture: Event-driven @mention detection → system reminder injection → LLM tool usage Performance: Eliminated 7-layer nested conditionals, reduced state synchronization issues Reliability: Fixed maximum update depth exceeded warnings, stable state management
1519 lines
47 KiB
JavaScript
1519 lines
47 KiB
JavaScript
#!/usr/bin/env -S node --no-warnings=ExperimentalWarning --enable-source-maps
|
|
import { initSentry } from '../services/sentry'
|
|
import { PRODUCT_COMMAND, PRODUCT_NAME } from '../constants/product'
|
|
initSentry() // Initialize Sentry as early as possible
|
|
|
|
// XXX: Without this line (and the Object.keys, even though it seems like it does nothing!),
|
|
// there is a bug in Bun only on Win32 that causes this import to be removed, even though
|
|
// its use is solely because of its side-effects.
|
|
import * as dontcare from '@anthropic-ai/sdk/shims/node'
|
|
Object.keys(dontcare)
|
|
|
|
import React from 'react'
|
|
import { ReadStream } from 'tty'
|
|
import { openSync, existsSync } from 'fs'
|
|
import { render, RenderOptions } from 'ink'
|
|
import { REPL } from '../screens/REPL'
|
|
import { addToHistory } from '../history'
|
|
import { getContext, setContext, removeContext } from '../context'
|
|
import { Command } from '@commander-js/extra-typings'
|
|
import { ask } from '../utils/ask'
|
|
import { hasPermissionsToUseTool } from '../permissions'
|
|
import { getTools } from '../tools'
|
|
import {
|
|
getGlobalConfig,
|
|
getCurrentProjectConfig,
|
|
saveGlobalConfig,
|
|
saveCurrentProjectConfig,
|
|
getCustomApiKeyStatus,
|
|
normalizeApiKeyForConfig,
|
|
setConfigForCLI,
|
|
deleteConfigForCLI,
|
|
getConfigForCLI,
|
|
listConfigForCLI,
|
|
enableConfigs,
|
|
validateAndRepairAllGPT5Profiles,
|
|
} from '../utils/config'
|
|
import { cwd } from 'process'
|
|
import { dateToFilename, logError, parseLogFilename } from '../utils/log'
|
|
import { initDebugLogger } from '../utils/debugLogger'
|
|
import { Onboarding } from '../components/Onboarding'
|
|
import { Doctor } from '../screens/Doctor'
|
|
import { ApproveApiKey } from '../components/ApproveApiKey'
|
|
import { TrustDialog } from '../components/TrustDialog'
|
|
import { checkHasTrustDialogAccepted, McpServerConfig } from '../utils/config'
|
|
import { isDefaultSlowAndCapableModel } from '../utils/model'
|
|
import { LogList } from '../screens/LogList'
|
|
import { ResumeConversation } from '../screens/ResumeConversation'
|
|
import { startMCPServer } from './mcp'
|
|
import { env } from '../utils/env'
|
|
import { getCwd, setCwd, setOriginalCwd } from '../utils/state'
|
|
import { omit } from 'lodash-es'
|
|
import { getCommands } from '../commands'
|
|
import { getNextAvailableLogForkNumber, loadLogList } from '../utils/log'
|
|
import { loadMessagesFromLog } from '../utils/conversationRecovery'
|
|
import { cleanupOldMessageFilesInBackground } from '../utils/cleanup'
|
|
import {
|
|
handleListApprovedTools,
|
|
handleRemoveApprovedTool,
|
|
} from '../commands/approvedTools'
|
|
import {
|
|
addMcpServer,
|
|
getMcpServer,
|
|
listMCPServers,
|
|
parseEnvVars,
|
|
removeMcpServer,
|
|
getClients,
|
|
ensureConfigScope,
|
|
} from '../services/mcpClient'
|
|
import { handleMcprcServerApprovals } from '../services/mcpServerApproval'
|
|
import { checkGate, initializeStatsig, logEvent } from '../services/statsig'
|
|
import { getExampleCommands } from '../utils/exampleCommands'
|
|
import { cursorShow } from 'ansi-escapes'
|
|
import {
|
|
getLatestVersion,
|
|
installGlobalPackage,
|
|
assertMinVersion,
|
|
} from '../utils/autoUpdater'
|
|
import { CACHE_PATHS } from '../utils/log'
|
|
import { PersistentShell } from '../utils/PersistentShell'
|
|
import { GATE_USE_EXTERNAL_UPDATER } from '../constants/betas'
|
|
import { clearTerminal } from '../utils/terminal'
|
|
import { showInvalidConfigDialog } from '../components/InvalidConfigDialog'
|
|
import { ConfigParseError } from '../utils/errors'
|
|
import { grantReadPermissionForOriginalDir } from '../utils/permissions/filesystem'
|
|
import { MACRO } from '../constants/macros'
|
|
export function completeOnboarding(): void {
|
|
const config = getGlobalConfig()
|
|
saveGlobalConfig({
|
|
...config,
|
|
hasCompletedOnboarding: true,
|
|
lastOnboardingVersion: MACRO.VERSION,
|
|
})
|
|
}
|
|
|
|
async function showSetupScreens(
|
|
safeMode?: boolean,
|
|
print?: boolean,
|
|
): Promise<void> {
|
|
if (process.env.NODE_ENV === 'test') {
|
|
return
|
|
}
|
|
|
|
const config = getGlobalConfig()
|
|
if (
|
|
!config.theme ||
|
|
!config.hasCompletedOnboarding // always show onboarding at least once
|
|
) {
|
|
await clearTerminal()
|
|
await new Promise<void>(resolve => {
|
|
render(
|
|
<Onboarding
|
|
onDone={async () => {
|
|
completeOnboarding()
|
|
await clearTerminal()
|
|
resolve()
|
|
}}
|
|
/>,
|
|
{
|
|
exitOnCtrlC: false,
|
|
},
|
|
)
|
|
})
|
|
}
|
|
|
|
// // Check for custom API key (only allowed for ants)
|
|
// if (process.env.ANTHROPIC_API_KEY && process.env.USER_TYPE === 'ant') {
|
|
// const customApiKeyTruncated = normalizeApiKeyForConfig(
|
|
// process.env.ANTHROPIC_API_KEY!,
|
|
// )
|
|
// const keyStatus = getCustomApiKeyStatus(customApiKeyTruncated)
|
|
// if (keyStatus === 'new') {
|
|
// await new Promise<void>(resolve => {
|
|
// render(
|
|
// <ApproveApiKey
|
|
// customApiKeyTruncated={customApiKeyTruncated}
|
|
// onDone={async () => {
|
|
// await clearTerminal()
|
|
// resolve()
|
|
// }}
|
|
// />,
|
|
// {
|
|
// exitOnCtrlC: false,
|
|
// },
|
|
// )
|
|
// })
|
|
// }
|
|
// }
|
|
|
|
// In non-interactive mode, only show trust dialog in safe mode
|
|
if (!print && safeMode) {
|
|
if (!checkHasTrustDialogAccepted()) {
|
|
await new Promise<void>(resolve => {
|
|
const onDone = () => {
|
|
// Grant read permission to the current working directory
|
|
grantReadPermissionForOriginalDir()
|
|
resolve()
|
|
}
|
|
render(<TrustDialog onDone={onDone} />, {
|
|
exitOnCtrlC: false,
|
|
})
|
|
})
|
|
}
|
|
|
|
// After trust dialog, check for any mcprc servers that need approval
|
|
if (process.env.USER_TYPE === 'ant') {
|
|
await handleMcprcServerApprovals()
|
|
}
|
|
}
|
|
}
|
|
|
|
function logStartup(): void {
|
|
const config = getGlobalConfig()
|
|
saveGlobalConfig({
|
|
...config,
|
|
numStartups: (config.numStartups ?? 0) + 1,
|
|
})
|
|
}
|
|
|
|
async function setup(cwd: string, safeMode?: boolean): Promise<void> {
|
|
// Set both current and original working directory if --cwd was provided
|
|
if (cwd !== process.cwd()) {
|
|
setOriginalCwd(cwd)
|
|
}
|
|
await setCwd(cwd)
|
|
|
|
// Always grant read permissions for original working dir
|
|
grantReadPermissionForOriginalDir()
|
|
|
|
// Start watching agent configuration files for changes
|
|
const { startAgentWatcher, clearAgentCache } = await import('../utils/agentLoader')
|
|
await startAgentWatcher(() => {
|
|
// Cache is already cleared in the watcher, just log
|
|
console.log('✅ Agent configurations hot-reloaded')
|
|
})
|
|
|
|
// If --safe mode is enabled, prevent root/sudo usage for security
|
|
if (safeMode) {
|
|
// Check if running as root/sudo on Unix-like systems
|
|
if (
|
|
process.platform !== 'win32' &&
|
|
typeof process.getuid === 'function' &&
|
|
process.getuid() === 0
|
|
) {
|
|
console.error(
|
|
`--safe mode cannot be used with root/sudo privileges for security reasons`,
|
|
)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
if (process.env.NODE_ENV === 'test') {
|
|
return
|
|
}
|
|
|
|
cleanupOldMessageFilesInBackground()
|
|
// getExampleCommands() // Pre-fetch example commands
|
|
getContext() // Pre-fetch all context data at once
|
|
// initializeStatsig() // Kick off statsig initialization
|
|
|
|
// Migrate old iterm2KeyBindingInstalled config to new shiftEnterKeyBindingInstalled
|
|
const globalConfig = getGlobalConfig()
|
|
if (
|
|
globalConfig.iterm2KeyBindingInstalled === true &&
|
|
globalConfig.shiftEnterKeyBindingInstalled !== true
|
|
) {
|
|
const updatedConfig = {
|
|
...globalConfig,
|
|
shiftEnterKeyBindingInstalled: true,
|
|
}
|
|
// Remove the old config property
|
|
delete updatedConfig.iterm2KeyBindingInstalled
|
|
saveGlobalConfig(updatedConfig)
|
|
}
|
|
|
|
// Check for last session's cost and duration
|
|
const projectConfig = getCurrentProjectConfig()
|
|
if (
|
|
projectConfig.lastCost !== undefined &&
|
|
projectConfig.lastDuration !== undefined
|
|
) {
|
|
logEvent('tengu_exit', {
|
|
last_session_cost: String(projectConfig.lastCost),
|
|
last_session_api_duration: String(projectConfig.lastAPIDuration),
|
|
last_session_duration: String(projectConfig.lastDuration),
|
|
last_session_id: projectConfig.lastSessionId,
|
|
})
|
|
// Clear the values after logging
|
|
// saveCurrentProjectConfig({
|
|
// ...projectConfig,
|
|
// lastCost: undefined,
|
|
// lastAPIDuration: undefined,
|
|
// lastDuration: undefined,
|
|
// lastSessionId: undefined,
|
|
// })
|
|
}
|
|
|
|
// Check auto-updater permissions
|
|
const autoUpdaterStatus = globalConfig.autoUpdaterStatus ?? 'not_configured'
|
|
if (autoUpdaterStatus === 'not_configured') {
|
|
logEvent('tengu_setup_auto_updater_not_configured', {})
|
|
await new Promise<void>(resolve => {
|
|
render(<Doctor onDone={() => resolve()} />)
|
|
})
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
// 初始化调试日志系统
|
|
initDebugLogger()
|
|
|
|
// Validate configs are valid and enable configuration system
|
|
try {
|
|
enableConfigs()
|
|
|
|
// 🔧 Validate and auto-repair GPT-5 model profiles
|
|
try {
|
|
const repairResult = validateAndRepairAllGPT5Profiles()
|
|
if (repairResult.repaired > 0) {
|
|
console.log(`🔧 Auto-repaired ${repairResult.repaired} GPT-5 model configurations`)
|
|
}
|
|
} catch (repairError) {
|
|
// Don't block startup if GPT-5 validation fails
|
|
console.warn('⚠️ GPT-5 configuration validation failed:', repairError)
|
|
}
|
|
} catch (error: unknown) {
|
|
if (error instanceof ConfigParseError) {
|
|
// Show the invalid config dialog with the error object
|
|
await showInvalidConfigDialog({ error })
|
|
return // Exit after handling the config error
|
|
}
|
|
}
|
|
|
|
let inputPrompt = ''
|
|
let renderContext: RenderOptions | undefined = {
|
|
exitOnCtrlC: false,
|
|
// @ts-expect-error - onFlicker not in RenderOptions interface
|
|
onFlicker() {
|
|
logEvent('tengu_flicker', {})
|
|
},
|
|
} as any
|
|
|
|
if (
|
|
!process.stdin.isTTY &&
|
|
!process.env.CI &&
|
|
// Input hijacking breaks MCP.
|
|
!process.argv.includes('mcp')
|
|
) {
|
|
inputPrompt = await stdin()
|
|
if (process.platform !== 'win32') {
|
|
try {
|
|
const ttyFd = openSync('/dev/tty', 'r')
|
|
renderContext = { ...renderContext, stdin: new ReadStream(ttyFd) }
|
|
} catch (err) {
|
|
logError(`Could not open /dev/tty: ${err}`)
|
|
}
|
|
}
|
|
}
|
|
await parseArgs(inputPrompt, renderContext)
|
|
}
|
|
|
|
async function parseArgs(
|
|
stdinContent: string,
|
|
renderContext: RenderOptions | undefined,
|
|
): Promise<Command> {
|
|
const program = new Command()
|
|
|
|
const renderContextWithExitOnCtrlC = {
|
|
...renderContext,
|
|
exitOnCtrlC: true,
|
|
}
|
|
|
|
// Get the initial list of commands filtering based on user type
|
|
const commands = await getCommands()
|
|
|
|
// Format command list for help text (using same filter as in help.ts)
|
|
const commandList = commands
|
|
.filter(cmd => !cmd.isHidden)
|
|
.map(cmd => `/${cmd.name} - ${cmd.description}`)
|
|
.join('\n')
|
|
|
|
program
|
|
.name(PRODUCT_COMMAND)
|
|
.description(
|
|
`${PRODUCT_NAME} - starts an interactive session by default, use -p/--print for non-interactive output
|
|
|
|
Slash commands available during an interactive session:
|
|
${commandList}`,
|
|
)
|
|
.argument('[prompt]', 'Your prompt', String)
|
|
.option('-c, --cwd <cwd>', 'The current working directory', String, cwd())
|
|
.option('-d, --debug', 'Enable debug mode', () => true)
|
|
.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(
|
|
'--safe',
|
|
'Enable strict permission checking mode (default is permissive)',
|
|
() => true,
|
|
)
|
|
.action(
|
|
async (prompt, { cwd, debug, verbose, enableArchitect, print, safe }) => {
|
|
await showSetupScreens(safe, print)
|
|
logEvent('tengu_init', {
|
|
entrypoint: PRODUCT_COMMAND,
|
|
hasInitialPrompt: Boolean(prompt).toString(),
|
|
hasStdin: Boolean(stdinContent).toString(),
|
|
enableArchitect: enableArchitect?.toString() ?? 'false',
|
|
verbose: verbose?.toString() ?? 'false',
|
|
debug: debug?.toString() ?? 'false',
|
|
print: print?.toString() ?? 'false',
|
|
})
|
|
await setup(cwd, safe)
|
|
|
|
assertMinVersion()
|
|
|
|
const [tools, mcpClients] = await Promise.all([
|
|
getTools(
|
|
enableArchitect ?? getCurrentProjectConfig().enableArchitectTool,
|
|
),
|
|
getClients(),
|
|
])
|
|
// logStartup()
|
|
const inputPrompt = [prompt, stdinContent].filter(Boolean).join('\n')
|
|
if (print) {
|
|
if (!inputPrompt) {
|
|
console.error(
|
|
'Error: Input must be provided either through stdin or as a prompt argument when using --print',
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
addToHistory(inputPrompt)
|
|
const { resultText: response } = await ask({
|
|
commands,
|
|
hasPermissionsToUseTool,
|
|
messageLogName: dateToFilename(new Date()),
|
|
prompt: inputPrompt,
|
|
cwd,
|
|
tools,
|
|
safeMode: safe,
|
|
})
|
|
console.log(response)
|
|
process.exit(0)
|
|
} else {
|
|
const isDefaultModel = await isDefaultSlowAndCapableModel()
|
|
|
|
render(
|
|
<REPL
|
|
commands={commands}
|
|
debug={debug}
|
|
initialPrompt={inputPrompt}
|
|
messageLogName={dateToFilename(new Date())}
|
|
shouldShowPromptInput={true}
|
|
verbose={verbose}
|
|
tools={tools}
|
|
safeMode={safe}
|
|
mcpClients={mcpClients}
|
|
isDefaultModel={isDefaultModel}
|
|
/>,
|
|
renderContext,
|
|
)
|
|
}
|
|
},
|
|
)
|
|
.version(MACRO.VERSION, '-v, --version')
|
|
|
|
// Enable melon mode for ants if --melon is passed
|
|
// For bun tree shaking to work, this has to be a top level --define, not inside MACRO
|
|
// if (process.env.USER_TYPE === 'ant') {
|
|
// program
|
|
// .option('--melon', 'Enable melon mode')
|
|
// .hook('preAction', async () => {
|
|
// if ((program.opts() as { melon?: boolean }).melon) {
|
|
// const { runMelonWrapper } = await import('../utils/melonWrapper')
|
|
// const melonArgs = process.argv.slice(
|
|
// process.argv.indexOf('--melon') + 1,
|
|
// )
|
|
// const exitCode = runMelonWrapper(melonArgs)
|
|
// process.exit(exitCode)
|
|
// }
|
|
// })
|
|
// }
|
|
|
|
// claude config
|
|
const config = program
|
|
.command('config')
|
|
.description(
|
|
`Manage configuration (eg. ${PRODUCT_COMMAND} config set -g theme dark)`,
|
|
)
|
|
|
|
config
|
|
.command('get <key>')
|
|
.description('Get a config value')
|
|
.option('-c, --cwd <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 <key> <value>')
|
|
.description('Set a config value')
|
|
.option('-c, --cwd <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 <key>')
|
|
.description('Remove a config value')
|
|
.option('-c, --cwd <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('-c, --cwd <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(listConfigForCLI(global ? (true as const) : (false as const)), null, 2),
|
|
)
|
|
process.exit(0)
|
|
})
|
|
|
|
// claude approved-tools
|
|
|
|
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 <tool>')
|
|
.description('Remove a tool from the list of approved tools')
|
|
.action(async (tool: string) => {
|
|
const result = handleRemoveApprovedTool(tool)
|
|
logEvent('tengu_approved_tool_remove', {
|
|
tool,
|
|
success: String(result.success),
|
|
})
|
|
console.log(result.message)
|
|
process.exit(result.success ? 0 : 1)
|
|
})
|
|
|
|
// claude mcp
|
|
|
|
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()
|
|
logEvent('tengu_mcp_start', { providedCwd })
|
|
|
|
// Verify the directory exists
|
|
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 <name> <url>')
|
|
.description('Add an SSE server')
|
|
.option(
|
|
'-s, --scope <scope>',
|
|
'Configuration scope (project or global)',
|
|
'project',
|
|
)
|
|
.action(async (name, url, options) => {
|
|
try {
|
|
const scope = ensureConfigScope(options.scope)
|
|
logEvent('tengu_mcp_add', { name, type: 'sse', scope })
|
|
|
|
addMcpServer(name, { type: 'sse', url }, scope)
|
|
console.log(
|
|
`Added SSE MCP server ${name} with URL ${url} to ${scope} 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 <scope>',
|
|
'Configuration scope (project or global)',
|
|
'project',
|
|
)
|
|
.option(
|
|
'-e, --env <env...>',
|
|
'Set environment variables (e.g. -e KEY=value)',
|
|
)
|
|
.action(async (name, commandOrUrl, args, options) => {
|
|
try {
|
|
// If name is not provided, start interactive wizard
|
|
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<string>(resolve => rl.question(query, resolve))
|
|
|
|
// Get server name
|
|
const serverName = await question('Server name: ')
|
|
if (!serverName) {
|
|
console.error('Error: Server name is required')
|
|
rl.close()
|
|
process.exit(1)
|
|
}
|
|
|
|
// Get server type
|
|
const serverType = await question(
|
|
'Server type (stdio or sse) [stdio]: ',
|
|
)
|
|
const type =
|
|
serverType && ['stdio', 'sse'].includes(serverType)
|
|
? serverType
|
|
: 'stdio'
|
|
|
|
// Get command or URL
|
|
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)
|
|
}
|
|
|
|
// Get args and env if stdio
|
|
let serverArgs: string[] = []
|
|
let serverEnv: Record<string, string> = {}
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
// Get scope
|
|
const scopeStr = await question(
|
|
'Configuration scope (project or global) [project]: ',
|
|
)
|
|
const serverScope = ensureConfigScope(scopeStr || 'project')
|
|
|
|
rl.close()
|
|
|
|
// Add the server
|
|
if (type === 'sse') {
|
|
logEvent('tengu_mcp_add', {
|
|
name: serverName,
|
|
type: 'sse',
|
|
scope: serverScope,
|
|
})
|
|
addMcpServer(
|
|
serverName,
|
|
{ type: 'sse', url: commandOrUrlValue },
|
|
serverScope,
|
|
)
|
|
console.log(
|
|
`Added SSE MCP server ${serverName} with URL ${commandOrUrlValue} to ${serverScope} config`,
|
|
)
|
|
} else {
|
|
logEvent('tengu_mcp_add', {
|
|
name: serverName,
|
|
type: 'stdio',
|
|
scope: serverScope,
|
|
})
|
|
addMcpServer(
|
|
serverName,
|
|
{
|
|
type: 'stdio',
|
|
command: commandOrUrlValue,
|
|
args: serverArgs,
|
|
env: serverEnv,
|
|
},
|
|
serverScope,
|
|
)
|
|
|
|
console.log(
|
|
`Added stdio MCP server ${serverName} with command: ${commandOrUrlValue} ${serverArgs.join(' ')} to ${serverScope} config`,
|
|
)
|
|
}
|
|
} else if (name && commandOrUrl) {
|
|
// Regular non-interactive flow
|
|
const scope = ensureConfigScope(options.scope)
|
|
|
|
// Check if it's an SSE URL (starts with http:// or https://)
|
|
if (commandOrUrl.match(/^https?:\/\//)) {
|
|
logEvent('tengu_mcp_add', { name, type: 'sse', scope })
|
|
addMcpServer(name, { type: 'sse', url: commandOrUrl }, scope)
|
|
console.log(
|
|
`Added SSE MCP server ${name} with URL ${commandOrUrl} to ${scope} config`,
|
|
)
|
|
} else {
|
|
logEvent('tengu_mcp_add', { name, type: 'stdio', scope })
|
|
const env = parseEnvVars(options.env)
|
|
addMcpServer(
|
|
name,
|
|
{ type: 'stdio', command: commandOrUrl, args: args || [], env },
|
|
scope,
|
|
)
|
|
|
|
console.log(
|
|
`Added stdio MCP server ${name} with command: ${commandOrUrl} ${(args || []).join(' ')} to ${scope} config`,
|
|
)
|
|
}
|
|
} 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 <name>')
|
|
.description('Remove an MCP server')
|
|
.option(
|
|
'-s, --scope <scope>',
|
|
'Configuration scope (project, global, or mcprc)',
|
|
'project',
|
|
)
|
|
.action(async (name: string, options: { scope?: string }) => {
|
|
try {
|
|
const scope = ensureConfigScope(options.scope)
|
|
logEvent('tengu_mcp_delete', { name, scope })
|
|
|
|
removeMcpServer(name, scope)
|
|
console.log(`Removed MCP server ${name} from ${scope} config`)
|
|
process.exit(0)
|
|
} catch (error) {
|
|
console.error((error as Error).message)
|
|
process.exit(1)
|
|
}
|
|
})
|
|
|
|
mcp
|
|
.command('list')
|
|
.description('List configured MCP servers')
|
|
.action(() => {
|
|
logEvent('tengu_mcp_list', {})
|
|
const servers = listMCPServers()
|
|
if (Object.keys(servers).length === 0) {
|
|
console.log(
|
|
`No MCP servers configured. Use \`${PRODUCT_COMMAND} mcp add\` to add a server.`,
|
|
)
|
|
} else {
|
|
for (const [name, server] of Object.entries(servers)) {
|
|
if (server.type === 'sse') {
|
|
console.log(`${name}: ${server.url} (SSE)`)
|
|
} else {
|
|
console.log(`${name}: ${server.command} ${server.args.join(' ')}`)
|
|
}
|
|
}
|
|
}
|
|
process.exit(0)
|
|
})
|
|
|
|
mcp
|
|
.command('add-json <name> <json>')
|
|
.description('Add an MCP server (stdio or SSE) with a JSON string')
|
|
.option(
|
|
'-s, --scope <scope>',
|
|
'Configuration scope (project or global)',
|
|
'project',
|
|
)
|
|
.action(async (name, jsonStr, options) => {
|
|
try {
|
|
const scope = ensureConfigScope(options.scope)
|
|
|
|
// Parse JSON string
|
|
let serverConfig
|
|
try {
|
|
serverConfig = JSON.parse(jsonStr)
|
|
} catch (e) {
|
|
console.error('Error: Invalid JSON string')
|
|
process.exit(1)
|
|
}
|
|
|
|
// Validate the server config
|
|
if (
|
|
!serverConfig.type ||
|
|
!['stdio', 'sse'].includes(serverConfig.type)
|
|
) {
|
|
console.error('Error: Server type must be "stdio" or "sse"')
|
|
process.exit(1)
|
|
}
|
|
|
|
if (serverConfig.type === 'sse' && !serverConfig.url) {
|
|
console.error('Error: SSE server 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)
|
|
}
|
|
|
|
// Add server with the provided config
|
|
logEvent('tengu_mcp_add_json', { name, type: serverConfig.type, scope })
|
|
addMcpServer(name, serverConfig, scope)
|
|
|
|
if (serverConfig.type === 'sse') {
|
|
console.log(
|
|
`Added SSE MCP server ${name} with URL ${serverConfig.url} to ${scope} config`,
|
|
)
|
|
} else {
|
|
console.log(
|
|
`Added stdio MCP server ${name} with command: ${serverConfig.command} ${(
|
|
serverConfig.args || []
|
|
).join(' ')} to ${scope} config`,
|
|
)
|
|
}
|
|
|
|
process.exit(0)
|
|
} catch (error) {
|
|
console.error((error as Error).message)
|
|
process.exit(1)
|
|
}
|
|
})
|
|
|
|
mcp
|
|
.command('get <name>')
|
|
.description('Get details about an MCP server')
|
|
.action((name: string) => {
|
|
logEvent('tengu_mcp_get', { name })
|
|
const server = getMcpServer(name)
|
|
if (!server) {
|
|
console.error(`No MCP server found with name: ${name}`)
|
|
process.exit(1)
|
|
}
|
|
console.log(`${name}:`)
|
|
console.log(` Scope: ${server.scope}`)
|
|
if (server.type === 'sse') {
|
|
console.log(` Type: sse`)
|
|
console.log(` URL: ${server.url}`)
|
|
} else {
|
|
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}`)
|
|
}
|
|
}
|
|
}
|
|
process.exit(0)
|
|
})
|
|
|
|
// Import servers from Claude Desktop
|
|
mcp
|
|
.command('add-from-claude-desktop')
|
|
.description(
|
|
'Import MCP servers from Claude Desktop (Mac, Windows and WSL)',
|
|
)
|
|
.option(
|
|
'-s, --scope <scope>',
|
|
'Configuration scope (project or global)',
|
|
'project',
|
|
)
|
|
.action(async options => {
|
|
try {
|
|
const scope = ensureConfigScope(options.scope)
|
|
const platform = process.platform
|
|
|
|
// Import fs and path modules
|
|
const { existsSync, readFileSync } = await import('fs')
|
|
const { join } = await import('path')
|
|
const { exec } = await import('child_process')
|
|
|
|
// Determine if running in WSL
|
|
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)
|
|
}
|
|
|
|
// Get Claude Desktop config path
|
|
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) {
|
|
// Get Windows username
|
|
const whoamiCommand = await new Promise<string>((resolve, reject) => {
|
|
exec(
|
|
'powershell.exe -Command "whoami"',
|
|
(err: Error, stdout: string) => {
|
|
if (err) reject(err)
|
|
else resolve(stdout.trim().split('\\').pop() || '')
|
|
},
|
|
)
|
|
})
|
|
|
|
configPath = `/mnt/c/Users/${whoamiCommand}/AppData/Roaming/Claude/claude_desktop_config.json`
|
|
}
|
|
|
|
// Check if config file exists
|
|
if (!existsSync(configPath)) {
|
|
console.error(
|
|
`Error: Claude Desktop config file not found at ${configPath}`,
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
// Read config file
|
|
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)
|
|
}
|
|
|
|
// Extract MCP servers
|
|
const mcpServers = config.mcpServers || {}
|
|
const serverNames = Object.keys(mcpServers)
|
|
const numServers = serverNames.length
|
|
|
|
if (numServers === 0) {
|
|
console.log('No MCP servers found in Claude Desktop config')
|
|
process.exit(0)
|
|
}
|
|
|
|
// Create server information for display
|
|
const serversInfo = serverNames.map(name => {
|
|
const server = mcpServers[name]
|
|
let description = ''
|
|
|
|
if (server.type === 'sse') {
|
|
description = `SSE: ${server.url}`
|
|
} else {
|
|
description = `stdio: ${server.command} ${(server.args || []).join(' ')}`
|
|
}
|
|
|
|
return { name, description, server }
|
|
})
|
|
|
|
// First import all required modules outside the component
|
|
// Import modules separately to avoid any issues
|
|
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 // React is already the default export when imported this way
|
|
const { MultiSelect } = inkjsui
|
|
const { Box, Text } = ink
|
|
const { getTheme } = utilsTheme
|
|
|
|
// Use Ink to render a nice UI for selection
|
|
await new Promise<void>(resolve => {
|
|
// Create a component for the server selection
|
|
function ClaudeDesktopImport() {
|
|
const { useState } = reactModule
|
|
const [isFinished, setIsFinished] = useState(false)
|
|
const [importResults, setImportResults] = useState<
|
|
{ name: string; success: boolean }[]
|
|
>([])
|
|
const [isImporting, setIsImporting] = useState(false)
|
|
const theme = getTheme()
|
|
|
|
// Function to import selected servers
|
|
const importServers = async (selectedServers: string[]) => {
|
|
setIsImporting(true)
|
|
const results = []
|
|
|
|
for (const name of selectedServers) {
|
|
try {
|
|
const server = mcpServers[name]
|
|
|
|
// Check if server already exists
|
|
const existingServer = getMcpServer(name)
|
|
if (existingServer) {
|
|
// Skip duplicates - we'll handle them in the confirmation step
|
|
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)
|
|
|
|
// Give time to show results
|
|
setTimeout(() => {
|
|
resolve()
|
|
}, 1000)
|
|
}
|
|
|
|
// Handle confirmation of selections
|
|
const handleConfirm = async (selectedServers: string[]) => {
|
|
// Check for existing servers and confirm overwrite
|
|
const existingServers = selectedServers.filter(name =>
|
|
getMcpServer(name),
|
|
)
|
|
|
|
if (existingServers.length > 0) {
|
|
// We'll just handle it directly since we have a simple UI
|
|
const results = []
|
|
|
|
// Process non-existing servers first
|
|
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 })
|
|
}
|
|
}
|
|
|
|
// Now handle existing servers by prompting for each one
|
|
for (const name of existingServers) {
|
|
try {
|
|
const server = mcpServers[name]
|
|
// Overwrite existing server - in a real interactive UI you'd prompt here
|
|
addMcpServer(name, server as McpServerConfig, scope)
|
|
results.push({ name, success: true })
|
|
} catch (err) {
|
|
results.push({ name, success: false })
|
|
}
|
|
}
|
|
|
|
setImportResults(results)
|
|
setIsImporting(false)
|
|
setIsFinished(true)
|
|
|
|
// Give time to show results before resolving
|
|
setTimeout(() => {
|
|
resolve()
|
|
}, 1000)
|
|
} else {
|
|
// No existing servers, proceed with import
|
|
await importServers(selectedServers)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Box flexDirection="column" padding={1}>
|
|
<Box
|
|
flexDirection="column"
|
|
borderStyle="round"
|
|
borderColor={theme.claude}
|
|
padding={1}
|
|
width={'100%'}
|
|
>
|
|
<Text bold color={theme.claude}>
|
|
Import MCP Servers from Claude Desktop
|
|
</Text>
|
|
|
|
<Box marginY={1}>
|
|
<Text>
|
|
Found {numServers} MCP servers in Claude Desktop.
|
|
</Text>
|
|
</Box>
|
|
|
|
<Text>Please select the servers you want to import:</Text>
|
|
|
|
<Box marginTop={1}>
|
|
<MultiSelect
|
|
options={serverNames.map(name => ({
|
|
label: name,
|
|
value: name,
|
|
}))}
|
|
defaultValue={serverNames}
|
|
onSubmit={handleConfirm}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box marginTop={0} marginLeft={3}>
|
|
<Text dimColor>
|
|
Space to select · Enter to confirm · Esc to cancel
|
|
</Text>
|
|
</Box>
|
|
|
|
{isFinished && (
|
|
<Box marginTop={1}>
|
|
<Text color={theme.success}>
|
|
Successfully imported{' '}
|
|
{importResults.filter(r => r.success).length} MCP server
|
|
to local config.
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
// Render the component
|
|
const { unmount } = render(<ClaudeDesktopImport />)
|
|
|
|
// Clean up when done
|
|
setTimeout(() => {
|
|
unmount()
|
|
resolve()
|
|
}, 30000) // Timeout after 30 seconds as a fallback
|
|
})
|
|
|
|
process.exit(0)
|
|
} catch (error) {
|
|
console.error(`Error: ${(error as Error).message}`)
|
|
process.exit(1)
|
|
}
|
|
})
|
|
|
|
// Function to reset MCP server choices
|
|
const resetMcpChoices = () => {
|
|
const config = getCurrentProjectConfig()
|
|
saveCurrentProjectConfig({
|
|
...config,
|
|
approvedMcprcServers: [],
|
|
rejectedMcprcServers: [],
|
|
})
|
|
console.log('All .mcprc server approvals and rejections have been reset.')
|
|
console.log(
|
|
`You will be prompted for approval next time you start ${PRODUCT_NAME}.`,
|
|
)
|
|
process.exit(0)
|
|
}
|
|
|
|
// New command name to match Kode
|
|
mcp
|
|
.command('reset-project-choices')
|
|
.description(
|
|
'Reset all approved and rejected project-scoped (.mcp.json) servers within this project',
|
|
)
|
|
.action(() => {
|
|
logEvent('tengu_mcp_reset_project_choices', {})
|
|
resetMcpChoices()
|
|
})
|
|
|
|
// Keep old command for backward compatibility (visible only to ants)
|
|
if (process.env.USER_TYPE === 'ant') {
|
|
mcp
|
|
.command('reset-mcprc-choices')
|
|
.description(
|
|
'Reset all approved and rejected .mcprc servers for this project',
|
|
)
|
|
.action(() => {
|
|
logEvent('tengu_mcp_reset_mcprc_choices', {})
|
|
resetMcpChoices()
|
|
})
|
|
}
|
|
|
|
// Doctor command - check installation health
|
|
program
|
|
.command('doctor')
|
|
.description(`Check the health of your ${PRODUCT_NAME} auto-updater`)
|
|
.action(async () => {
|
|
logEvent('tengu_doctor_command', {})
|
|
|
|
await new Promise<void>(resolve => {
|
|
render(<Doctor onDone={() => resolve()} doctorMode={true} />)
|
|
})
|
|
process.exit(0)
|
|
})
|
|
|
|
// ant-only commands
|
|
|
|
// claude update
|
|
program
|
|
.command('update')
|
|
.description('Check for updates and install if available')
|
|
.action(async () => {
|
|
const useExternalUpdater = await checkGate(GATE_USE_EXTERNAL_UPDATER)
|
|
if (useExternalUpdater) {
|
|
// The external updater intercepts calls to "claude update", which means if we have received
|
|
// this command at all, the extenral updater isn't installed on this machine.
|
|
console.log(`This version of ${PRODUCT_NAME} is no longer supported.`)
|
|
process.exit(0)
|
|
}
|
|
|
|
logEvent('tengu_update_check', {})
|
|
console.log(`Current version: ${MACRO.VERSION}`)
|
|
console.log('Checking for updates...')
|
|
|
|
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}`)
|
|
console.log('Installing update...')
|
|
|
|
const status = await installGlobalPackage()
|
|
|
|
switch (status) {
|
|
case 'success':
|
|
console.log(`Successfully updated to version ${latestVersion}`)
|
|
break
|
|
case 'no_permissions':
|
|
console.error('Error: Insufficient permissions to install update')
|
|
console.error('Try running with sudo or fix npm permissions')
|
|
process.exit(1)
|
|
break
|
|
case 'install_failed':
|
|
console.error('Error: Failed to install update')
|
|
process.exit(1)
|
|
break
|
|
case 'in_progress':
|
|
console.error(
|
|
'Error: Another instance is currently performing an update',
|
|
)
|
|
console.error('Please wait and try again later')
|
|
process.exit(1)
|
|
break
|
|
}
|
|
process.exit(0)
|
|
})
|
|
|
|
// claude log
|
|
program
|
|
.command('log')
|
|
.description('Manage conversation logs.')
|
|
.argument(
|
|
'[number]',
|
|
'A number (0, 1, 2, etc.) to display a specific log',
|
|
parseInt,
|
|
)
|
|
.option('-c, --cwd <cwd>', 'The current working directory', String, cwd())
|
|
.action(async (number, { cwd }) => {
|
|
await setup(cwd, false)
|
|
logEvent('tengu_view_logs', { number: number?.toString() ?? '' })
|
|
const context: { unmount?: () => void } = {}
|
|
const { unmount } = render(
|
|
<LogList context={context} type="messages" logNumber={number} />,
|
|
renderContextWithExitOnCtrlC,
|
|
)
|
|
context.unmount = unmount
|
|
})
|
|
|
|
// claude resume
|
|
program
|
|
.command('resume')
|
|
.description(
|
|
'Resume a previous conversation. Optionally provide a number (0, 1, 2, etc.) or file path to resume a specific conversation.',
|
|
)
|
|
.argument(
|
|
'[identifier]',
|
|
'A number (0, 1, 2, etc.) or file path to resume a specific conversation',
|
|
)
|
|
.option('-c, --cwd <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,
|
|
)
|
|
.action(async (identifier, { cwd, enableArchitect, safe, verbose }) => {
|
|
await setup(cwd, safe)
|
|
assertMinVersion()
|
|
|
|
const [tools, commands, logs, mcpClients] = await Promise.all([
|
|
getTools(
|
|
enableArchitect ?? getCurrentProjectConfig().enableArchitectTool,
|
|
),
|
|
getCommands(),
|
|
loadLogList(CACHE_PATHS.messages()),
|
|
getClients(),
|
|
])
|
|
// logStartup()
|
|
|
|
// If a specific conversation is requested, load and resume it directly
|
|
if (identifier !== undefined) {
|
|
// Check if identifier is a number or a file path
|
|
const number = Math.abs(parseInt(identifier))
|
|
const isNumber = !isNaN(number)
|
|
let messages, date, forkNumber
|
|
try {
|
|
if (isNumber) {
|
|
logEvent('tengu_resume', { number: number.toString() })
|
|
const log = logs[number]
|
|
if (!log) {
|
|
console.error('No conversation found at index', number)
|
|
process.exit(1)
|
|
}
|
|
messages = await loadMessagesFromLog(log.fullPath, tools)
|
|
;({ date, forkNumber } = log)
|
|
} else {
|
|
// Handle file path case
|
|
logEvent('tengu_resume', { filePath: identifier })
|
|
if (!existsSync(identifier)) {
|
|
console.error('File does not exist:', identifier)
|
|
process.exit(1)
|
|
}
|
|
messages = await loadMessagesFromLog(identifier, tools)
|
|
const pathSegments = identifier.split('/')
|
|
const filename = pathSegments[pathSegments.length - 1] ?? 'unknown'
|
|
;({ date, forkNumber } = parseLogFilename(filename))
|
|
}
|
|
const fork = getNextAvailableLogForkNumber(date, forkNumber ?? 1, 0)
|
|
const isDefaultModel = await isDefaultSlowAndCapableModel()
|
|
render(
|
|
<REPL
|
|
initialPrompt=""
|
|
messageLogName={date}
|
|
initialForkNumber={fork}
|
|
shouldShowPromptInput={true}
|
|
verbose={verbose}
|
|
commands={commands}
|
|
tools={tools}
|
|
safeMode={safe}
|
|
initialMessages={messages}
|
|
mcpClients={mcpClients}
|
|
isDefaultModel={isDefaultModel}
|
|
/>,
|
|
{ exitOnCtrlC: false },
|
|
)
|
|
} catch (error) {
|
|
logError(`Failed to load conversation: ${error}`)
|
|
process.exit(1)
|
|
}
|
|
} else {
|
|
// Show the conversation selector UI
|
|
const context: { unmount?: () => void } = {}
|
|
const { unmount } = render(
|
|
<ResumeConversation
|
|
context={context}
|
|
commands={commands}
|
|
logs={logs}
|
|
tools={tools}
|
|
verbose={verbose}
|
|
/>,
|
|
renderContextWithExitOnCtrlC,
|
|
)
|
|
context.unmount = unmount
|
|
}
|
|
})
|
|
|
|
// claude error
|
|
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('-c, --cwd <cwd>', 'The current working directory', String, cwd())
|
|
.action(async (number, { cwd }) => {
|
|
await setup(cwd, false)
|
|
logEvent('tengu_view_errors', { number: number?.toString() ?? '' })
|
|
const context: { unmount?: () => void } = {}
|
|
const { unmount } = render(
|
|
<LogList context={context} type="errors" logNumber={number} />,
|
|
renderContextWithExitOnCtrlC,
|
|
)
|
|
context.unmount = unmount
|
|
})
|
|
|
|
// claude context (TODO: deprecate)
|
|
const context = program
|
|
.command('context')
|
|
.description(
|
|
`Set static context (eg. ${PRODUCT_COMMAND} context add-file ./src/*.py)`,
|
|
)
|
|
|
|
context
|
|
.command('get <key>')
|
|
.option('-c, --cwd <cwd>', 'The current working directory', String, cwd())
|
|
.description('Get a value from context')
|
|
.action(async (key, { cwd }) => {
|
|
await setup(cwd, false)
|
|
logEvent('tengu_context_get', { key })
|
|
const context = omit(
|
|
await getContext(),
|
|
'codeStyle',
|
|
'directoryStructure',
|
|
)
|
|
console.log(context[key])
|
|
process.exit(0)
|
|
})
|
|
|
|
context
|
|
.command('set <key> <value>')
|
|
.description('Set a value in context')
|
|
.option('-c, --cwd <cwd>', 'The current working directory', String, cwd())
|
|
.action(async (key, value, { cwd }) => {
|
|
await setup(cwd, false)
|
|
logEvent('tengu_context_set', { key })
|
|
setContext(key, value)
|
|
console.log(`Set context.${key} to "${value}"`)
|
|
process.exit(0)
|
|
})
|
|
|
|
context
|
|
.command('list')
|
|
.description('List all context values')
|
|
.option('-c, --cwd <cwd>', 'The current working directory', String, cwd())
|
|
.action(async ({ cwd }) => {
|
|
await setup(cwd, false)
|
|
logEvent('tengu_context_list', {})
|
|
const context = omit(
|
|
await getContext(),
|
|
'codeStyle',
|
|
'directoryStructure',
|
|
'gitStatus',
|
|
)
|
|
console.log(JSON.stringify(context, null, 2))
|
|
process.exit(0)
|
|
})
|
|
|
|
context
|
|
.command('remove <key>')
|
|
.description('Remove a value from context')
|
|
.option('-c, --cwd <cwd>', 'The current working directory', String, cwd())
|
|
.action(async (key, { cwd }) => {
|
|
await setup(cwd, false)
|
|
logEvent('tengu_context_delete', { key })
|
|
removeContext(key)
|
|
console.log(`Removed context.${key}`)
|
|
process.exit(0)
|
|
})
|
|
|
|
await program.parseAsync(process.argv)
|
|
return program
|
|
}
|
|
|
|
// TODO: stream?
|
|
async function stdin() {
|
|
if (process.stdin.isTTY) {
|
|
return ''
|
|
}
|
|
|
|
let data = ''
|
|
for await (const chunk of process.stdin) data += chunk
|
|
return data
|
|
}
|
|
|
|
process.on('exit', () => {
|
|
resetCursor()
|
|
PersistentShell.getInstance().close()
|
|
})
|
|
|
|
process.on('SIGINT', () => {
|
|
console.log('SIGINT')
|
|
process.exit(0)
|
|
})
|
|
|
|
function resetCursor() {
|
|
const terminal = process.stderr.isTTY
|
|
? process.stderr
|
|
: process.stdout.isTTY
|
|
? process.stdout
|
|
: undefined
|
|
terminal?.write(`\u001B[?25h${cursorShow}`)
|
|
}
|
|
|
|
main()
|