import { execa } from 'execa' import React, { useCallback, useState } from 'react' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from 'src/services/analytics/index.js' import { WorkflowMultiselectDialog } from '../../components/WorkflowMultiselectDialog.js' import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js' import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' import { type KeyboardEvent, Box } from '@anthropic/ink' import type { LocalJSXCommandOnDone } from '../../types/command.js' import { getAnthropicApiKey, isAnthropicAuthEnabled } from '../../utils/auth.js' import { openBrowser } from '../../utils/browser.js' import { execFileNoThrow } from '../../utils/execFileNoThrow.js' import { getGithubRepo } from '../../utils/git.js' import { plural } from '../../utils/stringUtils.js' import { ApiKeyStep } from './ApiKeyStep.js' import { CheckExistingSecretStep } from './CheckExistingSecretStep.js' import { CheckGitHubStep } from './CheckGitHubStep.js' import { ChooseRepoStep } from './ChooseRepoStep.js' import { CreatingStep } from './CreatingStep.js' import { ErrorStep } from './ErrorStep.js' import { ExistingWorkflowStep } from './ExistingWorkflowStep.js' import { InstallAppStep } from './InstallAppStep.js' import { OAuthFlowStep } from './OAuthFlowStep.js' import { SuccessStep } from './SuccessStep.js' import { setupGitHubActions } from './setupGitHubActions.js' import type { State, Warning, Workflow } from './types.js' import { WarningsStep } from './WarningsStep.js' const INITIAL_STATE: State = { step: 'check-gh', selectedRepoName: '', currentRepo: '', useCurrentRepo: false, // Default to false, will be set to true if repo detected apiKeyOrOAuthToken: '', useExistingKey: true, currentWorkflowInstallStep: 0, warnings: [], secretExists: false, secretName: 'ANTHROPIC_API_KEY', useExistingSecret: true, workflowExists: false, selectedWorkflows: ['claude', 'claude-review'] as Workflow[], selectedApiKeyOption: 'new' as 'existing' | 'new' | 'oauth', authType: 'api_key', } function InstallGitHubApp(props: { onDone: (message: string) => void }): React.ReactNode { const [existingApiKey] = useState(() => getAnthropicApiKey()) const [state, setState] = useState({ ...INITIAL_STATE, useExistingKey: !!existingApiKey, selectedApiKeyOption: (existingApiKey ? 'existing' : isAnthropicAuthEnabled() ? 'oauth' : 'new') as 'existing' | 'new' | 'oauth', }) useExitOnCtrlCDWithKeybindings() React.useEffect(() => { logEvent('tengu_install_github_app_started', {}) }, []) const checkGitHubCLI = useCallback(async () => { const warnings: Warning[] = [] // Check if gh is installed const ghVersionResult = await execa('gh --version', { shell: true, reject: false, }) if (ghVersionResult.exitCode !== 0) { warnings.push({ title: 'GitHub CLI not found', message: 'GitHub CLI (gh) does not appear to be installed or accessible.', instructions: [ 'Install GitHub CLI from https://cli.github.com/', 'macOS: brew install gh', 'Windows: winget install --id GitHub.cli', 'Linux: See installation instructions at https://github.com/cli/cli#installation', ], }) } // Check auth status const authResult = await execa('gh auth status -a', { shell: true, reject: false, }) if (authResult.exitCode !== 0) { warnings.push({ title: 'GitHub CLI not authenticated', message: 'GitHub CLI does not appear to be authenticated.', instructions: [ 'Run: gh auth login', 'Follow the prompts to authenticate with GitHub', 'Or set up authentication using environment variables or other methods', ], }) } else { // Check if required scopes are present in the Token scopes line const tokenScopesMatch = authResult.stdout.match(/Token scopes:.*$/m) if (tokenScopesMatch) { const scopes = tokenScopesMatch[0] const missingScopes: string[] = [] if (!scopes.includes('repo')) { missingScopes.push('repo') } if (!scopes.includes('workflow')) { missingScopes.push('workflow') } if (missingScopes.length > 0) { // Missing required scopes - exit immediately setState(prev => ({ ...prev, step: 'error', error: `GitHub CLI is missing required permissions: ${missingScopes.join(', ')}.`, errorReason: 'Missing required scopes', errorInstructions: [ `Your GitHub CLI authentication is missing the "${missingScopes.join('" and "')}" ${plural(missingScopes.length, 'scope')} needed to manage GitHub Actions and secrets.`, '', 'To fix this, run:', ' gh auth refresh -h github.com -s repo,workflow', '', 'This will add the necessary permissions to manage workflows and secrets.', ], })) return } } } // Check if in a git repo and get remote URL const currentRepo = (await getGithubRepo()) ?? '' logEvent('tengu_install_github_app_step_completed', { step: 'check-gh' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) setState(prev => ({ ...prev, warnings, currentRepo, selectedRepoName: currentRepo, useCurrentRepo: !!currentRepo, // Set to false if no repo detected step: warnings.length > 0 ? 'warnings' : 'choose-repo', })) }, []) React.useEffect(() => { if (state.step === 'check-gh') { void checkGitHubCLI() } }, [state.step, checkGitHubCLI]) const runSetupGitHubActions = useCallback( async (apiKeyOrOAuthToken: string | null, secretName: string) => { setState(prev => ({ ...prev, step: 'creating', currentWorkflowInstallStep: 0, })) try { await setupGitHubActions( state.selectedRepoName, apiKeyOrOAuthToken, secretName, () => { setState(prev => ({ ...prev, currentWorkflowInstallStep: prev.currentWorkflowInstallStep + 1, })) }, state.workflowAction === 'skip', state.selectedWorkflows, state.authType, { useCurrentRepo: state.useCurrentRepo, workflowExists: state.workflowExists, secretExists: state.secretExists, }, ) logEvent('tengu_install_github_app_step_completed', { step: 'creating' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) setState(prev => ({ ...prev, step: 'success' })) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to set up GitHub Actions' if (errorMessage.includes('workflow file already exists')) { logEvent('tengu_install_github_app_error', { reason: 'workflow_file_exists' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) setState(prev => ({ ...prev, step: 'error', error: 'A Claude workflow file already exists in this repository.', errorReason: 'Workflow file conflict', errorInstructions: [ 'The file .github/workflows/claude.yml already exists', 'You can either:', ' 1. Delete the existing file and run this command again', ' 2. Update the existing file manually using the template from:', ` ${GITHUB_ACTION_SETUP_DOCS_URL}`, ], })) } else { logEvent('tengu_install_github_app_error', { reason: 'setup_github_actions_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) setState(prev => ({ ...prev, step: 'error', error: errorMessage, errorReason: 'GitHub Actions setup failed', errorInstructions: [], })) } } }, [ state.selectedRepoName, state.workflowAction, state.selectedWorkflows, state.useCurrentRepo, state.workflowExists, state.secretExists, state.authType, ], ) async function openGitHubAppInstallation() { const installUrl = 'https://github.com/apps/claude' await openBrowser(installUrl) } async function checkRepositoryPermissions( repoName: string, ): Promise<{ hasAccess: boolean; error?: string }> { try { const result = await execFileNoThrow('gh', [ 'api', `repos/${repoName}`, '--jq', '.permissions.admin', ]) if (result.code === 0) { const hasAdmin = result.stdout.trim() === 'true' return { hasAccess: hasAdmin } } if ( result.stderr.includes('404') || result.stderr.includes('Not Found') ) { return { hasAccess: false, error: 'repository_not_found', } } return { hasAccess: false } } catch { return { hasAccess: false } } } async function checkExistingWorkflowFile(repoName: string): Promise { const checkFileResult = await execFileNoThrow('gh', [ 'api', `repos/${repoName}/contents/.github/workflows/claude.yml`, '--jq', '.sha', ]) return checkFileResult.code === 0 } async function checkExistingSecret() { const checkSecretsResult = await execFileNoThrow('gh', [ 'secret', 'list', '--app', 'actions', '--repo', state.selectedRepoName, ]) if (checkSecretsResult.code === 0) { const lines = checkSecretsResult.stdout.split('\n') const hasAnthropicKey = lines.some((line: string) => { return /^ANTHROPIC_API_KEY\s+/.test(line) }) if (hasAnthropicKey) { setState(prev => ({ ...prev, secretExists: true, step: 'check-existing-secret', })) } else { // No existing secret found if (existingApiKey) { // User has local key, skip to creating with it setState(prev => ({ ...prev, apiKeyOrOAuthToken: existingApiKey, useExistingKey: true, })) await runSetupGitHubActions(existingApiKey, state.secretName) } else { // No local key, go to API key step setState(prev => ({ ...prev, step: 'api-key' })) } } } else { // Error checking secrets if (existingApiKey) { // User has local key, skip to creating with it setState(prev => ({ ...prev, apiKeyOrOAuthToken: existingApiKey, useExistingKey: true, })) await runSetupGitHubActions(existingApiKey, state.secretName) } else { // No local key, go to API key step setState(prev => ({ ...prev, step: 'api-key' })) } } } const handleSubmit = async () => { if (state.step === 'warnings') { logEvent('tengu_install_github_app_step_completed', { step: 'warnings' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) setState(prev => ({ ...prev, step: 'install-app' })) setTimeout(openGitHubAppInstallation, 0) } else if (state.step === 'choose-repo') { let repoName = state.useCurrentRepo ? state.currentRepo : state.selectedRepoName if (!repoName.trim()) { return } const repoWarnings: Warning[] = [] if (repoName.includes('github.com')) { const match = repoName.match(/github\.com[:/]([^/]+\/[^/]+)(\.git)?$/) if (!match) { repoWarnings.push({ title: 'Invalid GitHub URL format', message: 'The repository URL format appears to be invalid.', instructions: [ 'Use format: owner/repo or https://github.com/owner/repo', 'Example: anthropics/claude-cli', ], }) } else { repoName = match[1]?.replace(/\.git$/, '') || '' } } if (!repoName.includes('/')) { repoWarnings.push({ title: 'Repository format warning', message: 'Repository should be in format "owner/repo"', instructions: [ 'Use format: owner/repo', 'Example: anthropics/claude-cli', ], }) } const permissionCheck = await checkRepositoryPermissions(repoName) if (permissionCheck.error === 'repository_not_found') { repoWarnings.push({ title: 'Repository not found', message: `Repository ${repoName} was not found or you don't have access.`, instructions: [ `Check that the repository name is correct: ${repoName}`, 'Ensure you have access to this repository', 'For private repositories, make sure your GitHub token has the "repo" scope', 'You can add the repo scope with: gh auth refresh -h github.com -s repo,workflow', ], }) } else if (!permissionCheck.hasAccess) { repoWarnings.push({ title: 'Admin permissions required', message: `You might need admin permissions on ${repoName} to set up GitHub Actions.`, instructions: [ 'Repository admins can install GitHub Apps and set secrets', 'Ask a repository admin to run this command if setup fails', 'Alternatively, you can use the manual setup instructions', ], }) } const workflowExists = await checkExistingWorkflowFile(repoName) if (repoWarnings.length > 0) { const allWarnings = [...state.warnings, ...repoWarnings] setState(prev => ({ ...prev, selectedRepoName: repoName, workflowExists, warnings: allWarnings, step: 'warnings', })) } else { logEvent('tengu_install_github_app_step_completed', { step: 'choose-repo' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) setState(prev => ({ ...prev, selectedRepoName: repoName, workflowExists, step: 'install-app', })) setTimeout(openGitHubAppInstallation, 0) } } else if (state.step === 'install-app') { logEvent('tengu_install_github_app_step_completed', { step: 'install-app' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) if (state.workflowExists) { setState(prev => ({ ...prev, step: 'check-existing-workflow' })) } else { setState(prev => ({ ...prev, step: 'select-workflows' })) } } else if (state.step === 'check-existing-workflow') { return } else if (state.step === 'select-workflows') { // Handled by the WorkflowMultiselectDialog component return } else if (state.step === 'check-existing-secret') { logEvent('tengu_install_github_app_step_completed', { step: 'check-existing-secret' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) if (state.useExistingSecret) { await runSetupGitHubActions(null, state.secretName) } else { // User wants to use a new secret name with their API key await runSetupGitHubActions(state.apiKeyOrOAuthToken, state.secretName) } } else if (state.step === 'api-key') { // In the new flow, api-key step only appears when user has no existing key // They either entered a new key or will create OAuth token if (state.selectedApiKeyOption === 'oauth') { // OAuth flow already handled by handleCreateOAuthToken return } // If user selected 'existing' option, use the existing API key const apiKeyToUse = state.selectedApiKeyOption === 'existing' ? existingApiKey : state.apiKeyOrOAuthToken if (!apiKeyToUse) { logEvent('tengu_install_github_app_error', { reason: 'api_key_missing' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) setState(prev => ({ ...prev, step: 'error', error: 'API key is required', })) return } // Store the API key being used (either existing or newly entered) setState(prev => ({ ...prev, apiKeyOrOAuthToken: apiKeyToUse, useExistingKey: state.selectedApiKeyOption === 'existing', })) // Check if ANTHROPIC_API_KEY secret already exists const checkSecretsResult = await execFileNoThrow('gh', [ 'secret', 'list', '--app', 'actions', '--repo', state.selectedRepoName, ]) if (checkSecretsResult.code === 0) { const lines = checkSecretsResult.stdout.split('\n') const hasAnthropicKey = lines.some((line: string) => { return /^ANTHROPIC_API_KEY\s+/.test(line) }) if (hasAnthropicKey) { logEvent('tengu_install_github_app_step_completed', { step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) setState(prev => ({ ...prev, secretExists: true, step: 'check-existing-secret', })) } else { logEvent('tengu_install_github_app_step_completed', { step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) // No existing secret, proceed to creating await runSetupGitHubActions(apiKeyToUse, state.secretName) } } else { logEvent('tengu_install_github_app_step_completed', { step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) // Error checking secrets, proceed anyway await runSetupGitHubActions(apiKeyToUse, state.secretName) } } } const handleRepoUrlChange = (value: string) => { setState(prev => ({ ...prev, selectedRepoName: value })) } const handleApiKeyChange = (value: string) => { setState(prev => ({ ...prev, apiKeyOrOAuthToken: value })) } const handleApiKeyOptionChange = (option: 'existing' | 'new' | 'oauth') => { setState(prev => ({ ...prev, selectedApiKeyOption: option })) } const handleCreateOAuthToken = useCallback(() => { logEvent('tengu_install_github_app_step_completed', { step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) setState(prev => ({ ...prev, step: 'oauth-flow' })) }, []) const handleOAuthSuccess = useCallback( (token: string) => { logEvent('tengu_install_github_app_step_completed', { step: 'oauth-flow' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) setState(prev => ({ ...prev, apiKeyOrOAuthToken: token, useExistingKey: false, secretName: 'CLAUDE_CODE_OAUTH_TOKEN', authType: 'oauth_token', })) void runSetupGitHubActions(token, 'CLAUDE_CODE_OAUTH_TOKEN') }, [runSetupGitHubActions], ) const handleOAuthCancel = useCallback(() => { setState(prev => ({ ...prev, step: 'api-key' })) }, []) const handleSecretNameChange = (value: string) => { if (value && !/^[a-zA-Z0-9_]+$/.test(value)) return setState(prev => ({ ...prev, secretName: value })) } const handleToggleUseCurrentRepo = (useCurrentRepo: boolean) => { setState(prev => ({ ...prev, useCurrentRepo, selectedRepoName: useCurrentRepo ? prev.currentRepo : '', })) } const handleToggleUseExistingKey = (useExistingKey: boolean) => { setState(prev => ({ ...prev, useExistingKey })) } const handleToggleUseExistingSecret = (useExistingSecret: boolean) => { setState(prev => ({ ...prev, useExistingSecret, secretName: useExistingSecret ? 'ANTHROPIC_API_KEY' : '', })) } const handleWorkflowAction = async (action: 'update' | 'skip' | 'exit') => { if (action === 'exit') { props.onDone('Installation cancelled by user') return } logEvent('tengu_install_github_app_step_completed', { step: 'check-existing-workflow' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) setState(prev => ({ ...prev, workflowAction: action })) if (action === 'skip' || action === 'update') { // Check if user has existing local API key if (existingApiKey) { await checkExistingSecret() } else { // No local key, go straight to API key step setState(prev => ({ ...prev, step: 'api-key' })) } } } function handleDismissKeyDown(e: KeyboardEvent): void { e.preventDefault() if (state.step === 'success') { logEvent('tengu_install_github_app_completed', {}) } props.onDone( state.step === 'success' ? 'GitHub Actions setup complete!' : state.error ? `Couldn't install GitHub App: ${state.error}\nFor manual setup instructions, see: ${GITHUB_ACTION_SETUP_DOCS_URL}` : `GitHub App installation failed\nFor manual setup instructions, see: ${GITHUB_ACTION_SETUP_DOCS_URL}`, ) } switch (state.step) { case 'check-gh': return case 'warnings': return ( ) case 'choose-repo': return ( ) case 'install-app': return ( ) case 'check-existing-workflow': return ( ) case 'check-existing-secret': return ( ) case 'api-key': return ( ) case 'creating': return ( ) case 'success': return ( ) case 'error': return ( ) case 'select-workflows': return ( { logEvent('tengu_install_github_app_step_completed', { step: 'select-workflows' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) setState(prev => ({ ...prev, selectedWorkflows, })) // Check if user has existing local API key if (existingApiKey) { void checkExistingSecret() } else { // No local key, go straight to API key step setState(prev => ({ ...prev, step: 'api-key' })) } }} /> ) case 'oauth-flow': return ( ) } } export async function call( onDone: LocalJSXCommandOnDone, ): Promise { return }