style: 完成所有文件的lint

This commit is contained in:
claude-code-best
2026-05-01 21:39:30 +08:00
parent d136872cc9
commit 6182015005
1333 changed files with 68255 additions and 77882 deletions

View File

@@ -1,19 +1,19 @@
import React, { useCallback, useState } from 'react'
import TextInput from '../../components/TextInput.js'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { Box, color, Text, useTheme } from '@anthropic/ink'
import { useKeybindings } from '../../keybindings/useKeybinding.js'
import React, { useCallback, useState } from 'react';
import TextInput from '../../components/TextInput.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { Box, color, Text, useTheme } from '@anthropic/ink';
import { useKeybindings } from '../../keybindings/useKeybinding.js';
interface ApiKeyStepProps {
existingApiKey: string | null
useExistingKey: boolean
apiKeyOrOAuthToken: string
onApiKeyChange: (value: string) => void
onToggleUseExistingKey: (useExisting: boolean) => void
onSubmit: () => void
onCreateOAuthToken?: () => void
selectedOption?: 'existing' | 'new' | 'oauth'
onSelectOption?: (option: 'existing' | 'new' | 'oauth') => void
existingApiKey: string | null;
useExistingKey: boolean;
apiKeyOrOAuthToken: string;
onApiKeyChange: (value: string) => void;
onToggleUseExistingKey: (useExisting: boolean) => void;
onSubmit: () => void;
onCreateOAuthToken?: () => void;
selectedOption?: 'existing' | 'new' | 'oauth';
onSelectOption?: (option: 'existing' | 'new' | 'oauth') => void;
}
export function ApiKeyStep({
@@ -23,62 +23,47 @@ export function ApiKeyStep({
onSubmit,
onToggleUseExistingKey,
onCreateOAuthToken,
selectedOption = existingApiKey
? 'existing'
: onCreateOAuthToken
? 'oauth'
: 'new',
selectedOption = existingApiKey ? 'existing' : onCreateOAuthToken ? 'oauth' : 'new',
onSelectOption,
}: ApiKeyStepProps) {
const [cursorOffset, setCursorOffset] = useState(0)
const terminalSize = useTerminalSize()
const [theme] = useTheme()
const [cursorOffset, setCursorOffset] = useState(0);
const terminalSize = useTerminalSize();
const [theme] = useTheme();
const handlePrevious = useCallback(() => {
if (selectedOption === 'new' && onCreateOAuthToken) {
// From 'new' go up to 'oauth'
onSelectOption?.('oauth')
onSelectOption?.('oauth');
} else if (selectedOption === 'oauth' && existingApiKey) {
// From 'oauth' go up to 'existing' (only if it exists)
onSelectOption?.('existing')
onToggleUseExistingKey(true)
onSelectOption?.('existing');
onToggleUseExistingKey(true);
}
}, [
selectedOption,
onCreateOAuthToken,
existingApiKey,
onSelectOption,
onToggleUseExistingKey,
])
}, [selectedOption, onCreateOAuthToken, existingApiKey, onSelectOption, onToggleUseExistingKey]);
const handleNext = useCallback(() => {
if (selectedOption === 'existing') {
// From 'existing' go down to 'oauth' (if available) or 'new'
onSelectOption?.(onCreateOAuthToken ? 'oauth' : 'new')
onToggleUseExistingKey(false)
onSelectOption?.(onCreateOAuthToken ? 'oauth' : 'new');
onToggleUseExistingKey(false);
} else if (selectedOption === 'oauth') {
// From 'oauth' go down to 'new'
onSelectOption?.('new')
onSelectOption?.('new');
}
}, [
selectedOption,
onCreateOAuthToken,
onSelectOption,
onToggleUseExistingKey,
])
}, [selectedOption, onCreateOAuthToken, onSelectOption, onToggleUseExistingKey]);
const handleConfirm = useCallback(() => {
if (selectedOption === 'oauth' && onCreateOAuthToken) {
onCreateOAuthToken()
onCreateOAuthToken();
} else {
onSubmit()
onSubmit();
}
}, [selectedOption, onCreateOAuthToken, onSubmit])
}, [selectedOption, onCreateOAuthToken, onSubmit]);
// When the text input is visible, omit confirm:yes so bare 'y' passes
// through to the input instead of submitting. TextInput's onSubmit handles
// Enter. Keep the Confirmation context (not Settings) to avoid j/k bindings.
const isTextInputVisible = selectedOption === 'new'
const isTextInputVisible = selectedOption === 'new';
useKeybindings(
{
'confirm:previous': handlePrevious,
@@ -86,14 +71,14 @@ export function ApiKeyStep({
'confirm:yes': handleConfirm,
},
{ context: 'Confirmation', isActive: !isTextInputVisible },
)
);
useKeybindings(
{
'confirm:previous': handlePrevious,
'confirm:next': handleNext,
},
{ context: 'Confirmation', isActive: isTextInputVisible },
)
);
return (
<>
@@ -105,9 +90,7 @@ export function ApiKeyStep({
{existingApiKey && (
<Box marginBottom={1}>
<Text>
{selectedOption === 'existing'
? color('success', theme)('> ')
: ' '}
{selectedOption === 'existing' ? color('success', theme)('> ') : ' '}
Use your existing Claude Code API key
</Text>
</Box>
@@ -115,9 +98,7 @@ export function ApiKeyStep({
{onCreateOAuthToken && (
<Box marginBottom={1}>
<Text>
{selectedOption === 'oauth'
? color('success', theme)('> ')
: ' '}
{selectedOption === 'oauth' ? color('success', theme)('> ') : ' '}
Create a long-lived token with your Claude subscription
</Text>
</Box>
@@ -148,5 +129,5 @@ export function ApiKeyStep({
<Text dimColor>/ to select · Enter to continue</Text>
</Box>
</>
)
);
}

View File

@@ -1,15 +1,15 @@
import React, { useCallback, useState } from 'react'
import TextInput from '../../components/TextInput.js'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { Box, color, Text, useTheme } from '@anthropic/ink'
import { useKeybindings } from '../../keybindings/useKeybinding.js'
import React, { useCallback, useState } from 'react';
import TextInput from '../../components/TextInput.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { Box, color, Text, useTheme } from '@anthropic/ink';
import { useKeybindings } from '../../keybindings/useKeybinding.js';
interface CheckExistingSecretStepProps {
useExistingSecret: boolean
secretName: string
onToggleUseExistingSecret: (useExisting: boolean) => void
onSecretNameChange: (value: string) => void
onSubmit: () => void
useExistingSecret: boolean;
secretName: string;
onToggleUseExistingSecret: (useExisting: boolean) => void;
onSecretNameChange: (value: string) => void;
onSubmit: () => void;
}
export function CheckExistingSecretStep({
@@ -19,21 +19,15 @@ export function CheckExistingSecretStep({
onSecretNameChange,
onSubmit,
}: CheckExistingSecretStepProps) {
const [cursorOffset, setCursorOffset] = useState(0)
const terminalSize = useTerminalSize()
const [theme] = useTheme()
const [cursorOffset, setCursorOffset] = useState(0);
const terminalSize = useTerminalSize();
const [theme] = useTheme();
// When the text input is visible, omit confirm:yes so bare 'y' passes
// through to the input instead of submitting. TextInput's onSubmit handles
// Enter. Keep the Confirmation context (not Settings) to avoid j/k bindings.
const handlePrevious = useCallback(
() => onToggleUseExistingSecret(true),
[onToggleUseExistingSecret],
)
const handleNext = useCallback(
() => onToggleUseExistingSecret(false),
[onToggleUseExistingSecret],
)
const handlePrevious = useCallback(() => onToggleUseExistingSecret(true), [onToggleUseExistingSecret]);
const handleNext = useCallback(() => onToggleUseExistingSecret(false), [onToggleUseExistingSecret]);
useKeybindings(
{
'confirm:previous': handlePrevious,
@@ -41,14 +35,14 @@ export function CheckExistingSecretStep({
'confirm:yes': onSubmit,
},
{ context: 'Confirmation', isActive: useExistingSecret },
)
);
useKeybindings(
{
'confirm:previous': handlePrevious,
'confirm:next': handleNext,
},
{ context: 'Confirmation', isActive: !useExistingSecret },
)
);
return (
<>
@@ -58,9 +52,7 @@ export function CheckExistingSecretStep({
<Text dimColor>Setup API key secret</Text>
</Box>
<Box marginBottom={1}>
<Text color="warning">
ANTHROPIC_API_KEY already exists in repository secrets!
</Text>
<Text color="warning">ANTHROPIC_API_KEY already exists in repository secrets!</Text>
</Box>
<Box marginBottom={1}>
<Text>Would you like to:</Text>
@@ -80,9 +72,7 @@ export function CheckExistingSecretStep({
{!useExistingSecret && (
<>
<Box marginBottom={1}>
<Text>
Enter new secret name (alphanumeric with underscores):
</Text>
<Text>Enter new secret name (alphanumeric with underscores):</Text>
</Box>
<TextInput
value={secretName}
@@ -102,5 +92,5 @@ export function CheckExistingSecretStep({
<Text dimColor>/ to select · Enter to continue</Text>
</Box>
</>
)
);
}

View File

@@ -1,6 +1,6 @@
import React from 'react'
import { Text } from '@anthropic/ink'
import React from 'react';
import { Text } from '@anthropic/ink';
export function CheckGitHubStep() {
return <Text>Checking GitHub CLI installation</Text>
return <Text>Checking GitHub CLI installation</Text>;
}

View File

@@ -1,16 +1,16 @@
import React, { useCallback, useState } from 'react'
import TextInput from '../../components/TextInput.js'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { Box, Text } from '@anthropic/ink'
import { useKeybindings } from '../../keybindings/useKeybinding.js'
import React, { useCallback, useState } from 'react';
import TextInput from '../../components/TextInput.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { Box, Text } from '@anthropic/ink';
import { useKeybindings } from '../../keybindings/useKeybinding.js';
interface ChooseRepoStepProps {
currentRepo: string | null
useCurrentRepo: boolean
repoUrl: string
onRepoUrlChange: (value: string) => void
onToggleUseCurrentRepo: (useCurrentRepo: boolean) => void
onSubmit: () => void
currentRepo: string | null;
useCurrentRepo: boolean;
repoUrl: string;
onRepoUrlChange: (value: string) => void;
onToggleUseCurrentRepo: (useCurrentRepo: boolean) => void;
onSubmit: () => void;
}
export function ChooseRepoStep({
@@ -21,32 +21,32 @@ export function ChooseRepoStep({
onSubmit,
onToggleUseCurrentRepo,
}: ChooseRepoStepProps) {
const [cursorOffset, setCursorOffset] = useState(0)
const [showEmptyError, setShowEmptyError] = useState(false)
const terminalSize = useTerminalSize()
const textInputColumns = terminalSize.columns
const [cursorOffset, setCursorOffset] = useState(0);
const [showEmptyError, setShowEmptyError] = useState(false);
const terminalSize = useTerminalSize();
const textInputColumns = terminalSize.columns;
const handleSubmit = useCallback(() => {
const repoName = useCurrentRepo ? currentRepo : repoUrl
const repoName = useCurrentRepo ? currentRepo : repoUrl;
if (!repoName?.trim()) {
setShowEmptyError(true)
return
setShowEmptyError(true);
return;
}
onSubmit()
}, [useCurrentRepo, currentRepo, repoUrl, onSubmit])
onSubmit();
}, [useCurrentRepo, currentRepo, repoUrl, onSubmit]);
// When the text input is visible, omit confirm:yes so bare 'y' passes
// through to the input instead of submitting. TextInput's onSubmit handles
// Enter. Keep the Confirmation context (not Settings) to avoid j/k bindings.
const isTextInputVisible = !useCurrentRepo || !currentRepo
const isTextInputVisible = !useCurrentRepo || !currentRepo;
const handlePrevious = useCallback(() => {
onToggleUseCurrentRepo(true)
setShowEmptyError(false)
}, [onToggleUseCurrentRepo])
onToggleUseCurrentRepo(true);
setShowEmptyError(false);
}, [onToggleUseCurrentRepo]);
const handleNext = useCallback(() => {
onToggleUseCurrentRepo(false)
setShowEmptyError(false)
}, [onToggleUseCurrentRepo])
onToggleUseCurrentRepo(false);
setShowEmptyError(false);
}, [onToggleUseCurrentRepo]);
useKeybindings(
{
@@ -55,14 +55,14 @@ export function ChooseRepoStep({
'confirm:yes': handleSubmit,
},
{ context: 'Confirmation', isActive: !isTextInputVisible },
)
);
useKeybindings(
{
'confirm:previous': handlePrevious,
'confirm:next': handleNext,
},
{ context: 'Confirmation', isActive: isTextInputVisible },
)
);
return (
<>
@@ -73,10 +73,7 @@ export function ChooseRepoStep({
</Box>
{currentRepo && (
<Box marginBottom={1}>
<Text
bold={useCurrentRepo}
color={useCurrentRepo ? 'permission' : undefined}
>
<Text bold={useCurrentRepo} color={useCurrentRepo ? 'permission' : undefined}>
{useCurrentRepo ? '> ' : ' '}
Use current repository: {currentRepo}
</Text>
@@ -96,8 +93,8 @@ export function ChooseRepoStep({
<TextInput
value={repoUrl}
onChange={value => {
onRepoUrlChange(value)
setShowEmptyError(false)
onRepoUrlChange(value);
setShowEmptyError(false);
}}
onSubmit={handleSubmit}
focus={true}
@@ -116,10 +113,8 @@ export function ChooseRepoStep({
</Box>
)}
<Box marginLeft={3}>
<Text dimColor>
{currentRepo ? '↑/↓ to select · ' : ''}Enter to continue
</Text>
<Text dimColor>{currentRepo ? '↑/↓ to select · ' : ''}Enter to continue</Text>
</Box>
</>
)
);
}

View File

@@ -1,14 +1,14 @@
import React from 'react'
import { Box, Text } from '@anthropic/ink'
import type { Workflow } from './types.js'
import React from 'react';
import { Box, Text } from '@anthropic/ink';
import type { Workflow } from './types.js';
interface CreatingStepProps {
currentWorkflowInstallStep: number
secretExists: boolean
useExistingSecret: boolean
secretName: string
skipWorkflow?: boolean
selectedWorkflows: Workflow[]
currentWorkflowInstallStep: number;
secretExists: boolean;
useExistingSecret: boolean;
secretName: string;
skipWorkflow?: boolean;
selectedWorkflows: Workflow[];
}
export function CreatingStep({
@@ -22,21 +22,15 @@ export function CreatingStep({
const progressSteps = skipWorkflow
? [
'Getting repository information',
secretExists && useExistingSecret
? 'Using existing API key secret'
: `Setting up ${secretName} secret`,
secretExists && useExistingSecret ? 'Using existing API key secret' : `Setting up ${secretName} secret`,
]
: [
'Getting repository information',
'Creating branch',
selectedWorkflows.length > 1
? 'Creating workflow files'
: 'Creating workflow file',
secretExists && useExistingSecret
? 'Using existing API key secret'
: `Setting up ${secretName} secret`,
selectedWorkflows.length > 1 ? 'Creating workflow files' : 'Creating workflow file',
secretExists && useExistingSecret ? 'Using existing API key secret' : `Setting up ${secretName} secret`,
'Opening pull request page',
]
];
return (
<>
@@ -46,33 +40,25 @@ export function CreatingStep({
<Text dimColor>Create GitHub Actions workflow</Text>
</Box>
{progressSteps.map((stepText, index) => {
let status: 'completed' | 'in-progress' | 'pending' = 'pending'
let status: 'completed' | 'in-progress' | 'pending' = 'pending';
if (index < currentWorkflowInstallStep) {
status = 'completed'
status = 'completed';
} else if (index === currentWorkflowInstallStep) {
status = 'in-progress'
status = 'in-progress';
}
return (
<Box key={index}>
<Text
color={
status === 'completed'
? 'success'
: status === 'in-progress'
? 'warning'
: undefined
}
>
<Text color={status === 'completed' ? 'success' : status === 'in-progress' ? 'warning' : undefined}>
{status === 'completed' ? '✓ ' : ''}
{stepText}
{status === 'in-progress' ? '…' : ''}
</Text>
</Box>
)
);
})}
</Box>
</>
)
);
}

View File

@@ -1,18 +1,14 @@
import React from 'react'
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'
import { Box, Text } from '@anthropic/ink'
import React from 'react';
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
import { Box, Text } from '@anthropic/ink';
interface ErrorStepProps {
error: string | undefined
errorReason?: string
errorInstructions?: string[]
error: string | undefined;
errorReason?: string;
errorInstructions?: string[];
}
export function ErrorStep({
error,
errorReason,
errorInstructions,
}: ErrorStepProps) {
export function ErrorStep({ error, errorReason, errorInstructions }: ErrorStepProps) {
return (
<>
<Box flexDirection="column" borderStyle="round" paddingX={1}>
@@ -38,8 +34,7 @@ export function ErrorStep({
)}
<Box marginTop={1}>
<Text dimColor>
For manual setup instructions, see:{' '}
<Text color="claude">{GITHUB_ACTION_SETUP_DOCS_URL}</Text>
For manual setup instructions, see: <Text color="claude">{GITHUB_ACTION_SETUP_DOCS_URL}</Text>
</Text>
</Box>
</Box>
@@ -47,5 +42,5 @@ export function ErrorStep({
<Text dimColor>Press any key to exit</Text>
</Box>
</>
)
);
}

View File

@@ -1,16 +1,13 @@
import React from 'react'
import { Select } from 'src/components/CustomSelect/index.js'
import { Box, Text } from '@anthropic/ink'
import React from 'react';
import { Select } from 'src/components/CustomSelect/index.js';
import { Box, Text } from '@anthropic/ink';
interface ExistingWorkflowStepProps {
repoName: string
onSelectAction: (action: 'update' | 'skip' | 'exit') => void
repoName: string;
onSelectAction: (action: 'update' | 'skip' | 'exit') => void;
}
export function ExistingWorkflowStep({
repoName,
onSelectAction,
}: ExistingWorkflowStepProps) {
export function ExistingWorkflowStep({ repoName, onSelectAction }: ExistingWorkflowStepProps) {
const options = [
{
label: 'Update workflow file with latest version',
@@ -24,15 +21,15 @@ export function ExistingWorkflowStep({
label: 'Exit without making changes',
value: 'exit',
},
]
];
const handleSelect = (value: string) => {
onSelectAction(value as 'update' | 'skip' | 'exit')
}
onSelectAction(value as 'update' | 'skip' | 'exit');
};
const handleCancel = () => {
onSelectAction('exit')
}
onSelectAction('exit');
};
return (
<Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
@@ -43,28 +40,21 @@ export function ExistingWorkflowStep({
<Box flexDirection="column" marginBottom={1}>
<Text>
A Claude workflow file already exists at{' '}
<Text color="claude">.github/workflows/claude.yml</Text>
A Claude workflow file already exists at <Text color="claude">.github/workflows/claude.yml</Text>
</Text>
<Text dimColor>What would you like to do?</Text>
</Box>
<Box flexDirection="column">
<Select
options={options}
onChange={handleSelect}
onCancel={handleCancel}
/>
<Select options={options} onChange={handleSelect} onCancel={handleCancel} />
</Box>
<Box marginTop={1}>
<Text dimColor>
View the latest workflow template at:{' '}
<Text color="claude">
https://github.com/anthropics/claude-code-action/blob/main/examples/claude.yml
</Text>
<Text color="claude">https://github.com/anthropics/claude-code-action/blob/main/examples/claude.yml</Text>
</Text>
</Box>
</Box>
)
);
}

View File

@@ -1,17 +1,17 @@
import figures from 'figures'
import React from 'react'
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'
import { Box, Text } from '@anthropic/ink'
import { useKeybinding } from '../../keybindings/useKeybinding.js'
import figures from 'figures';
import React from 'react';
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
import { Box, Text } from '@anthropic/ink';
import { useKeybinding } from '../../keybindings/useKeybinding.js';
interface InstallAppStepProps {
repoUrl: string
onSubmit: () => void
repoUrl: string;
onSubmit: () => void;
}
export function InstallAppStep({ repoUrl, onSubmit }: InstallAppStepProps) {
// Enter to submit
useKeybinding('confirm:yes', onSubmit, { context: 'Confirmation' })
useKeybinding('confirm:yes', onSubmit, { context: 'Confirmation' });
return (
<Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
@@ -33,9 +33,7 @@ export function InstallAppStep({ repoUrl, onSubmit }: InstallAppStepProps) {
</Text>
</Box>
<Box marginBottom={1}>
<Text dimColor>
Important: Make sure to grant access to this specific repository
</Text>
<Text dimColor>Important: Make sure to grant access to this specific repository</Text>
</Box>
<Box>
<Text bold color="permission">
@@ -44,10 +42,9 @@ export function InstallAppStep({ repoUrl, onSubmit }: InstallAppStepProps) {
</Box>
<Box marginTop={1}>
<Text dimColor>
Having trouble? See manual setup instructions at:{' '}
<Text color="claude">{GITHUB_ACTION_SETUP_DOCS_URL}</Text>
Having trouble? See manual setup instructions at: <Text color="claude">{GITHUB_ACTION_SETUP_DOCS_URL}</Text>
</Text>
</Box>
</Box>
)
);
}

View File

@@ -1,20 +1,20 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from 'src/services/analytics/index.js'
import { KeyboardShortcutHint } from '@anthropic/ink'
import { Spinner } from '../../components/Spinner.js'
import TextInput from '../../components/TextInput.js'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { type KeyboardEvent, setClipboard, Box, Link, Text } from '@anthropic/ink'
import { OAuthService } from '../../services/oauth/index.js'
import { saveOAuthTokensIfNeeded } from '../../utils/auth.js'
import { logError } from '../../utils/log.js'
} from 'src/services/analytics/index.js';
import { KeyboardShortcutHint } from '@anthropic/ink';
import { Spinner } from '../../components/Spinner.js';
import TextInput from '../../components/TextInput.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { type KeyboardEvent, setClipboard, Box, Link, Text } from '@anthropic/ink';
import { OAuthService } from '../../services/oauth/index.js';
import { saveOAuthTokensIfNeeded } from '../../utils/auth.js';
import { logError } from '../../utils/log.js';
interface OAuthFlowStepProps {
onSuccess: (token: string) => void
onCancel: () => void
onSuccess: (token: string) => void;
onCancel: () => void;
}
type OAuthStatus =
@@ -23,139 +23,132 @@ type OAuthStatus =
| { state: 'processing' }
| { state: 'success'; token: string }
| { state: 'error'; message: string; toRetry?: OAuthStatus }
| { state: 'about_to_retry'; nextState: OAuthStatus }
| { state: 'about_to_retry'; nextState: OAuthStatus };
const PASTE_HERE_MSG = 'Paste code here if prompted > '
const PASTE_HERE_MSG = 'Paste code here if prompted > ';
export function OAuthFlowStep({
onSuccess,
onCancel,
}: OAuthFlowStepProps): React.ReactNode {
export function OAuthFlowStep({ onSuccess, onCancel }: OAuthFlowStepProps): React.ReactNode {
const [oauthStatus, setOAuthStatus] = useState<OAuthStatus>({
state: 'starting',
})
const [oauthService] = useState(() => new OAuthService())
const [pastedCode, setPastedCode] = useState('')
const [cursorOffset, setCursorOffset] = useState(0)
const [showPastePrompt, setShowPastePrompt] = useState(false)
const [urlCopied, setUrlCopied] = useState(false)
const timersRef = useRef<Set<NodeJS.Timeout>>(new Set())
});
const [oauthService] = useState(() => new OAuthService());
const [pastedCode, setPastedCode] = useState('');
const [cursorOffset, setCursorOffset] = useState(0);
const [showPastePrompt, setShowPastePrompt] = useState(false);
const [urlCopied, setUrlCopied] = useState(false);
const timersRef = useRef<Set<NodeJS.Timeout>>(new Set());
// Separate ref so startOAuth's timer clear doesn't cancel the urlCopied reset
const urlCopiedTimerRef = useRef<NodeJS.Timeout | undefined>(undefined)
const urlCopiedTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const terminalSize = useTerminalSize()
const textInputColumns = Math.max(
50,
terminalSize.columns - PASTE_HERE_MSG.length - 4,
)
const terminalSize = useTerminalSize();
const textInputColumns = Math.max(50, terminalSize.columns - PASTE_HERE_MSG.length - 4);
function handleKeyDown(e: KeyboardEvent): void {
if (oauthStatus.state !== 'error') return
e.preventDefault()
if (oauthStatus.state !== 'error') return;
e.preventDefault();
if (e.key === 'return' && oauthStatus.toRetry) {
setPastedCode('')
setCursorOffset(0)
setPastedCode('');
setCursorOffset(0);
setOAuthStatus({
state: 'about_to_retry',
nextState: oauthStatus.toRetry,
})
});
} else {
onCancel()
onCancel();
}
}
async function handleSubmitCode(value: string, url: string) {
try {
// Expecting format "authorizationCode#state" from the authorization callback URL
const [authorizationCode, state] = value.split('#')
const [authorizationCode, state] = value.split('#');
if (!authorizationCode || !state) {
setOAuthStatus({
state: 'error',
message: 'Invalid code. Please make sure the full code was copied',
toRetry: { state: 'waiting_for_login', url },
})
return
});
return;
}
// Track which path the user is taking (manual code entry)
logEvent('tengu_oauth_manual_entry', {})
logEvent('tengu_oauth_manual_entry', {});
oauthService.handleManualAuthCodeInput({
authorizationCode,
state,
})
});
} catch (err: unknown) {
logError(err)
logError(err);
setOAuthStatus({
state: 'error',
message: (err as Error).message,
toRetry: { state: 'waiting_for_login', url },
})
});
}
}
const startOAuth = useCallback(async () => {
// Clear any existing timers when starting new OAuth flow
timersRef.current.forEach(timer => clearTimeout(timer))
timersRef.current.clear()
timersRef.current.forEach(timer => clearTimeout(timer));
timersRef.current.clear();
try {
const result = await oauthService.startOAuthFlow(
async url => {
setOAuthStatus({ state: 'waiting_for_login', url })
const timer = setTimeout(setShowPastePrompt, 3000, true)
timersRef.current.add(timer)
setOAuthStatus({ state: 'waiting_for_login', url });
const timer = setTimeout(setShowPastePrompt, 3000, true);
timersRef.current.add(timer);
},
{
loginWithClaudeAi: true, // Always use Claude AI for subscription tokens
inferenceOnly: true,
expiresIn: 365 * 24 * 60 * 60, // 1 year
},
)
);
// Show processing state
setOAuthStatus({ state: 'processing' })
setOAuthStatus({ state: 'processing' });
// OAuthFlowStep creates inference-only tokens for GitHub Actions, not a
// replacement login. Use saveOAuthTokensIfNeeded directly to avoid
// performLogout which would destroy the user's existing auth session.
saveOAuthTokensIfNeeded(result)
saveOAuthTokensIfNeeded(result);
// For OAuth flow, the access token can be used as an API key
const timer1 = setTimeout(
(setOAuthStatus, accessToken, onSuccess, timersRef) => {
setOAuthStatus({ state: 'success', token: accessToken })
setOAuthStatus({ state: 'success', token: accessToken });
// Auto-continue after brief delay to show success
const timer2 = setTimeout(onSuccess, 1000, accessToken)
timersRef.current.add(timer2 as unknown as NodeJS.Timeout)
const timer2 = setTimeout(onSuccess, 1000, accessToken);
timersRef.current.add(timer2 as unknown as NodeJS.Timeout);
},
100,
setOAuthStatus,
result.accessToken,
onSuccess,
timersRef,
)
timersRef.current.add(timer1)
);
timersRef.current.add(timer1);
} catch (err) {
const errorMessage = (err as Error).message
const errorMessage = (err as Error).message;
setOAuthStatus({
state: 'error',
message: errorMessage,
toRetry: { state: 'starting' }, // Allow retry by starting fresh OAuth flow
})
logError(err)
});
logError(err);
logEvent('tengu_oauth_error', {
error:
errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
error: errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
}
}, [oauthService, onSuccess])
}, [oauthService, onSuccess]);
useEffect(() => {
if (oauthStatus.state === 'starting') {
void startOAuth()
void startOAuth();
}
}, [oauthStatus.state, startOAuth])
}, [oauthStatus.state, startOAuth]);
// Retry logic
useEffect(() => {
@@ -163,46 +156,41 @@ export function OAuthFlowStep({
const timer = setTimeout(
(nextState, setShowPastePrompt, setOAuthStatus) => {
// Only show paste prompt when retrying to waiting_for_login
setShowPastePrompt(nextState.state === 'waiting_for_login')
setOAuthStatus(nextState)
setShowPastePrompt(nextState.state === 'waiting_for_login');
setOAuthStatus(nextState);
},
500,
oauthStatus.nextState,
setShowPastePrompt,
setOAuthStatus,
)
timersRef.current.add(timer)
);
timersRef.current.add(timer);
}
}, [oauthStatus])
}, [oauthStatus]);
useEffect(() => {
if (
pastedCode === 'c' &&
oauthStatus.state === 'waiting_for_login' &&
showPastePrompt &&
!urlCopied
) {
if (pastedCode === 'c' && oauthStatus.state === 'waiting_for_login' && showPastePrompt && !urlCopied) {
void setClipboard(oauthStatus.url).then(raw => {
if (raw) process.stdout.write(raw)
setUrlCopied(true)
clearTimeout(urlCopiedTimerRef.current)
urlCopiedTimerRef.current = setTimeout(setUrlCopied, 2000, false)
})
setPastedCode('')
if (raw) process.stdout.write(raw);
setUrlCopied(true);
clearTimeout(urlCopiedTimerRef.current);
urlCopiedTimerRef.current = setTimeout(setUrlCopied, 2000, false);
});
setPastedCode('');
}
}, [pastedCode, oauthStatus, showPastePrompt, urlCopied])
}, [pastedCode, oauthStatus, showPastePrompt, urlCopied]);
// Cleanup OAuth service and timers when component unmounts
useEffect(() => {
const timers = timersRef.current
const timers = timersRef.current;
return () => {
oauthService.cleanup()
oauthService.cleanup();
// Clear all timers
timers.forEach(timer => clearTimeout(timer))
timers.clear()
clearTimeout(urlCopiedTimerRef.current)
}
}, [oauthService])
timers.forEach(timer => clearTimeout(timer));
timers.clear();
clearTimeout(urlCopiedTimerRef.current);
};
}, [oauthService]);
// Helper function to render the appropriate status message
function renderStatusMessage(): React.ReactNode {
@@ -213,7 +201,7 @@ export function OAuthFlowStep({
<Spinner />
<Text>Starting authentication</Text>
</Box>
)
);
case 'waiting_for_login':
return (
@@ -221,9 +209,7 @@ export function OAuthFlowStep({
{!showPastePrompt && (
<Box>
<Spinner />
<Text>
Opening browser to sign in with your Claude account
</Text>
<Text>Opening browser to sign in with your Claude account</Text>
</Box>
)}
@@ -233,9 +219,7 @@ export function OAuthFlowStep({
<TextInput
value={pastedCode}
onChange={setPastedCode}
onSubmit={(value: string) =>
handleSubmitCode(value, oauthStatus.url)
}
onSubmit={(value: string) => handleSubmitCode(value, oauthStatus.url)}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
columns={textInputColumns}
@@ -243,7 +227,7 @@ export function OAuthFlowStep({
</Box>
)}
</Box>
)
);
case 'processing':
return (
@@ -251,52 +235,42 @@ export function OAuthFlowStep({
<Spinner />
<Text>Processing authentication</Text>
</Box>
)
);
case 'success':
return (
<Box flexDirection="column" gap={1}>
<Text color="success">
Authentication token created successfully!
</Text>
<Text color="success"> Authentication token created successfully!</Text>
<Text dimColor>Using token for GitHub Actions setup</Text>
</Box>
)
);
case 'error':
return (
<Box flexDirection="column" gap={1}>
<Text color="error">OAuth error: {oauthStatus.message}</Text>
{oauthStatus.toRetry ? (
<Text dimColor>
Press Enter to try again, or any other key to cancel
</Text>
<Text dimColor>Press Enter to try again, or any other key to cancel</Text>
) : (
<Text dimColor>Press any key to return to API key selection</Text>
)}
</Box>
)
);
case 'about_to_retry':
return (
<Box flexDirection="column" gap={1}>
<Text color="permission">Retrying</Text>
</Box>
)
);
default:
return null
return null;
}
}
return (
<Box
flexDirection="column"
gap={1}
tabIndex={0}
autoFocus
onKeyDown={handleKeyDown}
>
<Box flexDirection="column" gap={1} tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
{/* Show header inline only for initial starting state */}
{oauthStatus.state === 'starting' && (
<Box flexDirection="column" gap={1} paddingBottom={1}>
@@ -305,21 +279,17 @@ export function OAuthFlowStep({
</Box>
)}
{/* Show header for non-starting states (to avoid duplicate with inline header)*/}
{oauthStatus.state !== 'success' &&
oauthStatus.state !== 'starting' &&
oauthStatus.state !== 'processing' && (
<Box key="header" flexDirection="column" gap={1} paddingBottom={1}>
<Text bold>Create Authentication Token</Text>
<Text dimColor>Creating a long-lived token for GitHub Actions</Text>
</Box>
)}
{oauthStatus.state !== 'success' && oauthStatus.state !== 'starting' && oauthStatus.state !== 'processing' && (
<Box key="header" flexDirection="column" gap={1} paddingBottom={1}>
<Text bold>Create Authentication Token</Text>
<Text dimColor>Creating a long-lived token for GitHub Actions</Text>
</Box>
)}
{/* Show URL when paste prompt is visible */}
{oauthStatus.state === 'waiting_for_login' && showPastePrompt && (
<Box flexDirection="column" key="urlToCopy" gap={1} paddingBottom={1}>
<Box paddingX={1}>
<Text dimColor>
Browser didn&apos;t open? Use the url below to sign in{' '}
</Text>
<Text dimColor>Browser didn&apos;t open? Use the url below to sign in </Text>
{urlCopied ? (
<Text color="success">(Copied!)</Text>
) : (
@@ -337,5 +307,5 @@ export function OAuthFlowStep({
{renderStatusMessage()}
</Box>
</Box>
)
);
}

View File

@@ -1,12 +1,12 @@
import React from 'react'
import { Box, Text } from '@anthropic/ink'
import React from 'react';
import { Box, Text } from '@anthropic/ink';
type SuccessStepProps = {
secretExists: boolean
useExistingSecret: boolean
secretName: string
skipWorkflow?: boolean
}
secretExists: boolean;
useExistingSecret: boolean;
secretName: string;
skipWorkflow?: boolean;
};
export function SuccessStep({
secretExists,
@@ -21,14 +21,10 @@ export function SuccessStep({
<Text bold>Install GitHub App</Text>
<Text dimColor>Success</Text>
</Box>
{!skipWorkflow && (
<Text color="success"> GitHub Actions workflow created!</Text>
)}
{!skipWorkflow && <Text color="success"> GitHub Actions workflow created!</Text>}
{secretExists && useExistingSecret && (
<Box marginTop={1}>
<Text color="success">
Using existing ANTHROPIC_API_KEY secret
</Text>
<Text color="success"> Using existing ANTHROPIC_API_KEY secret</Text>
</Box>
)}
{(!secretExists || !useExistingSecret) && (
@@ -41,18 +37,14 @@ export function SuccessStep({
</Box>
{skipWorkflow ? (
<>
<Text>
1. Install the Claude GitHub App if you haven&apos;t already
</Text>
<Text>1. Install the Claude GitHub App if you haven&apos;t already</Text>
<Text>2. Your workflow file was kept unchanged</Text>
<Text>3. API key is configured and ready to use</Text>
</>
) : (
<>
<Text>1. A pre-filled PR page has been created</Text>
<Text>
2. Install the Claude GitHub App if you haven&apos;t already
</Text>
<Text>2. Install the Claude GitHub App if you haven&apos;t already</Text>
<Text>3. Merge the PR to enable Claude PR assistance</Text>
</>
)}
@@ -61,5 +53,5 @@ export function SuccessStep({
<Text dimColor>Press any key to exit</Text>
</Box>
</>
)
);
}

View File

@@ -1,27 +1,25 @@
import figures from 'figures'
import React from 'react'
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'
import { Box, Text } from '@anthropic/ink'
import { useKeybinding } from '../../keybindings/useKeybinding.js'
import type { Warning } from './types.js'
import figures from 'figures';
import React from 'react';
import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js';
import { Box, Text } from '@anthropic/ink';
import { useKeybinding } from '../../keybindings/useKeybinding.js';
import type { Warning } from './types.js';
interface WarningsStepProps {
warnings: Warning[]
onContinue: () => void
warnings: Warning[];
onContinue: () => void;
}
export function WarningsStep({ warnings, onContinue }: WarningsStepProps) {
// Enter to continue
useKeybinding('confirm:yes', onContinue, { context: 'Confirmation' })
useKeybinding('confirm:yes', onContinue, { context: 'Confirmation' });
return (
<>
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Box flexDirection="column" marginBottom={1}>
<Text bold>{figures.warning} Setup Warnings</Text>
<Text dimColor>
We found some potential issues, but you can continue anyway
</Text>
<Text dimColor>We found some potential issues, but you can continue anyway</Text>
</Box>
{warnings.map((warning, index) => (
@@ -55,5 +53,5 @@ export function WarningsStep({ warnings, onContinue }: WarningsStepProps) {
</Box>
</Box>
</>
)
);
}

View File

@@ -1,32 +1,32 @@
import { execa } from 'execa'
import React, { useCallback, useState } from 'react'
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'
} 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',
@@ -44,54 +44,50 @@ const INITIAL_STATE: State = {
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())
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()
selectedApiKeyOption: (existingApiKey ? 'existing' : isAnthropicAuthEnabled() ? 'oauth' : 'new') as
| 'existing'
| 'new'
| 'oauth',
});
useExitOnCtrlCDWithKeybindings();
React.useEffect(() => {
logEvent('tengu_install_github_app_started', {})
}, [])
logEvent('tengu_install_github_app_started', {});
}, []);
const checkGitHubCLI = useCallback(async () => {
const warnings: Warning[] = []
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.',
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',
@@ -101,19 +97,19 @@ function InstallGitHubApp(props: {
'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)
const tokenScopesMatch = authResult.stdout.match(/Token scopes:.*$/m);
if (tokenScopesMatch) {
const scopes = tokenScopesMatch[0]
const missingScopes: string[] = []
const scopes = tokenScopesMatch[0];
const missingScopes: string[] = [];
if (!scopes.includes('repo')) {
missingScopes.push('repo')
missingScopes.push('repo');
}
if (!scopes.includes('workflow')) {
missingScopes.push('workflow')
missingScopes.push('workflow');
}
if (missingScopes.length > 0) {
@@ -131,18 +127,18 @@ function InstallGitHubApp(props: {
'',
'This will add the necessary permissions to manage workflows and secrets.',
],
}))
return
}));
return;
}
}
}
// Check if in a git repo and get remote URL
const currentRepo = (await getGithubRepo()) ?? ''
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,
@@ -151,14 +147,14 @@ function InstallGitHubApp(props: {
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()
void checkGitHubCLI();
}
}, [state.step, checkGitHubCLI])
}, [state.step, checkGitHubCLI]);
const runSetupGitHubActions = useCallback(
async (apiKeyOrOAuthToken: string | null, secretName: string) => {
@@ -166,7 +162,7 @@ function InstallGitHubApp(props: {
...prev,
step: 'creating',
currentWorkflowInstallStep: 0,
}))
}));
try {
await setupGitHubActions(
@@ -177,7 +173,7 @@ function InstallGitHubApp(props: {
setState(prev => ({
...prev,
currentWorkflowInstallStep: prev.currentWorkflowInstallStep + 1,
}))
}));
},
state.workflowAction === 'skip',
state.selectedWorkflows,
@@ -187,22 +183,18 @@ function InstallGitHubApp(props: {
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' }))
});
setState(prev => ({ ...prev, step: 'success' }));
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: 'Failed to set up GitHub Actions'
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,
})
reason: 'workflow_file_exists' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
setState(prev => ({
...prev,
step: 'error',
@@ -215,12 +207,11 @@ function InstallGitHubApp(props: {
' 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,
})
reason: 'setup_github_actions_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
setState(prev => ({
...prev,
@@ -228,7 +219,7 @@ function InstallGitHubApp(props: {
error: errorMessage,
errorReason: 'GitHub Actions setup failed',
errorInstructions: [],
}))
}));
}
}
},
@@ -241,42 +232,32 @@ function InstallGitHubApp(props: {
state.secretExists,
state.authType,
],
)
);
async function openGitHubAppInstallation() {
const installUrl = 'https://github.com/apps/claude'
await openBrowser(installUrl)
const installUrl = 'https://github.com/apps/claude';
await openBrowser(installUrl);
}
async function checkRepositoryPermissions(
repoName: string,
): Promise<{ hasAccess: boolean; error?: string }> {
async function checkRepositoryPermissions(repoName: string): Promise<{ hasAccess: boolean; error?: string }> {
try {
const result = await execFileNoThrow('gh', [
'api',
`repos/${repoName}`,
'--jq',
'.permissions.admin',
])
const result = await execFileNoThrow('gh', ['api', `repos/${repoName}`, '--jq', '.permissions.admin']);
if (result.code === 0) {
const hasAdmin = result.stdout.trim() === 'true'
return { hasAccess: hasAdmin }
const hasAdmin = result.stdout.trim() === 'true';
return { hasAccess: hasAdmin };
}
if (
result.stderr.includes('404') ||
result.stderr.includes('Not Found')
) {
if (result.stderr.includes('404') || result.stderr.includes('Not Found')) {
return {
hasAccess: false,
error: 'repository_not_found',
}
};
}
return { hasAccess: false }
return { hasAccess: false };
} catch {
return { hasAccess: false }
return { hasAccess: false };
}
}
@@ -286,9 +267,9 @@ function InstallGitHubApp(props: {
`repos/${repoName}/contents/.github/workflows/claude.yml`,
'--jq',
'.sha',
])
]);
return checkFileResult.code === 0
return checkFileResult.code === 0;
}
async function checkExistingSecret() {
@@ -299,20 +280,20 @@ function InstallGitHubApp(props: {
'actions',
'--repo',
state.selectedRepoName,
])
]);
if (checkSecretsResult.code === 0) {
const lines = checkSecretsResult.stdout.split('\n')
const lines = checkSecretsResult.stdout.split('\n');
const hasAnthropicKey = lines.some((line: string) => {
return /^ANTHROPIC_API_KEY\s+/.test(line)
})
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) {
@@ -321,11 +302,11 @@ function InstallGitHubApp(props: {
...prev,
apiKeyOrOAuthToken: existingApiKey,
useExistingKey: true,
}))
await runSetupGitHubActions(existingApiKey, state.secretName)
}));
await runSetupGitHubActions(existingApiKey, state.secretName);
} else {
// No local key, go to API key step
setState(prev => ({ ...prev, step: 'api-key' }))
setState(prev => ({ ...prev, step: 'api-key' }));
}
}
} else {
@@ -336,11 +317,11 @@ function InstallGitHubApp(props: {
...prev,
apiKeyOrOAuthToken: existingApiKey,
useExistingKey: true,
}))
await runSetupGitHubActions(existingApiKey, state.secretName)
}));
await runSetupGitHubActions(existingApiKey, state.secretName);
} else {
// No local key, go to API key step
setState(prev => ({ ...prev, step: 'api-key' }))
setState(prev => ({ ...prev, step: 'api-key' }));
}
}
}
@@ -349,33 +330,28 @@ function InstallGitHubApp(props: {
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)
});
setState(prev => ({ ...prev, step: 'install-app' }));
setTimeout(openGitHubAppInstallation, 0);
} else if (state.step === 'choose-repo') {
let repoName = state.useCurrentRepo
? state.currentRepo
: state.selectedRepoName
let repoName = state.useCurrentRepo ? state.currentRepo : state.selectedRepoName;
if (!repoName.trim()) {
return
return;
}
const repoWarnings: Warning[] = []
const repoWarnings: Warning[] = [];
if (repoName.includes('github.com')) {
const match = repoName.match(/github\.com[:/]([^/]+\/[^/]+)(\.git)?$/)
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',
],
})
instructions: ['Use format: owner/repo or https://github.com/owner/repo', 'Example: anthropics/claude-cli'],
});
} else {
repoName = match[1]?.replace(/\.git$/, '') || ''
repoName = match[1]?.replace(/\.git$/, '') || '';
}
}
@@ -383,14 +359,11 @@ function InstallGitHubApp(props: {
repoWarnings.push({
title: 'Repository format warning',
message: 'Repository should be in format "owner/repo"',
instructions: [
'Use format: owner/repo',
'Example: anthropics/claude-cli',
],
})
instructions: ['Use format: owner/repo', 'Example: anthropics/claude-cli'],
});
}
const permissionCheck = await checkRepositoryPermissions(repoName)
const permissionCheck = await checkRepositoryPermissions(repoName);
if (permissionCheck.error === 'repository_not_found') {
repoWarnings.push({
@@ -402,7 +375,7 @@ function InstallGitHubApp(props: {
'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',
@@ -412,81 +385,77 @@ function InstallGitHubApp(props: {
'Ask a repository admin to run this command if setup fails',
'Alternatively, you can use the manual setup instructions',
],
})
});
}
const workflowExists = await checkExistingWorkflowFile(repoName)
const workflowExists = await checkExistingWorkflowFile(repoName);
if (repoWarnings.length > 0) {
const allWarnings = [...state.warnings, ...repoWarnings]
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)
}));
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' }))
setState(prev => ({ ...prev, step: 'check-existing-workflow' }));
} else {
setState(prev => ({ ...prev, step: 'select-workflows' }))
setState(prev => ({ ...prev, step: 'select-workflows' }));
}
} else if (state.step === 'check-existing-workflow') {
return
return;
} else if (state.step === 'select-workflows') {
// Handled by the WorkflowMultiselectDialog component
return
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)
await runSetupGitHubActions(null, state.secretName);
} else {
// User wants to use a new secret name with their API key
await runSetupGitHubActions(state.apiKeyOrOAuthToken, state.secretName)
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
return;
}
// If user selected 'existing' option, use the existing API key
const apiKeyToUse =
state.selectedApiKeyOption === 'existing'
? existingApiKey
: state.apiKeyOrOAuthToken
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,
})
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
}));
return;
}
// Store the API key being used (either existing or newly entered)
@@ -494,7 +463,7 @@ function InstallGitHubApp(props: {
...prev,
apiKeyOrOAuthToken: apiKeyToUse,
useExistingKey: state.selectedApiKeyOption === 'existing',
}))
}));
// Check if ANTHROPIC_API_KEY secret already exists
const checkSecretsResult = await execFileNoThrow('gh', [
@@ -504,132 +473,132 @@ function InstallGitHubApp(props: {
'actions',
'--repo',
state.selectedRepoName,
])
]);
if (checkSecretsResult.code === 0) {
const lines = checkSecretsResult.stdout.split('\n')
const lines = checkSecretsResult.stdout.split('\n');
const hasAnthropicKey = lines.some((line: string) => {
return /^ANTHROPIC_API_KEY\s+/.test(line)
})
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)
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)
await runSetupGitHubActions(apiKeyToUse, state.secretName);
}
}
}
};
const handleRepoUrlChange = (value: string) => {
setState(prev => ({ ...prev, selectedRepoName: value }))
}
setState(prev => ({ ...prev, selectedRepoName: value }));
};
const handleApiKeyChange = (value: string) => {
setState(prev => ({ ...prev, apiKeyOrOAuthToken: value }))
}
setState(prev => ({ ...prev, apiKeyOrOAuthToken: value }));
};
const handleApiKeyOptionChange = (option: 'existing' | 'new' | 'oauth') => {
setState(prev => ({ ...prev, selectedApiKeyOption: option }))
}
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' }))
}, [])
});
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')
}));
void runSetupGitHubActions(token, 'CLAUDE_CODE_OAUTH_TOKEN');
},
[runSetupGitHubActions],
)
);
const handleOAuthCancel = useCallback(() => {
setState(prev => ({ ...prev, step: 'api-key' }))
}, [])
setState(prev => ({ ...prev, step: 'api-key' }));
}, []);
const handleSecretNameChange = (value: string) => {
if (value && !/^[a-zA-Z0-9_]+$/.test(value)) return
setState(prev => ({ ...prev, secretName: value }))
}
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 }))
}
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
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 }))
setState(prev => ({ ...prev, workflowAction: action }));
if (action === 'skip' || action === 'update') {
// Check if user has existing local API key
if (existingApiKey) {
await checkExistingSecret()
await checkExistingSecret();
} else {
// No local key, go straight to API key step
setState(prev => ({ ...prev, step: 'api-key' }))
setState(prev => ({ ...prev, step: 'api-key' }));
}
}
}
};
function handleDismissKeyDown(e: KeyboardEvent): void {
e.preventDefault()
e.preventDefault();
if (state.step === 'success') {
logEvent('tengu_install_github_app_completed', {})
logEvent('tengu_install_github_app_completed', {});
}
props.onDone(
state.step === 'success'
@@ -637,16 +606,14 @@ function InstallGitHubApp(props: {
: 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 <CheckGitHubStep />
return <CheckGitHubStep />;
case 'warnings':
return (
<WarningsStep warnings={state.warnings} onContinue={handleSubmit} />
)
return <WarningsStep warnings={state.warnings} onContinue={handleSubmit} />;
case 'choose-repo':
return (
<ChooseRepoStep
@@ -657,21 +624,11 @@ function InstallGitHubApp(props: {
onToggleUseCurrentRepo={handleToggleUseCurrentRepo}
onSubmit={handleSubmit}
/>
)
);
case 'install-app':
return (
<InstallAppStep
repoUrl={state.selectedRepoName}
onSubmit={handleSubmit}
/>
)
return <InstallAppStep repoUrl={state.selectedRepoName} onSubmit={handleSubmit} />;
case 'check-existing-workflow':
return (
<ExistingWorkflowStep
repoName={state.selectedRepoName}
onSelectAction={handleWorkflowAction}
/>
)
return <ExistingWorkflowStep repoName={state.selectedRepoName} onSelectAction={handleWorkflowAction} />;
case 'check-existing-secret':
return (
<CheckExistingSecretStep
@@ -681,7 +638,7 @@ function InstallGitHubApp(props: {
onSecretNameChange={handleSecretNameChange}
onSubmit={handleSubmit}
/>
)
);
case 'api-key':
return (
<ApiKeyStep
@@ -691,13 +648,11 @@ function InstallGitHubApp(props: {
onApiKeyChange={handleApiKeyChange}
onToggleUseExistingKey={handleToggleUseExistingKey}
onSubmit={handleSubmit}
onCreateOAuthToken={
isAnthropicAuthEnabled() ? handleCreateOAuthToken : undefined
}
onCreateOAuthToken={isAnthropicAuthEnabled() ? handleCreateOAuthToken : undefined}
selectedOption={state.selectedApiKeyOption}
onSelectOption={handleApiKeyOptionChange}
/>
)
);
case 'creating':
return (
<CreatingStep
@@ -708,7 +663,7 @@ function InstallGitHubApp(props: {
skipWorkflow={state.workflowAction === 'skip'}
selectedWorkflows={state.selectedWorkflows}
/>
)
);
case 'success':
return (
<Box tabIndex={0} autoFocus onKeyDown={handleDismissKeyDown}>
@@ -719,17 +674,13 @@ function InstallGitHubApp(props: {
skipWorkflow={state.workflowAction === 'skip'}
/>
</Box>
)
);
case 'error':
return (
<Box tabIndex={0} autoFocus onKeyDown={handleDismissKeyDown}>
<ErrorStep
error={state.error}
errorReason={state.errorReason}
errorInstructions={state.errorInstructions}
/>
<ErrorStep error={state.error} errorReason={state.errorReason} errorInstructions={state.errorInstructions} />
</Box>
)
);
case 'select-workflows':
return (
<WorkflowMultiselectDialog
@@ -737,33 +688,26 @@ function InstallGitHubApp(props: {
onSubmit={selectedWorkflows => {
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()
void checkExistingSecret();
} else {
// No local key, go straight to API key step
setState(prev => ({ ...prev, step: 'api-key' }))
setState(prev => ({ ...prev, step: 'api-key' }));
}
}}
/>
)
);
case 'oauth-flow':
return (
<OAuthFlowStep
onSuccess={handleOAuthSuccess}
onCancel={handleOAuthCancel}
/>
)
return <OAuthFlowStep onSuccess={handleOAuthSuccess} onCancel={handleOAuthCancel} />;
}
}
export async function call(
onDone: LocalJSXCommandOnDone,
): Promise<React.ReactNode> {
return <InstallGitHubApp onDone={onDone} />
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> {
return <InstallGitHubApp onDone={onDone} />;
}