cli.tsx
1 #!/usr/bin/env -S node --no-warnings=ExperimentalWarning --enable-source-maps 2 import { initSentry } from '../services/sentry.js' 3 import { PRODUCT_NAME } from '../constants/product.js' 4 initSentry() // Initialize Sentry as early as possible 5 6 // XXX: Without this line (and the Object.keys, even though it seems like it does nothing!), 7 // there is a bug in Bun only on Win32 that causes this import to be removed, even though 8 // its use is solely because of its side-effects. 9 import * as dontcare from '@anthropic-ai/sdk/shims/node' 10 Object.keys(dontcare) 11 12 import React from 'react' 13 import { ReadStream } from 'tty' 14 import { openSync, existsSync } from 'fs' 15 import { render, RenderOptions } from 'ink' 16 import { REPL } from '../screens/REPL.js' 17 import { addToHistory } from '../history.js' 18 import { getContext, setContext, removeContext } from '../context.js' 19 import { Command } from '@commander-js/extra-typings' 20 import { ask } from '../utils/ask.js' 21 import { hasPermissionsToUseTool } from '../permissions.js' 22 import { getTools } from '../tools.js' 23 import { 24 getGlobalConfig, 25 getCurrentProjectConfig, 26 saveGlobalConfig, 27 saveCurrentProjectConfig, 28 getCustomApiKeyStatus, 29 normalizeApiKeyForConfig, 30 setConfigForCLI, 31 deleteConfigForCLI, 32 getConfigForCLI, 33 listConfigForCLI, 34 enableConfigs, 35 } from '../utils/config.js' 36 import { cwd } from 'process' 37 import { dateToFilename, logError, parseLogFilename } from '../utils/log.js' 38 import { Onboarding } from '../components/Onboarding.js' 39 import { Doctor } from '../screens/Doctor.js' 40 import { ApproveApiKey } from '../components/ApproveApiKey.js' 41 import { TrustDialog } from '../components/TrustDialog.js' 42 import { checkHasTrustDialogAccepted } from '../utils/config.js' 43 import { isDefaultSlowAndCapableModel } from '../utils/model.js' 44 import { LogList } from '../screens/LogList.js' 45 import { ResumeConversation } from '../screens/ResumeConversation.js' 46 import { startMCPServer } from './mcp.js' 47 import { env } from '../utils/env.js' 48 import { getCwd, setCwd } from '../utils/state.js' 49 import { omit } from 'lodash-es' 50 import { getCommands } from '../commands.js' 51 import { getNextAvailableLogForkNumber, loadLogList } from '../utils/log.js' 52 import { loadMessagesFromLog } from '../utils/conversationRecovery.js' 53 import { cleanupOldMessageFilesInBackground } from '../utils/cleanup.js' 54 import { 55 handleListApprovedTools, 56 handleRemoveApprovedTool, 57 } from '../commands/approvedTools.js' 58 import { 59 addMcpServer, 60 getMcpServer, 61 listMCPServers, 62 parseEnvVars, 63 removeMcpServer, 64 getClients, 65 ensureConfigScope, 66 } from '../services/mcpClient.js' 67 import { handleMcprcServerApprovals } from '../services/mcpServerApproval.js' 68 import { checkGate, initializeStatsig, logEvent } from '../services/statsig.js' 69 import { getExampleCommands } from '../utils/exampleCommands.js' 70 import { cursorShow } from 'ansi-escapes' 71 import { 72 getLatestVersion, 73 installGlobalPackage, 74 assertMinVersion, 75 } from '../utils/autoUpdater.js' 76 import { CACHE_PATHS } from '../utils/log.js' 77 import { PersistentShell } from '../utils/PersistentShell.js' 78 import { GATE_USE_EXTERNAL_UPDATER } from '../constants/betas.js' 79 import { clearTerminal } from '../utils/terminal.js' 80 import { showInvalidConfigDialog } from '../components/InvalidConfigDialog.js' 81 import { ConfigParseError } from '../utils/errors.js' 82 import { grantReadPermissionForOriginalDir } from '../utils/permissions/filesystem.js' 83 84 export function completeOnboarding(): void { 85 const config = getGlobalConfig() 86 saveGlobalConfig({ 87 ...config, 88 hasCompletedOnboarding: true, 89 lastOnboardingVersion: MACRO.VERSION, 90 }) 91 } 92 93 async function showSetupScreens( 94 dangerouslySkipPermissions?: boolean, 95 print?: boolean, 96 ): Promise<void> { 97 if (process.env.NODE_ENV === 'test') { 98 return 99 } 100 101 const config = getGlobalConfig() 102 if ( 103 !config.theme || 104 !config.hasCompletedOnboarding // always show onboarding at least once 105 ) { 106 await clearTerminal() 107 await new Promise<void>(resolve => { 108 render( 109 <Onboarding 110 onDone={async () => { 111 completeOnboarding() 112 await clearTerminal() 113 resolve() 114 }} 115 />, 116 { 117 exitOnCtrlC: false, 118 }, 119 ) 120 }) 121 } 122 123 // Check for custom API key (only allowed for ants) 124 if (process.env.ANTHROPIC_API_KEY && process.env.USER_TYPE === 'ant') { 125 const customApiKeyTruncated = normalizeApiKeyForConfig( 126 process.env.ANTHROPIC_API_KEY!, 127 ) 128 const keyStatus = getCustomApiKeyStatus(customApiKeyTruncated) 129 if (keyStatus === 'new') { 130 await new Promise<void>(resolve => { 131 render( 132 <ApproveApiKey 133 customApiKeyTruncated={customApiKeyTruncated} 134 onDone={async () => { 135 await clearTerminal() 136 resolve() 137 }} 138 />, 139 { 140 exitOnCtrlC: false, 141 }, 142 ) 143 }) 144 } 145 } 146 147 // In non-interactive or dangerously-skip-permissions mode, skip the trust dialog 148 if (!print && !dangerouslySkipPermissions) { 149 if (!checkHasTrustDialogAccepted()) { 150 await new Promise<void>(resolve => { 151 const onDone = () => { 152 // Grant read permission to the current working directory 153 grantReadPermissionForOriginalDir() 154 resolve() 155 } 156 render(<TrustDialog onDone={onDone} />, { 157 exitOnCtrlC: false, 158 }) 159 }) 160 } 161 162 // After trust dialog, check for any mcprc servers that need approval 163 if (process.env.USER_TYPE === 'ant') { 164 await handleMcprcServerApprovals() 165 } 166 } 167 } 168 169 function logStartup(): void { 170 const config = getGlobalConfig() 171 saveGlobalConfig({ 172 ...config, 173 numStartups: (config.numStartups ?? 0) + 1, 174 }) 175 } 176 177 async function setup( 178 cwd: string, 179 dangerouslySkipPermissions?: boolean, 180 ): Promise<void> { 181 // Don't await so we don't block startup 182 setCwd(cwd) 183 184 // Always grant read permissions for original working dir 185 grantReadPermissionForOriginalDir() 186 187 // If --dangerously-skip-permissions is set, verify we're in a safe environment 188 if (dangerouslySkipPermissions) { 189 // Check if running as root/sudo on Unix-like systems 190 if ( 191 process.platform !== 'win32' && 192 typeof process.getuid === 'function' && 193 process.getuid() === 0 194 ) { 195 console.error( 196 `--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons`, 197 ) 198 process.exit(1) 199 } 200 201 // Only await if --dangerously-skip-permissions is set 202 const [isDocker, hasInternet] = await Promise.all([ 203 env.getIsDocker(), 204 env.hasInternetAccess(), 205 ]) 206 207 if (!isDocker || hasInternet) { 208 console.error( 209 `--dangerously-skip-permissions can only be used in Docker containers with no internet access but got Docker: ${isDocker} and hasInternet: ${hasInternet}`, 210 ) 211 process.exit(1) 212 } 213 } 214 215 if (process.env.NODE_ENV === 'test') { 216 return 217 } 218 219 cleanupOldMessageFilesInBackground() 220 getExampleCommands() // Pre-fetch example commands 221 getContext() // Pre-fetch all context data at once 222 initializeStatsig() // Kick off statsig initialization 223 224 // Migrate old iterm2KeyBindingInstalled config to new shiftEnterKeyBindingInstalled 225 const globalConfig = getGlobalConfig() 226 if ( 227 globalConfig.iterm2KeyBindingInstalled === true && 228 globalConfig.shiftEnterKeyBindingInstalled !== true 229 ) { 230 const updatedConfig = { 231 ...globalConfig, 232 shiftEnterKeyBindingInstalled: true, 233 } 234 // Remove the old config property 235 delete updatedConfig.iterm2KeyBindingInstalled 236 saveGlobalConfig(updatedConfig) 237 } 238 239 // Check for last session's cost and duration 240 const projectConfig = getCurrentProjectConfig() 241 if ( 242 projectConfig.lastCost !== undefined && 243 projectConfig.lastDuration !== undefined 244 ) { 245 logEvent('tengu_exit', { 246 last_session_cost: String(projectConfig.lastCost), 247 last_session_api_duration: String(projectConfig.lastAPIDuration), 248 last_session_duration: String(projectConfig.lastDuration), 249 last_session_id: projectConfig.lastSessionId, 250 }) 251 // Clear the values after logging 252 saveCurrentProjectConfig({ 253 ...projectConfig, 254 lastCost: undefined, 255 lastAPIDuration: undefined, 256 lastDuration: undefined, 257 lastSessionId: undefined, 258 }) 259 } 260 261 // Check auto-updater permissions 262 const autoUpdaterStatus = globalConfig.autoUpdaterStatus ?? 'not_configured' 263 if (autoUpdaterStatus === 'not_configured') { 264 logEvent('tengu_setup_auto_updater_not_configured', {}) 265 await new Promise<void>(resolve => { 266 render(<Doctor onDone={() => resolve()} />) 267 }) 268 } 269 } 270 271 async function main() { 272 // Validate configs are valid and enable configuration system 273 try { 274 enableConfigs() 275 } catch (error: unknown) { 276 if (error instanceof ConfigParseError) { 277 // Show the invalid config dialog with the error object 278 await showInvalidConfigDialog({ error }) 279 return // Exit after handling the config error 280 } 281 } 282 283 let inputPrompt = '' 284 let renderContext: RenderOptions | undefined = { 285 exitOnCtrlC: false, 286 onFlicker() { 287 logEvent('tengu_flicker', {}) 288 }, 289 } 290 291 if ( 292 !process.stdin.isTTY && 293 !process.env.CI && 294 // Input hijacking breaks MCP. 295 !process.argv.includes('mcp') 296 ) { 297 inputPrompt = await stdin() 298 if (process.platform !== 'win32') { 299 try { 300 const ttyFd = openSync('/dev/tty', 'r') 301 renderContext = { ...renderContext, stdin: new ReadStream(ttyFd) } 302 } catch (err) { 303 logError(`Could not open /dev/tty: ${err}`) 304 } 305 } 306 } 307 await parseArgs(inputPrompt, renderContext) 308 } 309 310 async function parseArgs( 311 stdinContent: string, 312 renderContext: RenderOptions | undefined, 313 ): Promise<Command> { 314 const program = new Command() 315 316 const renderContextWithExitOnCtrlC = { 317 ...renderContext, 318 exitOnCtrlC: true, 319 } 320 321 // Get the initial list of commands filtering based on user type 322 const commands = await getCommands() 323 324 // Format command list for help text (using same filter as in help.ts) 325 const commandList = commands 326 .filter(cmd => !cmd.isHidden) 327 .map(cmd => `/${cmd.name} - ${cmd.description}`) 328 .join('\n') 329 330 program 331 .name('claude') 332 .description( 333 `${PRODUCT_NAME} - starts an interactive session by default, use -p/--print for non-interactive output 334 335 Slash commands available during an interactive session: 336 ${commandList}`, 337 ) 338 .argument('[prompt]', 'Your prompt', String) 339 .option('-c, --cwd <cwd>', 'The current working directory', String, cwd()) 340 .option('-d, --debug', 'Enable debug mode', () => true) 341 .option( 342 '--verbose', 343 'Override verbose mode setting from config', 344 () => true, 345 ) 346 .option('-ea, --enable-architect', 'Enable the Architect tool', () => true) 347 .option( 348 '-p, --print', 349 'Print response and exit (useful for pipes)', 350 () => true, 351 ) 352 .option( 353 '--dangerously-skip-permissions', 354 'Skip all permission checks. Only works in Docker containers with no internet access. Will crash otherwise.', 355 () => true, 356 ) 357 .action( 358 async ( 359 prompt, 360 { 361 cwd, 362 debug, 363 verbose, 364 enableArchitect, 365 print, 366 dangerouslySkipPermissions, 367 }, 368 ) => { 369 await showSetupScreens(dangerouslySkipPermissions, print) 370 logEvent('tengu_init', { 371 entrypoint: 'claude', 372 hasInitialPrompt: Boolean(prompt).toString(), 373 hasStdin: Boolean(stdinContent).toString(), 374 enableArchitect: enableArchitect?.toString() ?? 'false', 375 verbose: verbose?.toString() ?? 'false', 376 debug: debug?.toString() ?? 'false', 377 print: print?.toString() ?? 'false', 378 }) 379 await setup(cwd, dangerouslySkipPermissions) 380 381 assertMinVersion() 382 383 const [tools, mcpClients] = await Promise.all([ 384 getTools( 385 enableArchitect ?? getCurrentProjectConfig().enableArchitectTool, 386 ), 387 getClients(), 388 ]) 389 logStartup() 390 const inputPrompt = [prompt, stdinContent].filter(Boolean).join('\n') 391 if (print) { 392 if (!inputPrompt) { 393 console.error( 394 'Error: Input must be provided either through stdin or as a prompt argument when using --print', 395 ) 396 process.exit(1) 397 } 398 399 addToHistory(inputPrompt) 400 const { resultText: response } = await ask({ 401 commands, 402 hasPermissionsToUseTool, 403 messageLogName: dateToFilename(new Date()), 404 prompt: inputPrompt, 405 cwd, 406 tools, 407 dangerouslySkipPermissions, 408 }) 409 console.log(response) 410 process.exit(0) 411 } else { 412 const isDefaultModel = await isDefaultSlowAndCapableModel() 413 414 render( 415 <REPL 416 commands={commands} 417 debug={debug} 418 initialPrompt={inputPrompt} 419 messageLogName={dateToFilename(new Date())} 420 shouldShowPromptInput={true} 421 verbose={verbose} 422 tools={tools} 423 dangerouslySkipPermissions={dangerouslySkipPermissions} 424 mcpClients={mcpClients} 425 isDefaultModel={isDefaultModel} 426 />, 427 renderContext, 428 ) 429 } 430 }, 431 ) 432 .version(MACRO.VERSION, '-v, --version') 433 434 // Enable melon mode for ants if --melon is passed 435 // For bun tree shaking to work, this has to be a top level --define, not inside MACRO 436 if (process.env.USER_TYPE === 'ant') { 437 program 438 .option('--melon', 'Enable melon mode') 439 .hook('preAction', async () => { 440 if ((program.opts() as { melon?: boolean }).melon) { 441 const { runMelonWrapper } = await import('../utils/melonWrapper.js') 442 const melonArgs = process.argv.slice( 443 process.argv.indexOf('--melon') + 1, 444 ) 445 const exitCode = runMelonWrapper(melonArgs) 446 process.exit(exitCode) 447 } 448 }) 449 } 450 451 // claude config 452 const config = program 453 .command('config') 454 .description('Manage configuration (eg. claude config set -g theme dark)') 455 456 config 457 .command('get <key>') 458 .description('Get a config value') 459 .option('-c, --cwd <cwd>', 'The current working directory', String, cwd()) 460 .option('-g, --global', 'Use global config') 461 .action(async (key, { cwd, global }) => { 462 await setup(cwd, false) 463 console.log(getConfigForCLI(key, global ?? false)) 464 process.exit(0) 465 }) 466 467 config 468 .command('set <key> <value>') 469 .description('Set a config value') 470 .option('-c, --cwd <cwd>', 'The current working directory', String, cwd()) 471 .option('-g, --global', 'Use global config') 472 .action(async (key, value, { cwd, global }) => { 473 await setup(cwd, false) 474 setConfigForCLI(key, value, global ?? false) 475 console.log(`Set ${key} to ${value}`) 476 process.exit(0) 477 }) 478 479 config 480 .command('remove <key>') 481 .description('Remove a config value') 482 .option('-c, --cwd <cwd>', 'The current working directory', String, cwd()) 483 .option('-g, --global', 'Use global config') 484 .action(async (key, { cwd, global }) => { 485 await setup(cwd, false) 486 deleteConfigForCLI(key, global ?? false) 487 console.log(`Removed ${key}`) 488 process.exit(0) 489 }) 490 491 config 492 .command('list') 493 .description('List all config values') 494 .option('-c, --cwd <cwd>', 'The current working directory', String, cwd()) 495 .option('-g, --global', 'Use global config', false) 496 .action(async ({ cwd, global }) => { 497 await setup(cwd, false) 498 console.log( 499 JSON.stringify(listConfigForCLI((global as true) ?? false), null, 2), 500 ) 501 process.exit(0) 502 }) 503 504 // claude approved-tools 505 506 const allowedTools = program 507 .command('approved-tools') 508 .description('Manage approved tools') 509 510 allowedTools 511 .command('list') 512 .description('List all approved tools') 513 .action(async () => { 514 const result = handleListApprovedTools(getCwd()) 515 console.log(result) 516 process.exit(0) 517 }) 518 519 allowedTools 520 .command('remove <tool>') 521 .description('Remove a tool from the list of approved tools') 522 .action(async (tool: string) => { 523 const result = handleRemoveApprovedTool(tool) 524 logEvent('tengu_approved_tool_remove', { 525 tool, 526 success: String(result.success), 527 }) 528 console.log(result.message) 529 process.exit(result.success ? 0 : 1) 530 }) 531 532 // claude mcp 533 534 const mcp = program 535 .command('mcp') 536 .description('Configure and manage MCP servers') 537 538 mcp 539 .command('serve') 540 .description(`Start the ${PRODUCT_NAME} MCP server`) 541 .action(async () => { 542 const providedCwd = (program.opts() as { cwd?: string }).cwd ?? cwd() 543 logEvent('tengu_mcp_start', { providedCwd }) 544 545 // Verify the directory exists 546 if (!existsSync(providedCwd)) { 547 console.error(`Error: Directory ${providedCwd} does not exist`) 548 process.exit(1) 549 } 550 551 try { 552 await setup(providedCwd, false) 553 await startMCPServer(providedCwd) 554 } catch (error) { 555 console.error('Error: Failed to start MCP server:', error) 556 process.exit(1) 557 } 558 }) 559 560 if (process.env.USER_TYPE === 'ant') { 561 mcp 562 .command('add-sse <name> <url>') 563 .description('Add an SSE server') 564 .option( 565 '-s, --scope <scope>', 566 'Configuration scope (project or global)', 567 'project', 568 ) 569 .action(async (name, url, options) => { 570 try { 571 const scope = ensureConfigScope(options.scope) 572 logEvent('tengu_mcp_add', { name, type: 'sse', scope }) 573 574 addMcpServer(name, { type: 'sse', url }, scope) 575 console.log( 576 `Added SSE MCP server ${name} with URL ${url} to ${scope} config`, 577 ) 578 process.exit(0) 579 } catch (error) { 580 console.error((error as Error).message) 581 process.exit(1) 582 } 583 }) 584 } 585 586 mcp 587 .command('add <name> <command> [args...]') 588 .description('Add a stdio server') 589 .option( 590 '-s, --scope <scope>', 591 'Configuration scope (project or global)', 592 'project', 593 ) 594 .option( 595 '-e, --env <env...>', 596 'Set environment variables (e.g. -e KEY=value)', 597 ) 598 .action(async (name, command, args, options) => { 599 try { 600 const scope = ensureConfigScope(options.scope) 601 logEvent('tengu_mcp_add', { name, type: 'stdio', scope }) 602 603 const env = parseEnvVars(options.env) 604 addMcpServer( 605 name, 606 { type: 'stdio', command, args: args || [], env }, 607 scope, 608 ) 609 610 console.log( 611 `Added stdio MCP server ${name} with command: ${command} ${(args || []).join(' ')} to ${scope} config`, 612 ) 613 process.exit(0) 614 } catch (error) { 615 console.error((error as Error).message) 616 process.exit(1) 617 } 618 }) 619 mcp 620 .command('remove <name>') 621 .description('Remove an MCP server') 622 .option( 623 '-s, --scope <scope>', 624 'Configuration scope (project, global, or mcprc)', 625 'project', 626 ) 627 .action(async (name: string, options: { scope?: string }) => { 628 try { 629 const scope = ensureConfigScope(options.scope) 630 logEvent('tengu_mcp_delete', { name, scope }) 631 632 removeMcpServer(name, scope) 633 console.log(`Removed MCP server ${name} from ${scope} config`) 634 process.exit(0) 635 } catch (error) { 636 console.error((error as Error).message) 637 process.exit(1) 638 } 639 }) 640 641 mcp 642 .command('list') 643 .description('List configured MCP servers') 644 .action(() => { 645 logEvent('tengu_mcp_list', {}) 646 const servers = listMCPServers() 647 if (Object.keys(servers).length === 0) { 648 console.log( 649 'No MCP servers configured. Use `claude mcp add` to add a server.', 650 ) 651 } else { 652 for (const [name, server] of Object.entries(servers)) { 653 if (server.type === 'sse') { 654 console.log(`${name}: ${server.url} (SSE)`) 655 } else { 656 console.log(`${name}: ${server.command} ${server.args.join(' ')}`) 657 } 658 } 659 } 660 process.exit(0) 661 }) 662 663 mcp 664 .command('get <name>') 665 .description('Get details about an MCP server') 666 .action((name: string) => { 667 logEvent('tengu_mcp_get', { name }) 668 const server = getMcpServer(name) 669 if (!server) { 670 console.error(`No MCP server found with name: ${name}`) 671 process.exit(1) 672 } 673 console.log(`${name}:`) 674 console.log(` Scope: ${server.scope}`) 675 if (server.type === 'sse') { 676 console.log(` Type: sse`) 677 console.log(` URL: ${server.url}`) 678 } else { 679 console.log(` Type: stdio`) 680 console.log(` Command: ${server.command}`) 681 console.log(` Args: ${server.args.join(' ')}`) 682 if (server.env) { 683 console.log(' Environment:') 684 for (const [key, value] of Object.entries(server.env)) { 685 console.log(` ${key}=${value}`) 686 } 687 } 688 } 689 process.exit(0) 690 }) 691 692 if (process.env.USER_TYPE === 'ant') { 693 mcp 694 .command('reset-mcprc-choices') 695 .description( 696 'Reset all approved and rejected .mcprc servers for this project', 697 ) 698 .action(() => { 699 logEvent('tengu_mcp_reset_mcprc_choices', {}) 700 const config = getCurrentProjectConfig() 701 saveCurrentProjectConfig({ 702 ...config, 703 approvedMcprcServers: [], 704 rejectedMcprcServers: [], 705 }) 706 console.log( 707 'All .mcprc server approvals and rejections have been reset.', 708 ) 709 console.log( 710 'You will be prompted for approval next time you start Claude Code.', 711 ) 712 process.exit(0) 713 }) 714 } 715 716 // Doctor command - check installation health 717 program 718 .command('doctor') 719 .description('Check the health of your Claude Code auto-updater') 720 .action(async () => { 721 logEvent('tengu_doctor_command', {}) 722 723 await new Promise<void>(resolve => { 724 render(<Doctor onDone={() => resolve()} doctorMode={true} />) 725 }) 726 process.exit(0) 727 }) 728 729 // ant-only commands 730 if (process.env.USER_TYPE === 'ant') { 731 // claude update 732 program 733 .command('update') 734 .description('Check for updates and install if available') 735 .action(async () => { 736 const useExternalUpdater = await checkGate(GATE_USE_EXTERNAL_UPDATER) 737 if (useExternalUpdater) { 738 // The external updater intercepts calls to "claude update", which means if we have received 739 // this command at all, the extenral updater isn't installed on this machine. 740 console.log('This version of Claude Code is no longer supported.') 741 process.exit(0) 742 } 743 744 logEvent('tengu_update_check', {}) 745 console.log(`Current version: ${MACRO.VERSION}`) 746 console.log('Checking for updates...') 747 748 const latestVersion = await getLatestVersion() 749 750 if (!latestVersion) { 751 console.error('Failed to check for updates') 752 process.exit(1) 753 } 754 755 if (latestVersion === MACRO.VERSION) { 756 console.log(`${PRODUCT_NAME} is up to date`) 757 process.exit(0) 758 } 759 760 console.log(`New version available: ${latestVersion}`) 761 console.log('Installing update...') 762 763 const status = await installGlobalPackage() 764 765 switch (status) { 766 case 'success': 767 console.log(`Successfully updated to version ${latestVersion}`) 768 break 769 case 'no_permissions': 770 console.error('Error: Insufficient permissions to install update') 771 console.error('Try running with sudo or fix npm permissions') 772 process.exit(1) 773 break 774 case 'install_failed': 775 console.error('Error: Failed to install update') 776 process.exit(1) 777 break 778 case 'in_progress': 779 console.error( 780 'Error: Another instance is currently performing an update', 781 ) 782 console.error('Please wait and try again later') 783 process.exit(1) 784 break 785 } 786 process.exit(0) 787 }) 788 789 // claude log 790 program 791 .command('log') 792 .description('Manage conversation logs.') 793 .argument( 794 '[number]', 795 'A number (0, 1, 2, etc.) to display a specific log', 796 parseInt, 797 ) 798 .option('-c, --cwd <cwd>', 'The current working directory', String, cwd()) 799 .action(async (number, { cwd }) => { 800 await setup(cwd, false) 801 logEvent('tengu_view_logs', { number: number?.toString() ?? '' }) 802 const context: { unmount?: () => void } = {} 803 const { unmount } = render( 804 <LogList context={context} type="messages" logNumber={number} />, 805 renderContextWithExitOnCtrlC, 806 ) 807 context.unmount = unmount 808 }) 809 810 // claude resume 811 program 812 .command('resume') 813 .description( 814 'Resume a previous conversation. Optionally provide a number (0, 1, 2, etc.) or file path to resume a specific conversation.', 815 ) 816 .argument( 817 '[identifier]', 818 'A number (0, 1, 2, etc.) or file path to resume a specific conversation', 819 ) 820 .option('-c, --cwd <cwd>', 'The current working directory', String, cwd()) 821 .option( 822 '-ea, --enable-architect', 823 'Enable the Architect tool', 824 () => true, 825 ) 826 .option('-v, --verbose', 'Do not truncate message output', () => true) 827 .option( 828 '--dangerously-skip-permissions', 829 'Skip all permission checks. Only works in Docker containers with no internet access. Will crash otherwise.', 830 () => true, 831 ) 832 .action( 833 async ( 834 identifier, 835 { cwd, enableArchitect, dangerouslySkipPermissions, verbose }, 836 ) => { 837 await setup(cwd, dangerouslySkipPermissions) 838 assertMinVersion() 839 840 const [tools, commands, logs, mcpClients] = await Promise.all([ 841 getTools( 842 enableArchitect ?? getCurrentProjectConfig().enableArchitectTool, 843 ), 844 getCommands(), 845 loadLogList(CACHE_PATHS.messages()), 846 getClients(), 847 ]) 848 logStartup() 849 850 // If a specific conversation is requested, load and resume it directly 851 if (identifier !== undefined) { 852 // Check if identifier is a number or a file path 853 const number = Math.abs(parseInt(identifier)) 854 const isNumber = !isNaN(number) 855 let messages, date, forkNumber 856 try { 857 if (isNumber) { 858 logEvent('tengu_resume', { number: number.toString() }) 859 const log = logs[number] 860 if (!log) { 861 console.error('No conversation found at index', number) 862 process.exit(1) 863 } 864 messages = await loadMessagesFromLog(log.fullPath, tools) 865 ;({ date, forkNumber } = log) 866 } else { 867 // Handle file path case 868 logEvent('tengu_resume', { filePath: identifier }) 869 if (!existsSync(identifier)) { 870 console.error('File does not exist:', identifier) 871 process.exit(1) 872 } 873 messages = await loadMessagesFromLog(identifier, tools) 874 const pathSegments = identifier.split('/') 875 const filename = 876 pathSegments[pathSegments.length - 1] ?? 'unknown' 877 ;({ date, forkNumber } = parseLogFilename(filename)) 878 } 879 const fork = getNextAvailableLogForkNumber( 880 date, 881 forkNumber ?? 1, 882 0, 883 ) 884 const isDefaultModel = await isDefaultSlowAndCapableModel() 885 render( 886 <REPL 887 initialPrompt="" 888 messageLogName={date} 889 initialForkNumber={fork} 890 shouldShowPromptInput={true} 891 verbose={verbose} 892 commands={commands} 893 tools={tools} 894 initialMessages={messages} 895 mcpClients={mcpClients} 896 isDefaultModel={isDefaultModel} 897 />, 898 { exitOnCtrlC: false }, 899 ) 900 } catch (error) { 901 logError(`Failed to load conversation: ${error}`) 902 process.exit(1) 903 } 904 } else { 905 // Show the conversation selector UI 906 const context: { unmount?: () => void } = {} 907 const { unmount } = render( 908 <ResumeConversation 909 context={context} 910 commands={commands} 911 logs={logs} 912 tools={tools} 913 verbose={verbose} 914 />, 915 renderContextWithExitOnCtrlC, 916 ) 917 context.unmount = unmount 918 } 919 }, 920 ) 921 922 // claude error 923 program 924 .command('error') 925 .description( 926 'View error logs. Optionally provide a number (0, -1, -2, etc.) to display a specific log.', 927 ) 928 .argument( 929 '[number]', 930 'A number (0, 1, 2, etc.) to display a specific log', 931 parseInt, 932 ) 933 .option('-c, --cwd <cwd>', 'The current working directory', String, cwd()) 934 .action(async (number, { cwd }) => { 935 await setup(cwd, false) 936 logEvent('tengu_view_errors', { number: number?.toString() ?? '' }) 937 const context: { unmount?: () => void } = {} 938 const { unmount } = render( 939 <LogList context={context} type="errors" logNumber={number} />, 940 renderContextWithExitOnCtrlC, 941 ) 942 context.unmount = unmount 943 }) 944 945 // claude context (TODO: deprecate) 946 const context = program 947 .command('context') 948 .description( 949 'Set static context (eg. claude context add-file ./src/*.py)', 950 ) 951 952 context 953 .command('get <key>') 954 .option('-c, --cwd <cwd>', 'The current working directory', String, cwd()) 955 .description('Get a value from context') 956 .action(async (key, { cwd }) => { 957 await setup(cwd, false) 958 logEvent('tengu_context_get', { key }) 959 const context = omit( 960 await getContext(), 961 'codeStyle', 962 'directoryStructure', 963 ) 964 console.log(context[key]) 965 process.exit(0) 966 }) 967 968 context 969 .command('set <key> <value>') 970 .description('Set a value in context') 971 .option('-c, --cwd <cwd>', 'The current working directory', String, cwd()) 972 .action(async (key, value, { cwd }) => { 973 await setup(cwd, false) 974 logEvent('tengu_context_set', { key }) 975 setContext(key, value) 976 console.log(`Set context.${key} to "${value}"`) 977 process.exit(0) 978 }) 979 980 context 981 .command('list') 982 .description('List all context values') 983 .option('-c, --cwd <cwd>', 'The current working directory', String, cwd()) 984 .action(async ({ cwd }) => { 985 await setup(cwd, false) 986 logEvent('tengu_context_list', {}) 987 const context = omit( 988 await getContext(), 989 'codeStyle', 990 'directoryStructure', 991 'gitStatus', 992 ) 993 console.log(JSON.stringify(context, null, 2)) 994 process.exit(0) 995 }) 996 997 context 998 .command('remove <key>') 999 .description('Remove a value from context') 1000 .option('-c, --cwd <cwd>', 'The current working directory', String, cwd()) 1001 .action(async (key, { cwd }) => { 1002 await setup(cwd, false) 1003 logEvent('tengu_context_delete', { key }) 1004 removeContext(key) 1005 console.log(`Removed context.${key}`) 1006 process.exit(0) 1007 }) 1008 } 1009 1010 await program.parseAsync(process.argv) 1011 return program 1012 } 1013 1014 // TODO: stream? 1015 async function stdin() { 1016 if (process.stdin.isTTY) { 1017 return '' 1018 } 1019 1020 let data = '' 1021 for await (const chunk of process.stdin) data += chunk 1022 return data 1023 } 1024 1025 process.on('exit', () => { 1026 resetCursor() 1027 PersistentShell.getInstance().close() 1028 }) 1029 1030 process.on('SIGINT', () => { 1031 process.exit(0) 1032 }) 1033 1034 function resetCursor() { 1035 const terminal = process.stderr.isTTY 1036 ? process.stderr 1037 : process.stdout.isTTY 1038 ? process.stdout 1039 : undefined 1040 terminal?.write(`\u001B[?25h${cursorShow}`) 1041 } 1042 1043 main()