/ src / entrypoints / cli.tsx
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()