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

@@ -41,7 +41,11 @@ import { type Tools, type ToolUseContext, toolMatchesName } from './Tool.js'
import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
import { SYNTHETIC_OUTPUT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SyntheticOutputTool/SyntheticOutputTool.js'
import type { APIError } from '@anthropic-ai/sdk'
import type { CompactMetadata, Message, SystemCompactBoundaryMessage } from './types/message.js'
import type {
CompactMetadata,
Message,
SystemCompactBoundaryMessage,
} from './types/message.js'
import type { OrphanedPermission } from './types/textInputTypes.js'
import { createAbortController } from './utils/abortController.js'
import type { AttributionState } from './utils/commitAttribution.js'
@@ -86,7 +90,9 @@ import {
// Lazy: MessageSelector.tsx pulls React/ink; only needed for message filtering at query time
/* eslint-disable @typescript-eslint/no-require-imports */
const messageSelector = (): typeof import('src/components/MessageSelector.js') | null => {
const messageSelector = ():
| typeof import('src/components/MessageSelector.js')
| null => {
try {
return require('src/components/MessageSelector.js')
} catch {
@@ -649,20 +655,19 @@ export class QueryEngine {
if (fileHistoryEnabled() && persistSession) {
const _sel = messageSelector()
const _filter = _sel?.selectableUserMessagesFilter ?? ((_msg: unknown) => true)
messagesFromUserInput
.filter(_filter)
.forEach(message => {
void fileHistoryMakeSnapshot(
(updater: (prev: FileHistoryState) => FileHistoryState) => {
setAppState(prev => ({
...prev,
fileHistory: updater(prev.fileHistory),
}))
},
message.uuid,
)
})
const _filter =
_sel?.selectableUserMessagesFilter ?? ((_msg: unknown) => true)
messagesFromUserInput.filter(_filter).forEach(message => {
void fileHistoryMakeSnapshot(
(updater: (prev: FileHistoryState) => FileHistoryState) => {
setAppState(prev => ({
...prev,
fileHistory: updater(prev.fileHistory),
}))
},
message.uuid,
)
})
}
// Track current message usage (reset on each message_start)
@@ -715,7 +720,8 @@ export class QueryEngine {
message.subtype === 'compact_boundary'
) {
const compactMsg = message as SystemCompactBoundaryMessage
const tailUuid = compactMsg.compactMetadata?.preservedSegment?.tailUuid
const tailUuid =
compactMsg.compactMetadata?.preservedSegment?.tailUuid
if (tailUuid) {
const tailIdx = this.mutableMessages.findLastIndex(
m => m.uuid === tailUuid,
@@ -775,7 +781,10 @@ export class QueryEngine {
// streamed responses, this is null at content_block_stop time;
// the real value arrives via message_delta (handled below).
const msg = message as Message
const stopReason = msg.message?.stop_reason as string | null | undefined
const stopReason = msg.message?.stop_reason as
| string
| null
| undefined
if (stopReason != null) {
lastStopReason = stopReason
}
@@ -805,11 +814,15 @@ export class QueryEngine {
break
}
case 'stream_event': {
const event = (message as unknown as { event: Record<string, unknown> }).event
const event = (
message as unknown as { event: Record<string, unknown> }
).event
if (event.type === 'message_start') {
// Reset current message usage for new message
currentMessageUsage = EMPTY_USAGE
const eventMessage = event.message as { usage: BetaMessageDeltaUsage }
const eventMessage = event.message as {
usage: BetaMessageDeltaUsage
}
currentMessageUsage = updateUsage(
currentMessageUsage,
eventMessage.usage,
@@ -858,7 +871,15 @@ export class QueryEngine {
void recordTranscript(messages)
}
const attachment = msg.attachment as { type: string; data?: unknown; turnCount?: number; maxTurns?: number; prompt?: string; source_uuid?: string; [key: string]: unknown }
const attachment = msg.attachment as {
type: string
data?: unknown
turnCount?: number
maxTurns?: number
prompt?: string
source_uuid?: string
[key: string]: unknown
}
// Extract structured output from StructuredOutput tool calls
if (attachment.type === 'structured_output') {
@@ -899,10 +920,7 @@ export class QueryEngine {
return
}
// Yield queued_command attachments as SDK user message replays
else if (
replayUserMessages &&
attachment.type === 'queued_command'
) {
else if (replayUserMessages && attachment.type === 'queued_command') {
yield {
type: 'user',
message: {
@@ -930,10 +948,7 @@ export class QueryEngine {
// never shrinks (memory leak in long SDK sessions). The subtype
// check lives inside the injected callback so feature-gated strings
// stay out of this file (excluded-strings check).
const snipResult = this.config.snipReplay?.(
msg,
this.mutableMessages,
)
const snipResult = this.config.snipReplay?.(msg, this.mutableMessages)
if (snipResult !== undefined) {
if (snipResult.executed) {
this.mutableMessages.length = 0
@@ -943,10 +958,7 @@ export class QueryEngine {
}
this.mutableMessages.push(msg)
// Yield compact boundary messages to SDK
if (
msg.subtype === 'compact_boundary' &&
msg.compactMetadata
) {
if (msg.subtype === 'compact_boundary' && msg.compactMetadata) {
const compactMsg = msg as SystemCompactBoundaryMessage
// Release pre-compaction messages for GC. The boundary was just
// pushed so it's the last element. query.ts already uses
@@ -966,11 +978,18 @@ export class QueryEngine {
subtype: 'compact_boundary' as const,
session_id: getSessionId(),
uuid: msg.uuid,
compact_metadata: toSDKCompactMetadata(compactMsg.compactMetadata),
compact_metadata: toSDKCompactMetadata(
compactMsg.compactMetadata,
),
}
}
if (msg.subtype === 'api_error') {
const apiErrorMsg = msg as Message & { retryAttempt: number; maxRetries: number; retryInMs: number; error: APIError }
const apiErrorMsg = msg as Message & {
retryAttempt: number
maxRetries: number
retryInMs: number
error: APIError
}
yield {
type: 'system',
subtype: 'api_retry' as const,
@@ -987,7 +1006,10 @@ export class QueryEngine {
break
}
case 'tool_use_summary': {
const msg = message as Message & { summary: unknown; precedingToolUseIds: unknown }
const msg = message as Message & {
summary: unknown
precedingToolUseIds: unknown
}
// Yield tool use summary messages to SDK
yield {
type: 'tool_use_summary' as const,
@@ -1096,7 +1118,10 @@ export class QueryEngine {
const edeResultType = result?.type ?? 'undefined'
const edeLastContentType =
result?.type === 'assistant'
? (last(result.message!.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[])?.type ?? 'none')
? (last(
result.message!
.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[],
)?.type ?? 'none')
: 'n/a'
// Flush buffered transcript writes before yielding result.
@@ -1154,7 +1179,10 @@ export class QueryEngine {
let isApiError = false
if (result.type === 'assistant') {
const lastContent = last(result.message!.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[])
const lastContent = last(
result.message!
.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[],
)
if (
lastContent?.type === 'text' &&
!SYNTHETIC_MESSAGES.has(lastContent.text)

View File

@@ -10,7 +10,11 @@ import {
setSystemPromptInjection,
} from '../context'
import { clearMemoryFileCaches } from '../utils/claudemd'
import { cleanupTempDir, createTempDir, writeTempFile } from '../../tests/mocks/file-system'
import {
cleanupTempDir,
createTempDir,
writeTempFile,
} from '../../tests/mocks/file-system'
let tempDir = ''
let projectClaudeMdContent = ''

View File

@@ -1740,4 +1740,6 @@ export function getPromptId(): string | null {
export function setPromptId(id: string | null): void {
STATE.promptId = id
}
export function isReplBridgeActive(): boolean { return false; }
export function isReplBridgeActive(): boolean {
return false
}

View File

@@ -225,7 +225,9 @@ export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
)
handleErrorStatus(response.status, response.data, 'Poll')
rcLog(`poll response: status=${response.status} hasData=${!!response.data} url=${deps.baseUrl}`)
rcLog(
`poll response: status=${response.status} hasData=${!!response.data} url=${deps.baseUrl}`,
)
// Empty body or null = no work available
if (!response.data) {

View File

@@ -447,9 +447,11 @@ export async function runBridgeLoop(
): (status: SessionDoneStatus) => void {
return (rawStatus: SessionDoneStatus): void => {
const workId = sessionWorkIds.get(sessionId)
rcLog(`session done: sessionId=${sessionId} workId=${workId ?? 'none'} status=${rawStatus}` +
` wasTimedOut=${timedOutSessions.has(sessionId)} duration=${Math.round((Date.now() - startTime) / 1000)}s` +
` stderr=${handle.lastStderr.length > 0 ? handle.lastStderr.join('\\n').slice(0, 500) : '(none)'}`)
rcLog(
`session done: sessionId=${sessionId} workId=${workId ?? 'none'} status=${rawStatus}` +
` wasTimedOut=${timedOutSessions.has(sessionId)} duration=${Math.round((Date.now() - startTime) / 1000)}s` +
` stderr=${handle.lastStderr.length > 0 ? handle.lastStderr.join('\\n').slice(0, 500) : '(none)'}`,
)
activeSessions.delete(sessionId)
sessionStartTimes.delete(sessionId)
sessionWorkIds.delete(sessionId)
@@ -608,7 +610,9 @@ export async function runBridgeLoop(
const pollConfig = getPollIntervalConfig()
try {
rcLog(`poll: envId=${environmentId} activeSessions=${activeSessions.size}`)
rcLog(
`poll: envId=${environmentId} activeSessions=${activeSessions.size}`,
)
const work = await api.pollForWork(
environmentId,
environmentSecret,
@@ -863,7 +867,9 @@ export async function runBridgeLoop(
break
case 'session': {
const sessionId = work.data.id
rcLog(`work received: type=session sessionId=${sessionId} workId=${work.id}`)
rcLog(
`work received: type=session sessionId=${sessionId} workId=${work.id}`,
)
try {
validateBridgeId(sessionId, 'session_id')
} catch {
@@ -1031,9 +1037,9 @@ export async function runBridgeLoop(
rcLog(
`spawning session: sessionId=${sessionId} sdkUrl=${sdkUrl}` +
` useCcrV2=${useCcrV2} workerEpoch=${workerEpoch}` +
` dir=${sessionDir}` +
` accessToken=${secret.session_ingress_token ? secret.session_ingress_token.slice(0, 8) + '...' : 'NONE'}`,
` useCcrV2=${useCcrV2} workerEpoch=${workerEpoch}` +
` dir=${sessionDir}` +
` accessToken=${secret.session_ingress_token ? secret.session_ingress_token.slice(0, 8) + '...' : 'NONE'}`,
)
const spawnResult = safeSpawn(
spawner,
@@ -1280,8 +1286,8 @@ export async function runBridgeLoop(
const errMsg = describeAxiosError(err)
rcLog(
`poll error: ${errMsg}` +
` isConn=${isConnectionError(err)} isServer=${isServerError(err)}` +
` activeSessions=${activeSessions.size}`,
` isConn=${isConnectionError(err)} isServer=${isServerError(err)}` +
` activeSessions=${activeSessions.size}`,
)
if (isConnectionError(err) || isServerError(err)) {

View File

@@ -116,7 +116,8 @@ export function isEligibleBridgeMessage(m: Message): boolean {
export function extractTitleText(m: Message): string | undefined {
if (m.type !== 'user' || m.isMeta || m.toolUseResult || m.isCompactSummary)
return undefined
if (m.origin && (m.origin as { kind?: string }).kind !== 'human') return undefined
if (m.origin && (m.origin as { kind?: string }).kind !== 'human')
return undefined
const content = m.message!.content
let raw: string | undefined
if (typeof content === 'string') {
@@ -135,8 +136,7 @@ export function extractTitleText(m: Message): string | undefined {
}
const SYSTEM_REMINDER_TAG = 'system-reminder'
const XML_BLOCK_PATTERN =
/\s*<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/gy
const XML_BLOCK_PATTERN = /\s*<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/gy
const RUNNING_STATE_META_TAGS = new Set([
BASH_INPUT_TAG,
CHANNEL_MESSAGE_TAG,
@@ -357,7 +357,13 @@ export function handleServerControlRequest(
// Outbound-only: reply error for mutable requests so claude.ai doesn't show
// false success. initialize must still succeed (server kills the connection
// if it doesn't — see comment above).
const req = request.request as { subtype: string; model?: string; max_thinking_tokens?: number | null; mode?: string; [key: string]: unknown }
const req = request.request as {
subtype: string
model?: string
max_thinking_tokens?: number | null
mode?: string
[key: string]: unknown
}
if (outboundOnly && req.subtype !== 'initialize') {
response = {
type: 'control_response',
@@ -480,8 +486,8 @@ export function handleServerControlRequest(
void transport.write(event)
rcLog(
`control_response: subtype=${req.subtype}` +
` request_id=${request.request_id}` +
` result=${(response.response as { subtype?: string }).subtype}`,
` request_id=${request.request_id}` +
` result=${(response.response as { subtype?: string }).subtype}`,
)
logForDebugging(
`[bridge:repl] Sent control_response for ${req.subtype} request_id=${request.request_id} result=${(response.response as { subtype?: string }).subtype}`,

View File

@@ -24,7 +24,9 @@ export function extractInboundMessageFields(
| { content: string | Array<ContentBlockParam>; uuid: UUID | undefined }
| undefined {
if (msg.type !== 'user') return undefined
const content = (msg.message as { content?: string | Array<ContentBlockParam> } | undefined)?.content
const content = (
msg.message as { content?: string | Array<ContentBlockParam> } | undefined
)?.content
if (!content) return undefined
if (Array.isArray(content) && content.length === 0) return undefined

View File

@@ -290,7 +290,9 @@ export async function initReplBridge(
isSyntheticMessage(msg)
)
continue
const rawContent = getContentText(msg.message!.content as string | ContentBlockParam[])
const rawContent = getContentText(
msg.message!.content as string | ContentBlockParam[],
)
if (!rawContent) continue
const derived = deriveTitle(rawContent)
if (!derived) continue

View File

@@ -20,7 +20,10 @@ export function rcLog(msg: string): void {
try {
if (!headerWritten) {
ensureLogDir()
appendFileSync(LOG_PATH, `\n===== RC-DEBUG session ${new Date().toISOString()} =====\n`)
appendFileSync(
LOG_PATH,
`\n===== RC-DEBUG session ${new Date().toISOString()} =====\n`,
)
headerWritten = true
}
const ts = new Date().toISOString().slice(11, 23) // HH:mm:ss.SSS

View File

@@ -850,7 +850,10 @@ export async function initEnvLessBridgeCore(
for (const msg of filtered) {
if (msg.uuid) recentPostedUUIDs.add(msg.uuid as string)
}
const events = filtered.map(m => ({ ...m, session_id: sessionId })) as StdoutMessage[]
const events = filtered.map(m => ({
...m,
session_id: sessionId,
})) as StdoutMessage[]
void transport.writeBatch(events)
},
sendControlRequest(request: SDKControlRequest) {
@@ -860,8 +863,14 @@ export async function initEnvLessBridgeCore(
)
return
}
const event: TransportMessage = { ...request, session_id: sessionId } as TransportMessage
if ((request as { request?: { subtype?: string } }).request?.subtype === 'can_use_tool') {
const event: TransportMessage = {
...request,
session_id: sessionId,
} as TransportMessage
if (
(request as { request?: { subtype?: string } }).request?.subtype ===
'can_use_tool'
) {
transport.reportState('requires_action')
}
void transport.write(event as StdoutMessage)
@@ -876,7 +885,10 @@ export async function initEnvLessBridgeCore(
)
return
}
const event: TransportMessage = { ...response, session_id: sessionId } as TransportMessage
const event: TransportMessage = {
...response,
session_id: sessionId,
} as TransportMessage
transport.reportState('running')
void transport.write(event as StdoutMessage)
logForDebugging('[remote-bridge] Sent control_response')

View File

@@ -454,7 +454,6 @@ export async function initBridgeCore(
// re-created after a connection loss.
let currentSessionId: string
if (reusedPriorSession && prior) {
currentSessionId = prior.sessionId
logForDebugging(
@@ -645,9 +644,9 @@ export async function initBridgeCore(
environmentRecreations++
rcLog(
`doReconnect: attempt=${environmentRecreations}/${MAX_ENVIRONMENT_RECREATIONS}` +
` envId=${environmentId}` +
` sessionId=${currentSessionId}` +
` workId=${currentWorkId}`,
` envId=${environmentId}` +
` sessionId=${currentSessionId}` +
` workId=${currentWorkId}`,
)
// Invalidate any in-flight v2 handshake — the environment is being
// recreated, so a stale transport arriving post-reconnect would be
@@ -859,7 +858,6 @@ export async function initBridgeCore(
// UUIDs are scoped per-session on the server, so re-flushing is safe.
previouslyFlushedUUIDs?.clear()
// Reset the counter so independent reconnections hours apart don't
// exhaust the limit — it guards against rapid consecutive failures,
// not lifetime total.
@@ -920,8 +918,8 @@ export async function initBridgeCore(
function handleTransportPermanentClose(closeCode: number | undefined): void {
rcLog(
`handleTransportPermanentClose: code=${closeCode}` +
` transport=${transport ? 'exists' : 'null'}` +
` pollAborted=${pollController.signal.aborted}`,
` transport=${transport ? 'exists' : 'null'}` +
` pollAborted=${pollController.signal.aborted}`,
)
logForDebugging(
`[bridge:repl] Transport permanently closed: code=${closeCode}`,
@@ -1316,7 +1314,9 @@ export async function initBridgeCore(
session_id: currentSessionId,
})) as TransportMessage[]
const dropsBefore = newTransport.droppedBatchCount
void newTransport.writeBatch(events as StdoutMessage[]).then(() => {
void newTransport
.writeBatch(events as StdoutMessage[])
.then(() => {
// If any batch was dropped during this flush (SI down for
// maxConsecutiveFailures attempts), flush() still resolved
// normally but the events were NOT delivered. Don't mark
@@ -1370,10 +1370,10 @@ export async function initBridgeCore(
const parsed = JSON.parse(data)
rcLog(
`ingress: type=${parsed.type}` +
`${parsed.type === 'control_request' ? ` subtype=${(parsed.request as Record<string, unknown>)?.subtype} request_id=${parsed.request_id}` : ''}` +
`${parsed.type === 'control_response' ? ` subtype=${(parsed.response as Record<string, unknown>)?.subtype} request_id=${(parsed.response as Record<string, unknown>)?.request_id}` : ''}` +
`${parsed.type === 'user' ? ` uuid=${parsed.uuid}` : ''}` +
`${parsed.type === 'keep_alive' ? '' : ` len=${data.length}`}`,
`${parsed.type === 'control_request' ? ` subtype=${(parsed.request as Record<string, unknown>)?.subtype} request_id=${parsed.request_id}` : ''}` +
`${parsed.type === 'control_response' ? ` subtype=${(parsed.response as Record<string, unknown>)?.subtype} request_id=${(parsed.response as Record<string, unknown>)?.request_id}` : ''}` +
`${parsed.type === 'user' ? ` uuid=${parsed.uuid}` : ''}` +
`${parsed.type === 'keep_alive' ? '' : ` len=${data.length}`}`,
)
} catch {
rcLog(`ingress (non-JSON): ${String(data).slice(0, 200)}`)
@@ -1400,9 +1400,9 @@ export async function initBridgeCore(
if (transport !== newTransport) return
rcLog(
`transport onClose: code=${closeCode}` +
` connected=${newTransport.isConnectedStatus()}` +
` state=${newTransport.getStateLabel()}` +
` seq=${newTransport.getLastSequenceNum()}`,
` connected=${newTransport.isConnectedStatus()}` +
` state=${newTransport.getStateLabel()}` +
` seq=${newTransport.getLastSequenceNum()}`,
)
handleTransportPermanentClose(closeCode)
})
@@ -1831,7 +1831,10 @@ export async function initBridgeCore(
for (const msg of filtered) {
if (msg.uuid) recentPostedUUIDs.add(msg.uuid as string)
}
const events: TransportMessage[] = filtered.map(m => ({ ...m, session_id: currentSessionId })) as TransportMessage[]
const events: TransportMessage[] = filtered.map(m => ({
...m,
session_id: currentSessionId,
})) as TransportMessage[]
void transport.writeBatch(events as StdoutMessage[])
},
sendControlRequest(request: SDKControlRequest) {
@@ -1841,7 +1844,10 @@ export async function initBridgeCore(
)
return
}
const event: TransportMessage = { ...request, session_id: currentSessionId } as TransportMessage
const event: TransportMessage = {
...request,
session_id: currentSessionId,
} as TransportMessage
void transport.write(event as StdoutMessage)
logForDebugging(
`[bridge:repl] Sent control_request request_id=${request.request_id}`,
@@ -1854,7 +1860,10 @@ export async function initBridgeCore(
)
return
}
const event: TransportMessage = { ...response, session_id: currentSessionId } as TransportMessage
const event: TransportMessage = {
...response,
session_id: currentSessionId,
} as TransportMessage
void transport.write(event as StdoutMessage)
logForDebugging('[bridge:repl] Sent control_response')
},

View File

@@ -11,21 +11,44 @@
/** Patterns that match known secret/token formats. */
const SECRET_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
// GitHub tokens (PAT, OAuth, App, Server-to-server)
{ pattern: /\b(ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{10,}\b/g, replacement: '[REDACTED_GITHUB_TOKEN]' },
{
pattern: /\b(ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{10,}\b/g,
replacement: '[REDACTED_GITHUB_TOKEN]',
},
// Anthropic API keys
{ pattern: /\bsk-ant-[A-Za-z0-9_-]{10,}\b/g, replacement: '[REDACTED_ANTHROPIC_KEY]' },
{
pattern: /\bsk-ant-[A-Za-z0-9_-]{10,}\b/g,
replacement: '[REDACTED_ANTHROPIC_KEY]',
},
// Generic Bearer tokens in headers
{ pattern: /(Bearer\s+)[A-Za-z0-9._\-/+=]{20,}/gi, replacement: '$1[REDACTED_TOKEN]' },
{
pattern: /(Bearer\s+)[A-Za-z0-9._\-/+=]{20,}/gi,
replacement: '$1[REDACTED_TOKEN]',
},
// AWS access keys
{ pattern: /\b(AKIA|ASIA)[A-Z0-9]{16}\b/g, replacement: '[REDACTED_AWS_KEY]' },
{
pattern: /\b(AKIA|ASIA)[A-Z0-9]{16}\b/g,
replacement: '[REDACTED_AWS_KEY]',
},
// AWS secret keys (40-char base64-like strings after common labels)
{ pattern: /(aws_secret_access_key|secret_key|SecretAccessKey)['":\s=]+[A-Za-z0-9/+=]{30,}/gi, replacement: '$1=[REDACTED_AWS_SECRET]' },
{
pattern:
/(aws_secret_access_key|secret_key|SecretAccessKey)['":\s=]+[A-Za-z0-9/+=]{30,}/gi,
replacement: '$1=[REDACTED_AWS_SECRET]',
},
// Generic API key patterns (key=value or "key": "value")
{ pattern: /(api[_-]?key|apikey|secret|password|token|credential)['":\s=]+["']?[A-Za-z0-9._\-/+=]{16,}["']?/gi, replacement: '$1=[REDACTED]' },
{
pattern:
/(api[_-]?key|apikey|secret|password|token|credential)['":\s=]+["']?[A-Za-z0-9._\-/+=]{16,}["']?/gi,
replacement: '$1=[REDACTED]',
},
// npm tokens
{ pattern: /\bnpm_[A-Za-z0-9]{36}\b/g, replacement: '[REDACTED_NPM_TOKEN]' },
// Slack tokens
{ pattern: /\bxox[bporas]-[A-Za-z0-9-]{10,}\b/g, replacement: '[REDACTED_SLACK_TOKEN]' },
{
pattern: /\bxox[bporas]-[A-Za-z0-9-]{10,}\b/g,
replacement: '[REDACTED_SLACK_TOKEN]',
},
]
/** Maximum content length before truncation (100KB). */

View File

@@ -1,50 +1,50 @@
import { feature } from 'bun:bundle'
import figures from 'figures'
import React, { useEffect, useRef, useState } from 'react'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { Box, Text, stringWidth } from '@anthropic/ink'
import { useAppState, useSetAppState } from '../state/AppState.js'
import type { AppState } from '../state/AppStateStore.js'
import { getGlobalConfig } from '../utils/config.js'
import { isFullscreenActive } from '../utils/fullscreen.js'
import type { Theme } from '../utils/theme.js'
import { getCompanion } from './companion.js'
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js'
import { RARITY_COLORS } from './types.js'
import { feature } from 'bun:bundle';
import figures from 'figures';
import React, { useEffect, useRef, useState } from 'react';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { Box, Text, stringWidth } from '@anthropic/ink';
import { useAppState, useSetAppState } from '../state/AppState.js';
import type { AppState } from '../state/AppStateStore.js';
import { getGlobalConfig } from '../utils/config.js';
import { isFullscreenActive } from '../utils/fullscreen.js';
import type { Theme } from '../utils/theme.js';
import { getCompanion } from './companion.js';
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js';
import { RARITY_COLORS } from './types.js';
const TICK_MS = 500
const BUBBLE_SHOW = 20 // ticks → ~10s at 500ms
const FADE_WINDOW = 6 // last ~3s the bubble dims so you know it's about to go
const PET_BURST_MS = 2500 // how long hearts float after /buddy pet
const TICK_MS = 500;
const BUBBLE_SHOW = 20; // ticks → ~10s at 500ms
const FADE_WINDOW = 6; // last ~3s the bubble dims so you know it's about to go
const PET_BURST_MS = 2500; // how long hearts float after /buddy pet
// Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink.
// Sequence indices map to sprite frames; -1 means "blink on frame 0".
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0];
// Hearts float up-and-out over 5 ticks (~2.5s). Prepended above the sprite.
const H = figures.heart
const H = figures.heart;
const PET_HEARTS = [
` ${H} ${H} `,
` ${H} ${H} ${H} `,
` ${H} ${H} ${H} `,
`${H} ${H} ${H} `,
'· · · ',
]
];
function wrap(text: string, width: number): string[] {
const words = text.split(' ')
const lines: string[] = []
let cur = ''
const words = text.split(' ');
const lines: string[] = [];
let cur = '';
for (const w of words) {
if (cur.length + w.length + 1 > width && cur) {
lines.push(cur)
cur = w
lines.push(cur);
cur = w;
} else {
cur = cur ? `${cur} ${w}` : w
cur = cur ? `${cur} ${w}` : w;
}
}
if (cur) lines.push(cur)
return lines
if (cur) lines.push(cur);
return lines;
}
function SpeechBubble({
@@ -53,40 +53,29 @@ function SpeechBubble({
fading,
tail,
}: {
text: string
color: keyof Theme
fading: boolean
tail: 'down' | 'right'
text: string;
color: keyof Theme;
fading: boolean;
tail: 'down' | 'right';
}): React.ReactNode {
const lines = wrap(text, 30)
const borderColor = fading ? 'inactive' : color
const lines = wrap(text, 30);
const borderColor = fading ? 'inactive' : color;
const bubble = (
<Box
flexDirection="column"
borderStyle="round"
borderColor={borderColor}
paddingX={1}
width={34}
>
<Box flexDirection="column" borderStyle="round" borderColor={borderColor} paddingX={1} width={34}>
{lines.map((l, i) => (
<Text
key={i}
italic
dimColor={!fading}
color={fading ? 'inactive' : undefined}
>
<Text key={i} italic dimColor={!fading} color={fading ? 'inactive' : undefined}>
{l}
</Text>
))}
</Box>
)
);
if (tail === 'right') {
return (
<Box flexDirection="row" alignItems="center">
{bubble}
<Text color={borderColor}></Text>
</Box>
)
);
}
return (
<Box flexDirection="column" alignItems="flex-end" marginRight={1}>
@@ -96,18 +85,18 @@ function SpeechBubble({
<Text color={borderColor}></Text>
</Box>
</Box>
)
);
}
export const MIN_COLS_FOR_FULL_SPRITE = 100
const SPRITE_BODY_WIDTH = 12
const NAME_ROW_PAD = 2 // focused state wraps name in spaces: ` name `
const SPRITE_PADDING_X = 2
const BUBBLE_WIDTH = 36 // SpeechBubble box (34) + tail column
const NARROW_QUIP_CAP = 24
export const MIN_COLS_FOR_FULL_SPRITE = 100;
const SPRITE_BODY_WIDTH = 12;
const NAME_ROW_PAD = 2; // focused state wraps name in spaces: ` name `
const SPRITE_PADDING_X = 2;
const BUBBLE_WIDTH = 36; // SpeechBubble box (34) + tail column
const NARROW_QUIP_CAP = 24;
function spriteColWidth(nameWidth: number): number {
return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD)
return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD);
}
// Width the sprite area consumes. PromptInput subtracts this so text wraps
@@ -115,89 +104,73 @@ function spriteColWidth(nameWidth: number): number {
// width); in non-fullscreen it sits inline and needs BUBBLE_WIDTH more.
// Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row
// (above input in fullscreen, below in scrollback), so no reservation.
export function companionReservedColumns(
terminalColumns: number,
speaking: boolean,
): number {
if (!feature('BUDDY')) return 0
const companion = getCompanion()
if (!companion || getGlobalConfig().companionMuted) return 0
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0
const nameWidth = stringWidth(companion.name)
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble
export function companionReservedColumns(terminalColumns: number, speaking: boolean): number {
if (!feature('BUDDY')) return 0;
const companion = getCompanion();
if (!companion || getGlobalConfig().companionMuted) return 0;
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0;
const nameWidth = stringWidth(companion.name);
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0;
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble;
}
export function CompanionSprite(): React.ReactNode {
const reaction = useAppState(s => s.companionReaction)
const petAt = useAppState(s => s.companionPetAt)
const focused = useAppState(s => s.footerSelection === 'companion')
const setAppState = useSetAppState()
const { columns } = useTerminalSize()
const [tick, setTick] = useState(0)
const lastSpokeTick = useRef(0)
const reaction = useAppState(s => s.companionReaction);
const petAt = useAppState(s => s.companionPetAt);
const focused = useAppState(s => s.footerSelection === 'companion');
const setAppState = useSetAppState();
const { columns } = useTerminalSize();
const [tick, setTick] = useState(0);
const lastSpokeTick = useRef(0);
// Sync-during-render (not useEffect) so the first post-pet render already
// has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped.
const [{ petStartTick, forPetAt }, setPetStart] = useState({
petStartTick: 0,
forPetAt: petAt,
})
});
if (petAt !== forPetAt) {
setPetStart({ petStartTick: tick, forPetAt: petAt })
setPetStart({ petStartTick: tick, forPetAt: petAt });
}
useEffect(() => {
const timer = setInterval(
setT => setT((t: number) => t + 1),
TICK_MS,
setTick,
)
return () => clearInterval(timer)
}, [])
const timer = setInterval(setT => setT((t: number) => t + 1), TICK_MS, setTick);
return () => clearInterval(timer);
}, []);
useEffect(() => {
if (!reaction) return
lastSpokeTick.current = tick
if (!reaction) return;
lastSpokeTick.current = tick;
const timer = setTimeout(
setA =>
setA((prev: AppState) =>
prev.companionReaction === undefined
? prev
: { ...prev, companionReaction: undefined },
prev.companionReaction === undefined ? prev : { ...prev, companionReaction: undefined },
),
BUBBLE_SHOW * TICK_MS,
setAppState,
)
return () => clearTimeout(timer)
);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked
}, [reaction, setAppState])
}, [reaction, setAppState]);
if (!feature('BUDDY')) return null
const companion = getCompanion()
if (!companion || getGlobalConfig().companionMuted) return null
if (!feature('BUDDY')) return null;
const companion = getCompanion();
if (!companion || getGlobalConfig().companionMuted) return null;
const color = RARITY_COLORS[companion.rarity]
const colWidth = spriteColWidth(stringWidth(companion.name))
const color = RARITY_COLORS[companion.rarity];
const colWidth = spriteColWidth(stringWidth(companion.name));
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0
const fading =
reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0;
const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW;
const petAge = petAt ? tick - petStartTick : Infinity
const petting = petAge * TICK_MS < PET_BURST_MS
const petAge = petAt ? tick - petStartTick : Infinity;
const petting = petAge * TICK_MS < PET_BURST_MS;
// Narrow terminals: collapse to one-line face. When speaking, the quip
// replaces the name beside the face (no room for a bubble).
if (columns < MIN_COLS_FOR_FULL_SPRITE) {
const quip =
reaction && reaction.length > NARROW_QUIP_CAP
? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…'
: reaction
const label = quip
? `"${quip}"`
: focused
? ` ${companion.name} `
: companion.name
reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction;
const label = quip ? `"${quip}"` : focused ? ` ${companion.name} ` : companion.name;
return (
<Box paddingX={1} alignSelf="flex-end">
<Text>
@@ -210,44 +183,34 @@ export function CompanionSprite(): React.ReactNode {
dimColor={!focused && !reaction}
bold={focused}
inverse={focused && !reaction}
color={
reaction
? fading
? 'inactive'
: color
: focused
? color
: undefined
}
color={reaction ? (fading ? 'inactive' : color) : focused ? color : undefined}
>
{label}
</Text>
</Text>
</Box>
)
);
}
const frameCount = spriteFrameCount(companion.species)
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null
const frameCount = spriteFrameCount(companion.species);
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null;
let spriteFrame: number
let blink = false
let spriteFrame: number;
let blink = false;
if (reaction || petting) {
// Excited: cycle all fidget frames fast
spriteFrame = tick % frameCount
spriteFrame = tick % frameCount;
} else {
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!;
if (step === -1) {
spriteFrame = 0
blink = true
spriteFrame = 0;
blink = true;
} else {
spriteFrame = step % frameCount
spriteFrame = step % frameCount;
}
}
const body = renderSprite(companion, spriteFrame).map(line =>
blink ? line.replaceAll(companion.eye, '-') : line,
)
const sprite = heartFrame ? [heartFrame, ...body] : body
const body = renderSprite(companion, spriteFrame).map(line => (blink ? line.replaceAll(companion.eye, '-') : line));
const sprite = heartFrame ? [heartFrame, ...body] : body;
// Name row doubles as hint row — unfocused shows dim name + ↓ discovery,
// focused shows inverse name. The enter-to-open hint lives in
@@ -255,31 +218,20 @@ export function CompanionSprite(): React.ReactNode {
// sprite doesn't jump up when selected. flexShrink=0 stops the
// inline-bubble row wrapper from squeezing the sprite to fit.
const spriteColumn = (
<Box
flexDirection="column"
flexShrink={0}
alignItems="center"
width={colWidth}
>
<Box flexDirection="column" flexShrink={0} alignItems="center" width={colWidth}>
{sprite.map((line, i) => (
<Text key={i} color={i === 0 && heartFrame ? 'autoAccept' : color}>
{line}
</Text>
))}
<Text
italic
bold={focused}
dimColor={!focused}
color={focused ? color : undefined}
inverse={focused}
>
<Text italic bold={focused} dimColor={!focused} color={focused ? color : undefined} inverse={focused}>
{focused ? ` ${companion.name} ` : companion.name}
</Text>
</Box>
)
);
if (!reaction) {
return <Box paddingX={1}>{spriteColumn}</Box>
return <Box paddingX={1}>{spriteColumn}</Box>;
}
// Fullscreen: bubble renders separately via CompanionFloatingBubble in
@@ -288,19 +240,14 @@ export function CompanionSprite(): React.ReactNode {
// Non-fullscreen: bubble sits inline beside the sprite (input shrinks)
// because floating into Static scrollback can't be cleared.
if (isFullscreenActive()) {
return <Box paddingX={1}>{spriteColumn}</Box>
return <Box paddingX={1}>{spriteColumn}</Box>;
}
return (
<Box flexDirection="row" alignItems="flex-end" paddingX={1} flexShrink={0}>
<SpeechBubble
text={reaction}
color={color}
fading={fading}
tail="right"
/>
<SpeechBubble text={reaction} color={color} fading={fading} tail="right" />
{spriteColumn}
</Box>
)
);
}
// Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's
@@ -308,33 +255,29 @@ export function CompanionSprite(): React.ReactNode {
// the ScrollBox region. CompanionSprite owns the clear-after-10s timer; this
// just reads companionReaction and renders the fade.
export function CompanionFloatingBubble(): React.ReactNode {
const reaction = useAppState(s => s.companionReaction)
const reaction = useAppState(s => s.companionReaction);
const [{ tick, forReaction }, setTick] = useState({
tick: 0,
forReaction: reaction,
})
});
// Reset tick synchronously when reaction changes (not in useEffect, which
// runs post-render and would show one stale-faded frame). Storing the
// reaction the tick is counting FOR alongside the tick itself means the
// fade computation never sees a tick from a previous reaction.
if (reaction !== forReaction) {
setTick({ tick: 0, forReaction: reaction })
setTick({ tick: 0, forReaction: reaction });
}
useEffect(() => {
if (!reaction) return
const timer = setInterval(
set => set(s => ({ ...s, tick: s.tick + 1 })),
TICK_MS,
setTick,
)
return () => clearInterval(timer)
}, [reaction])
if (!reaction) return;
const timer = setInterval(set => set(s => ({ ...s, tick: s.tick + 1 })), TICK_MS, setTick);
return () => clearInterval(timer);
}, [reaction]);
if (!feature('BUDDY') || !reaction) return null
const companion = getCompanion()
if (!companion || getGlobalConfig().companionMuted) return null
if (!feature('BUDDY') || !reaction) return null;
const companion = getCompanion();
if (!companion || getGlobalConfig().companionMuted) return null;
return (
<SpeechBubble
@@ -343,5 +286,5 @@ export function CompanionFloatingBubble(): React.ReactNode {
fading={tick >= BUBBLE_SHOW - FADE_WINDOW}
tail="down"
/>
)
);
}

View File

@@ -1,25 +1,23 @@
import { feature } from 'bun:bundle'
import React, { useEffect } from 'react'
import { useNotifications } from '../context/notifications.js'
import { Text } from '@anthropic/ink'
import { getGlobalConfig } from '../utils/config.js'
import { getRainbowColor } from '../utils/thinking.js'
import { feature } from 'bun:bundle';
import React, { useEffect } from 'react';
import { useNotifications } from '../context/notifications.js';
import { Text } from '@anthropic/ink';
import { getGlobalConfig } from '../utils/config.js';
import { getRainbowColor } from '../utils/thinking.js';
// Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter
// buzz instead of a single UTC-midnight spike, gentler on soul-gen load.
// Teaser window: April 1-7, 2026 only. Command stays live forever after.
export function isBuddyTeaserWindow(): boolean {
if (process.env.USER_TYPE === 'ant') return true
const d = new Date()
return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7
if (process.env.USER_TYPE === 'ant') return true;
const d = new Date();
return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7;
}
export function isBuddyLive(): boolean {
if (process.env.USER_TYPE === 'ant') return true
const d = new Date()
return (
d.getFullYear() > 2026 || (d.getFullYear() === 2026 && d.getMonth() >= 3)
)
if (process.env.USER_TYPE === 'ant') return true;
const d = new Date();
return d.getFullYear() > 2026 || (d.getFullYear() === 2026 && d.getMonth() >= 3);
}
function RainbowText({ text }: { text: string }): React.ReactNode {
@@ -31,37 +29,35 @@ function RainbowText({ text }: { text: string }): React.ReactNode {
</Text>
))}
</>
)
);
}
// Rainbow /buddy teaser shown on startup when no companion hatched yet.
// Idle presence and reactions are handled by CompanionSprite directly.
export function useBuddyNotification(): void {
const { addNotification, removeNotification } = useNotifications()
const { addNotification, removeNotification } = useNotifications();
useEffect(() => {
if (!feature('BUDDY')) return
const config = getGlobalConfig()
if (config.companion || !isBuddyTeaserWindow()) return
if (!feature('BUDDY')) return;
const config = getGlobalConfig();
if (config.companion || !isBuddyTeaserWindow()) return;
addNotification({
key: 'buddy-teaser',
jsx: <RainbowText text="/buddy" />,
priority: 'immediate',
timeoutMs: 15_000,
})
return () => removeNotification('buddy-teaser')
}, [addNotification, removeNotification])
});
return () => removeNotification('buddy-teaser');
}, [addNotification, removeNotification]);
}
export function findBuddyTriggerPositions(
text: string,
): Array<{ start: number; end: number }> {
if (!feature('BUDDY')) return []
const triggers: Array<{ start: number; end: number }> = []
const re = /\/buddy\b/g
let m: RegExpExecArray | null
export function findBuddyTriggerPositions(text: string): Array<{ start: number; end: number }> {
if (!feature('BUDDY')) return [];
const triggers: Array<{ start: number; end: number }> = [];
const re = /\/buddy\b/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
triggers.push({ start: m.index, end: m.index + m[0].length })
triggers.push({ start: m.index, end: m.index + m[0].length });
}
return triggers
return triggers;
}

View File

@@ -200,7 +200,9 @@ export async function attachHandler(target: string | undefined): Promise<void> {
const { TmuxEngine } = await import('./bg/engines/tmux.js')
const tmux = new TmuxEngine()
if (!(await tmux.available())) {
console.error('tmux is no longer available. Cannot attach to tmux session.')
console.error(
'tmux is no longer available. Cannot attach to tmux session.',
)
process.exitCode = 1
return
}
@@ -324,7 +326,9 @@ export async function handleBgStart(args: string[]): Promise<void> {
console.log(` Engine: ${result.engineUsed}`)
console.log(` Log: ${result.logPath}`)
console.log()
console.log(`Use \`claude daemon attach ${result.sessionName}\` to reconnect.`)
console.log(
`Use \`claude daemon attach ${result.sessionName}\` to reconnect.`,
)
console.log(`Use \`claude daemon status\` to check status.`)
console.log(`Use \`claude daemon kill ${result.sessionName}\` to stop.`)
} catch (e) {

View File

@@ -1,7 +1,12 @@
import { closeSync, mkdirSync, openSync } from 'fs'
import { dirname } from 'path'
import { buildCliLaunch, spawnCli } from '../../../utils/cliLaunch.js'
import type { BgEngine, BgStartOptions, BgStartResult, SessionEntry } from '../engine.js'
import type {
BgEngine,
BgStartOptions,
BgStartResult,
SessionEntry,
} from '../engine.js'
import { tailLog } from '../tail.js'
export class DetachedEngine implements BgEngine {

View File

@@ -1,4 +1,9 @@
export type { BgEngine, BgStartOptions, BgStartResult, SessionEntry } from '../engine.js'
export type {
BgEngine,
BgStartOptions,
BgStartResult,
SessionEntry,
} from '../engine.js'
export async function selectEngine(): Promise<import('../engine.js').BgEngine> {
if (process.platform === 'win32') {

View File

@@ -1,7 +1,12 @@
import { spawnSync } from 'child_process'
import { execFileNoThrow } from '../../../utils/execFileNoThrow.js'
import { buildCliLaunch, quoteCliLaunch } from '../../../utils/cliLaunch.js'
import type { BgEngine, BgStartOptions, BgStartResult, SessionEntry } from '../engine.js'
import type {
BgEngine,
BgStartOptions,
BgStartResult,
SessionEntry,
} from '../engine.js'
export class TmuxEngine implements BgEngine {
readonly name = 'tmux' as const

View File

@@ -87,8 +87,12 @@ describe('autonomy CLI handler', () => {
})
test('prints individual deep status sections for panel actions', async () => {
const pipes = await getAutonomyDeepSectionText('pipes', { rootDir: tempDir })
const remoteControl = await getAutonomyDeepSectionText('remote-control', { rootDir: tempDir })
const pipes = await getAutonomyDeepSectionText('pipes', {
rootDir: tempDir,
})
const remoteControl = await getAutonomyDeepSectionText('remote-control', {
rootDir: tempDir,
})
expect(pipes).toContain('# Pipes')
expect(pipes).toContain('Pipe registry:')
@@ -116,17 +120,24 @@ describe('autonomy CLI handler', () => {
})
const [waitingFlow] = await listAutonomyFlows(tempDir)
expect(await getAutonomyFlowsText(undefined, { rootDir: tempDir })).toContain(waitingFlow!.flowId)
expect(await getAutonomyFlowText(waitingFlow!.flowId, { rootDir: tempDir })).toContain(
'Current step: wait',
)
expect(
await getAutonomyFlowsText(undefined, { rootDir: tempDir }),
).toContain(waitingFlow!.flowId)
expect(
await getAutonomyFlowText(waitingFlow!.flowId, { rootDir: tempDir }),
).toContain('Current step: wait')
const resumed = await resumeAutonomyFlowText(waitingFlow!.flowId, { rootDir: tempDir, currentDir: tempDir })
const resumed = await resumeAutonomyFlowText(waitingFlow!.flowId, {
rootDir: tempDir,
currentDir: tempDir,
})
expect(resumed).toContain('Prepared the next managed step')
expect(resumed).toContain('Prompt:')
expect(resumed).toContain('Wait for manual signal')
const cancelled = await cancelAutonomyFlowText(waitingFlow!.flowId, { rootDir: tempDir })
const cancelled = await cancelAutonomyFlowText(waitingFlow!.flowId, {
rootDir: tempDir,
})
expect(cancelled).toContain('Cancelled flow')
})
})

View File

@@ -159,7 +159,9 @@ export async function authLogin({
const orgResult = await validateForceLoginOrg()
if (!orgResult.valid) {
process.stderr.write((orgResult as { valid: false; message: string }).message + '\n')
process.stderr.write(
(orgResult as { valid: false; message: string }).message + '\n',
)
process.exit(1)
}
@@ -209,7 +211,9 @@ export async function authLogin({
const orgResult = await validateForceLoginOrg()
if (!orgResult.valid) {
process.stderr.write((orgResult as { valid: false; message: string }).message + '\n')
process.stderr.write(
(orgResult as { valid: false; message: string }).message + '\n',
)
process.exit(1)
}

View File

@@ -113,7 +113,9 @@ export async function getAutonomyFlowText(
flowId: string,
options?: { rootDir?: string },
): Promise<string> {
return formatAutonomyFlowDetail(await getAutonomyFlowById(flowId, options?.rootDir))
return formatAutonomyFlowDetail(
await getAutonomyFlowById(flowId, options?.rootDir),
)
}
export async function autonomyFlowHandler(flowId: string): Promise<void> {

View File

@@ -3,201 +3,163 @@
* These are dynamically imported only when the corresponding `claude mcp *` command runs.
*/
import { stat } from 'fs/promises'
import pMap from 'p-map'
import { cwd } from 'process'
import React from 'react'
import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js'
import { wrappedRender as render } from '@anthropic/ink'
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'
import { stat } from 'fs/promises';
import pMap from 'p-map';
import { cwd } from 'process';
import React from 'react';
import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js';
import { wrappedRender as render } from '@anthropic/ink';
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
} from '../../services/analytics/index.js';
import {
clearMcpClientConfig,
clearServerTokensFromLocalStorage,
getMcpClientConfig,
readClientSecret,
saveMcpClientSecret,
} from '../../services/mcp/auth.js'
import {
connectToServer,
getMcpServerConnectionBatchSize,
} from '../../services/mcp/client.js'
} from '../../services/mcp/auth.js';
import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js';
import {
addMcpConfig,
getAllMcpConfigs,
getMcpConfigByName,
getMcpConfigsByScope,
removeMcpConfig,
} from '../../services/mcp/config.js'
import type {
ConfigScope,
ScopedMcpServerConfig,
} from '../../services/mcp/types.js'
import {
describeMcpConfigFilePath,
ensureConfigScope,
getScopeLabel,
} from '../../services/mcp/utils.js'
import { AppStateProvider } from '../../state/AppState.js'
import {
getCurrentProjectConfig,
getGlobalConfig,
saveCurrentProjectConfig,
} from '../../utils/config.js'
import { isFsInaccessible } from '../../utils/errors.js'
import { gracefulShutdown } from '../../utils/gracefulShutdown.js'
import { safeParseJSON } from '../../utils/json.js'
import { getPlatform } from '../../utils/platform.js'
import { cliError, cliOk } from '../exit.js'
} from '../../services/mcp/config.js';
import type { ConfigScope, ScopedMcpServerConfig } from '../../services/mcp/types.js';
import { describeMcpConfigFilePath, ensureConfigScope, getScopeLabel } from '../../services/mcp/utils.js';
import { AppStateProvider } from '../../state/AppState.js';
import { getCurrentProjectConfig, getGlobalConfig, saveCurrentProjectConfig } from '../../utils/config.js';
import { isFsInaccessible } from '../../utils/errors.js';
import { gracefulShutdown } from '../../utils/gracefulShutdown.js';
import { safeParseJSON } from '../../utils/json.js';
import { getPlatform } from '../../utils/platform.js';
import { cliError, cliOk } from '../exit.js';
async function checkMcpServerHealth(
name: string,
server: ScopedMcpServerConfig,
): Promise<string> {
async function checkMcpServerHealth(name: string, server: ScopedMcpServerConfig): Promise<string> {
try {
const result = await connectToServer(name, server)
const result = await connectToServer(name, server);
if (result.type === 'connected') {
return '✓ Connected'
return '✓ Connected';
} else if (result.type === 'needs-auth') {
return '! Needs authentication'
return '! Needs authentication';
} else {
return '✗ Failed to connect'
return '✗ Failed to connect';
}
} catch (_error) {
return '✗ Connection error'
return '✗ Connection error';
}
}
// mcp serve (lines 45124532)
export async function mcpServeHandler({
debug,
verbose,
}: {
debug?: boolean
verbose?: boolean
}): Promise<void> {
const providedCwd = cwd()
logEvent('tengu_mcp_start', {})
export async function mcpServeHandler({ debug, verbose }: { debug?: boolean; verbose?: boolean }): Promise<void> {
const providedCwd = cwd();
logEvent('tengu_mcp_start', {});
try {
await stat(providedCwd)
await stat(providedCwd);
} catch (error) {
if (isFsInaccessible(error)) {
cliError(`Error: Directory ${providedCwd} does not exist`)
cliError(`Error: Directory ${providedCwd} does not exist`);
}
throw error
throw error;
}
try {
const { setup } = await import('../../setup.js')
await setup(providedCwd, 'default', false, false, undefined, false)
const { startMCPServer } = await import('../../entrypoints/mcp.js')
await startMCPServer(providedCwd, debug ?? false, verbose ?? false)
const { setup } = await import('../../setup.js');
await setup(providedCwd, 'default', false, false, undefined, false);
const { startMCPServer } = await import('../../entrypoints/mcp.js');
await startMCPServer(providedCwd, debug ?? false, verbose ?? false);
} catch (error) {
cliError(`Error: Failed to start MCP server: ${error}`)
cliError(`Error: Failed to start MCP server: ${error}`);
}
}
// mcp remove (lines 45454635)
export async function mcpRemoveHandler(
name: string,
options: { scope?: string },
): Promise<void> {
export async function mcpRemoveHandler(name: string, options: { scope?: string }): Promise<void> {
// Look up config before removing so we can clean up secure storage
const serverBeforeRemoval = getMcpConfigByName(name)
const serverBeforeRemoval = getMcpConfigByName(name);
const cleanupSecureStorage = () => {
if (
serverBeforeRemoval &&
(serverBeforeRemoval.type === 'sse' ||
serverBeforeRemoval.type === 'http')
) {
clearServerTokensFromLocalStorage(name, serverBeforeRemoval)
clearMcpClientConfig(name, serverBeforeRemoval)
if (serverBeforeRemoval && (serverBeforeRemoval.type === 'sse' || serverBeforeRemoval.type === 'http')) {
clearServerTokensFromLocalStorage(name, serverBeforeRemoval);
clearMcpClientConfig(name, serverBeforeRemoval);
}
}
};
try {
if (options.scope) {
const scope = ensureConfigScope(options.scope)
const scope = ensureConfigScope(options.scope);
logEvent('tengu_mcp_delete', {
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
scope:
scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
await removeMcpConfig(name, scope)
cleanupSecureStorage()
process.stdout.write(`Removed MCP server ${name} from ${scope} config\n`)
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`)
await removeMcpConfig(name, scope);
cleanupSecureStorage();
process.stdout.write(`Removed MCP server ${name} from ${scope} config\n`);
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`);
}
// If no scope specified, check where the server exists
const projectConfig = getCurrentProjectConfig()
const globalConfig = getGlobalConfig()
const projectConfig = getCurrentProjectConfig();
const globalConfig = getGlobalConfig();
// Check if server exists in project scope (.mcp.json)
const { servers: projectServers } = getMcpConfigsByScope('project')
const mcpJsonExists = !!projectServers[name]
const { servers: projectServers } = getMcpConfigsByScope('project');
const mcpJsonExists = !!projectServers[name];
// Count how many scopes contain this server
const scopes: Array<Exclude<ConfigScope, 'dynamic'>> = []
if (projectConfig.mcpServers?.[name]) scopes.push('local')
if (mcpJsonExists) scopes.push('project')
if (globalConfig.mcpServers?.[name]) scopes.push('user')
const scopes: Array<Exclude<ConfigScope, 'dynamic'>> = [];
if (projectConfig.mcpServers?.[name]) scopes.push('local');
if (mcpJsonExists) scopes.push('project');
if (globalConfig.mcpServers?.[name]) scopes.push('user');
if (scopes.length === 0) {
cliError(`No MCP server found with name: "${name}"`)
cliError(`No MCP server found with name: "${name}"`);
} else if (scopes.length === 1) {
// Server exists in only one scope, remove it
const scope = scopes[0]!
const scope = scopes[0]!;
logEvent('tengu_mcp_delete', {
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
scope:
scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
await removeMcpConfig(name, scope)
cleanupSecureStorage()
process.stdout.write(
`Removed MCP server "${name}" from ${scope} config\n`,
)
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`)
await removeMcpConfig(name, scope);
cleanupSecureStorage();
process.stdout.write(`Removed MCP server "${name}" from ${scope} config\n`);
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`);
} else {
// Server exists in multiple scopes
process.stderr.write(`MCP server "${name}" exists in multiple scopes:\n`)
process.stderr.write(`MCP server "${name}" exists in multiple scopes:\n`);
scopes.forEach(scope => {
process.stderr.write(
` - ${getScopeLabel(scope)} (${describeMcpConfigFilePath(scope)})\n`,
)
})
process.stderr.write('\nTo remove from a specific scope, use:\n')
process.stderr.write(` - ${getScopeLabel(scope)} (${describeMcpConfigFilePath(scope)})\n`);
});
process.stderr.write('\nTo remove from a specific scope, use:\n');
scopes.forEach(scope => {
process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`)
})
cliError()
process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`);
});
cliError();
}
} catch (error) {
cliError((error as Error).message)
cliError((error as Error).message);
}
}
// mcp list (lines 46414688)
export async function mcpListHandler(): Promise<void> {
logEvent('tengu_mcp_list', {})
const { servers: configs } = await getAllMcpConfigs()
logEvent('tengu_mcp_list', {});
const { servers: configs } = await getAllMcpConfigs();
if (Object.keys(configs).length === 0) {
console.log(
'No MCP servers configured. Use `claude mcp add` to add a server.',
)
console.log('No MCP servers configured. Use `claude mcp add` to add a server.');
} else {
console.log('Checking MCP server health...\n')
console.log('Checking MCP server health...\n');
// Check servers concurrently
const entries = Object.entries(configs)
const entries = Object.entries(configs);
const results = await pMap(
entries,
async ([name, server]) => ({
@@ -206,104 +168,100 @@ export async function mcpListHandler(): Promise<void> {
status: await checkMcpServerHealth(name, server),
}),
{ concurrency: getMcpServerConnectionBatchSize() },
)
);
for (const { name, server, status } of results) {
// Intentionally excluding sse-ide servers here since they're internal
if (server.type === 'sse') {
console.log(`${name}: ${server.url} (SSE) - ${status}`)
console.log(`${name}: ${server.url} (SSE) - ${status}`);
} else if (server.type === 'http') {
console.log(`${name}: ${server.url} (HTTP) - ${status}`)
console.log(`${name}: ${server.url} (HTTP) - ${status}`);
} else if (server.type === 'claudeai-proxy') {
console.log(`${name}: ${server.url} - ${status}`)
console.log(`${name}: ${server.url} - ${status}`);
} else if (!server.type || server.type === 'stdio') {
const stdioServer = server as { command: string; args: string[]; type?: string }
const args = Array.isArray(stdioServer.args) ? stdioServer.args : []
console.log(`${name}: ${stdioServer.command} ${args.join(' ')} - ${status}`)
const stdioServer = server as { command: string; args: string[]; type?: string };
const args = Array.isArray(stdioServer.args) ? stdioServer.args : [];
console.log(`${name}: ${stdioServer.command} ${args.join(' ')} - ${status}`);
}
}
}
// Use gracefulShutdown to properly clean up MCP server connections
// (process.exit bypasses cleanup handlers, leaving child processes orphaned)
await gracefulShutdown(0)
await gracefulShutdown(0);
}
// mcp get (lines 46944786)
export async function mcpGetHandler(name: string): Promise<void> {
logEvent('tengu_mcp_get', {
name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
const server = getMcpConfigByName(name)
});
const server = getMcpConfigByName(name);
if (!server) {
cliError(`No MCP server found with name: ${name}`)
cliError(`No MCP server found with name: ${name}`);
}
console.log(`${name}:`)
console.log(` Scope: ${getScopeLabel(server.scope)}`)
console.log(`${name}:`);
console.log(` Scope: ${getScopeLabel(server.scope)}`);
// Check server health
const status = await checkMcpServerHealth(name, server)
console.log(` Status: ${status}`)
const status = await checkMcpServerHealth(name, server);
console.log(` Status: ${status}`);
// Intentionally excluding sse-ide servers here since they're internal
if (server.type === 'sse') {
console.log(` Type: sse`)
console.log(` URL: ${server.url}`)
console.log(` Type: sse`);
console.log(` URL: ${server.url}`);
if (server.headers) {
console.log(' Headers:')
console.log(' Headers:');
for (const [key, value] of Object.entries(server.headers)) {
console.log(` ${key}: ${value}`)
console.log(` ${key}: ${value}`);
}
}
if (server.oauth?.clientId || server.oauth?.callbackPort) {
const parts: string[] = []
const parts: string[] = [];
if (server.oauth.clientId) {
parts.push('client_id configured')
const clientConfig = getMcpClientConfig(name, server)
if (clientConfig?.clientSecret) parts.push('client_secret configured')
parts.push('client_id configured');
const clientConfig = getMcpClientConfig(name, server);
if (clientConfig?.clientSecret) parts.push('client_secret configured');
}
if (server.oauth.callbackPort)
parts.push(`callback_port ${server.oauth.callbackPort}`)
console.log(` OAuth: ${parts.join(', ')}`)
if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`);
console.log(` OAuth: ${parts.join(', ')}`);
}
} else if (server.type === 'http') {
console.log(` Type: http`)
console.log(` URL: ${server.url}`)
console.log(` Type: http`);
console.log(` URL: ${server.url}`);
if (server.headers) {
console.log(' Headers:')
console.log(' Headers:');
for (const [key, value] of Object.entries(server.headers)) {
console.log(` ${key}: ${value}`)
console.log(` ${key}: ${value}`);
}
}
if (server.oauth?.clientId || server.oauth?.callbackPort) {
const parts: string[] = []
const parts: string[] = [];
if (server.oauth.clientId) {
parts.push('client_id configured')
const clientConfig = getMcpClientConfig(name, server)
if (clientConfig?.clientSecret) parts.push('client_secret configured')
parts.push('client_id configured');
const clientConfig = getMcpClientConfig(name, server);
if (clientConfig?.clientSecret) parts.push('client_secret configured');
}
if (server.oauth.callbackPort)
parts.push(`callback_port ${server.oauth.callbackPort}`)
console.log(` OAuth: ${parts.join(', ')}`)
if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`);
console.log(` OAuth: ${parts.join(', ')}`);
}
} else if (server.type === 'stdio') {
console.log(` Type: stdio`)
console.log(` Command: ${server.command}`)
const args = Array.isArray(server.args) ? server.args : []
console.log(` Args: ${args.join(' ')}`)
console.log(` Type: stdio`);
console.log(` Command: ${server.command}`);
const args = Array.isArray(server.args) ? server.args : [];
console.log(` Args: ${args.join(' ')}`);
if (server.env) {
console.log(' Environment:')
console.log(' Environment:');
for (const [key, value] of Object.entries(server.env)) {
console.log(` ${key}=${value}`)
console.log(` ${key}=${value}`);
}
}
}
console.log(
`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`,
)
console.log(`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`);
// Use gracefulShutdown to properly clean up MCP server connections
// (process.exit bypasses cleanup handlers, leaving child processes orphaned)
await gracefulShutdown(0)
await gracefulShutdown(0);
}
// mcp add-json (lines 48014870)
@@ -313,8 +271,8 @@ export async function mcpAddJsonHandler(
options: { scope?: string; clientSecret?: true },
): Promise<void> {
try {
const scope = ensureConfigScope(options.scope)
const parsedJson = safeParseJSON(json)
const scope = ensureConfigScope(options.scope);
const parsedJson = safeParseJSON(json);
// Read secret before writing config so cancellation doesn't leave partial state
const needsSecret =
@@ -328,15 +286,15 @@ export async function mcpAddJsonHandler(
'oauth' in parsedJson &&
parsedJson.oauth &&
typeof parsedJson.oauth === 'object' &&
'clientId' in parsedJson.oauth
const clientSecret = needsSecret ? await readClientSecret() : undefined
'clientId' in parsedJson.oauth;
const clientSecret = needsSecret ? await readClientSecret() : undefined;
await addMcpConfig(name, parsedJson, scope)
await addMcpConfig(name, parsedJson, scope);
const transportType =
parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson
? String(parsedJson.type || 'stdio')
: 'stdio'
: 'stdio';
if (
clientSecret &&
@@ -347,53 +305,38 @@ export async function mcpAddJsonHandler(
'url' in parsedJson &&
typeof parsedJson.url === 'string'
) {
saveMcpClientSecret(
name,
{ type: parsedJson.type, url: parsedJson.url },
clientSecret,
)
saveMcpClientSecret(name, { type: parsedJson.type, url: parsedJson.url }, clientSecret);
}
logEvent('tengu_mcp_add', {
scope:
scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
source:
'json' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
source: 'json' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
type: transportType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
});
cliOk(`Added ${transportType} MCP server ${name} to ${scope} config`)
cliOk(`Added ${transportType} MCP server ${name} to ${scope} config`);
} catch (error) {
cliError((error as Error).message)
cliError((error as Error).message);
}
}
// mcp add-from-claude-desktop (lines 48814927)
export async function mcpAddFromDesktopHandler(options: {
scope?: string
}): Promise<void> {
export async function mcpAddFromDesktopHandler(options: { scope?: string }): Promise<void> {
try {
const scope = ensureConfigScope(options.scope)
const platform = getPlatform()
const scope = ensureConfigScope(options.scope);
const platform = getPlatform();
logEvent('tengu_mcp_add', {
scope:
scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
platform:
platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
source:
'desktop' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
platform: platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
source: 'desktop' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
const { readClaudeDesktopMcpServers } = await import(
'../../utils/claudeDesktop.js'
)
const servers = await readClaudeDesktopMcpServers()
const { readClaudeDesktopMcpServers } = await import('../../utils/claudeDesktop.js');
const servers = await readClaudeDesktopMcpServers();
if (Object.keys(servers).length === 0) {
cliOk(
'No MCP servers found in Claude Desktop configuration or configuration file does not exist.',
)
cliOk('No MCP servers found in Claude Desktop configuration or configuration file does not exist.');
}
const { unmount } = await render(
@@ -403,29 +346,29 @@ export async function mcpAddFromDesktopHandler(options: {
servers={servers}
scope={scope}
onDone={() => {
unmount()
unmount();
}}
/>
</KeybindingSetup>
</AppStateProvider>,
{ exitOnCtrlC: true },
)
);
} catch (error) {
cliError((error as Error).message)
cliError((error as Error).message);
}
}
// mcp reset-project-choices (lines 49354952)
export async function mcpResetChoicesHandler(): Promise<void> {
logEvent('tengu_mcp_reset_mcpjson_choices', {})
logEvent('tengu_mcp_reset_mcpjson_choices', {});
saveCurrentProjectConfig(current => ({
...current,
enabledMcpjsonServers: [],
disabledMcpjsonServers: [],
enableAllProjectMcpServers: false,
}))
}));
cliOk(
'All project-scoped (.mcp.json) server approvals and rejections have been reset.\n' +
'You will be prompted for approval next time you start Claude Code.',
)
);
}

View File

@@ -4,26 +4,24 @@
*/
/* eslint-disable custom-rules/no-process-exit -- CLI subcommand handlers intentionally exit */
import { cwd } from 'process'
import React from 'react'
import { WelcomeV2 } from '../../components/LogoV2/WelcomeV2.js'
import { useManagePlugins } from '../../hooks/useManagePlugins.js'
import type { Root } from '@anthropic/ink'
import { Box, Text } from '@anthropic/ink'
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'
import { logEvent } from '../../services/analytics/index.js'
import { MCPConnectionManager } from '../../services/mcp/MCPConnectionManager.js'
import { AppStateProvider } from '../../state/AppState.js'
import { onChangeAppState } from '../../state/onChangeAppState.js'
import { isAnthropicAuthEnabled } from '../../utils/auth.js'
import { cwd } from 'process';
import React from 'react';
import { WelcomeV2 } from '../../components/LogoV2/WelcomeV2.js';
import { useManagePlugins } from '../../hooks/useManagePlugins.js';
import type { Root } from '@anthropic/ink';
import { Box, Text } from '@anthropic/ink';
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';
import { logEvent } from '../../services/analytics/index.js';
import { MCPConnectionManager } from '../../services/mcp/MCPConnectionManager.js';
import { AppStateProvider } from '../../state/AppState.js';
import { onChangeAppState } from '../../state/onChangeAppState.js';
import { isAnthropicAuthEnabled } from '../../utils/auth.js';
export async function setupTokenHandler(root: Root): Promise<void> {
logEvent('tengu_setup_token_command', {})
logEvent('tengu_setup_token_command', {});
const showAuthWarning = !isAnthropicAuthEnabled()
const { ConsoleOAuthFlow } = await import(
'../../components/ConsoleOAuthFlow.js'
)
const showAuthWarning = !isAnthropicAuthEnabled();
const { ConsoleOAuthFlow } = await import('../../components/ConsoleOAuthFlow.js');
await new Promise<void>(resolve => {
root.render(
<AppStateProvider onChangeAppState={onChangeAppState}>
@@ -33,18 +31,16 @@ export async function setupTokenHandler(root: Root): Promise<void> {
{showAuthWarning && (
<Box flexDirection="column">
<Text color="warning">
Warning: You already have authentication configured via
environment variable or API key helper.
Warning: You already have authentication configured via environment variable or API key helper.
</Text>
<Text color="warning">
The setup-token command will create a new OAuth token which
you can use instead.
The setup-token command will create a new OAuth token which you can use instead.
</Text>
</Box>
)}
<ConsoleOAuthFlow
onDone={() => {
void resolve()
void resolve();
}}
mode="setup-token"
startingMessage="This will guide you through long-lived (1-year) auth token setup for your Claude account. Claude subscription required."
@@ -52,75 +48,63 @@ export async function setupTokenHandler(root: Root): Promise<void> {
</Box>
</KeybindingSetup>
</AppStateProvider>,
)
})
root.unmount()
process.exit(0)
);
});
root.unmount();
process.exit(0);
}
// DoctorWithPlugins wrapper + doctor handler
const DoctorLazy = React.lazy(() =>
import('../../screens/Doctor.js').then(m => ({ default: m.Doctor })),
)
const DoctorLazy = React.lazy(() => import('../../screens/Doctor.js').then(m => ({ default: m.Doctor })));
function DoctorWithPlugins({
onDone,
}: {
onDone: () => void
}): React.ReactNode {
useManagePlugins()
function DoctorWithPlugins({ onDone }: { onDone: () => void }): React.ReactNode {
useManagePlugins();
return (
<React.Suspense fallback={null}>
<DoctorLazy onDone={onDone} />
</React.Suspense>
)
);
}
export async function doctorHandler(root: Root): Promise<void> {
logEvent('tengu_doctor_command', {})
logEvent('tengu_doctor_command', {});
await new Promise<void>(resolve => {
root.render(
<AppStateProvider>
<KeybindingSetup>
<MCPConnectionManager
dynamicMcpConfig={undefined}
isStrictMcpConfig={false}
>
<MCPConnectionManager dynamicMcpConfig={undefined} isStrictMcpConfig={false}>
<DoctorWithPlugins
onDone={() => {
void resolve()
void resolve();
}}
/>
</MCPConnectionManager>
</KeybindingSetup>
</AppStateProvider>,
)
})
root.unmount()
process.exit(0)
);
});
root.unmount();
process.exit(0);
}
// install handler
export async function installHandler(
target: string | undefined,
options: { force?: boolean },
): Promise<void> {
const { setup } = await import('../../setup.js')
await setup(cwd(), 'default', false, false, undefined, false)
const { install } = await import('../../commands/install.js')
export async function installHandler(target: string | undefined, options: { force?: boolean }): Promise<void> {
const { setup } = await import('../../setup.js');
await setup(cwd(), 'default', false, false, undefined, false);
const { install } = await import('../../commands/install.js');
await new Promise<void>(resolve => {
const args: string[] = []
if (target) args.push(target)
if (options.force) args.push('--force')
const args: string[] = [];
if (target) args.push(target);
if (options.force) args.push('--force');
void install.call(
result => {
void resolve()
process.exit(result.includes('failed') ? 1 : 0)
void resolve();
process.exit(result.includes('failed') ? 1 : 0);
},
{},
args,
)
})
);
});
}

View File

@@ -267,7 +267,9 @@ export class StructuredIO {
getPendingPermissionRequests() {
return Array.from(this.pendingRequests.values())
.map(entry => entry.request)
.filter(pr => (pr.request as { subtype?: string }).subtype === 'can_use_tool')
.filter(
pr => (pr.request as { subtype?: string }).subtype === 'can_use_tool',
)
}
setUnexpectedResponseCallback(
@@ -285,7 +287,14 @@ export class StructuredIO {
* callback is aborted via the signal — otherwise the callback hangs.
*/
injectControlResponse(response: SDKControlResponse): void {
const responseInner = response.response as { request_id?: string; subtype?: string; error?: string; response?: unknown } | undefined
const responseInner = response.response as
| {
request_id?: string
subtype?: string
error?: string
response?: unknown
}
| undefined
const requestId = responseInner?.request_id
if (!requestId) return
const request = this.pendingRequests.get(requestId as string)
@@ -377,7 +386,12 @@ export class StructuredIO {
if (uuid) {
notifyCommandLifecycle(uuid, 'completed')
}
const resp = message.response as { request_id: string; subtype: string; response?: Record<string, unknown>; error?: string }
const resp = message.response as {
request_id: string
subtype: string
response?: Record<string, unknown>
error?: string
}
const request = this.pendingRequests.get(resp.request_id)
if (!request) {
// Check if this tool_use was already resolved through the normal
@@ -386,9 +400,7 @@ export class StructuredIO {
// re-processing them would push duplicate assistant messages into
// the conversation, causing API 400 errors.
const responsePayload =
resp.subtype === 'success'
? resp.response
: undefined
resp.subtype === 'success' ? resp.response : undefined
const toolUseID = responsePayload?.toolUseID
if (
typeof toolUseID === 'string' &&
@@ -400,7 +412,9 @@ export class StructuredIO {
return undefined
}
if (this.unexpectedResponseCallback) {
await this.unexpectedResponseCallback(message as SDKControlResponse & { uuid?: string })
await this.unexpectedResponseCallback(
message as SDKControlResponse & { uuid?: string },
)
}
return undefined // Ignore responses for requests we don't know about
}
@@ -409,7 +423,8 @@ export class StructuredIO {
// Notify the bridge when the SDK consumer resolves a can_use_tool
// request, so it can cancel the stale permission prompt on claude.ai.
if (
(request.request.request as { subtype?: string }).subtype === 'can_use_tool' &&
(request.request.request as { subtype?: string }).subtype ===
'can_use_tool' &&
this.onControlRequestResolved
) {
this.onControlRequestResolved(resp.request_id)
@@ -455,7 +470,9 @@ export class StructuredIO {
if (message.type === 'assistant' || message.type === 'system') {
return message
}
if ((message as { message?: { role?: string } }).message?.role !== 'user') {
if (
(message as { message?: { role?: string } }).message?.role !== 'user'
) {
exitWithMessage(
`Error: Expected message role 'user', got '${(message as { message?: { role?: string } }).message?.role}'`,
)
@@ -490,7 +507,10 @@ export class StructuredIO {
throw new Error('Request aborted')
}
this.outbound.enqueue(message)
if ((request as { subtype?: string }).subtype === 'can_use_tool' && this.onControlRequestSent) {
if (
(request as { subtype?: string }).subtype === 'can_use_tool' &&
this.onControlRequestSent
) {
this.onControlRequestSent(message)
}
const aborted = () => {
@@ -820,7 +840,8 @@ async function executePermissionRequestHooksForSDK(
const finalInput = decision.updatedInput || input
// Apply permission updates if provided by hook ("always allow")
const permissionUpdates = (decision.updatedPermissions ?? []) as unknown as InternalPermissionUpdate[]
const permissionUpdates = (decision.updatedPermissions ??
[]) as unknown as InternalPermissionUpdate[]
if (permissionUpdates.length > 0) {
persistPermissionUpdates(permissionUpdates)
const currentAppState = toolUseContext.getAppState()

View File

@@ -244,7 +244,7 @@ export class HybridTransport extends WebSocketTransport {
) {
rcLog(
`Hybrid POST ${response.status}: url=${this.postUrl.replace(/token=[^&]+/, 'token=***')}` +
` events=${events.length} body=${JSON.stringify(response.data).slice(0, 200)}`,
` events=${events.length} body=${JSON.stringify(response.data).slice(0, 200)}`,
)
logForDebugging(
`HybridTransport: POST returned ${response.status} (permanent), dropping`,

View File

@@ -82,9 +82,7 @@ export function parseSSEFrames(buffer: string): {
for (const rawLine of rawFrame.split('\n')) {
// Normalize CRLF lines in mixed-line-ending streams.
const line =
rawLine[rawLine.length - 1] === '\r'
? rawLine.slice(0, -1)
: rawLine
rawLine[rawLine.length - 1] === '\r' ? rawLine.slice(0, -1) : rawLine
if (line.startsWith(':')) {
// SSE comment (e.g., `:keepalive`)
@@ -482,9 +480,9 @@ export class SSETransport implements Transport {
private handleConnectionError(): void {
rcLog(
`SSE handleConnectionError: state=${this.state}` +
` lastSeqNum=${this.getLastSequenceNum()}` +
` reconnectAttempts=${this.reconnectAttempts}` +
` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}`,
` lastSeqNum=${this.getLastSequenceNum()}` +
` reconnectAttempts=${this.reconnectAttempts}` +
` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}`,
)
this.clearLivenessTimer()
@@ -561,8 +559,8 @@ export class SSETransport implements Transport {
this.livenessTimer = null
rcLog(
`SSE liveness timeout (${LIVENESS_TIMEOUT_MS}ms)` +
` lastSeqNum=${this.getLastSequenceNum()}` +
` state=${this.state}`,
` lastSeqNum=${this.getLastSequenceNum()}` +
` state=${this.state}`,
)
logForDebugging('SSETransport: Liveness timeout, reconnecting', {
level: 'error',

View File

@@ -1,2 +1,2 @@
// Auto-generated stub — replace with real implementation
export type Transport = any;
export type Transport = any

View File

@@ -398,10 +398,10 @@ export class WebSocketTransport implements Transport {
private handleConnectionError(closeCode?: number): void {
rcLog(
`WS handleConnectionError: code=${closeCode}` +
` state=${this.state}` +
` url=${this.url.href.replace(/token=[^&]+/, 'token=***')}` +
` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}` +
` reconnectAttempts=${this.reconnectAttempts}`,
` state=${this.state}` +
` url=${this.url.href.replace(/token=[^&]+/, 'token=***')}` +
` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}` +
` reconnectAttempts=${this.reconnectAttempts}`,
)
logForDebugging(
`WebSocketTransport: Disconnected from ${this.url.href}` +

View File

@@ -35,7 +35,8 @@ describe('parseSSEFrames', () => {
})
test('keeps incomplete trailing frame in remaining buffer for CRLF streams', () => {
const input = 'event: client_event\r\ndata: {"ok":true}\r\n\r\ndata: {"tail":1}\r\n'
const input =
'event: client_event\r\ndata: {"ok":true}\r\n\r\ndata: {"tail":1}\r\n'
const { frames, remaining } = parseSSEFrames(input)
expect(frames).toEqual([

View File

@@ -180,7 +180,10 @@ export function accumulateStreamEvents(
chunks.push(delta.text as string)
const existing = touched.get(chunks)
if (existing) {
;(existing.event as Record<string, unknown>).delta = { type: 'text_delta', text: chunks.join('') }
;(existing.event as Record<string, unknown>).delta = {
type: 'text_delta',
text: chunks.join(''),
}
break
}
const snapshot: CoalescedStreamEvent = {
@@ -430,7 +433,10 @@ export class CCRClient {
'delivery batch',
)
if (!result.ok) {
throw new RetryableError('delivery POST failed', (result as any).retryAfterMs)
throw new RetryableError(
'delivery POST failed',
(result as any).retryAfterMs,
)
}
},
baseDelayMs: 500,
@@ -749,7 +755,14 @@ export class CCRClient {
}
await this.flushStreamEventBuffer()
if (message.type === 'assistant') {
clearStreamAccumulatorForMessage(this.streamTextAccumulator, message as { session_id: string; parent_tool_use_id: string | null; message: { id: string } })
clearStreamAccumulatorForMessage(
this.streamTextAccumulator,
message as {
session_id: string
parent_tool_use_id: string | null
message: { id: string }
},
)
}
await this.eventUploader.enqueue(this.toClientEvent(message))
}

View File

@@ -74,10 +74,9 @@ const assistantCommand = feature('KAIROS')
const bridge = feature('BRIDGE_MODE')
? require('./commands/bridge/index.js').default
: null
const remoteControlServerCommand =
feature('BRIDGE_MODE')
? require('./commands/remoteControlServer/index.js').default
: null
const remoteControlServerCommand = feature('BRIDGE_MODE')
? require('./commands/remoteControlServer/index.js').default
: null
const voiceCommand = feature('VOICE_MODE')
? require('./commands/voice/index.js').default
: null

View File

@@ -1,42 +1,33 @@
import chalk from 'chalk'
import figures from 'figures'
import React, { useEffect } from 'react'
import {
getAdditionalDirectoriesForClaudeMd,
setAdditionalDirectoriesForClaudeMd,
} from '../../bootstrap/state.js'
import type { LocalJSXCommandContext } from '../../commands.js'
import { MessageResponse } from '../../components/MessageResponse.js'
import { AddWorkspaceDirectory } from '../../components/permissions/rules/AddWorkspaceDirectory.js'
import { Box, Text } from '@anthropic/ink'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import {
applyPermissionUpdate,
persistPermissionUpdate,
} from '../../utils/permissions/PermissionUpdate.js'
import type { PermissionUpdateDestination } from '../../utils/permissions/PermissionUpdateSchema.js'
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'
import {
addDirHelpMessage,
validateDirectoryForWorkspace,
} from './validation.js'
import chalk from 'chalk';
import figures from 'figures';
import React, { useEffect } from 'react';
import { getAdditionalDirectoriesForClaudeMd, setAdditionalDirectoriesForClaudeMd } from '../../bootstrap/state.js';
import type { LocalJSXCommandContext } from '../../commands.js';
import { MessageResponse } from '../../components/MessageResponse.js';
import { AddWorkspaceDirectory } from '../../components/permissions/rules/AddWorkspaceDirectory.js';
import { Box, Text } from '@anthropic/ink';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import { applyPermissionUpdate, persistPermissionUpdate } from '../../utils/permissions/PermissionUpdate.js';
import type { PermissionUpdateDestination } from '../../utils/permissions/PermissionUpdateSchema.js';
import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js';
import { addDirHelpMessage, validateDirectoryForWorkspace } from './validation.js';
function AddDirError({
message,
args,
onDone,
}: {
message: string
args: string
onDone: () => void
message: string;
args: string;
onDone: () => void;
}): React.ReactNode {
useEffect(() => {
// We need to defer calling onDone to avoid the "return null" bug where
// the component unmounts before React can render the error message.
// Using setTimeout ensures the error displays before the command exits.
const timer = setTimeout(onDone, 0)
return () => clearTimeout(timer)
}, [onDone])
const timer = setTimeout(onDone, 0);
return () => clearTimeout(timer);
}, [onDone]);
return (
<Box flexDirection="column">
@@ -47,7 +38,7 @@ function AddDirError({
<Text>{message}</Text>
</MessageResponse>
</Box>
)
);
}
export async function call(
@@ -55,58 +46,53 @@ export async function call(
context: LocalJSXCommandContext,
args?: string,
): Promise<React.ReactNode> {
const directoryPath = (args ?? '').trim()
const appState = context.getAppState()
const directoryPath = (args ?? '').trim();
const appState = context.getAppState();
// Helper to handle adding a directory (shared by both with-path and no-path cases)
const handleAddDirectory = async (path: string, remember = false) => {
const destination: PermissionUpdateDestination = remember
? 'localSettings'
: 'session'
const destination: PermissionUpdateDestination = remember ? 'localSettings' : 'session';
const permissionUpdate = {
type: 'addDirectories' as const,
directories: [path],
destination,
}
};
// Apply to session context
const latestAppState = context.getAppState()
const updatedContext = applyPermissionUpdate(
latestAppState.toolPermissionContext,
permissionUpdate,
)
const latestAppState = context.getAppState();
const updatedContext = applyPermissionUpdate(latestAppState.toolPermissionContext, permissionUpdate);
context.setAppState(prev => ({
...prev,
toolPermissionContext: updatedContext,
}))
}));
// Update sandbox config so Bash commands can access the new directory.
// Bootstrap state is the source of truth for session-only dirs; persisted
// dirs are picked up via the settings subscription, but we refresh
// eagerly here to avoid a race when the user acts immediately.
const currentDirs = getAdditionalDirectoriesForClaudeMd()
const currentDirs = getAdditionalDirectoriesForClaudeMd();
if (!currentDirs.includes(path)) {
setAdditionalDirectoriesForClaudeMd([...currentDirs, path])
setAdditionalDirectoriesForClaudeMd([...currentDirs, path]);
}
SandboxManager.refreshConfig()
SandboxManager.refreshConfig();
let message: string
let message: string;
if (remember) {
try {
persistPermissionUpdate(permissionUpdate)
message = `Added ${chalk.bold(path)} as a working directory and saved to local settings`
persistPermissionUpdate(permissionUpdate);
message = `Added ${chalk.bold(path)} as a working directory and saved to local settings`;
} catch (error) {
message = `Added ${chalk.bold(path)} as a working directory. Failed to save to local settings: ${error instanceof Error ? error.message : 'Unknown error'}`
message = `Added ${chalk.bold(path)} as a working directory. Failed to save to local settings: ${error instanceof Error ? error.message : 'Unknown error'}`;
}
} else {
message = `Added ${chalk.bold(path)} as a working directory for this session`
message = `Added ${chalk.bold(path)} as a working directory for this session`;
}
const messageWithHint = `${message} ${chalk.dim('· /permissions to manage')}`
onDone(messageWithHint)
}
const messageWithHint = `${message} ${chalk.dim('· /permissions to manage')}`;
onDone(messageWithHint);
};
// When no path is provided, show AddWorkspaceDirectory input form directly
// and return to REPL after confirmation
@@ -116,27 +102,18 @@ export async function call(
permissionContext={appState.toolPermissionContext}
onAddDirectory={handleAddDirectory}
onCancel={() => {
onDone('Did not add a working directory.')
onDone('Did not add a working directory.');
}}
/>
)
);
}
const result = await validateDirectoryForWorkspace(
directoryPath,
appState.toolPermissionContext,
)
const result = await validateDirectoryForWorkspace(directoryPath, appState.toolPermissionContext);
if (result.resultType !== 'success') {
const message = addDirHelpMessage(result)
const message = addDirHelpMessage(result);
return (
<AddDirError
message={message}
args={args ?? ''}
onDone={() => onDone(message)}
/>
)
return <AddDirError message={message} args={args ?? ''} onDone={() => onDone(message)} />;
}
return (
@@ -145,10 +122,8 @@ export async function call(
permissionContext={appState.toolPermissionContext}
onAddDirectory={handleAddDirectory}
onCancel={() => {
onDone(
`Did not add ${chalk.bold(result.absolutePath)} as a working directory.`,
)
onDone(`Did not add ${chalk.bold(result.absolutePath)} as a working directory.`);
}}
/>
)
);
}

View File

@@ -1 +1,5 @@
export default { name: 'agents-platform', type: 'local', isEnabled: () => false }
export default {
name: 'agents-platform',
type: 'local',
isEnabled: () => false,
}

View File

@@ -1,16 +1,13 @@
import * as React from 'react'
import { AgentsMenu } from '../../components/agents/AgentsMenu.js'
import type { ToolUseContext } from '../../Tool.js'
import { getTools } from '../../tools.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import * as React from 'react';
import { AgentsMenu } from '../../components/agents/AgentsMenu.js';
import type { ToolUseContext } from '../../Tool.js';
import { getTools } from '../../tools.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
export async function call(
onDone: LocalJSXCommandOnDone,
context: ToolUseContext,
): Promise<React.ReactNode> {
const appState = context.getAppState()
const permissionContext = appState.toolPermissionContext
const tools = getTools(permissionContext)
export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext): Promise<React.ReactNode> {
const appState = context.getAppState();
const permissionContext = appState.toolPermissionContext;
const tools = getTools(permissionContext);
return <AgentsMenu tools={tools} onExit={onDone} />
return <AgentsMenu tools={tools} onExit={onDone} />;
}

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -44,8 +44,10 @@ export function deriveFirstPrompt(
typeof content === 'string'
? content
: content.find(
(block: { type: string; text?: string }): block is { type: 'text'; text: string } =>
block.type === 'text',
(block: {
type: string
text?: string
}): block is { type: 'text'; text: string } => block.type === 'text',
)?.text
if (!raw) return 'Branched conversation'
return (
@@ -240,7 +242,9 @@ export async function call(
// Build LogOption for resume
const now = new Date()
const firstPrompt = deriveFirstPrompt(
serializedMessages.find(m => m.type === 'user') as Extract<SerializedMessage, { type: 'user' }> | undefined,
serializedMessages.find(m => m.type === 'user') as
| Extract<SerializedMessage, { type: 'user' }>
| undefined,
)
// Save custom title - use provided title or firstPrompt as default

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -1,39 +1,29 @@
import { feature } from 'bun:bundle'
import { toString as qrToString } from 'qrcode'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { getBridgeAccessToken } from '../../bridge/bridgeConfig.js'
import {
checkBridgeMinVersion,
getBridgeDisabledReason,
isEnvLessBridgeEnabled,
} from '../../bridge/bridgeEnabled.js'
import { checkEnvLessBridgeMinVersion } from '../../bridge/envLessBridgeConfig.js'
import {
BRIDGE_LOGIN_INSTRUCTION,
REMOTE_CONTROL_DISCONNECTED_MSG,
} from '../../bridge/types.js'
import { Dialog, ListItem } from '@anthropic/ink'
import { shouldShowRemoteCallout } from '../../components/RemoteCallout.js'
import { useRegisterOverlay } from '../../context/overlayContext.js'
import { Box, Text } from '@anthropic/ink'
import { useKeybindings } from '../../keybindings/useKeybinding.js'
import { feature } from 'bun:bundle';
import { toString as qrToString } from 'qrcode';
import * as React from 'react';
import { useEffect, useState } from 'react';
import { getBridgeAccessToken } from '../../bridge/bridgeConfig.js';
import { checkBridgeMinVersion, getBridgeDisabledReason, isEnvLessBridgeEnabled } from '../../bridge/bridgeEnabled.js';
import { checkEnvLessBridgeMinVersion } from '../../bridge/envLessBridgeConfig.js';
import { BRIDGE_LOGIN_INSTRUCTION, REMOTE_CONTROL_DISCONNECTED_MSG } from '../../bridge/types.js';
import { Dialog, ListItem } from '@anthropic/ink';
import { shouldShowRemoteCallout } from '../../components/RemoteCallout.js';
import { useRegisterOverlay } from '../../context/overlayContext.js';
import { Box, Text } from '@anthropic/ink';
import { useKeybindings } from '../../keybindings/useKeybinding.js';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import { useAppState, useSetAppState } from '../../state/AppState.js'
import type { ToolUseContext } from '../../Tool.js'
import type {
LocalJSXCommandContext,
LocalJSXCommandOnDone,
} from '../../types/command.js'
import { logForDebugging } from '../../utils/debug.js'
} from '../../services/analytics/index.js';
import { useAppState, useSetAppState } from '../../state/AppState.js';
import type { ToolUseContext } from '../../Tool.js';
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
import { logForDebugging } from '../../utils/debug.js';
type Props = {
onDone: LocalJSXCommandOnDone
name?: string
}
onDone: LocalJSXCommandOnDone;
name?: string;
};
/**
* /remote-control command — manages the bidirectional bridge connection.
@@ -48,34 +38,33 @@ type Props = {
* URL and options to disconnect or continue.
*/
function BridgeToggle({ onDone, name }: Props): React.ReactNode {
const setAppState = useSetAppState()
const replBridgeConnected = useAppState(s => s.replBridgeConnected)
const replBridgeEnabled = useAppState(s => s.replBridgeEnabled)
const replBridgeOutboundOnly = useAppState(s => s.replBridgeOutboundOnly)
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false)
const setAppState = useSetAppState();
const replBridgeConnected = useAppState(s => s.replBridgeConnected);
const replBridgeEnabled = useAppState(s => s.replBridgeEnabled);
const replBridgeOutboundOnly = useAppState(s => s.replBridgeOutboundOnly);
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false);
useEffect(() => {
// If already connected or enabled in full bidirectional mode, show
// disconnect confirmation. Outbound-only (CCR mirror) doesn't count —
// /remote-control upgrades it to full RC instead.
if ((replBridgeConnected || replBridgeEnabled) && !replBridgeOutboundOnly) {
setShowDisconnectDialog(true)
return
setShowDisconnectDialog(true);
return;
}
let cancelled = false
let cancelled = false;
void (async () => {
// Pre-flight checks before enabling (awaits GrowthBook init if disk
// cache is stale — so Max users don't get a false "not enabled" error)
const error = await checkBridgePrerequisites()
if (cancelled) return
const error = await checkBridgePrerequisites();
if (cancelled) return;
if (error) {
logEvent('tengu_bridge_command', {
action:
'preflight_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
onDone(error, { display: 'system' })
return
action: 'preflight_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(error, { display: 'system' });
return;
}
// Show first-time remote dialog if not yet seen.
@@ -83,48 +72,47 @@ function BridgeToggle({ onDone, name }: Props): React.ReactNode {
// enables the bridge (the handler only sets replBridgeEnabled, not the name).
if (shouldShowRemoteCallout()) {
setAppState(prev => {
if (prev.showRemoteCallout) return prev
if (prev.showRemoteCallout) return prev;
return {
...prev,
showRemoteCallout: true,
replBridgeInitialName: name,
}
})
onDone('', { display: 'system' })
return
};
});
onDone('', { display: 'system' });
return;
}
// Enable the bridge — useReplBridge in REPL.tsx handles the rest:
// registers environment, creates session with conversation, connects WebSocket
logEvent('tengu_bridge_command', {
action:
'connect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
action: 'connect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
setAppState(prev => {
if (prev.replBridgeEnabled && !prev.replBridgeOutboundOnly) return prev
if (prev.replBridgeEnabled && !prev.replBridgeOutboundOnly) return prev;
return {
...prev,
replBridgeEnabled: true,
replBridgeExplicit: true,
replBridgeOutboundOnly: false,
replBridgeInitialName: name,
}
})
};
});
onDone('Remote Control connecting\u2026', {
display: 'system',
})
})()
});
})();
return () => {
cancelled = true
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount
cancelled = true;
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount
if (showDisconnectDialog) {
return <BridgeDisconnectDialog onDone={onDone} />
return <BridgeDisconnectDialog onDone={onDone} />;
}
return null
return null;
}
/**
@@ -132,22 +120,22 @@ function BridgeToggle({ onDone, name }: Props): React.ReactNode {
* Shows the session URL and lets the user disconnect or continue.
*/
function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode {
useRegisterOverlay('bridge-disconnect-dialog')
const setAppState = useSetAppState()
const sessionUrl = useAppState(s => s.replBridgeSessionUrl)
const connectUrl = useAppState(s => s.replBridgeConnectUrl)
const sessionActive = useAppState(s => s.replBridgeSessionActive)
const [focusIndex, setFocusIndex] = useState(2)
const [showQR, setShowQR] = useState(false)
const [qrText, setQrText] = useState('')
useRegisterOverlay('bridge-disconnect-dialog');
const setAppState = useSetAppState();
const sessionUrl = useAppState(s => s.replBridgeSessionUrl);
const connectUrl = useAppState(s => s.replBridgeConnectUrl);
const sessionActive = useAppState(s => s.replBridgeSessionActive);
const [focusIndex, setFocusIndex] = useState(2);
const [showQR, setShowQR] = useState(false);
const [qrText, setQrText] = useState('');
const displayUrl = sessionActive ? sessionUrl : connectUrl
const displayUrl = sessionActive ? sessionUrl : connectUrl;
// Generate QR code when URL changes or QR is toggled on
useEffect(() => {
if (!showQR || !displayUrl) {
setQrText('')
return
setQrText('');
return;
}
qrToString(displayUrl, {
type: 'utf8',
@@ -155,55 +143,53 @@ function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode {
small: true,
} as Parameters<typeof qrToString>[1])
.then(setQrText)
.catch(() => setQrText(''))
}, [showQR, displayUrl])
.catch(() => setQrText(''));
}, [showQR, displayUrl]);
function handleDisconnect(): void {
setAppState(prev => {
if (!prev.replBridgeEnabled) return prev
if (!prev.replBridgeEnabled) return prev;
return {
...prev,
replBridgeEnabled: false,
replBridgeExplicit: false,
replBridgeOutboundOnly: false,
}
})
};
});
logEvent('tengu_bridge_command', {
action:
'disconnect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
onDone(REMOTE_CONTROL_DISCONNECTED_MSG, { display: 'system' })
action: 'disconnect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
onDone(REMOTE_CONTROL_DISCONNECTED_MSG, { display: 'system' });
}
function handleShowQR(): void {
setShowQR(prev => !prev)
setShowQR(prev => !prev);
}
function handleContinue(): void {
onDone(undefined, { display: 'skip' })
onDone(undefined, { display: 'skip' });
}
const ITEM_COUNT = 3
const ITEM_COUNT = 3;
useKeybindings(
{
'select:next': () => setFocusIndex(i => (i + 1) % ITEM_COUNT),
'select:previous': () =>
setFocusIndex(i => (i - 1 + ITEM_COUNT) % ITEM_COUNT),
'select:previous': () => setFocusIndex(i => (i - 1 + ITEM_COUNT) % ITEM_COUNT),
'select:accept': () => {
if (focusIndex === 0) {
handleDisconnect()
handleDisconnect();
} else if (focusIndex === 1) {
handleShowQR()
handleShowQR();
} else {
handleContinue()
handleContinue();
}
},
},
{ context: 'Select' },
)
);
const qrLines = qrText ? qrText.split('\n').filter(l => l.length > 0) : []
const qrLines = qrText ? qrText.split('\n').filter(l => l.length > 0) : [];
return (
<Dialog title="Remote Control" onCancel={handleContinue} hideInputGuide>
@@ -233,7 +219,7 @@ function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode {
<Text dimColor>Enter to select · Esc to continue</Text>
</Box>
</Dialog>
)
);
}
/**
@@ -244,43 +230,39 @@ function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode {
*/
async function checkBridgePrerequisites(): Promise<string | null> {
// Check organization policy — remote control may be disabled
const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import(
'../../services/policyLimits/index.js'
)
await waitForPolicyLimitsToLoad()
const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import('../../services/policyLimits/index.js');
await waitForPolicyLimitsToLoad();
if (!isPolicyAllowed('allow_remote_control')) {
return "Remote Control is disabled by your organization's policy."
return "Remote Control is disabled by your organization's policy.";
}
const disabledReason = await getBridgeDisabledReason()
const disabledReason = await getBridgeDisabledReason();
if (disabledReason) {
return disabledReason
return disabledReason;
}
// Mirror the v1/v2 branching logic in initReplBridge: env-less (v2) is used
// only when the flag is on AND the session is not perpetual. In assistant
// mode (KAIROS) useReplBridge sets perpetual=true, which forces
// initReplBridge onto the v1 path — so the prerequisite check must match.
let useV2 = isEnvLessBridgeEnabled()
let useV2 = isEnvLessBridgeEnabled();
if (feature('KAIROS') && useV2) {
const { isAssistantMode } = await import('../../assistant/index.js')
const { isAssistantMode } = await import('../../assistant/index.js');
if (isAssistantMode()) {
useV2 = false
useV2 = false;
}
}
const versionError = useV2
? await checkEnvLessBridgeMinVersion()
: checkBridgeMinVersion()
const versionError = useV2 ? await checkEnvLessBridgeMinVersion() : checkBridgeMinVersion();
if (versionError) {
return versionError
return versionError;
}
if (!getBridgeAccessToken()) {
return BRIDGE_LOGIN_INSTRUCTION
return BRIDGE_LOGIN_INSTRUCTION;
}
logForDebugging('[bridge] Prerequisites passed, enabling bridge')
return null
logForDebugging('[bridge] Prerequisites passed, enabling bridge');
return null;
}
export async function call(
@@ -288,6 +270,6 @@ export async function call(
_context: ToolUseContext & LocalJSXCommandContext,
args: string,
): Promise<React.ReactNode> {
const name = args.trim() || undefined
return <BridgeToggle onDone={onDone} name={name} />
const name = args.trim() || undefined;
return <BridgeToggle onDone={onDone} name={name} />;
}

View File

@@ -1,118 +1,96 @@
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import { useInterval } from 'usehooks-ts'
import type { CommandResultDisplay } from '../../commands.js'
import { Markdown } from '../../components/Markdown.js'
import { SpinnerGlyph } from '../../components/Spinner/SpinnerGlyph.js'
import { DOWN_ARROW, UP_ARROW } from '../../constants/figures.js'
import { getSystemPrompt } from '../../constants/prompts.js'
import { useModalOrTerminalSize } from '../../context/modalContext.js'
import { getSystemContext, getUserContext } from '../../context.js'
import { useTerminalSize } from '../../hooks/useTerminalSize.js'
import { type KeyboardEvent, type ScrollBoxHandle, ScrollBox } from '@anthropic/ink'
import { Box, Text } from '@anthropic/ink'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import type { Message } from '../../types/message.js'
import { createAbortController } from '../../utils/abortController.js'
import { saveGlobalConfig } from '../../utils/config.js'
import { errorMessage } from '../../utils/errors.js'
import {
type CacheSafeParams,
getLastCacheSafeParams,
} from '../../utils/forkedAgent.js'
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'
import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'
import { runSideQuestion } from '../../utils/sideQuestion.js'
import { asSystemPrompt } from '../../utils/systemPromptType.js'
import * as React from 'react';
import { useEffect, useRef, useState } from 'react';
import { useInterval } from 'usehooks-ts';
import type { CommandResultDisplay } from '../../commands.js';
import { Markdown } from '../../components/Markdown.js';
import { SpinnerGlyph } from '../../components/Spinner/SpinnerGlyph.js';
import { DOWN_ARROW, UP_ARROW } from '../../constants/figures.js';
import { getSystemPrompt } from '../../constants/prompts.js';
import { useModalOrTerminalSize } from '../../context/modalContext.js';
import { getSystemContext, getUserContext } from '../../context.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { type KeyboardEvent, type ScrollBoxHandle, ScrollBox } from '@anthropic/ink';
import { Box, Text } from '@anthropic/ink';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import type { Message } from '../../types/message.js';
import { createAbortController } from '../../utils/abortController.js';
import { saveGlobalConfig } from '../../utils/config.js';
import { errorMessage } from '../../utils/errors.js';
import { type CacheSafeParams, getLastCacheSafeParams } from '../../utils/forkedAgent.js';
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js';
import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js';
import { runSideQuestion } from '../../utils/sideQuestion.js';
import { asSystemPrompt } from '../../utils/systemPromptType.js';
type BtwComponentProps = {
question: string
context: ProcessUserInputContext
onDone: (
result?: string,
options?: { display?: CommandResultDisplay },
) => void
}
question: string;
context: ProcessUserInputContext;
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
};
const CHROME_ROWS = 5
const OUTER_CHROME_ROWS = 6
const SCROLL_LINES = 3
const CHROME_ROWS = 5;
const OUTER_CHROME_ROWS = 6;
const SCROLL_LINES = 3;
function BtwSideQuestion({
question,
context,
onDone,
}: BtwComponentProps): React.ReactNode {
const [response, setResponse] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [frame, setFrame] = useState(0)
const scrollRef = useRef<ScrollBoxHandle>(null)
const { rows } = useModalOrTerminalSize(useTerminalSize())
function BtwSideQuestion({ question, context, onDone }: BtwComponentProps): React.ReactNode {
const [response, setResponse] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [frame, setFrame] = useState(0);
const scrollRef = useRef<ScrollBoxHandle>(null);
const { rows } = useModalOrTerminalSize(useTerminalSize());
// Animate spinner while loading
useInterval(() => setFrame(f => f + 1), response || error ? null : 80)
useInterval(() => setFrame(f => f + 1), response || error ? null : 80);
function handleKeyDown(e: KeyboardEvent): void {
if (
e.key === 'escape' ||
e.key === 'return' ||
e.key === ' ' ||
(e.ctrl && (e.key === 'c' || e.key === 'd'))
) {
e.preventDefault()
onDone(undefined, { display: 'skip' })
return
if (e.key === 'escape' || e.key === 'return' || e.key === ' ' || (e.ctrl && (e.key === 'c' || e.key === 'd'))) {
e.preventDefault();
onDone(undefined, { display: 'skip' });
return;
}
if (e.key === 'up' || (e.ctrl && e.key === 'p')) {
e.preventDefault()
scrollRef.current?.scrollBy(-SCROLL_LINES)
e.preventDefault();
scrollRef.current?.scrollBy(-SCROLL_LINES);
}
if (e.key === 'down' || (e.ctrl && e.key === 'n')) {
e.preventDefault()
scrollRef.current?.scrollBy(SCROLL_LINES)
e.preventDefault();
scrollRef.current?.scrollBy(SCROLL_LINES);
}
}
useEffect(() => {
const abortController = createAbortController()
const abortController = createAbortController();
async function fetchResponse(): Promise<void> {
try {
const cacheSafeParams = await buildCacheSafeParams(context)
const result = await runSideQuestion({ question, cacheSafeParams })
const cacheSafeParams = await buildCacheSafeParams(context);
const result = await runSideQuestion({ question, cacheSafeParams });
if (!abortController.signal.aborted) {
if (result.response) {
setResponse(result.response)
setResponse(result.response);
} else {
setError('No response received')
setError('No response received');
}
}
} catch (err) {
if (!abortController.signal.aborted) {
setError(errorMessage(err) || 'Failed to get response')
setError(errorMessage(err) || 'Failed to get response');
}
}
}
void fetchResponse()
void fetchResponse();
return () => {
abortController.abort()
}
}, [question, context])
abortController.abort();
};
}, [question, context]);
const maxContentHeight = Math.max(5, rows - CHROME_ROWS - OUTER_CHROME_ROWS)
const maxContentHeight = Math.max(5, rows - CHROME_ROWS - OUTER_CHROME_ROWS);
return (
<Box
flexDirection="column"
paddingLeft={2}
marginTop={1}
tabIndex={0}
autoFocus
onKeyDown={handleKeyDown}
>
<Box flexDirection="column" paddingLeft={2} marginTop={1} tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
<Box>
<Text color="warning" bold>
/btw{' '}
@@ -136,13 +114,12 @@ function BtwSideQuestion({
{(response || error) && (
<Box marginTop={1}>
<Text dimColor>
{UP_ARROW}/{DOWN_ARROW} to scroll · Space, Enter, or Escape to
dismiss
{UP_ARROW}/{DOWN_ARROW} to scroll · Space, Enter, or Escape to dismiss
</Text>
</Box>
)}
</Box>
)
);
}
/**
@@ -161,20 +138,16 @@ function BtwSideQuestion({
* --append-system-prompt, coordinator mode).
*/
function stripInProgressAssistantMessage(messages: Message[]): Message[] {
const last = messages.at(-1)
const last = messages.at(-1);
if (last?.type === 'assistant' && last.message!.stop_reason === null) {
return messages.slice(0, -1)
return messages.slice(0, -1);
}
return messages
return messages;
}
async function buildCacheSafeParams(
context: ProcessUserInputContext,
): Promise<CacheSafeParams> {
const forkContextMessages = getMessagesAfterCompactBoundary(
stripInProgressAssistantMessage(context.messages),
)
const saved = getLastCacheSafeParams()
async function buildCacheSafeParams(context: ProcessUserInputContext): Promise<CacheSafeParams> {
const forkContextMessages = getMessagesAfterCompactBoundary(stripInProgressAssistantMessage(context.messages));
const saved = getLastCacheSafeParams();
if (saved) {
return {
systemPrompt: saved.systemPrompt,
@@ -182,25 +155,20 @@ async function buildCacheSafeParams(
systemContext: saved.systemContext,
toolUseContext: context,
forkContextMessages,
}
};
}
const [rawSystemPrompt, userContext, systemContext] = await Promise.all([
getSystemPrompt(
context.options.tools,
context.options.mainLoopModel,
[],
context.options.mcpClients,
),
getSystemPrompt(context.options.tools, context.options.mainLoopModel, [], context.options.mcpClients),
getUserContext(),
getSystemContext(),
])
]);
return {
systemPrompt: asSystemPrompt(rawSystemPrompt),
userContext,
systemContext,
toolUseContext: context,
forkContextMessages,
}
};
}
export async function call(
@@ -208,19 +176,17 @@ export async function call(
context: ProcessUserInputContext,
args: string,
): Promise<React.ReactNode> {
const question = args?.trim()
const question = args?.trim();
if (!question) {
onDone('Usage: /btw <your question>', { display: 'system' })
return null
onDone('Usage: /btw <your question>', { display: 'system' });
return null;
}
saveGlobalConfig(current => ({
...current,
btwUseCount: current.btwUseCount + 1,
}))
}));
return (
<BtwSideQuestion question={question} context={context} onDone={onDone} />
)
return <BtwSideQuestion question={question} context={context} onDone={onDone} />;
}

View File

@@ -128,7 +128,9 @@ export async function call(
return React.createElement(CompanionCard, {
companion,
lastReaction,
onDone: onDone as unknown as Parameters<typeof CompanionCard>[0]['onDone'],
onDone: onDone as unknown as Parameters<
typeof CompanionCard
>[0]['onDone'],
})
}

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -1,39 +1,29 @@
import React, { useState } from 'react'
import {
type OptionWithDescription,
Select,
} from '../../components/CustomSelect/select.js'
import { Dialog } from '@anthropic/ink'
import { Box, Text } from '@anthropic/ink'
import { useAppState } from '../../state/AppState.js'
import { isClaudeAISubscriber } from '../../utils/auth.js'
import { openBrowser } from '../../utils/browser.js'
import {
CLAUDE_IN_CHROME_MCP_SERVER_NAME,
openInChrome,
} from '../../utils/claudeInChrome/common.js'
import { isChromeExtensionInstalled } from '../../utils/claudeInChrome/setup.js'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import { env } from '../../utils/env.js'
import { isRunningOnHomespace } from '../../utils/envUtils.js'
import React, { useState } from 'react';
import { type OptionWithDescription, Select } from '../../components/CustomSelect/select.js';
import { Dialog } from '@anthropic/ink';
import { Box, Text } from '@anthropic/ink';
import { useAppState } from '../../state/AppState.js';
import { isClaudeAISubscriber } from '../../utils/auth.js';
import { openBrowser } from '../../utils/browser.js';
import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, openInChrome } from '../../utils/claudeInChrome/common.js';
import { isChromeExtensionInstalled } from '../../utils/claudeInChrome/setup.js';
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
import { env } from '../../utils/env.js';
import { isRunningOnHomespace } from '../../utils/envUtils.js';
const CHROME_EXTENSION_URL = 'https://claude.ai/chrome'
const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions'
const CHROME_RECONNECT_URL = 'https://clau.de/chrome/reconnect'
const CHROME_EXTENSION_URL = 'https://claude.ai/chrome';
const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions';
const CHROME_RECONNECT_URL = 'https://clau.de/chrome/reconnect';
type MenuAction =
| 'install-extension'
| 'reconnect'
| 'manage-permissions'
| 'toggle-default'
type MenuAction = 'install-extension' | 'reconnect' | 'manage-permissions' | 'toggle-default';
type Props = {
onDone: (result?: string) => void
isExtensionInstalled: boolean
configEnabled: boolean | undefined
isClaudeAISubscriber: boolean
isWSL: boolean
}
onDone: (result?: string) => void;
isExtensionInstalled: boolean;
configEnabled: boolean | undefined;
isClaudeAISubscriber: boolean;
isWSL: boolean;
};
function ClaudeInChromeMenu({
onDone,
@@ -42,72 +32,66 @@ function ClaudeInChromeMenu({
isClaudeAISubscriber,
isWSL,
}: Props): React.ReactNode {
const mcpClients = useAppState(s => s.mcp.clients)
const [selectKey, setSelectKey] = useState(0)
const [enabledByDefault, setEnabledByDefault] = useState(
configEnabled ?? false,
)
const [showInstallHint, setShowInstallHint] = useState(false)
const [isExtensionInstalled, setIsExtensionInstalled] = useState(installed)
const mcpClients = useAppState(s => s.mcp.clients);
const [selectKey, setSelectKey] = useState(0);
const [enabledByDefault, setEnabledByDefault] = useState(configEnabled ?? false);
const [showInstallHint, setShowInstallHint] = useState(false);
const [isExtensionInstalled, setIsExtensionInstalled] = useState(installed);
const isHomespace = process.env.USER_TYPE === 'ant' && isRunningOnHomespace()
const isHomespace = process.env.USER_TYPE === 'ant' && isRunningOnHomespace();
const chromeClient = mcpClients.find(
c => c.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME,
)
const isConnected = chromeClient?.type === 'connected'
const chromeClient = mcpClients.find(c => c.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME);
const isConnected = chromeClient?.type === 'connected';
function openUrl(url: string): void {
if (isHomespace) {
void openBrowser(url)
void openBrowser(url);
} else {
void openInChrome(url)
void openInChrome(url);
}
}
function handleAction(action: MenuAction): void {
switch (action) {
case 'install-extension':
setSelectKey(k => k + 1)
setShowInstallHint(true)
openUrl(CHROME_EXTENSION_URL)
break
setSelectKey(k => k + 1);
setShowInstallHint(true);
openUrl(CHROME_EXTENSION_URL);
break;
case 'reconnect':
setSelectKey(k => k + 1)
setSelectKey(k => k + 1);
void isChromeExtensionInstalled().then(installed => {
setIsExtensionInstalled(installed)
setIsExtensionInstalled(installed);
if (installed) {
setShowInstallHint(false)
setShowInstallHint(false);
}
})
openUrl(CHROME_RECONNECT_URL)
break
});
openUrl(CHROME_RECONNECT_URL);
break;
case 'manage-permissions':
setSelectKey(k => k + 1)
openUrl(CHROME_PERMISSIONS_URL)
break
setSelectKey(k => k + 1);
openUrl(CHROME_PERMISSIONS_URL);
break;
case 'toggle-default': {
const newValue = !enabledByDefault
const newValue = !enabledByDefault;
saveGlobalConfig(current => ({
...current,
claudeInChromeDefaultEnabled: newValue,
}))
setEnabledByDefault(newValue)
break
}));
setEnabledByDefault(newValue);
break;
}
}
}
const options: OptionWithDescription<MenuAction>[] = []
const requiresExtensionSuffix = isExtensionInstalled
? ''
: ' (requires extension)'
const options: OptionWithDescription<MenuAction>[] = [];
const requiresExtensionSuffix = isExtensionInstalled ? '' : ' (requires extension)';
if (!isExtensionInstalled && !isHomespace) {
options.push({
label: 'Install Chrome extension',
value: 'install-extension',
})
});
}
options.push(
@@ -133,36 +117,23 @@ function ClaudeInChromeMenu({
label: `Enabled by default: ${enabledByDefault ? 'Yes' : 'No'}`,
value: 'toggle-default',
},
)
);
const isDisabled =
isWSL || ((process.env.USER_TYPE as string) !== 'ant' && !isClaudeAISubscriber)
const isDisabled = isWSL || ((process.env.USER_TYPE as string) !== 'ant' && !isClaudeAISubscriber);
return (
<Dialog
title="Claude in Chrome (Beta)"
onCancel={() => onDone()}
color="chromeYellow"
>
<Dialog title="Claude in Chrome (Beta)" onCancel={() => onDone()} color="chromeYellow">
<Box flexDirection="column" gap={1}>
<Text>
Claude in Chrome works with the Chrome extension to let you control
your browser directly from Claude Code. Navigate websites, fill forms,
capture screenshots, record GIFs, and debug with console logs and
network requests.
Claude in Chrome works with the Chrome extension to let you control your browser directly from Claude Code.
Navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and network
requests.
</Text>
{isWSL && (
<Text color="error">
Claude in Chrome is not supported in WSL at this time.
</Text>
)}
{isWSL && <Text color="error">Claude in Chrome is not supported in WSL at this time.</Text>}
{(process.env.USER_TYPE as string) !== 'ant' && !isClaudeAISubscriber && (
<Text color="error">
Claude in Chrome requires a claude.ai subscription.
</Text>
<Text color="error">Claude in Chrome requires a claude.ai subscription.</Text>
)}
{!isDisabled && (
@@ -170,12 +141,7 @@ function ClaudeInChromeMenu({
{!isHomespace && (
<Box flexDirection="column">
<Text>
Status:{' '}
{isConnected ? (
<Text color="success">Enabled</Text>
) : (
<Text color="inactive">Disabled</Text>
)}
Status: {isConnected ? <Text color="success">Enabled</Text> : <Text color="inactive">Disabled</Text>}
</Text>
<Text>
Extension:{' '}
@@ -187,17 +153,10 @@ function ClaudeInChromeMenu({
</Text>
</Box>
)}
<Select
key={selectKey}
options={options}
onChange={handleAction}
hideIndexes
/>
<Select key={selectKey} options={options} onChange={handleAction} hideIndexes />
{showInstallHint && (
<Text color="warning">
Once installed, select {'"Reconnect extension"'} to connect.
</Text>
<Text color="warning">Once installed, select {'"Reconnect extension"'} to connect.</Text>
)}
<Text>
@@ -208,25 +167,22 @@ function ClaudeInChromeMenu({
</Text>
<Text dimColor>
Site-level permissions are inherited from the Chrome extension.
Manage permissions in the Chrome extension settings to control
which sites Claude can browse, click, and type on.
Site-level permissions are inherited from the Chrome extension. Manage permissions in the Chrome extension
settings to control which sites Claude can browse, click, and type on.
</Text>
</>
)}
<Text dimColor>Learn more: https://code.claude.com/docs/en/chrome</Text>
</Box>
</Dialog>
)
);
}
export const call = async function (
onDone: (result?: string) => void,
): Promise<React.ReactNode> {
const isExtensionInstalled = await isChromeExtensionInstalled()
const config = getGlobalConfig()
const isSubscriber = isClaudeAISubscriber()
const isWSL = env.isWslEnvironment()
export const call = async function (onDone: (result?: string) => void): Promise<React.ReactNode> {
const isExtensionInstalled = await isChromeExtensionInstalled();
const config = getGlobalConfig();
const isSubscriber = isClaudeAISubscriber();
const isWSL = env.isWslEnvironment();
return (
<ClaudeInChromeMenu
@@ -236,5 +192,5 @@ export const call = async function (
isClaudeAISubscriber={isSubscriber}
isWSL={isWSL}
/>
)
}
);
};

View File

@@ -93,12 +93,12 @@ export function clearSessionCaches(
// Clear tungsten session usage tracking
if (process.env.USER_TYPE === 'ant') {
void import('@claude-code-best/builtin-tools/tools/TungstenTool/TungstenTool.js').then(
({ clearSessionsWithTungstenUsage, resetInitializationState }) => {
clearSessionsWithTungstenUsage()
resetInitializationState()
},
)
void import(
'@claude-code-best/builtin-tools/tools/TungstenTool/TungstenTool.js'
).then(({ clearSessionsWithTungstenUsage, resetInitializationState }) => {
clearSessionsWithTungstenUsage()
resetInitializationState()
})
}
// Clear attribution caches (file content cache, pending bash states)
// Dynamic import to preserve dead code elimination for COMMIT_ATTRIBUTION feature flag
@@ -126,19 +126,21 @@ export function clearSessionCaches(
// Clear session environment variables
clearSessionEnvVars()
// Clear WebFetch URL cache (up to 50MB of cached page content)
void import('@claude-code-best/builtin-tools/tools/WebFetchTool/utils.js').then(
({ clearWebFetchCache }) => clearWebFetchCache(),
)
void import(
'@claude-code-best/builtin-tools/tools/WebFetchTool/utils.js'
).then(({ clearWebFetchCache }) => clearWebFetchCache())
// Clear ToolSearch description cache (full tool prompts, ~500KB for 50 MCP tools)
void import('@claude-code-best/builtin-tools/tools/ToolSearchTool/ToolSearchTool.js').then(
({ clearToolSearchDescriptionCache }) => clearToolSearchDescriptionCache(),
void import(
'@claude-code-best/builtin-tools/tools/ToolSearchTool/ToolSearchTool.js'
).then(({ clearToolSearchDescriptionCache }) =>
clearToolSearchDescriptionCache(),
)
// Clear agent definitions cache (accumulates per-cwd via EnterWorktreeTool)
void import('@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js').then(
({ clearAgentDefinitionsCache }) => clearAgentDefinitionsCache(),
)
void import(
'@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
).then(({ clearAgentDefinitionsCache }) => clearAgentDefinitionsCache())
// Clear SkillTool prompt cache (accumulates per project root)
void import('@claude-code-best/builtin-tools/tools/SkillTool/prompt.js').then(({ clearPromptCache }) =>
clearPromptCache(),
void import('@claude-code-best/builtin-tools/tools/SkillTool/prompt.js').then(
({ clearPromptCache }) => clearPromptCache(),
)
}

View File

@@ -224,7 +224,7 @@ async function compactViaReactive(
context.setStreamMode?.('requesting')
context.setResponseLength?.(() => 0)
context.onCompactProgress?.({ type: 'compact_end' })
context.setSDKStatus?.("" as SDKStatus)
context.setSDKStatus?.('' as SDKStatus)
}
}

View File

@@ -1,7 +1,7 @@
import * as React from 'react'
import { Settings } from '../../components/Settings/Settings.js'
import type { LocalJSXCommandCall } from '../../types/command.js'
import * as React from 'react';
import { Settings } from '../../components/Settings/Settings.js';
import type { LocalJSXCommandCall } from '../../types/command.js';
export const call: LocalJSXCommandCall = async (onDone, context) => {
return <Settings onClose={onDone} context={context} defaultTab="Config" />
}
return <Settings onClose={onDone} context={context} defaultTab="Config" />;
};

View File

@@ -1,13 +1,13 @@
import { feature } from 'bun:bundle'
import * as React from 'react'
import type { LocalJSXCommandContext } from '../../commands.js'
import { ContextVisualization } from '../../components/ContextVisualization.js'
import { microcompactMessages } from '../../services/compact/microCompact.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import type { Message } from '../../types/message.js'
import { analyzeContextUsage } from '../../utils/analyzeContext.js'
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'
import { renderToAnsiString } from '../../utils/staticRender.js'
import { feature } from 'bun:bundle';
import * as React from 'react';
import type { LocalJSXCommandContext } from '../../commands.js';
import { ContextVisualization } from '../../components/ContextVisualization.js';
import { microcompactMessages } from '../../services/compact/microCompact.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import type { Message } from '../../types/message.js';
import { analyzeContextUsage } from '../../utils/analyzeContext.js';
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js';
import { renderToAnsiString } from '../../utils/staticRender.js';
/**
* Apply the same context transforms query.ts does before the API call, so
@@ -16,36 +16,33 @@ import { renderToAnsiString } from '../../utils/staticRender.js'
* was collapsed — user sees "180k, 3 spans collapsed" when the API sees 120k.
*/
function toApiView(messages: Message[]): Message[] {
let view = getMessagesAfterCompactBoundary(messages)
let view = getMessagesAfterCompactBoundary(messages);
if (feature('CONTEXT_COLLAPSE')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const { projectView } =
require('../../services/contextCollapse/operations.js') as typeof import('../../services/contextCollapse/operations.js')
require('../../services/contextCollapse/operations.js') as typeof import('../../services/contextCollapse/operations.js');
/* eslint-enable @typescript-eslint/no-require-imports */
view = projectView(view)
view = projectView(view);
}
return view
return view;
}
export async function call(
onDone: LocalJSXCommandOnDone,
context: LocalJSXCommandContext,
): Promise<React.ReactNode> {
export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise<React.ReactNode> {
const {
messages,
getAppState,
options: { mainLoopModel, tools },
} = context
} = context;
const apiView = toApiView(messages)
const apiView = toApiView(messages);
// Apply microcompact to get accurate representation of messages sent to API
const { messages: compactedMessages } = await microcompactMessages(apiView)
const { messages: compactedMessages } = await microcompactMessages(apiView);
// Get terminal width for responsive sizing
const terminalWidth = process.stdout.columns || 80
const terminalWidth = process.stdout.columns || 80;
const appState = getAppState()
const appState = getAppState();
// Analyze context with compacted messages
// Pass original messages as last parameter for accurate API usage extraction
@@ -59,10 +56,10 @@ export async function call(
context, // Pass full context for system prompt calculation
undefined, // mainThreadAgentDefinition
apiView, // Original messages for API usage extraction
)
);
// Render to ANSI string to preserve colors and pass to onDone like local commands do
const output = await renderToAnsiString(<ContextVisualization data={data} />)
onDone(output)
return null
const output = await renderToAnsiString(<ContextVisualization data={data} />);
onDone(output);
return null;
}

View File

@@ -1,39 +1,39 @@
import { mkdir, writeFile } from 'fs/promises'
import { marked, type Tokens } from 'marked'
import { tmpdir } from 'os'
import { join } from 'path'
import React, { useRef } from 'react'
import type { CommandResultDisplay } from '../../commands.js'
import type { OptionWithDescription } from '../../components/CustomSelect/select.js'
import { Select } from '../../components/CustomSelect/select.js'
import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink'
import { Box, setClipboard, Text, stringWidth, type KeyboardEvent } from '@anthropic/ink'
import { logEvent } from '../../services/analytics/index.js'
import type { LocalJSXCommandCall } from '../../types/command.js'
import type { AssistantMessage, Message } from '../../types/message.js'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import { extractTextContent, stripPromptXMLTags } from '../../utils/messages.js'
import { countCharInString } from '../../utils/stringUtils.js'
import { mkdir, writeFile } from 'fs/promises';
import { marked, type Tokens } from 'marked';
import { tmpdir } from 'os';
import { join } from 'path';
import React, { useRef } from 'react';
import type { CommandResultDisplay } from '../../commands.js';
import type { OptionWithDescription } from '../../components/CustomSelect/select.js';
import { Select } from '../../components/CustomSelect/select.js';
import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink';
import { Box, setClipboard, Text, stringWidth, type KeyboardEvent } from '@anthropic/ink';
import { logEvent } from '../../services/analytics/index.js';
import type { LocalJSXCommandCall } from '../../types/command.js';
import type { AssistantMessage, Message } from '../../types/message.js';
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
import { extractTextContent, stripPromptXMLTags } from '../../utils/messages.js';
import { countCharInString } from '../../utils/stringUtils.js';
const COPY_DIR = join(tmpdir(), 'claude')
const RESPONSE_FILENAME = 'response.md'
const MAX_LOOKBACK = 20
const COPY_DIR = join(tmpdir(), 'claude');
const RESPONSE_FILENAME = 'response.md';
const MAX_LOOKBACK = 20;
type CodeBlock = {
code: string
lang: string | undefined
}
code: string;
lang: string | undefined;
};
function extractCodeBlocks(markdown: string): CodeBlock[] {
const tokens = marked.lexer(stripPromptXMLTags(markdown))
const blocks: CodeBlock[] = []
const tokens = marked.lexer(stripPromptXMLTags(markdown));
const blocks: CodeBlock[] = [];
for (const token of tokens) {
if (token.type === 'code') {
const codeToken = token as Tokens.Code
blocks.push({ code: codeToken.text, lang: codeToken.lang })
const codeToken = token as Tokens.Code;
blocks.push({ code: codeToken.text, lang: codeToken.lang });
}
}
return blocks
return blocks;
}
/**
@@ -42,95 +42,80 @@ function extractCodeBlocks(markdown: string): CodeBlock[] {
* Index 0 = latest, 1 = second-to-latest, etc. Caps at MAX_LOOKBACK.
*/
export function collectRecentAssistantTexts(messages: Message[]): string[] {
const texts: string[] = []
for (
let i = messages.length - 1;
i >= 0 && texts.length < MAX_LOOKBACK;
i--
) {
const msg = messages[i]
if (msg?.type !== 'assistant' || msg.isApiErrorMessage) continue
const content = (msg as AssistantMessage).message.content
if (!Array.isArray(content)) continue
const text = extractTextContent(content, '\n\n')
if (text) texts.push(text)
const texts: string[] = [];
for (let i = messages.length - 1; i >= 0 && texts.length < MAX_LOOKBACK; i--) {
const msg = messages[i];
if (msg?.type !== 'assistant' || msg.isApiErrorMessage) continue;
const content = (msg as AssistantMessage).message.content;
if (!Array.isArray(content)) continue;
const text = extractTextContent(content, '\n\n');
if (text) texts.push(text);
}
return texts
return texts;
}
export function fileExtension(lang: string | undefined): string {
if (lang) {
// Sanitize to prevent path traversal (e.g. ```../../etc/passwd)
// Language identifiers are alphanumeric: python, tsx, jsonc, etc.
const sanitized = lang.replace(/[^a-zA-Z0-9]/g, '')
const sanitized = lang.replace(/[^a-zA-Z0-9]/g, '');
if (sanitized && sanitized !== 'plaintext') {
return `.${sanitized}`
return `.${sanitized}`;
}
}
return '.txt'
return '.txt';
}
async function writeToFile(text: string, filename: string): Promise<string> {
const filePath = join(COPY_DIR, filename)
await mkdir(COPY_DIR, { recursive: true })
await writeFile(filePath, text, 'utf-8')
return filePath
const filePath = join(COPY_DIR, filename);
await mkdir(COPY_DIR, { recursive: true });
await writeFile(filePath, text, 'utf-8');
return filePath;
}
async function copyOrWriteToFile(
text: string,
filename: string,
): Promise<string> {
const raw = await setClipboard(text)
if (raw) process.stdout.write(raw)
const lineCount = countCharInString(text, '\n') + 1
const charCount = text.length
async function copyOrWriteToFile(text: string, filename: string): Promise<string> {
const raw = await setClipboard(text);
if (raw) process.stdout.write(raw);
const lineCount = countCharInString(text, '\n') + 1;
const charCount = text.length;
// Also write to a temp file — clipboard paths are best-effort (OSC 52 needs
// terminal support), so the file provides a reliable fallback.
try {
const filePath = await writeToFile(text, filename)
return `Copied to clipboard (${charCount} characters, ${lineCount} lines)\nAlso written to ${filePath}`
const filePath = await writeToFile(text, filename);
return `Copied to clipboard (${charCount} characters, ${lineCount} lines)\nAlso written to ${filePath}`;
} catch {
return `Copied to clipboard (${charCount} characters, ${lineCount} lines)`
return `Copied to clipboard (${charCount} characters, ${lineCount} lines)`;
}
}
function truncateLine(text: string, maxLen: number): string {
const firstLine = text.split('\n')[0] ?? ''
const firstLine = text.split('\n')[0] ?? '';
if (stringWidth(firstLine) <= maxLen) {
return firstLine
return firstLine;
}
let result = ''
let width = 0
const targetWidth = maxLen - 1
let result = '';
let width = 0;
const targetWidth = maxLen - 1;
for (const char of firstLine) {
const charWidth = stringWidth(char)
if (width + charWidth > targetWidth) break
result += char
width += charWidth
const charWidth = stringWidth(char);
if (width + charWidth > targetWidth) break;
result += char;
width += charWidth;
}
return result + '\u2026'
return result + '\u2026';
}
type PickerProps = {
fullText: string
codeBlocks: CodeBlock[]
messageAge: number
onDone: (
result?: string,
options?: { display?: CommandResultDisplay },
) => void
}
fullText: string;
codeBlocks: CodeBlock[];
messageAge: number;
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
};
type PickerSelection = number | 'full' | 'always'
type PickerSelection = number | 'full' | 'always';
function CopyPicker({
fullText,
codeBlocks,
messageAge,
onDone,
}: PickerProps): React.ReactNode {
const focusedRef = useRef<PickerSelection>('full')
function CopyPicker({ fullText, codeBlocks, messageAge, onDone }: PickerProps): React.ReactNode {
const focusedRef = useRef<PickerSelection>('full');
const options: OptionWithDescription<PickerSelection>[] = [
{
@@ -139,109 +124,99 @@ function CopyPicker({
description: `${fullText.length} chars, ${countCharInString(fullText, '\n') + 1} lines`,
},
...codeBlocks.map((block, index) => {
const blockLines = countCharInString(block.code, '\n') + 1
const blockLines = countCharInString(block.code, '\n') + 1;
return {
label: truncateLine(block.code, 60),
value: index,
description:
[block.lang, blockLines > 1 ? `${blockLines} lines` : undefined]
.filter(Boolean)
.join(', ') || undefined,
}
[block.lang, blockLines > 1 ? `${blockLines} lines` : undefined].filter(Boolean).join(', ') || undefined,
};
}),
{
label: 'Always copy full response',
value: 'always' as const,
description: 'Skip this picker in the future (revert via /config)',
},
]
];
function getSelectionContent(selected: PickerSelection): {
text: string
filename: string
blockIndex?: number
text: string;
filename: string;
blockIndex?: number;
} {
if (selected === 'full' || selected === 'always') {
return { text: fullText, filename: RESPONSE_FILENAME }
return { text: fullText, filename: RESPONSE_FILENAME };
}
const block = codeBlocks[selected]!
const block = codeBlocks[selected]!;
return {
text: block.code,
filename: `copy${fileExtension(block.lang)}`,
blockIndex: selected,
}
};
}
async function handleSelect(selected: PickerSelection): Promise<void> {
const content = getSelectionContent(selected)
const content = getSelectionContent(selected);
if (selected === 'always') {
if (!getGlobalConfig().copyFullResponse) {
saveGlobalConfig(c => ({ ...c, copyFullResponse: true }))
saveGlobalConfig(c => ({ ...c, copyFullResponse: true }));
}
logEvent('tengu_copy', {
block_count: codeBlocks.length,
always: true,
message_age: messageAge,
})
const result = await copyOrWriteToFile(content.text, content.filename)
onDone(
`${result}\nPreference saved. Use /config to change copyFullResponse`,
)
return
});
const result = await copyOrWriteToFile(content.text, content.filename);
onDone(`${result}\nPreference saved. Use /config to change copyFullResponse`);
return;
}
logEvent('tengu_copy', {
selected_block: content.blockIndex,
block_count: codeBlocks.length,
message_age: messageAge,
})
const result = await copyOrWriteToFile(content.text, content.filename)
onDone(result)
});
const result = await copyOrWriteToFile(content.text, content.filename);
onDone(result);
}
async function handleWrite(selected: PickerSelection): Promise<void> {
const content = getSelectionContent(selected)
const content = getSelectionContent(selected);
logEvent('tengu_copy', {
selected_block: content.blockIndex,
block_count: codeBlocks.length,
message_age: messageAge,
write_shortcut: true,
})
});
try {
const filePath = await writeToFile(content.text, content.filename)
onDone(`Written to ${filePath}`)
const filePath = await writeToFile(content.text, content.filename);
onDone(`Written to ${filePath}`);
} catch (e) {
onDone(`Failed to write file: ${e instanceof Error ? e.message : e}`)
onDone(`Failed to write file: ${e instanceof Error ? e.message : e}`);
}
}
function handleKeyDown(e: KeyboardEvent): void {
if (e.key === 'w') {
e.preventDefault()
void handleWrite(focusedRef.current)
e.preventDefault();
void handleWrite(focusedRef.current);
}
}
return (
<Pane>
<Box
flexDirection="column"
gap={1}
tabIndex={0}
autoFocus
onKeyDown={handleKeyDown}
>
<Box flexDirection="column" gap={1} tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
<Text dimColor>Select content to copy:</Text>
<Select<PickerSelection>
options={options}
hideIndexes={false}
onFocus={value => {
focusedRef.current = value
focusedRef.current = value;
}}
onChange={selected => {
void handleSelect(selected)
void handleSelect(selected);
}}
onCancel={() => {
onDone('Copy cancelled', { display: 'system' })
onDone('Copy cancelled', { display: 'system' });
}}
/>
<Text dimColor>
@@ -253,56 +228,47 @@ function CopyPicker({
</Text>
</Box>
</Pane>
)
);
}
export const call: LocalJSXCommandCall = async (onDone, context, args) => {
const texts = collectRecentAssistantTexts(context.messages)
const texts = collectRecentAssistantTexts(context.messages);
if (texts.length === 0) {
onDone('No assistant message to copy')
return null
onDone('No assistant message to copy');
return null;
}
// /copy N reaches back N-1 messages (1 = latest, 2 = second-to-latest, ...)
let age = 0
const arg = args?.trim()
let age = 0;
const arg = args?.trim();
if (arg) {
const n = Number(arg)
const n = Number(arg);
if (!Number.isInteger(n) || n < 1) {
onDone(`Usage: /copy [N] where N is 1 (latest), 2, 3, \u2026 Got: ${arg}`)
return null
onDone(`Usage: /copy [N] where N is 1 (latest), 2, 3, \u2026 Got: ${arg}`);
return null;
}
if (n > texts.length) {
onDone(
`Only ${texts.length} assistant ${texts.length === 1 ? 'message' : 'messages'} available to copy`,
)
return null
onDone(`Only ${texts.length} assistant ${texts.length === 1 ? 'message' : 'messages'} available to copy`);
return null;
}
age = n - 1
age = n - 1;
}
const text = texts[age]!
const codeBlocks = extractCodeBlocks(text)
const config = getGlobalConfig()
const text = texts[age]!;
const codeBlocks = extractCodeBlocks(text);
const config = getGlobalConfig();
if (codeBlocks.length === 0 || config.copyFullResponse) {
logEvent('tengu_copy', {
always: config.copyFullResponse,
block_count: codeBlocks.length,
message_age: age,
})
const result = await copyOrWriteToFile(text, RESPONSE_FILENAME)
onDone(result)
return null
});
const result = await copyOrWriteToFile(text, RESPONSE_FILENAME);
onDone(result);
return null;
}
return (
<CopyPicker
fullText={text}
codeBlocks={codeBlocks}
messageAge={age}
onDone={onDone}
/>
)
}
return <CopyPicker fullText={text} codeBlocks={codeBlocks} messageAge={age} onDone={onDone} />;
};

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -1,7 +1,4 @@
import type {
LocalJSXCommandOnDone,
LocalJSXCommandContext,
} from '../../types/command.js'
import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js';
/**
* /daemon slash command — manages daemon and background sessions from the REPL.
@@ -14,44 +11,41 @@ export async function call(
_context: LocalJSXCommandContext,
args: string,
): Promise<React.ReactNode> {
const parts = args ? args.trim().split(/\s+/) : []
const sub = parts[0] || 'status'
const parts = args ? args.trim().split(/\s+/) : [];
const sub = parts[0] || 'status';
// attach is interactive/blocking — not available inside the REPL
if (sub === 'attach') {
onDone(
'Use `claude daemon attach` from the CLI. Attach is not available inside the REPL.',
{ display: 'system' },
)
return null
onDone('Use `claude daemon attach` from the CLI. Attach is not available inside the REPL.', { display: 'system' });
return null;
}
// For all other subcommands, capture console output and return via onDone
const lines = await captureConsole(async () => {
if (sub === 'bg') {
const bg = await import('../../cli/bg.js')
await bg.handleBgStart(parts.slice(1))
const bg = await import('../../cli/bg.js');
await bg.handleBgStart(parts.slice(1));
} else {
const { daemonMain } = await import('../../daemon/main.js')
await daemonMain([sub, ...parts.slice(1)])
const { daemonMain } = await import('../../daemon/main.js');
await daemonMain([sub, ...parts.slice(1)]);
}
})
});
onDone(lines.join('\n') || 'Done.', { display: 'system' })
return null
onDone(lines.join('\n') || 'Done.', { display: 'system' });
return null;
}
async function captureConsole(fn: () => Promise<void>): Promise<string[]> {
const lines: string[] = []
const origLog = console.log
const origError = console.error
console.log = (...a: unknown[]) => lines.push(a.map(String).join(' '))
console.error = (...a: unknown[]) => lines.push(a.map(String).join(' '))
const lines: string[] = [];
const origLog = console.log;
const origError = console.error;
console.log = (...a: unknown[]) => lines.push(a.map(String).join(' '));
console.error = (...a: unknown[]) => lines.push(a.map(String).join(' '));
try {
await fn()
await fn();
} finally {
console.log = origLog
console.error = origError
console.log = origLog;
console.error = origError;
}
return lines
return lines;
}

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -1,12 +1,9 @@
import React from 'react'
import type { CommandResultDisplay } from '../../commands.js'
import { DesktopHandoff } from '../../components/DesktopHandoff.js'
import React from 'react';
import type { CommandResultDisplay } from '../../commands.js';
import { DesktopHandoff } from '../../components/DesktopHandoff.js';
export async function call(
onDone: (
result?: string,
options?: { display?: CommandResultDisplay },
) => void,
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void,
): Promise<React.ReactNode> {
return <DesktopHandoff onDone={onDone} />
return <DesktopHandoff onDone={onDone} />;
}

View File

@@ -1,7 +1,7 @@
import * as React from 'react'
import type { LocalJSXCommandCall } from '../../types/command.js'
import * as React from 'react';
import type { LocalJSXCommandCall } from '../../types/command.js';
export const call: LocalJSXCommandCall = async (onDone, context) => {
const { DiffDialog } = await import('../../components/diff/DiffDialog.js')
return <DiffDialog messages={context.messages} onDone={onDone} />
}
const { DiffDialog } = await import('../../components/diff/DiffDialog.js');
return <DiffDialog messages={context.messages} onDone={onDone} />;
};

View File

@@ -1,7 +1,7 @@
import React from 'react'
import { Doctor } from '../../screens/Doctor.js'
import type { LocalJSXCommandCall } from '../../types/command.js'
import React from 'react';
import { Doctor } from '../../screens/Doctor.js';
import type { LocalJSXCommandCall } from '../../types/command.js';
export const call: LocalJSXCommandCall = (onDone, _context, _args) => {
return Promise.resolve(<Doctor onDone={onDone} />)
}
return Promise.resolve(<Doctor onDone={onDone} />);
};

View File

@@ -1,11 +1,11 @@
import * as React from 'react'
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'
import * as React from 'react';
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import { useAppState, useSetAppState } from '../../state/AppState.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
} from '../../services/analytics/index.js';
import { useAppState, useSetAppState } from '../../state/AppState.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import {
type EffortValue,
getDisplayedEffortLevel,
@@ -13,171 +13,157 @@ import {
getEffortValueDescription,
isEffortLevel,
toPersistableEffort,
} from '../../utils/effort.js'
import { updateSettingsForSource } from '../../utils/settings/settings.js'
} from '../../utils/effort.js';
import { updateSettingsForSource } from '../../utils/settings/settings.js';
const COMMON_HELP_ARGS = ['help', '-h', '--help']
const COMMON_HELP_ARGS = ['help', '-h', '--help'];
type EffortCommandResult = {
message: string
effortUpdate?: { value: EffortValue | undefined }
}
message: string;
effortUpdate?: { value: EffortValue | undefined };
};
function setEffortValue(effortValue: EffortValue): EffortCommandResult {
const persistable = toPersistableEffort(effortValue)
const persistable = toPersistableEffort(effortValue);
if (persistable !== undefined) {
const result = updateSettingsForSource('userSettings', {
effortLevel: persistable,
})
});
if (result.error) {
return {
message: `Failed to set effort level: ${result.error.message}`,
}
};
}
}
logEvent('tengu_effort_command', {
effort:
effortValue as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
effort: effortValue as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
// Env var wins at resolveAppliedEffort time. Only flag it when it actually
// conflicts — if env matches what the user just asked for, the outcome is
// the same, so "Set effort to X" is true and the note is noise.
const envOverride = getEffortEnvOverride()
const envOverride = getEffortEnvOverride();
if (envOverride !== undefined && envOverride !== effortValue) {
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL;
if (persistable === undefined) {
return {
message: `Not applied: CLAUDE_CODE_EFFORT_LEVEL=${envRaw} overrides effort this session, and ${effortValue} is session-only (nothing saved)`,
effortUpdate: { value: effortValue },
}
};
}
return {
message: `CLAUDE_CODE_EFFORT_LEVEL=${envRaw} overrides this session — clear it and ${effortValue} takes over`,
effortUpdate: { value: effortValue },
}
};
}
const description = getEffortValueDescription(effortValue)
const suffix = persistable !== undefined ? '' : ' (this session only)'
const description = getEffortValueDescription(effortValue);
const suffix = persistable !== undefined ? '' : ' (this session only)';
return {
message: `Set effort level to ${effortValue}${suffix}: ${description}`,
effortUpdate: { value: effortValue },
}
};
}
export function showCurrentEffort(
appStateEffort: EffortValue | undefined,
model: string,
): EffortCommandResult {
const envOverride = getEffortEnvOverride()
const effectiveValue =
envOverride === null ? undefined : (envOverride ?? appStateEffort)
export function showCurrentEffort(appStateEffort: EffortValue | undefined, model: string): EffortCommandResult {
const envOverride = getEffortEnvOverride();
const effectiveValue = envOverride === null ? undefined : (envOverride ?? appStateEffort);
if (effectiveValue === undefined) {
const level = getDisplayedEffortLevel(model, appStateEffort)
return { message: `Effort level: auto (currently ${level})` }
const level = getDisplayedEffortLevel(model, appStateEffort);
return { message: `Effort level: auto (currently ${level})` };
}
const description = getEffortValueDescription(effectiveValue)
const description = getEffortValueDescription(effectiveValue);
return {
message: `Current effort level: ${effectiveValue} (${description})`,
}
};
}
function unsetEffortLevel(): EffortCommandResult {
const result = updateSettingsForSource('userSettings', {
effortLevel: undefined,
})
});
if (result.error) {
return {
message: `Failed to set effort level: ${result.error.message}`,
}
};
}
logEvent('tengu_effort_command', {
effort:
'auto' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
effort: 'auto' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
// env=auto/unset (null) matches what /effort auto asks for, so only warn
// when env is pinning a specific level that will keep overriding.
const envOverride = getEffortEnvOverride()
const envOverride = getEffortEnvOverride();
if (envOverride !== undefined && envOverride !== null) {
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL;
return {
message: `Cleared effort from settings, but CLAUDE_CODE_EFFORT_LEVEL=${envRaw} still controls this session`,
effortUpdate: { value: undefined },
}
};
}
return {
message: 'Effort level set to auto',
effortUpdate: { value: undefined },
}
};
}
export function executeEffort(args: string): EffortCommandResult {
const normalized = args.toLowerCase()
const normalized = args.toLowerCase();
if (normalized === 'auto' || normalized === 'unset') {
return unsetEffortLevel()
return unsetEffortLevel();
}
if (!isEffortLevel(normalized)) {
return {
message: `Invalid argument: ${args}. Valid options are: low, medium, high, max, auto`,
}
};
}
return setEffortValue(normalized)
return setEffortValue(normalized);
}
function ShowCurrentEffort({
onDone,
}: {
onDone: (result: string) => void
}): React.ReactNode {
const effortValue = useAppState(s => s.effortValue)
const model = useMainLoopModel()
const { message } = showCurrentEffort(effortValue, model)
onDone(message)
return null
function ShowCurrentEffort({ onDone }: { onDone: (result: string) => void }): React.ReactNode {
const effortValue = useAppState(s => s.effortValue);
const model = useMainLoopModel();
const { message } = showCurrentEffort(effortValue, model);
onDone(message);
return null;
}
function ApplyEffortAndClose({
result,
onDone,
}: {
result: EffortCommandResult
onDone: (result: string) => void
result: EffortCommandResult;
onDone: (result: string) => void;
}): React.ReactNode {
const setAppState = useSetAppState()
const { effortUpdate, message } = result
const setAppState = useSetAppState();
const { effortUpdate, message } = result;
React.useEffect(() => {
if (effortUpdate) {
setAppState(prev => ({
...prev,
effortValue: effortUpdate.value,
}))
}));
}
onDone(message)
}, [setAppState, effortUpdate, message, onDone])
return null
onDone(message);
}, [setAppState, effortUpdate, message, onDone]);
return null;
}
export async function call(
onDone: LocalJSXCommandOnDone,
_context: unknown,
args?: string,
): Promise<React.ReactNode> {
args = args?.trim() || ''
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
args = args?.trim() || '';
if (COMMON_HELP_ARGS.includes(args)) {
onDone(
'Usage: /effort [low|medium|high|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.6/4.7, DeepSeek V4 Pro)\n- auto: Use the default effort level for your model',
)
return
);
return;
}
if (!args || args === 'current' || args === 'status') {
return <ShowCurrentEffort onDone={onDone} />
return <ShowCurrentEffort onDone={onDone} />;
}
const result = executeEffort(args)
return <ApplyEffortAndClose result={result} onDone={onDone} />
const result = executeEffort(args);
return <ApplyEffortAndClose result={result} onDone={onDone} />;
}

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -1,44 +1,36 @@
import { feature } from 'bun:bundle'
import { spawnSync } from 'child_process'
import sample from 'lodash-es/sample.js'
import * as React from 'react'
import { ExitFlow } from '../../components/ExitFlow.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import { isBgSession } from '../../utils/concurrentSessions.js'
import { gracefulShutdown } from '../../utils/gracefulShutdown.js'
import { getCurrentWorktreeSession } from '../../utils/worktree.js'
import { feature } from 'bun:bundle';
import { spawnSync } from 'child_process';
import sample from 'lodash-es/sample.js';
import * as React from 'react';
import { ExitFlow } from '../../components/ExitFlow.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import { isBgSession } from '../../utils/concurrentSessions.js';
import { gracefulShutdown } from '../../utils/gracefulShutdown.js';
import { getCurrentWorktreeSession } from '../../utils/worktree.js';
const GOODBYE_MESSAGES = ['Goodbye!', 'See ya!', 'Bye!', 'Catch you later!']
const GOODBYE_MESSAGES = ['Goodbye!', 'See ya!', 'Bye!', 'Catch you later!'];
function getRandomGoodbyeMessage(): string {
return sample(GOODBYE_MESSAGES) ?? 'Goodbye!'
return sample(GOODBYE_MESSAGES) ?? 'Goodbye!';
}
export async function call(
onDone: LocalJSXCommandOnDone,
): Promise<React.ReactNode> {
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> {
// Inside a `claude --bg` tmux session: detach instead of kill. The REPL
// keeps running; `claude attach` can reconnect. Covers /exit, /quit,
// ctrl+c, ctrl+d — all funnel through here via REPL's handleExit.
if (feature('BG_SESSIONS') && isBgSession()) {
onDone()
spawnSync('tmux', ['detach-client'], { stdio: 'ignore' })
return null
onDone();
spawnSync('tmux', ['detach-client'], { stdio: 'ignore' });
return null;
}
const showWorktree = getCurrentWorktreeSession() !== null
const showWorktree = getCurrentWorktreeSession() !== null;
if (showWorktree) {
return (
<ExitFlow
showWorktree={showWorktree}
onDone={onDone}
onCancel={() => onDone()}
/>
)
return <ExitFlow showWorktree={showWorktree} onDone={onDone} onCancel={() => onDone()} />;
}
onDone(getRandomGoodbyeMessage())
await gracefulShutdown(0, 'prompt_input_exit')
return null
onDone(getRandomGoodbyeMessage());
await gracefulShutdown(0, 'prompt_input_exit');
return null;
}

View File

@@ -1,49 +1,49 @@
import { join } from 'path'
import React from 'react'
import { ExportDialog } from '../../components/ExportDialog.js'
import type { ToolUseContext } from '../../Tool.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import type { Message } from '../../types/message.js'
import { getCwd } from '../../utils/cwd.js'
import { renderMessagesToPlainText } from '../../utils/exportRenderer.js'
import { writeFileSync_DEPRECATED } from '../../utils/slowOperations.js'
import { join } from 'path';
import React from 'react';
import { ExportDialog } from '../../components/ExportDialog.js';
import type { ToolUseContext } from '../../Tool.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import type { Message } from '../../types/message.js';
import { getCwd } from '../../utils/cwd.js';
import { renderMessagesToPlainText } from '../../utils/exportRenderer.js';
import { writeFileSync_DEPRECATED } from '../../utils/slowOperations.js';
function formatTimestamp(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day}-${hours}${minutes}${seconds}`
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day}-${hours}${minutes}${seconds}`;
}
export function extractFirstPrompt(messages: Message[]): string {
const firstUserMessage = messages.find(msg => msg.type === 'user')
const firstUserMessage = messages.find(msg => msg.type === 'user');
if (!firstUserMessage || firstUserMessage.type !== 'user') {
return ''
return '';
}
const content = firstUserMessage.message?.content
let result = ''
const content = firstUserMessage.message?.content;
let result = '';
if (typeof content === 'string') {
result = content.trim()
result = content.trim();
} else if (Array.isArray(content)) {
const textContent = content.find(item => item.type === 'text')
const textContent = content.find(item => item.type === 'text');
if (textContent && 'text' in textContent) {
result = textContent.text.trim()
result = textContent.text.trim();
}
}
// Take first line only and limit length
result = result.split('\n')[0] || ''
result = result.split('\n')[0] || '';
if (result.length > 50) {
result = result.substring(0, 49) + '…'
result = result.substring(0, 49) + '…';
}
return result
return result;
}
export function sanitizeFilename(text: string): string {
@@ -53,14 +53,12 @@ export function sanitizeFilename(text: string): string {
.replace(/[^a-z0-9\s-]/g, '') // Remove special chars
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
}
async function exportWithReactRenderer(
context: ToolUseContext,
): Promise<string> {
const tools = context.options.tools || []
return renderMessagesToPlainText(context.messages, tools)
async function exportWithReactRenderer(context: ToolUseContext): Promise<string> {
const tools = context.options.tools || [];
return renderMessagesToPlainText(context.messages, tools);
}
export async function call(
@@ -69,43 +67,37 @@ export async function call(
args: string,
): Promise<React.ReactNode> {
// Render the conversation content
const content = await exportWithReactRenderer(context)
const content = await exportWithReactRenderer(context);
// If args are provided, write directly to file and skip dialog
const filename = args.trim()
const filename = args.trim();
if (filename) {
const finalFilename = filename.endsWith('.txt')
? filename
: filename.replace(/\.[^.]+$/, '') + '.txt'
const filepath = join(getCwd(), finalFilename)
const finalFilename = filename.endsWith('.txt') ? filename : filename.replace(/\.[^.]+$/, '') + '.txt';
const filepath = join(getCwd(), finalFilename);
try {
writeFileSync_DEPRECATED(filepath, content, {
encoding: 'utf-8',
flush: true,
})
onDone(`Conversation exported to: ${filepath}`)
return null
});
onDone(`Conversation exported to: ${filepath}`);
return null;
} catch (error) {
onDone(
`Failed to export conversation: ${error instanceof Error ? error.message : 'Unknown error'}`,
)
return null
onDone(`Failed to export conversation: ${error instanceof Error ? error.message : 'Unknown error'}`);
return null;
}
}
// Generate default filename from first prompt or timestamp
const firstPrompt = extractFirstPrompt(context.messages)
const timestamp = formatTimestamp(new Date())
const firstPrompt = extractFirstPrompt(context.messages);
const timestamp = formatTimestamp(new Date());
let defaultFilename: string
let defaultFilename: string;
if (firstPrompt) {
const sanitized = sanitizeFilename(firstPrompt)
defaultFilename = sanitized
? `${timestamp}-${sanitized}.txt`
: `conversation-${timestamp}.txt`
const sanitized = sanitizeFilename(firstPrompt);
defaultFilename = sanitized ? `${timestamp}-${sanitized}.txt` : `conversation-${timestamp}.txt`;
} else {
defaultFilename = `conversation-${timestamp}.txt`
defaultFilename = `conversation-${timestamp}.txt`;
}
// Return the dialog component when no args provided
@@ -114,8 +106,8 @@ export async function call(
content={content}
defaultFilename={defaultFilename}
onDone={result => {
onDone(result.message)
onDone(result.message);
}}
/>
)
);
}

View File

@@ -1,29 +1,27 @@
import React from 'react'
import type { LocalJSXCommandContext } from '../../commands.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import { Login } from '../login/login.js'
import { runExtraUsage } from './extra-usage-core.js'
import React from 'react';
import type { LocalJSXCommandContext } from '../../commands.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import { Login } from '../login/login.js';
import { runExtraUsage } from './extra-usage-core.js';
export async function call(
onDone: LocalJSXCommandOnDone,
context: LocalJSXCommandContext,
): Promise<React.ReactNode | null> {
const result = await runExtraUsage()
const result = await runExtraUsage();
if (result.type === 'message') {
onDone(result.value)
return null
onDone(result.value);
return null;
}
return (
<Login
startingMessage={
'Starting new login following /extra-usage. Exit with Ctrl-C to use existing account.'
}
startingMessage={'Starting new login following /extra-usage. Exit with Ctrl-C to use existing account.'}
onDone={success => {
context.onChangeAPIKey()
onDone(success ? 'Login successful' : 'Login interrupted')
context.onChangeAPIKey();
onDone(success ? 'Login successful' : 'Login interrupted');
}}
/>
)
);
}

View File

@@ -1,23 +1,16 @@
import * as React from 'react'
import { useState } from 'react'
import type {
CommandResultDisplay,
LocalJSXCommandContext,
} from '../../commands.js'
import { Dialog } from '@anthropic/ink'
import { FastIcon, getFastIconString } from '../../components/FastIcon.js'
import { Box, Link, Text } from '@anthropic/ink'
import { useKeybindings } from '../../keybindings/useKeybinding.js'
import * as React from 'react';
import { useState } from 'react';
import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js';
import { Dialog } from '@anthropic/ink';
import { FastIcon, getFastIconString } from '../../components/FastIcon.js';
import { Box, Link, Text } from '@anthropic/ink';
import { useKeybindings } from '../../keybindings/useKeybinding.js';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import {
type AppState,
useAppState,
useSetAppState,
} from '../../state/AppState.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
} from '../../services/analytics/index.js';
import { type AppState, useAppState, useSetAppState } from '../../state/AppState.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import {
clearFastModeCooldown,
FAST_MODE_MODEL_DISPLAY,
@@ -27,33 +20,28 @@ import {
isFastModeEnabled,
isFastModeSupportedByModel,
prefetchFastModeStatus,
} from '../../utils/fastMode.js'
import { formatDuration } from '../../utils/format.js'
import { formatModelPricing, getOpus46CostTier } from '../../utils/modelCost.js'
import { updateSettingsForSource } from '../../utils/settings/settings.js'
} from '../../utils/fastMode.js';
import { formatDuration } from '../../utils/format.js';
import { formatModelPricing, getOpus46CostTier } from '../../utils/modelCost.js';
import { updateSettingsForSource } from '../../utils/settings/settings.js';
function applyFastMode(
enable: boolean,
setAppState: (f: (prev: AppState) => AppState) => void,
): void {
clearFastModeCooldown()
function applyFastMode(enable: boolean, setAppState: (f: (prev: AppState) => AppState) => void): void {
clearFastModeCooldown();
updateSettingsForSource('userSettings', {
fastMode: enable ? true : undefined,
})
});
if (enable) {
setAppState(prev => {
// Only switch model if current model doesn't support fast mode
const needsModelSwitch = !isFastModeSupportedByModel(prev.mainLoopModel)
const needsModelSwitch = !isFastModeSupportedByModel(prev.mainLoopModel);
return {
...prev,
...(needsModelSwitch
? { mainLoopModel: getFastModeModel(), mainLoopModelForSession: null }
: {}),
...(needsModelSwitch ? { mainLoopModel: getFastModeModel(), mainLoopModelForSession: null } : {}),
fastMode: true,
}
})
};
});
} else {
setAppState(prev => ({ ...prev, fastMode: false }))
setAppState(prev => ({ ...prev, fastMode: false }));
}
}
@@ -61,38 +49,32 @@ export function FastModePicker({
onDone,
unavailableReason,
}: {
onDone: (
result?: string,
options?: { display?: CommandResultDisplay },
) => void
unavailableReason: string | null
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
unavailableReason: string | null;
}): React.ReactNode {
const model = useAppState(s => s.mainLoopModel)
const initialFastMode = useAppState(s => s.fastMode)
const setAppState = useSetAppState()
const [enableFastMode, setEnableFastMode] = useState(initialFastMode ?? false)
const runtimeState = getFastModeRuntimeState()
const isCooldown = runtimeState.status === 'cooldown'
const isUnavailable = unavailableReason !== null
const pricing = formatModelPricing(getOpus46CostTier(true))
const model = useAppState(s => s.mainLoopModel);
const initialFastMode = useAppState(s => s.fastMode);
const setAppState = useSetAppState();
const [enableFastMode, setEnableFastMode] = useState(initialFastMode ?? false);
const runtimeState = getFastModeRuntimeState();
const isCooldown = runtimeState.status === 'cooldown';
const isUnavailable = unavailableReason !== null;
const pricing = formatModelPricing(getOpus46CostTier(true));
function handleConfirm(): void {
if (isUnavailable) return
applyFastMode(enableFastMode, setAppState)
if (isUnavailable) return;
applyFastMode(enableFastMode, setAppState);
logEvent('tengu_fast_mode_toggled', {
enabled: enableFastMode,
source:
'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
source: 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
if (enableFastMode) {
const fastIcon = getFastIconString(enableFastMode)
const modelUpdated = !isFastModeSupportedByModel(model)
? ` · model set to ${FAST_MODE_MODEL_DISPLAY}`
: ''
onDone(`${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`)
const fastIcon = getFastIconString(enableFastMode);
const modelUpdated = !isFastModeSupportedByModel(model) ? ` · model set to ${FAST_MODE_MODEL_DISPLAY}` : '';
onDone(`${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`);
} else {
setAppState(prev => ({ ...prev, fastMode: false }))
onDone(`Fast mode OFF`)
setAppState(prev => ({ ...prev, fastMode: false }));
onDone(`Fast mode OFF`);
}
}
@@ -100,20 +82,18 @@ export function FastModePicker({
if (isUnavailable) {
// Ensure fast mode is off if the org has disabled it
if (initialFastMode) {
applyFastMode(false, setAppState)
applyFastMode(false, setAppState);
}
onDone('Fast mode OFF', { display: 'system' })
return
onDone('Fast mode OFF', { display: 'system' });
return;
}
const message = initialFastMode
? `${getFastIconString()} Kept Fast mode ON`
: `Kept Fast mode OFF`
onDone(message, { display: 'system' })
const message = initialFastMode ? `${getFastIconString()} Kept Fast mode ON` : `Kept Fast mode OFF`;
onDone(message, { display: 'system' });
}
function handleToggle(): void {
if (isUnavailable) return
setEnableFastMode(prev => !prev)
if (isUnavailable) return;
setEnableFastMode(prev => !prev);
}
useKeybindings(
@@ -126,13 +106,13 @@ export function FastModePicker({
'confirm:toggle': handleToggle,
},
{ context: 'Confirmation' },
)
);
const title = (
<Text>
<FastIcon cooldown={isCooldown} /> Fast mode (research preview)
</Text>
)
);
return (
<Dialog
@@ -159,10 +139,7 @@ export function FastModePicker({
<Box flexDirection="column" gap={0} marginLeft={2}>
<Box flexDirection="row" gap={2}>
<Text bold>Fast mode</Text>
<Text
color={enableFastMode ? 'fastMode' : undefined}
bold={enableFastMode}
>
<Text color={enableFastMode ? 'fastMode' : undefined} bold={enableFastMode}>
{enableFastMode ? 'ON ' : 'OFF'}
</Text>
<Text dimColor>{pricing}</Text>
@@ -186,12 +163,10 @@ export function FastModePicker({
)}
<Text dimColor>
Learn more:{' '}
<Link url="https://code.claude.com/docs/en/fast-mode">
https://code.claude.com/docs/en/fast-mode
</Link>
<Link url="https://code.claude.com/docs/en/fast-mode">https://code.claude.com/docs/en/fast-mode</Link>
</Text>
</Dialog>
)
);
}
async function handleFastModeShortcut(
@@ -199,28 +174,25 @@ async function handleFastModeShortcut(
getAppState: () => AppState,
setAppState: (f: (prev: AppState) => AppState) => void,
): Promise<string> {
const unavailableReason = getFastModeUnavailableReason()
const unavailableReason = getFastModeUnavailableReason();
if (unavailableReason) {
return `Fast mode unavailable: ${unavailableReason}`
return `Fast mode unavailable: ${unavailableReason}`;
}
const { mainLoopModel } = getAppState()
applyFastMode(enable, setAppState)
const { mainLoopModel } = getAppState();
applyFastMode(enable, setAppState);
logEvent('tengu_fast_mode_toggled', {
enabled: enable,
source:
'shortcut' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
source: 'shortcut' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
if (enable) {
const fastIcon = getFastIconString(true)
const modelUpdated = !isFastModeSupportedByModel(mainLoopModel)
? ` · model set to ${FAST_MODE_MODEL_DISPLAY}`
: ''
const pricing = formatModelPricing(getOpus46CostTier(true))
return `${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`
const fastIcon = getFastIconString(true);
const modelUpdated = !isFastModeSupportedByModel(mainLoopModel) ? ` · model set to ${FAST_MODE_MODEL_DISPLAY}` : '';
const pricing = formatModelPricing(getOpus46CostTier(true));
return `${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`;
} else {
return `Fast mode OFF`
return `Fast mode OFF`;
}
}
@@ -230,31 +202,24 @@ export async function call(
args?: string,
): Promise<React.ReactNode | null> {
if (!isFastModeEnabled()) {
return null
return null;
}
// Fetch org fast mode status before showing the picker. We must know
// whether the org has disabled fast mode before allowing any toggle.
// If a startup prefetch is already in flight, this awaits it.
await prefetchFastModeStatus()
await prefetchFastModeStatus();
const arg = args?.trim().toLowerCase()
const arg = args?.trim().toLowerCase();
if (arg === 'on' || arg === 'off') {
const result = await handleFastModeShortcut(
arg === 'on',
context.getAppState,
context.setAppState,
)
onDone(result)
return null
const result = await handleFastModeShortcut(arg === 'on', context.getAppState, context.setAppState);
onDone(result);
return null;
}
const unavailableReason = getFastModeUnavailableReason()
const unavailableReason = getFastModeUnavailableReason();
logEvent('tengu_fast_mode_picker_shown', {
unavailable_reason: (unavailableReason ??
'') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
return (
<FastModePicker onDone={onDone} unavailableReason={unavailableReason} />
)
unavailable_reason: (unavailableReason ?? '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
return <FastModePicker onDone={onDone} unavailableReason={unavailableReason} />;
}

View File

@@ -1,27 +1,21 @@
import * as React from 'react'
import type {
CommandResultDisplay,
LocalJSXCommandContext,
} from '../../commands.js'
import { Feedback } from '../../components/Feedback.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import type { Message } from '../../types/message.js'
import * as React from 'react';
import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js';
import { Feedback } from '../../components/Feedback.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import type { Message } from '../../types/message.js';
// Shared function to render the Feedback component
export function renderFeedbackComponent(
onDone: (
result?: string,
options?: { display?: CommandResultDisplay },
) => void,
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void,
abortSignal: AbortSignal,
messages: Message[],
initialDescription: string = '',
backgroundTasks: {
[taskId: string]: {
type: string
identity?: { agentId: string }
messages?: Message[]
}
type: string;
identity?: { agentId: string };
messages?: Message[];
};
} = {},
): React.ReactNode {
return (
@@ -32,7 +26,7 @@ export function renderFeedbackComponent(
onDone={onDone}
backgroundTasks={backgroundTasks}
/>
)
);
}
export async function call(
@@ -40,11 +34,6 @@ export async function call(
context: LocalJSXCommandContext,
args?: string,
): Promise<React.ReactNode> {
const initialDescription = args || ''
return renderFeedbackComponent(
onDone,
context.abortController.signal,
context.messages,
initialDescription,
)
const initialDescription = args || '';
return renderFeedbackComponent(onDone, context.abortController.signal, context.messages, initialDescription);
}

View File

@@ -25,7 +25,7 @@ const call: LocalCommandCall = async (_args, context) => {
// Collect UUIDs of every message that will be snipped (everything currently
// in the conversation). The next call to `snipCompactIfNeeded` will honour
// the boundary and strip these from the model-facing view.
const removedUuids = messages.map((m) => m.uuid)
const removedUuids = messages.map(m => m.uuid)
const boundaryMessage: Message = {
type: 'system',
@@ -39,7 +39,7 @@ const call: LocalCommandCall = async (_args, context) => {
},
} as Message // subtype is feature-gated; cast through Message
setMessages((prev) => [...prev, boundaryMessage])
setMessages(prev => [...prev, boundaryMessage])
return {
type: 'text',

View File

@@ -1,9 +1,9 @@
import { feature } from 'bun:bundle'
import React from 'react'
import { AgentTool } from '@claude-code-best/builtin-tools/tools/AgentTool/AgentTool.js'
import { isInForkChild } from '@claude-code-best/builtin-tools/tools/AgentTool/forkSubagent.js'
import { logForDebugging } from '../../utils/debug.js'
import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js'
import { feature } from 'bun:bundle';
import React from 'react';
import { AgentTool } from '@claude-code-best/builtin-tools/tools/AgentTool/AgentTool.js';
import { isInForkChild } from '@claude-code-best/builtin-tools/tools/AgentTool/forkSubagent.js';
import { logForDebugging } from '../../utils/debug.js';
import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js';
export async function call(
onDone: LocalJSXCommandOnDone,
@@ -12,30 +12,30 @@ export async function call(
): Promise<React.ReactNode> {
// Check feature flag
if (!feature('FORK_SUBAGENT')) {
onDone('Fork subagent feature is not enabled. Set FEATURE_FORK_SUBAGENT=1 to enable.', { display: 'system' })
return null
onDone('Fork subagent feature is not enabled. Set FEATURE_FORK_SUBAGENT=1 to enable.', { display: 'system' });
return null;
}
// Recursive fork guard
if (isInForkChild(context.messages)) {
onDone('Fork is not available inside a forked worker. Complete your task directly using your tools.', { display: 'system' })
return null
onDone('Fork is not available inside a forked worker. Complete your task directly using your tools.', {
display: 'system',
});
return null;
}
const directive = args.trim()
const directive = args.trim();
if (!directive) {
onDone('Usage: /fork <directive>\nExample: /fork Fix the null check in validate.ts', { display: 'system' })
return null
onDone('Usage: /fork <directive>\nExample: /fork Fix the null check in validate.ts', { display: 'system' });
return null;
}
// Find the last assistant message to fork from
const lastAssistantMessage = [...context.messages].reverse().find(
m => m.type === 'assistant'
) as any // Type assertion to avoid complex type import
const lastAssistantMessage = [...context.messages].reverse().find(m => m.type === 'assistant') as any; // Type assertion to avoid complex type import
if (!lastAssistantMessage) {
onDone('Cannot fork: no assistant response in conversation history.', { display: 'system' })
return null
onDone('Cannot fork: no assistant response in conversation history.', { display: 'system' });
return null;
}
try {
@@ -45,29 +45,24 @@ export async function call(
prompt: directive,
run_in_background: true, // fork always runs async
description: `Fork: ${directive.slice(0, 30)}${directive.length > 30 ? '...' : ''}`,
}
};
// Call AgentTool with proper parameters:
// - input: the agent parameters (no subagent_type => fork path)
// - toolUseContext: the current context (ToolUseContext)
// - canUseTool: permission-check function from context
// - assistantMessage: the last assistant message to fork from
AgentTool.call(
input,
context,
context.canUseTool!,
lastAssistantMessage
).catch(error => {
logForDebugging(`Fork subagent async error: ${error}`, { level: 'error' })
})
AgentTool.call(input, context, context.canUseTool!, lastAssistantMessage).catch(error => {
logForDebugging(`Fork subagent async error: ${error}`, { level: 'error' });
});
// Notify user that fork has been started
onDone(`Forked subagent started with directive: "${directive}"`, { display: 'system' })
return null
onDone(`Forked subagent started with directive: "${directive}"`, { display: 'system' });
return null;
} catch (error) {
// Catches synchronous setup errors only
logForDebugging(`Fork command setup error: ${error}`, { level: 'error' })
onDone(`Fork failed: ${error instanceof Error ? error.message : String(error)}`, { display: 'system' })
return null
logForDebugging(`Fork command setup error: ${error}`, { level: 'error' });
onDone(`Fork failed: ${error instanceof Error ? error.message : String(error)}`, { display: 'system' });
return null;
}
}

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -1,10 +1,7 @@
import * as React from 'react'
import { HelpV2 } from '../../components/HelpV2/HelpV2.js'
import type { LocalJSXCommandCall } from '../../types/command.js'
import * as React from 'react';
import { HelpV2 } from '../../components/HelpV2/HelpV2.js';
import type { LocalJSXCommandCall } from '../../types/command.js';
export const call: LocalJSXCommandCall = async (
onDone,
{ options: { commands } },
) => {
return <HelpV2 commands={commands} onClose={onDone} />
}
export const call: LocalJSXCommandCall = async (onDone, { options: { commands } }) => {
return <HelpV2 commands={commands} onClose={onDone} />;
};

View File

@@ -1,13 +1,13 @@
import * as React from 'react'
import { HooksConfigMenu } from '../../components/hooks/HooksConfigMenu.js'
import { logEvent } from '../../services/analytics/index.js'
import { getTools } from '../../tools.js'
import type { LocalJSXCommandCall } from '../../types/command.js'
import * as React from 'react';
import { HooksConfigMenu } from '../../components/hooks/HooksConfigMenu.js';
import { logEvent } from '../../services/analytics/index.js';
import { getTools } from '../../tools.js';
import type { LocalJSXCommandCall } from '../../types/command.js';
export const call: LocalJSXCommandCall = async (onDone, context) => {
logEvent('tengu_hooks_command', {})
const appState = context.getAppState()
const permissionContext = appState.toolPermissionContext
const toolNames = getTools(permissionContext).map(tool => tool.name)
return <HooksConfigMenu toolNames={toolNames} onExit={onDone} />
}
logEvent('tengu_hooks_command', {});
const appState = context.getAppState();
const permissionContext = appState.toolPermissionContext;
const toolNames = getTools(permissionContext).map(tool => tool.name);
return <HooksConfigMenu toolNames={toolNames} onExit={onDone} />;
};

View File

@@ -1,25 +1,22 @@
import chalk from 'chalk'
import * as path from 'path'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { logEvent } from 'src/services/analytics/index.js'
import type {
CommandResultDisplay,
LocalJSXCommandContext,
} from '../../commands.js'
import { Select } from '../../components/CustomSelect/index.js'
import { Dialog } from '@anthropic/ink'
import chalk from 'chalk';
import * as path from 'path';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { logEvent } from 'src/services/analytics/index.js';
import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js';
import { Select } from '../../components/CustomSelect/index.js';
import { Dialog } from '@anthropic/ink';
import {
IdeAutoConnectDialog,
IdeDisableAutoConnectDialog,
shouldShowAutoConnectDialog,
shouldShowDisableAutoConnectDialog,
} from '../../components/IdeAutoConnectDialog.js'
import { Box, Text } from '@anthropic/ink'
import { clearServerCache } from '../../services/mcp/client.js'
import type { ScopedMcpServerConfig } from '../../services/mcp/types.js'
import { useAppState, useSetAppState } from '../../state/AppState.js'
import { getCwd } from '../../utils/cwd.js'
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
} from '../../components/IdeAutoConnectDialog.js';
import { Box, Text } from '@anthropic/ink';
import { clearServerCache } from '../../services/mcp/client.js';
import type { ScopedMcpServerConfig } from '../../services/mcp/types.js';
import { useAppState, useSetAppState } from '../../state/AppState.js';
import { getCwd } from '../../utils/cwd.js';
import { execFileNoThrow } from '../../utils/execFileNoThrow.js';
import {
type DetectedIDEInfo,
detectIDEs,
@@ -29,16 +26,16 @@ import {
isSupportedJetBrainsTerminal,
isSupportedTerminal,
toIDEDisplayName,
} from '../../utils/ide.js'
import { getCurrentWorktreeSession } from '../../utils/worktree.js'
} from '../../utils/ide.js';
import { getCurrentWorktreeSession } from '../../utils/worktree.js';
type IDEScreenProps = {
availableIDEs: DetectedIDEInfo[]
unavailableIDEs: DetectedIDEInfo[]
selectedIDE?: DetectedIDEInfo | null
onClose: () => void
onSelect: (ide?: DetectedIDEInfo) => void
}
availableIDEs: DetectedIDEInfo[];
unavailableIDEs: DetectedIDEInfo[];
selectedIDE?: DetectedIDEInfo | null;
onClose: () => void;
onSelect: (ide?: DetectedIDEInfo) => void;
};
function IDEScreen({
availableIDEs,
@@ -47,51 +44,43 @@ function IDEScreen({
onClose,
onSelect,
}: IDEScreenProps): React.ReactNode {
const [selectedValue, setSelectedValue] = useState(
selectedIDE?.port?.toString() ?? 'None',
)
const [showAutoConnectDialog, setShowAutoConnectDialog] = useState(false)
const [showDisableAutoConnectDialog, setShowDisableAutoConnectDialog] =
useState(false)
const [selectedValue, setSelectedValue] = useState(selectedIDE?.port?.toString() ?? 'None');
const [showAutoConnectDialog, setShowAutoConnectDialog] = useState(false);
const [showDisableAutoConnectDialog, setShowDisableAutoConnectDialog] = useState(false);
const handleSelectIDE = useCallback(
(value: string) => {
if (value !== 'None' && shouldShowAutoConnectDialog()) {
setShowAutoConnectDialog(true)
setShowAutoConnectDialog(true);
} else if (value === 'None' && shouldShowDisableAutoConnectDialog()) {
setShowDisableAutoConnectDialog(true)
setShowDisableAutoConnectDialog(true);
} else {
onSelect(availableIDEs.find(ide => ide.port === parseInt(value, 10)))
onSelect(availableIDEs.find(ide => ide.port === parseInt(value, 10)));
}
},
[availableIDEs, onSelect],
)
);
const ideCounts = availableIDEs.reduce<Record<string, number>>((acc, ide) => {
acc[ide.name] = (acc[ide.name] || 0) + 1
return acc
}, {})
acc[ide.name] = (acc[ide.name] || 0) + 1;
return acc;
}, {});
const options = availableIDEs
.map(ide => {
const hasMultipleInstances = (ideCounts[ide.name] || 0) > 1
const showWorkspace =
hasMultipleInstances && ide.workspaceFolders.length > 0
const hasMultipleInstances = (ideCounts[ide.name] || 0) > 1;
const showWorkspace = hasMultipleInstances && ide.workspaceFolders.length > 0;
return {
label: ide.name,
value: ide.port.toString(),
description: showWorkspace
? formatWorkspaceFolders(ide.workspaceFolders)
: undefined,
}
description: showWorkspace ? formatWorkspaceFolders(ide.workspaceFolders) : undefined,
};
})
.concat([{ label: 'None', value: 'None', description: undefined }])
.concat([{ label: 'None', value: 'None', description: undefined }]);
if (showAutoConnectDialog) {
return (
<IdeAutoConnectDialog onComplete={() => handleSelectIDE(selectedValue)} />
)
return <IdeAutoConnectDialog onComplete={() => handleSelectIDE(selectedValue)} />;
}
if (showDisableAutoConnectDialog) {
@@ -100,10 +89,10 @@ function IDEScreen({
onComplete={() => {
// Always disconnect when user selects "None", regardless of their
// choice about disabling auto-connect
onSelect(undefined)
onSelect(undefined);
}}
/>
)
);
}
return (
@@ -129,36 +118,28 @@ function IDEScreen({
defaultFocusValue={selectedValue}
options={options}
onChange={value => {
setSelectedValue(value)
handleSelectIDE(value)
setSelectedValue(value);
handleSelectIDE(value);
}}
/>
)}
{availableIDEs.length !== 0 &&
availableIDEs.some(
ide => ide.name === 'VS Code' || ide.name === 'Visual Studio Code',
) && (
availableIDEs.some(ide => ide.name === 'VS Code' || ide.name === 'Visual Studio Code') && (
<Box marginTop={1}>
<Text color="warning">
Note: Only one Claude Code instance can be connected to VS Code
at a time.
</Text>
<Text color="warning">Note: Only one Claude Code instance can be connected to VS Code at a time.</Text>
</Box>
)}
{availableIDEs.length !== 0 && !isSupportedTerminal() && (
<Box marginTop={1}>
<Text dimColor>
Tip: You can enable auto-connect to IDE in /config or with the
--ide flag
</Text>
<Text dimColor>Tip: You can enable auto-connect to IDE in /config or with the --ide flag</Text>
</Box>
)}
{unavailableIDEs.length > 0 && (
<Box marginTop={1} flexDirection="column">
<Text dimColor>
Found {unavailableIDEs.length} other running IDE(s). However,
their workspace/project directories do not match the current cwd.
Found {unavailableIDEs.length} other running IDE(s). However, their workspace/project directories do not
match the current cwd.
</Text>
<Box marginTop={1} flexDirection="column">
{unavailableIDEs.map((ide, index) => (
@@ -173,82 +154,64 @@ function IDEScreen({
)}
</Box>
</Dialog>
)
);
}
async function findCurrentIDE(
availableIDEs: DetectedIDEInfo[],
dynamicMcpConfig?: Record<string, ScopedMcpServerConfig>,
): Promise<DetectedIDEInfo | null> {
const currentConfig = dynamicMcpConfig?.ide
if (
!currentConfig ||
(currentConfig.type !== 'sse-ide' && currentConfig.type !== 'ws-ide')
) {
return null
const currentConfig = dynamicMcpConfig?.ide;
if (!currentConfig || (currentConfig.type !== 'sse-ide' && currentConfig.type !== 'ws-ide')) {
return null;
}
for (const ide of availableIDEs) {
if (ide.url === currentConfig.url) {
return ide
return ide;
}
}
return null
return null;
}
type IDEOpenSelectionProps = {
availableIDEs: DetectedIDEInfo[]
onSelectIDE: (ide?: DetectedIDEInfo) => void
onDone: (
result?: string,
options?: { display?: CommandResultDisplay },
) => void
}
availableIDEs: DetectedIDEInfo[];
onSelectIDE: (ide?: DetectedIDEInfo) => void;
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
};
function IDEOpenSelection({
availableIDEs,
onSelectIDE,
onDone,
}: IDEOpenSelectionProps): React.ReactNode {
const [selectedValue, setSelectedValue] = useState(
availableIDEs[0]?.port?.toString() ?? '',
)
function IDEOpenSelection({ availableIDEs, onSelectIDE, onDone }: IDEOpenSelectionProps): React.ReactNode {
const [selectedValue, setSelectedValue] = useState(availableIDEs[0]?.port?.toString() ?? '');
const handleSelectIDE = useCallback(
(value: string) => {
const selectedIDE = availableIDEs.find(
ide => ide.port === parseInt(value, 10),
)
onSelectIDE(selectedIDE)
const selectedIDE = availableIDEs.find(ide => ide.port === parseInt(value, 10));
onSelectIDE(selectedIDE);
},
[availableIDEs, onSelectIDE],
)
);
const options = availableIDEs.map(ide => ({
label: ide.name,
value: ide.port.toString(),
}))
}));
function handleCancel(): void {
onDone('IDE selection cancelled', { display: 'system' })
onDone('IDE selection cancelled', { display: 'system' });
}
return (
<Dialog
title="Select an IDE to open the project"
onCancel={handleCancel}
color="ide"
>
<Dialog title="Select an IDE to open the project" onCancel={handleCancel} color="ide">
<Select
defaultValue={selectedValue}
defaultFocusValue={selectedValue}
options={options}
onChange={value => {
setSelectedValue(value)
handleSelectIDE(value)
setSelectedValue(value);
handleSelectIDE(value);
}}
/>
</Dialog>
)
);
}
function RunningIDESelector({
@@ -256,88 +219,72 @@ function RunningIDESelector({
onSelectIDE,
onDone,
}: {
runningIDEs: IdeType[]
onSelectIDE: (ide: IdeType) => void
onDone: (
result?: string,
options?: { display?: CommandResultDisplay },
) => void
runningIDEs: IdeType[];
onSelectIDE: (ide: IdeType) => void;
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
}): React.ReactNode {
const [selectedValue, setSelectedValue] = useState(runningIDEs[0] ?? '')
const [selectedValue, setSelectedValue] = useState(runningIDEs[0] ?? '');
const handleSelectIDE = useCallback(
(value: string) => {
onSelectIDE(value as IdeType)
onSelectIDE(value as IdeType);
},
[onSelectIDE],
)
);
const options = runningIDEs.map(ide => ({
label: toIDEDisplayName(ide),
value: ide,
}))
}));
function handleCancel(): void {
onDone('IDE selection cancelled', { display: 'system' })
onDone('IDE selection cancelled', { display: 'system' });
}
return (
<Dialog
title="Select IDE to install extension"
onCancel={handleCancel}
color="ide"
>
<Dialog title="Select IDE to install extension" onCancel={handleCancel} color="ide">
<Select
defaultFocusValue={selectedValue}
options={options}
onChange={value => {
setSelectedValue(value)
handleSelectIDE(value)
setSelectedValue(value);
handleSelectIDE(value);
}}
/>
</Dialog>
)
);
}
function InstallOnMount({
ide,
onInstall,
}: {
ide: IdeType
onInstall: (ide: IdeType) => void
}): React.ReactNode {
function InstallOnMount({ ide, onInstall }: { ide: IdeType; onInstall: (ide: IdeType) => void }): React.ReactNode {
useEffect(() => {
onInstall(ide)
}, [ide, onInstall])
return null
onInstall(ide);
}, [ide, onInstall]);
return null;
}
export async function call(
onDone: (
result?: string,
options?: { display?: CommandResultDisplay },
) => void,
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void,
context: LocalJSXCommandContext,
args: string,
): Promise<React.ReactNode | null> {
logEvent('tengu_ext_ide_command', {})
logEvent('tengu_ext_ide_command', {});
const {
options: { dynamicMcpConfig },
onChangeDynamicMcpConfig,
} = context
} = context;
// Handle 'open' argument
if (args?.trim() === 'open') {
const worktreeSession = getCurrentWorktreeSession()
const targetPath = worktreeSession ? worktreeSession.worktreePath : getCwd()
const worktreeSession = getCurrentWorktreeSession();
const targetPath = worktreeSession ? worktreeSession.worktreePath : getCwd();
// Detect available IDEs
const detectedIDEs = await detectIDEs(true)
const availableIDEs = detectedIDEs.filter(ide => ide.isValid)
const detectedIDEs = await detectIDEs(true);
const availableIDEs = detectedIDEs.filter(ide => ide.isValid);
if (availableIDEs.length === 0) {
onDone('No IDEs with Claude Code extension detected.')
return null
onDone('No IDEs with Claude Code extension detected.');
return null;
}
// Return IDE selection component
@@ -346,8 +293,8 @@ export async function call(
availableIDEs={availableIDEs}
onSelectIDE={async (selectedIDE?: DetectedIDEInfo) => {
if (!selectedIDE) {
onDone('No IDE selected.')
return
onDone('No IDE selected.');
return;
}
// Try to open the project in the selected IDE
@@ -357,58 +304,50 @@ export async function call(
selectedIDE.name.toLowerCase().includes('windsurf')
) {
// VS Code-based IDEs
const { code } = await execFileNoThrow('code', [targetPath])
const { code } = await execFileNoThrow('code', [targetPath]);
if (code === 0) {
onDone(
`Opened ${worktreeSession ? 'worktree' : 'project'} in ${chalk.bold(selectedIDE.name)}`,
)
onDone(`Opened ${worktreeSession ? 'worktree' : 'project'} in ${chalk.bold(selectedIDE.name)}`);
} else {
onDone(
`Failed to open in ${selectedIDE.name}. Try opening manually: ${targetPath}`,
)
onDone(`Failed to open in ${selectedIDE.name}. Try opening manually: ${targetPath}`);
}
} else if (isSupportedJetBrainsTerminal()) {
// JetBrains IDEs - they usually open via their CLI tools
onDone(
`Please open the ${worktreeSession ? 'worktree' : 'project'} manually in ${chalk.bold(selectedIDE.name)}: ${targetPath}`,
)
);
} else {
onDone(
`Please open the ${worktreeSession ? 'worktree' : 'project'} manually in ${chalk.bold(selectedIDE.name)}: ${targetPath}`,
)
);
}
}}
onDone={() => {
onDone('Exited without opening IDE', { display: 'system' })
onDone('Exited without opening IDE', { display: 'system' });
}}
/>
)
);
}
const detectedIDEs = await detectIDEs(true)
const detectedIDEs = await detectIDEs(true);
// If no IDEs with extensions detected, check for running IDEs and offer to install
if (
detectedIDEs.length === 0 &&
context.onInstallIDEExtension &&
!isSupportedTerminal()
) {
const runningIDEs = await detectRunningIDEs()
if (detectedIDEs.length === 0 && context.onInstallIDEExtension && !isSupportedTerminal()) {
const runningIDEs = await detectRunningIDEs();
const onInstall = (ide: IdeType) => {
if (context.onInstallIDEExtension) {
context.onInstallIDEExtension(ide)
context.onInstallIDEExtension(ide);
// The completion message will be shown after installation
if (isJetBrainsIde(ide)) {
onDone(
`Installed plugin to ${chalk.bold(toIDEDisplayName(ide))}\n` +
`Please ${chalk.bold('restart your IDE')} completely for it to take effect`,
)
);
} else {
onDone(`Installed extension to ${chalk.bold(toIDEDisplayName(ide))}`)
onDone(`Installed extension to ${chalk.bold(toIDEDisplayName(ide))}`);
}
}
}
};
if (runningIDEs.length > 1) {
// Show selector when multiple IDEs are running
@@ -417,19 +356,19 @@ export async function call(
runningIDEs={runningIDEs}
onSelectIDE={onInstall}
onDone={() => {
onDone('No IDE selected.', { display: 'system' })
onDone('No IDE selected.', { display: 'system' });
}}
/>
)
);
} else if (runningIDEs.length === 1) {
return <InstallOnMount ide={runningIDEs[0]!} onInstall={onInstall} />
return <InstallOnMount ide={runningIDEs[0]!} onInstall={onInstall} />;
}
}
const availableIDEs = detectedIDEs.filter(ide => ide.isValid)
const unavailableIDEs = detectedIDEs.filter(ide => !ide.isValid)
const availableIDEs = detectedIDEs.filter(ide => ide.isValid);
const unavailableIDEs = detectedIDEs.filter(ide => !ide.isValid);
const currentIDE = await findCurrentIDE(availableIDEs, dynamicMcpConfig)
const currentIDE = await findCurrentIDE(availableIDEs, dynamicMcpConfig);
return (
<IDECommandFlow
@@ -440,25 +379,20 @@ export async function call(
onChangeDynamicMcpConfig={onChangeDynamicMcpConfig}
onDone={onDone}
/>
)
);
}
// Connection timeout slightly longer than the 30s MCP connection timeout
const IDE_CONNECTION_TIMEOUT_MS = 35000
const IDE_CONNECTION_TIMEOUT_MS = 35000;
type IDECommandFlowProps = {
availableIDEs: DetectedIDEInfo[]
unavailableIDEs: DetectedIDEInfo[]
currentIDE: DetectedIDEInfo | null
dynamicMcpConfig?: Record<string, ScopedMcpServerConfig>
onChangeDynamicMcpConfig?: (
config: Record<string, ScopedMcpServerConfig>,
) => void
onDone: (
result?: string,
options?: { display?: CommandResultDisplay },
) => void
}
availableIDEs: DetectedIDEInfo[];
unavailableIDEs: DetectedIDEInfo[];
currentIDE: DetectedIDEInfo | null;
dynamicMcpConfig?: Record<string, ScopedMcpServerConfig>;
onChangeDynamicMcpConfig?: (config: Record<string, ScopedMcpServerConfig>) => void;
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
};
function IDECommandFlow({
availableIDEs,
@@ -468,80 +402,66 @@ function IDECommandFlow({
onChangeDynamicMcpConfig,
onDone,
}: IDECommandFlowProps): React.ReactNode {
const [connectingIDE, setConnectingIDE] = useState<DetectedIDEInfo | null>(
null,
)
const ideClient = useAppState(s => s.mcp.clients.find(c => c.name === 'ide'))
const setAppState = useSetAppState()
const isFirstCheckRef = useRef(true)
const [connectingIDE, setConnectingIDE] = useState<DetectedIDEInfo | null>(null);
const ideClient = useAppState(s => s.mcp.clients.find(c => c.name === 'ide'));
const setAppState = useSetAppState();
const isFirstCheckRef = useRef(true);
// Watch for connection result
useEffect(() => {
if (!connectingIDE) return
if (!connectingIDE) return;
// Skip the first check — it reflects stale state from before the
// config change was dispatched
if (isFirstCheckRef.current) {
isFirstCheckRef.current = false
return
isFirstCheckRef.current = false;
return;
}
if (!ideClient || ideClient.type === 'pending') return
if (!ideClient || ideClient.type === 'pending') return;
if (ideClient.type === 'connected') {
onDone(`Connected to ${connectingIDE.name}.`)
onDone(`Connected to ${connectingIDE.name}.`);
} else if (ideClient.type === 'failed') {
onDone(`Failed to connect to ${connectingIDE.name}.`)
onDone(`Failed to connect to ${connectingIDE.name}.`);
}
}, [ideClient, connectingIDE, onDone])
}, [ideClient, connectingIDE, onDone]);
// Timeout fallback
useEffect(() => {
if (!connectingIDE) return
const timer = setTimeout(
onDone,
IDE_CONNECTION_TIMEOUT_MS,
`Connection to ${connectingIDE.name} timed out.`,
)
return () => clearTimeout(timer)
}, [connectingIDE, onDone])
if (!connectingIDE) return;
const timer = setTimeout(onDone, IDE_CONNECTION_TIMEOUT_MS, `Connection to ${connectingIDE.name} timed out.`);
return () => clearTimeout(timer);
}, [connectingIDE, onDone]);
const handleSelectIDE = useCallback(
(selectedIDE?: DetectedIDEInfo) => {
if (!onChangeDynamicMcpConfig) {
onDone('Error connecting to IDE.')
return
onDone('Error connecting to IDE.');
return;
}
const newConfig = { ...(dynamicMcpConfig || {}) }
const newConfig = { ...(dynamicMcpConfig || {}) };
if (currentIDE) {
delete newConfig.ide
delete newConfig.ide;
}
if (!selectedIDE) {
// Close the MCP transport and remove the client from state
if (ideClient && ideClient.type === 'connected' && currentIDE) {
// Null out onclose to prevent auto-reconnection
ideClient.client.onclose = () => {}
void clearServerCache('ide', ideClient.config)
ideClient.client.onclose = () => {};
void clearServerCache('ide', ideClient.config);
setAppState(prev => ({
...prev,
mcp: {
...prev.mcp,
clients: prev.mcp.clients.filter(c => c.name !== 'ide'),
tools: prev.mcp.tools.filter(
t => !t.name?.startsWith('mcp__ide__'),
),
commands: prev.mcp.commands.filter(
c => !c.name?.startsWith('mcp__ide__'),
),
tools: prev.mcp.tools.filter(t => !t.name?.startsWith('mcp__ide__')),
commands: prev.mcp.commands.filter(c => !c.name?.startsWith('mcp__ide__')),
},
}))
}));
}
onChangeDynamicMcpConfig(newConfig)
onDone(
currentIDE
? `Disconnected from ${currentIDE.name}.`
: 'No IDE selected.',
)
return
onChangeDynamicMcpConfig(newConfig);
onDone(currentIDE ? `Disconnected from ${currentIDE.name}.` : 'No IDE selected.');
return;
}
const url = selectedIDE.url
const url = selectedIDE.url;
newConfig.ide = {
type: url.startsWith('ws:') ? 'ws-ide' : 'sse-ide',
url: url,
@@ -549,23 +469,16 @@ function IDECommandFlow({
authToken: selectedIDE.authToken,
ideRunningInWindows: selectedIDE.ideRunningInWindows,
scope: 'dynamic' as const,
} as ScopedMcpServerConfig
isFirstCheckRef.current = true
setConnectingIDE(selectedIDE)
onChangeDynamicMcpConfig(newConfig)
} as ScopedMcpServerConfig;
isFirstCheckRef.current = true;
setConnectingIDE(selectedIDE);
onChangeDynamicMcpConfig(newConfig);
},
[
dynamicMcpConfig,
currentIDE,
ideClient,
setAppState,
onChangeDynamicMcpConfig,
onDone,
],
)
[dynamicMcpConfig, currentIDE, ideClient, setAppState, onChangeDynamicMcpConfig, onDone],
);
if (connectingIDE) {
return <Text dimColor>Connecting to {connectingIDE.name}</Text>
return <Text dimColor>Connecting to {connectingIDE.name}</Text>;
}
return (
@@ -576,7 +489,7 @@ function IDECommandFlow({
onClose={() => onDone('IDE selection cancelled', { display: 'system' })}
onSelect={handleSelectIDE}
/>
)
);
}
/**
@@ -585,46 +498,43 @@ function IDECommandFlow({
* @param maxLength Maximum total length of the formatted string
* @returns Formatted string with folder paths
*/
export function formatWorkspaceFolders(
folders: string[],
maxLength: number = 100,
): string {
if (folders.length === 0) return ''
export function formatWorkspaceFolders(folders: string[], maxLength: number = 100): string {
if (folders.length === 0) return '';
const cwd = getCwd()
const cwd = getCwd();
// Only show first 2 workspaces
const foldersToShow = folders.slice(0, 2)
const hasMore = folders.length > 2
const foldersToShow = folders.slice(0, 2);
const hasMore = folders.length > 2;
// Account for ", …" if there are more folders
const ellipsisOverhead = hasMore ? 3 : 0 // ", …"
const ellipsisOverhead = hasMore ? 3 : 0; // ", …"
// Account for commas and spaces between paths (", " = 2 chars per separator)
const separatorOverhead = (foldersToShow.length - 1) * 2
const availableLength = maxLength - separatorOverhead - ellipsisOverhead
const separatorOverhead = (foldersToShow.length - 1) * 2;
const availableLength = maxLength - separatorOverhead - ellipsisOverhead;
const maxLengthPerPath = Math.floor(availableLength / foldersToShow.length)
const maxLengthPerPath = Math.floor(availableLength / foldersToShow.length);
const cwdNFC = cwd.normalize('NFC')
const cwdNFC = cwd.normalize('NFC');
const formattedFolders = foldersToShow.map(folder => {
// Strip cwd from the beginning if present
// Normalize both to NFC for consistent comparison (macOS uses NFD paths)
const folderNFC = folder.normalize('NFC')
const folderNFC = folder.normalize('NFC');
if (folderNFC.startsWith(cwdNFC + path.sep)) {
folder = folderNFC.slice(cwdNFC.length + 1)
folder = folderNFC.slice(cwdNFC.length + 1);
}
if (folder.length <= maxLengthPerPath) {
return folder
return folder;
}
return '…' + folder.slice(-(maxLengthPerPath - 1))
})
return '…' + folder.slice(-(maxLengthPerPath - 1));
});
let result = formattedFolders.join(', ')
let result = formattedFolders.join(', ');
if (hasMore) {
result += ', …'
result += ', …';
}
return result
return result;
}

View File

@@ -895,7 +895,9 @@ async function summarizeTranscriptChunk(chunk: string): Promise<string> {
},
})
const text = extractTextContent(result.message.content as readonly { readonly type: string }[])
const text = extractTextContent(
result.message.content as readonly { readonly type: string }[],
)
return text || chunk.slice(0, 2000)
} catch {
// On error, just return truncated chunk
@@ -1038,7 +1040,9 @@ RESPOND WITH ONLY A VALID JSON OBJECT matching this schema:
},
})
const text = extractTextContent(result.message.content as readonly { readonly type: string }[])
const text = extractTextContent(
result.message.content as readonly { readonly type: string }[],
)
// Parse JSON from response
const jsonMatch = text.match(/\{[\s\S]*\}/)
@@ -1589,7 +1593,9 @@ async function generateSectionInsight(
},
})
const text = extractTextContent(result.message.content as readonly { readonly type: string }[])
const text = extractTextContent(
result.message.content as readonly { readonly type: string }[],
)
if (text) {
// Parse JSON from response

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} />;
}

View File

@@ -1,28 +1,25 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import React, { useEffect, useState } from 'react'
import type { CommandResultDisplay } from 'src/commands.js'
import { logEvent } from 'src/services/analytics/index.js'
import { StatusIcon } from '@anthropic/ink'
import { Box, wrappedRender as render, Text } from '@anthropic/ink'
import { logForDebugging } from '../utils/debug.js'
import { env } from '../utils/env.js'
import { errorMessage } from '../utils/errors.js'
import { homedir } from 'node:os';
import { join } from 'node:path';
import React, { useEffect, useState } from 'react';
import type { CommandResultDisplay } from 'src/commands.js';
import { logEvent } from 'src/services/analytics/index.js';
import { StatusIcon } from '@anthropic/ink';
import { Box, wrappedRender as render, Text } from '@anthropic/ink';
import { logForDebugging } from '../utils/debug.js';
import { env } from '../utils/env.js';
import { errorMessage } from '../utils/errors.js';
import {
checkInstall,
cleanupNpmInstallations,
cleanupShellAliases,
installLatest,
} from '../utils/nativeInstaller/index.js'
import {
getInitialSettings,
updateSettingsForSource,
} from '../utils/settings/settings.js'
} from '../utils/nativeInstaller/index.js';
import { getInitialSettings, updateSettingsForSource } from '../utils/settings/settings.js';
interface InstallProps {
onDone: (result: string, options?: { display?: CommandResultDisplay }) => void
force?: boolean
target?: string // 'latest', 'stable', or version like '1.0.34'
onDone: (result: string, options?: { display?: CommandResultDisplay }) => void;
force?: boolean;
target?: string; // 'latest', 'stable', or version like '1.0.34'
}
type InstallState =
@@ -32,24 +29,24 @@ type InstallState =
| { type: 'setting-up' }
| { type: 'set-up'; messages: string[] }
| { type: 'success'; version: string; setupMessages?: string[] }
| { type: 'error'; message: string; warnings?: string[] }
| { type: 'error'; message: string; warnings?: string[] };
function getInstallationPath(): string {
const isWindows = env.platform === 'win32'
const homeDir = homedir()
const isWindows = env.platform === 'win32';
const homeDir = homedir();
if (isWindows) {
// Convert to Windows-style path
const windowsPath = join(homeDir, '.local', 'bin', 'claude.exe')
const windowsPath = join(homeDir, '.local', 'bin', 'claude.exe');
// Replace forward slashes with backslashes for Windows display
return windowsPath.replace(/\//g, '\\')
return windowsPath.replace(/\//g, '\\');
}
return '~/.local/bin/claude'
return '~/.local/bin/claude';
}
function SetupNotes({ messages }: { messages: string[] }): React.ReactNode {
if (messages.length === 0) return null
if (messages.length === 0) return null;
return (
<Box flexDirection="column" gap={0} marginBottom={1}>
@@ -65,183 +62,151 @@ function SetupNotes({ messages }: { messages: string[] }): React.ReactNode {
</Box>
))}
</Box>
)
);
}
function Install({ onDone, force, target }: InstallProps): React.ReactNode {
const [state, setState] = useState<InstallState>({ type: 'checking' })
const [state, setState] = useState<InstallState>({ type: 'checking' });
useEffect(() => {
async function run() {
try {
logForDebugging(
`Install: Starting installation process (force=${force}, target=${target})`,
)
logForDebugging(`Install: Starting installation process (force=${force}, target=${target})`);
// Install native build first
const channelOrVersion =
target || getInitialSettings()?.autoUpdatesChannel || 'latest'
setState({ type: 'installing', version: channelOrVersion })
const channelOrVersion = target || getInitialSettings()?.autoUpdatesChannel || 'latest';
setState({ type: 'installing', version: channelOrVersion });
// Pass force flag to trigger reinstall even if up to date
logForDebugging(
`Install: Calling installLatest(channelOrVersion=${channelOrVersion}, forceReinstall=${force})`,
)
const result = await installLatest(channelOrVersion, force)
);
const result = await installLatest(channelOrVersion, force);
logForDebugging(
`Install: installLatest returned version=${result.latestVersion}, wasUpdated=${result.wasUpdated}, lockFailed=${result.lockFailed}`,
)
);
// Check specifically for lock failure
if (result.lockFailed) {
throw new Error(
'Could not install - another process is currently installing Claude. Please try again in a moment.',
)
);
}
// If we couldn't get the version, there might be an issue
if (!result.latestVersion) {
logForDebugging(
'Install: Failed to retrieve version information during install',
{ level: 'error' },
)
logForDebugging('Install: Failed to retrieve version information during install', { level: 'error' });
}
if (!result.wasUpdated) {
logForDebugging('Install: Already up to date')
logForDebugging('Install: Already up to date');
}
// Set up launcher and shell integration
setState({ type: 'setting-up' })
const setupMessages = await checkInstall(true)
setState({ type: 'setting-up' });
const setupMessages = await checkInstall(true);
logForDebugging(
`Install: Setup launcher completed with ${setupMessages.length} messages`,
)
logForDebugging(`Install: Setup launcher completed with ${setupMessages.length} messages`);
if (setupMessages.length > 0) {
setupMessages.forEach(msg =>
logForDebugging(`Install: Setup message: ${msg.message}`),
)
setupMessages.forEach(msg => logForDebugging(`Install: Setup message: ${msg.message}`));
}
// Now that native installation succeeded, clean up old npm installations
logForDebugging(
'Install: Cleaning up npm installations after successful install',
)
const { removed, errors, warnings } = await cleanupNpmInstallations()
logForDebugging('Install: Cleaning up npm installations after successful install');
const { removed, errors, warnings } = await cleanupNpmInstallations();
if (removed > 0) {
logForDebugging(`Cleaned up ${removed} npm installation(s)`)
logForDebugging(`Cleaned up ${removed} npm installation(s)`);
}
if (errors.length > 0) {
logForDebugging(`Cleanup errors: ${errors.join(', ')}`)
logForDebugging(`Cleanup errors: ${errors.join(', ')}`);
// Continue despite cleanup errors - native install already succeeded
}
// Clean up old shell aliases
const aliasMessages = await cleanupShellAliases()
const aliasMessages = await cleanupShellAliases();
if (aliasMessages.length > 0) {
logForDebugging(
`Shell alias cleanup: ${aliasMessages.map(m => m.message).join('; ')}`,
)
logForDebugging(`Shell alias cleanup: ${aliasMessages.map(m => m.message).join('; ')}`);
}
// Log success event
logEvent('tengu_claude_install_command', {
has_version: result.latestVersion ? 1 : 0,
forced: force ? 1 : 0,
})
});
// If user explicitly specified a channel, save it to settings
if (target === 'latest' || target === 'stable') {
updateSettingsForSource('userSettings', {
autoUpdatesChannel: target,
})
logForDebugging(
`Install: Saved autoUpdatesChannel=${target} to user settings`,
)
});
logForDebugging(`Install: Saved autoUpdatesChannel=${target} to user settings`);
}
// Combine all warning/info messages (convert SetupMessage to string)
const allWarnings = [...warnings, ...aliasMessages.map(m => m.message)]
const allWarnings = [...warnings, ...aliasMessages.map(m => m.message)];
// Check if there were any setup errors or notes
if (setupMessages.length > 0) {
setState({
type: 'set-up',
messages: setupMessages.map(m => m.message),
})
});
// Still mark as success but show both setup messages and cleanup warnings
setTimeout(setState, 2000, {
type: 'success' as const,
version: result.latestVersion || 'current',
setupMessages: [
...setupMessages.map(m => m.message),
...allWarnings,
],
})
setupMessages: [...setupMessages.map(m => m.message), ...allWarnings],
});
} else {
// No setup messages, go straight to success (but still show cleanup warnings if any)
logForDebugging('Install: Shell PATH already configured')
logForDebugging('Install: Shell PATH already configured');
setState({
type: 'success',
version: result.latestVersion || 'current',
setupMessages: allWarnings.length > 0 ? allWarnings : undefined,
})
});
}
} catch (error) {
logForDebugging(`Install command failed: ${error}`, {
level: 'error',
})
});
setState({
type: 'error',
message: errorMessage(error),
})
});
}
}
void run()
}, [force, target])
void run();
}, [force, target]);
useEffect(() => {
if (state.type === 'success') {
// Give success message time to render before exiting
setTimeout(
onDone,
2000,
'Claude Code installation completed successfully',
{
display: 'system' as const,
},
)
setTimeout(onDone, 2000, 'Claude Code installation completed successfully', {
display: 'system' as const,
});
} else if (state.type === 'error') {
// Give error message time to render before exiting
setTimeout(onDone, 3000, 'Claude Code installation failed', {
display: 'system' as const,
})
});
}
}, [state, onDone])
}, [state, onDone]);
return (
<Box flexDirection="column" marginTop={1}>
{state.type === 'checking' && (
<Text color="claude">Checking installation status...</Text>
)}
{state.type === 'checking' && <Text color="claude">Checking installation status...</Text>}
{state.type === 'cleaning-npm' && (
<Text color="warning">Cleaning up old npm installations...</Text>
)}
{state.type === 'cleaning-npm' && <Text color="warning">Cleaning up old npm installations...</Text>}
{state.type === 'installing' && (
<Text color="claude">
Installing Claude Code native build {state.version}...
</Text>
<Text color="claude">Installing Claude Code native build {state.version}...</Text>
)}
{state.type === 'setting-up' && (
<Text color="claude">Setting up launcher and shell integration...</Text>
)}
{state.type === 'setting-up' && <Text color="claude">Setting up launcher and shell integration...</Text>}
{state.type === 'set-up' && <SetupNotes messages={state.messages} />}
@@ -291,7 +256,7 @@ function Install({ onDone, force, target }: InstallProps): React.ReactNode {
</Box>
)}
</Box>
)
);
}
// This is only used from cli.tsx, not as a slash command
@@ -301,27 +266,24 @@ export const install = {
description: 'Install Claude Code native build',
argumentHint: '[options]',
async call(
onDone: (
result: string,
options?: { display?: CommandResultDisplay },
) => void,
onDone: (result: string, options?: { display?: CommandResultDisplay }) => void,
_context: unknown,
args: string[],
) {
// Parse arguments
const force = args.includes('--force')
const nonFlagArgs = args.filter(arg => !arg.startsWith('--'))
const target = nonFlagArgs[0] // 'latest', 'stable', or version like '1.0.34'
const force = args.includes('--force');
const nonFlagArgs = args.filter(arg => !arg.startsWith('--'));
const target = nonFlagArgs[0]; // 'latest', 'stable', or version like '1.0.34'
const { unmount } = await render(
<Install
onDone={(result, options) => {
unmount()
onDone(result, options)
unmount();
onDone(result, options);
}}
force={force}
target={target}
/>,
)
);
},
}
};

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -1,4 +1,4 @@
import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js'
import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js';
/**
* /job slash command — manages template jobs from inside the REPL.
@@ -11,24 +11,24 @@ export async function call(
_context: LocalJSXCommandContext,
args: string,
): Promise<React.ReactNode> {
const parts = args ? args.trim().split(/\s+/) : []
const sub = parts[0] || 'list'
const parts = args ? args.trim().split(/\s+/) : [];
const sub = parts[0] || 'list';
// Capture console output so we can return it as onDone text
const lines: string[] = []
const origLog = console.log
const origError = console.error
console.log = (...a: unknown[]) => lines.push(a.map(String).join(' '))
console.error = (...a: unknown[]) => lines.push(a.map(String).join(' '))
const lines: string[] = [];
const origLog = console.log;
const origError = console.error;
console.log = (...a: unknown[]) => lines.push(a.map(String).join(' '));
console.error = (...a: unknown[]) => lines.push(a.map(String).join(' '));
try {
const { templatesMain } = await import('../../cli/handlers/templateJobs.js')
await templatesMain([sub, ...parts.slice(1)])
const { templatesMain } = await import('../../cli/handlers/templateJobs.js');
await templatesMain([sub, ...parts.slice(1)]);
} finally {
console.log = origLog
console.error = origError
console.log = origLog;
console.error = origError;
}
onDone(lines.join('\n') || 'Done.', { display: 'system' })
return null
onDone(lines.join('\n') || 'Done.', { display: 'system' });
return null;
}

View File

@@ -1,81 +1,71 @@
import { feature } from 'bun:bundle'
import * as React from 'react'
import { resetCostState } from '../../bootstrap/state.js'
import {
clearTrustedDeviceToken,
enrollTrustedDevice,
} from '../../bridge/trustedDevice.js'
import type { LocalJSXCommandContext } from '../../commands.js'
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'
import { ConsoleOAuthFlow } from '../../components/ConsoleOAuthFlow.js'
import { Dialog } from '@anthropic/ink'
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'
import { Text } from '@anthropic/ink'
import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js'
import { refreshPolicyLimits } from '../../services/policyLimits/index.js'
import { refreshRemoteManagedSettings } from '../../services/remoteManagedSettings/index.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import { stripSignatureBlocks } from '../../utils/messages.js'
import { feature } from 'bun:bundle';
import * as React from 'react';
import { resetCostState } from '../../bootstrap/state.js';
import { clearTrustedDeviceToken, enrollTrustedDevice } from '../../bridge/trustedDevice.js';
import type { LocalJSXCommandContext } from '../../commands.js';
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js';
import { ConsoleOAuthFlow } from '../../components/ConsoleOAuthFlow.js';
import { Dialog } from '@anthropic/ink';
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
import { Text } from '@anthropic/ink';
import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js';
import { refreshPolicyLimits } from '../../services/policyLimits/index.js';
import { refreshRemoteManagedSettings } from '../../services/remoteManagedSettings/index.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import { stripSignatureBlocks } from '../../utils/messages.js';
import {
checkAndDisableAutoModeIfNeeded,
resetAutoModeGateCheck,
} from '../../utils/permissions/bypassPermissionsKillswitch.js'
import { resetUserCache } from '../../utils/user.js'
} from '../../utils/permissions/bypassPermissionsKillswitch.js';
import { resetUserCache } from '../../utils/user.js';
export async function call(
onDone: LocalJSXCommandOnDone,
context: LocalJSXCommandContext,
): Promise<React.ReactNode> {
export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise<React.ReactNode> {
return (
<Login
onDone={async success => {
context.onChangeAPIKey()
context.onChangeAPIKey();
// Signature-bearing blocks (thinking, connector_text) are bound to the API key —
// strip them so the new key doesn't reject stale signatures.
context.setMessages(stripSignatureBlocks)
context.setMessages(stripSignatureBlocks);
if (success) {
// Post-login refresh logic. Keep in sync with onboarding in src/interactiveHelpers.tsx
// Reset cost state when switching accounts
resetCostState()
resetCostState();
// Refresh remotely managed settings after login (non-blocking)
void refreshRemoteManagedSettings()
void refreshRemoteManagedSettings();
// Refresh policy limits after login (non-blocking)
void refreshPolicyLimits()
void refreshPolicyLimits();
// Clear user data cache BEFORE GrowthBook refresh so it picks up fresh credentials
resetUserCache()
resetUserCache();
// Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs)
refreshGrowthBookAfterAuthChange()
refreshGrowthBookAfterAuthChange();
// Clear any stale trusted device token from a previous account before
// re-enrolling — prevents sending the old token on bridge calls while
// the async enrollTrustedDevice() is in-flight.
clearTrustedDeviceToken()
clearTrustedDeviceToken();
// Enroll as a trusted device for Remote Control (10-min fresh-session window)
void enrollTrustedDevice()
void enrollTrustedDevice();
// Reset killswitch gate checks and re-run with new org
resetAutoModeGateCheck()
const appState = context.getAppState()
void checkAndDisableAutoModeIfNeeded(
appState.toolPermissionContext,
context.setAppState,
appState.fastMode,
)
resetAutoModeGateCheck();
const appState = context.getAppState();
void checkAndDisableAutoModeIfNeeded(appState.toolPermissionContext, context.setAppState, appState.fastMode);
// Increment authVersion to trigger re-fetching of auth-dependent data in hooks (e.g., MCP servers)
context.setAppState(prev => ({
...prev,
authVersion: prev.authVersion + 1,
}))
}));
}
onDone(success ? 'Login successful' : 'Login interrupted')
onDone(success ? 'Login successful' : 'Login interrupted');
}}
/>
)
);
}
export function Login(props: {
onDone: (success: boolean, mainLoopModel: string) => void
startingMessage?: string
onDone: (success: boolean, mainLoopModel: string) => void;
startingMessage?: string;
}): React.ReactNode {
const mainLoopModel = useMainLoopModel()
const mainLoopModel = useMainLoopModel();
return (
<Dialog
@@ -86,19 +76,11 @@ export function Login(props: {
exitState.pending ? (
<Text>Press {exitState.keyName} again to exit</Text>
) : (
<ConfigurableShortcutHint
action="confirm:no"
context="Confirmation"
fallback="Esc"
description="cancel"
/>
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />
)
}
>
<ConsoleOAuthFlow
onDone={() => props.onDone(true, mainLoopModel)}
startingMessage={props.startingMessage}
/>
<ConsoleOAuthFlow onDone={() => props.onDone(true, mainLoopModel)} startingMessage={props.startingMessage} />
</Dialog>
)
);
}

View File

@@ -1,89 +1,80 @@
import * as React from 'react'
import { clearTrustedDeviceTokenCache } from '../../bridge/trustedDevice.js'
import { Text } from '@anthropic/ink'
import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js'
import {
getGroveNoticeConfig,
getGroveSettings,
} from '../../services/api/grove.js'
import { clearPolicyLimitsCache } from '../../services/policyLimits/index.js'
import * as React from 'react';
import { clearTrustedDeviceTokenCache } from '../../bridge/trustedDevice.js';
import { Text } from '@anthropic/ink';
import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js';
import { getGroveNoticeConfig, getGroveSettings } from '../../services/api/grove.js';
import { clearPolicyLimitsCache } from '../../services/policyLimits/index.js';
// flushTelemetry is loaded lazily to avoid pulling in ~1.1MB of OpenTelemetry at startup
import { clearRemoteManagedSettingsCache } from '../../services/remoteManagedSettings/index.js'
import { getClaudeAIOAuthTokens, removeApiKey } from '../../utils/auth.js'
import { clearBetasCaches } from '../../utils/betas.js'
import { saveGlobalConfig } from '../../utils/config.js'
import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js'
import { getSecureStorage } from '../../utils/secureStorage/index.js'
import { clearToolSchemaCache } from '../../utils/toolSchemaCache.js'
import { resetUserCache } from '../../utils/user.js'
import { clearRemoteManagedSettingsCache } from '../../services/remoteManagedSettings/index.js';
import { getClaudeAIOAuthTokens, removeApiKey } from '../../utils/auth.js';
import { clearBetasCaches } from '../../utils/betas.js';
import { saveGlobalConfig } from '../../utils/config.js';
import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js';
import { getSecureStorage } from '../../utils/secureStorage/index.js';
import { clearToolSchemaCache } from '../../utils/toolSchemaCache.js';
import { resetUserCache } from '../../utils/user.js';
export async function performLogout({
clearOnboarding = false,
}): Promise<void> {
export async function performLogout({ clearOnboarding = false }): Promise<void> {
// Flush telemetry BEFORE clearing credentials to prevent org data leakage
const { flushTelemetry } = await import(
'../../utils/telemetry/instrumentation.js'
)
await flushTelemetry()
const { flushTelemetry } = await import('../../utils/telemetry/instrumentation.js');
await flushTelemetry();
await removeApiKey()
await removeApiKey();
// Wipe all secure storage data on logout
const secureStorage = getSecureStorage()
secureStorage.delete()
const secureStorage = getSecureStorage();
secureStorage.delete();
await clearAuthRelatedCaches()
await clearAuthRelatedCaches();
saveGlobalConfig(current => {
const updated = { ...current }
const updated = { ...current };
if (clearOnboarding) {
updated.hasCompletedOnboarding = false
updated.subscriptionNoticeCount = 0
updated.hasAvailableSubscription = false
updated.hasCompletedOnboarding = false;
updated.subscriptionNoticeCount = 0;
updated.hasAvailableSubscription = false;
if (updated.customApiKeyResponses?.approved) {
updated.customApiKeyResponses = {
...updated.customApiKeyResponses,
approved: [],
}
};
}
}
updated.oauthAccount = undefined
return updated
})
updated.oauthAccount = undefined;
return updated;
});
}
// clearing anything memoized that must be invalidated when user/session/auth changes
export async function clearAuthRelatedCaches(): Promise<void> {
// Clear the OAuth token cache
getClaudeAIOAuthTokens.cache?.clear?.()
clearTrustedDeviceTokenCache()
clearBetasCaches()
clearToolSchemaCache()
getClaudeAIOAuthTokens.cache?.clear?.();
clearTrustedDeviceTokenCache();
clearBetasCaches();
clearToolSchemaCache();
// Clear user data cache BEFORE GrowthBook refresh so it picks up fresh credentials
resetUserCache()
refreshGrowthBookAfterAuthChange()
resetUserCache();
refreshGrowthBookAfterAuthChange();
// Clear Grove config cache
getGroveNoticeConfig.cache?.clear?.()
getGroveSettings.cache?.clear?.()
getGroveNoticeConfig.cache?.clear?.();
getGroveSettings.cache?.clear?.();
// Clear remotely managed settings cache
await clearRemoteManagedSettingsCache()
await clearRemoteManagedSettingsCache();
// Clear policy limits cache
await clearPolicyLimitsCache()
await clearPolicyLimitsCache();
}
export async function call(): Promise<React.ReactNode> {
await performLogout({ clearOnboarding: true })
await performLogout({ clearOnboarding: true });
const message = (
<Text>Successfully logged out from your Anthropic account.</Text>
)
const message = <Text>Successfully logged out from your Anthropic account.</Text>;
setTimeout(() => {
gracefulShutdownSync(0, 'logout')
}, 200)
gracefulShutdownSync(0, 'logout');
}, 200);
return message
return message;
}

View File

@@ -1,10 +1,10 @@
import React, { useEffect, useRef } from 'react'
import { MCPSettings } from '../../components/mcp/index.js'
import { MCPReconnect } from '../../components/mcp/MCPReconnect.js'
import { useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js'
import { useAppState } from '../../state/AppState.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import { PluginSettings } from '../plugin/PluginSettings.js'
import React, { useEffect, useRef } from 'react';
import { MCPSettings } from '../../components/mcp/index.js';
import { MCPReconnect } from '../../components/mcp/MCPReconnect.js';
import { useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js';
import { useAppState } from '../../state/AppState.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import { PluginSettings } from '../plugin/PluginSettings.js';
// TODO: This is a hack to get the context value from toggleMcpServer (useContext only works in a component)
// Ideally, all MCP state and functions would be in global state.
@@ -13,93 +13,72 @@ function MCPToggle({
target,
onComplete,
}: {
action: 'enable' | 'disable'
target: string
onComplete: (result: string) => void
action: 'enable' | 'disable';
target: string;
onComplete: (result: string) => void;
}): null {
const mcpClients = useAppState(s => s.mcp.clients)
const toggleMcpServer = useMcpToggleEnabled()
const didRun = useRef(false)
const mcpClients = useAppState(s => s.mcp.clients);
const toggleMcpServer = useMcpToggleEnabled();
const didRun = useRef(false);
useEffect(() => {
if (didRun.current) return
didRun.current = true
if (didRun.current) return;
didRun.current = true;
const isEnabling = action === 'enable'
const clients = mcpClients.filter(c => c.name !== 'ide')
const isEnabling = action === 'enable';
const clients = mcpClients.filter(c => c.name !== 'ide');
const toToggle =
target === 'all'
? clients.filter(c =>
isEnabling ? c.type === 'disabled' : c.type !== 'disabled',
)
: clients.filter(c => c.name === target)
? clients.filter(c => (isEnabling ? c.type === 'disabled' : c.type !== 'disabled'))
: clients.filter(c => c.name === target);
if (toToggle.length === 0) {
onComplete(
target === 'all'
? `All MCP servers are already ${isEnabling ? 'enabled' : 'disabled'}`
: `MCP server "${target}" not found`,
)
return
);
return;
}
for (const s of toToggle) {
void toggleMcpServer(s.name)
void toggleMcpServer(s.name);
}
onComplete(
target === 'all'
? `${isEnabling ? 'Enabled' : 'Disabled'} ${toToggle.length} MCP server(s)`
: `MCP server "${target}" ${isEnabling ? 'enabled' : 'disabled'}`,
)
}, [action, target, mcpClients, toggleMcpServer, onComplete])
);
}, [action, target, mcpClients, toggleMcpServer, onComplete]);
return null
return null;
}
export async function call(
onDone: LocalJSXCommandOnDone,
_context: unknown,
args?: string,
): Promise<React.ReactNode> {
export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise<React.ReactNode> {
if (args) {
const parts = args.trim().split(/\s+/)
const parts = args.trim().split(/\s+/);
// Allow /mcp no-redirect to bypass the redirect for testing
if (parts[0] === 'no-redirect') {
return <MCPSettings onComplete={onDone} />
return <MCPSettings onComplete={onDone} />;
}
if (parts[0] === 'reconnect' && parts[1]) {
return (
<MCPReconnect
serverName={parts.slice(1).join(' ')}
onComplete={onDone}
/>
)
return <MCPReconnect serverName={parts.slice(1).join(' ')} onComplete={onDone} />;
}
if (parts[0] === 'enable' || parts[0] === 'disable') {
return (
<MCPToggle
action={parts[0]}
target={parts.length > 1 ? parts.slice(1).join(' ') : 'all'}
onComplete={onDone}
/>
)
<MCPToggle action={parts[0]} target={parts.length > 1 ? parts.slice(1).join(' ') : 'all'} onComplete={onDone} />
);
}
}
// Redirect base /mcp command to /plugins installed tab for ant users
if (process.env.USER_TYPE === 'ant') {
return (
<PluginSettings
onComplete={onDone}
args="manage"
showMcpRedirectMessage
/>
)
return <PluginSettings onComplete={onDone} args="manage" showMcpRedirectMessage />;
}
return <MCPSettings onComplete={onDone} />
return <MCPSettings onComplete={onDone} />;
}

View File

@@ -1,86 +1,74 @@
import { mkdir, writeFile } from 'fs/promises'
import * as React from 'react'
import type { CommandResultDisplay } from '../../commands.js'
import { Dialog } from '@anthropic/ink'
import { MemoryFileSelector } from '../../components/memory/MemoryFileSelector.js'
import { getRelativeMemoryPath } from '../../components/memory/MemoryUpdateNotification.js'
import { Box, Link, Text } from '@anthropic/ink'
import type { LocalJSXCommandCall } from '../../types/command.js'
import { clearMemoryFileCaches, getMemoryFiles } from '../../utils/claudemd.js'
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'
import { getErrnoCode } from '../../utils/errors.js'
import { logError } from '../../utils/log.js'
import { editFileInEditor } from '../../utils/promptEditor.js'
import { mkdir, writeFile } from 'fs/promises';
import * as React from 'react';
import type { CommandResultDisplay } from '../../commands.js';
import { Dialog } from '@anthropic/ink';
import { MemoryFileSelector } from '../../components/memory/MemoryFileSelector.js';
import { getRelativeMemoryPath } from '../../components/memory/MemoryUpdateNotification.js';
import { Box, Link, Text } from '@anthropic/ink';
import type { LocalJSXCommandCall } from '../../types/command.js';
import { clearMemoryFileCaches, getMemoryFiles } from '../../utils/claudemd.js';
import { getClaudeConfigHomeDir } from '../../utils/envUtils.js';
import { getErrnoCode } from '../../utils/errors.js';
import { logError } from '../../utils/log.js';
import { editFileInEditor } from '../../utils/promptEditor.js';
function MemoryCommand({
onDone,
}: {
onDone: (
result?: string,
options?: { display?: CommandResultDisplay },
) => void
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
}): React.ReactNode {
const handleSelectMemoryFile = async (memoryPath: string) => {
try {
// Create claude directory if it doesn't exist (idempotent with recursive)
if (memoryPath.includes(getClaudeConfigHomeDir())) {
await mkdir(getClaudeConfigHomeDir(), { recursive: true })
await mkdir(getClaudeConfigHomeDir(), { recursive: true });
}
// Create file if it doesn't exist (wx flag fails if file exists,
// which we catch to preserve existing content)
try {
await writeFile(memoryPath, '', { encoding: 'utf8', flag: 'wx' })
await writeFile(memoryPath, '', { encoding: 'utf8', flag: 'wx' });
} catch (e: unknown) {
if (getErrnoCode(e) !== 'EEXIST') {
throw e
throw e;
}
}
await editFileInEditor(memoryPath)
await editFileInEditor(memoryPath);
// Determine which environment variable controls the editor
let editorSource = 'default'
let editorValue = ''
let editorSource = 'default';
let editorValue = '';
if (process.env.VISUAL) {
editorSource = '$VISUAL'
editorValue = process.env.VISUAL
editorSource = '$VISUAL';
editorValue = process.env.VISUAL;
} else if (process.env.EDITOR) {
editorSource = '$EDITOR'
editorValue = process.env.EDITOR
editorSource = '$EDITOR';
editorValue = process.env.EDITOR;
}
const editorInfo =
editorSource !== 'default'
? `Using ${editorSource}="${editorValue}".`
: ''
const editorInfo = editorSource !== 'default' ? `Using ${editorSource}="${editorValue}".` : '';
const editorHint = editorInfo
? `> ${editorInfo} To change editor, set $EDITOR or $VISUAL environment variable.`
: `> To use a different editor, set the $EDITOR or $VISUAL environment variable.`
: `> To use a different editor, set the $EDITOR or $VISUAL environment variable.`;
onDone(
`Opened memory file at ${getRelativeMemoryPath(memoryPath)}\n\n${editorHint}`,
{ display: 'system' },
)
onDone(`Opened memory file at ${getRelativeMemoryPath(memoryPath)}\n\n${editorHint}`, { display: 'system' });
} catch (error) {
logError(error)
onDone(`Error opening memory file: ${error}`)
logError(error);
onDone(`Error opening memory file: ${error}`);
}
}
};
const handleCancel = () => {
onDone('Cancelled memory editing', { display: 'system' })
}
onDone('Cancelled memory editing', { display: 'system' });
};
return (
<Dialog title="Memory" onCancel={handleCancel} color="remember">
<Box flexDirection="column">
<React.Suspense fallback={null}>
<MemoryFileSelector
onSelect={handleSelectMemoryFile}
onCancel={handleCancel}
/>
<MemoryFileSelector onSelect={handleSelectMemoryFile} onCancel={handleCancel} />
</React.Suspense>
<Box marginTop={1}>
@@ -90,13 +78,13 @@ function MemoryCommand({
</Box>
</Box>
</Dialog>
)
);
}
export const call: LocalJSXCommandCall = async onDone => {
// Clear + prime before rendering — Suspense handles the unprimed case,
// but awaiting here avoids a fallback flash on initial open.
clearMemoryFileCaches()
await getMemoryFiles()
return <MemoryCommand onDone={onDone} />
}
clearMemoryFileCaches();
await getMemoryFiles();
return <MemoryCommand onDone={onDone} />;
};

View File

@@ -1,16 +1,16 @@
import { toString as qrToString } from 'qrcode'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Pane } from '@anthropic/ink'
import { type KeyboardEvent, Box, Text } from '@anthropic/ink'
import { useKeybinding } from '../../keybindings/useKeybinding.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import { toString as qrToString } from 'qrcode';
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Pane } from '@anthropic/ink';
import { type KeyboardEvent, Box, Text } from '@anthropic/ink';
import { useKeybinding } from '../../keybindings/useKeybinding.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
type Platform = 'ios' | 'android'
type Platform = 'ios' | 'android';
type Props = {
onDone: () => void
}
onDone: () => void;
};
const PLATFORMS: Record<Platform, { url: string }> = {
ios: {
@@ -19,17 +19,17 @@ const PLATFORMS: Record<Platform, { url: string }> = {
android: {
url: 'https://play.google.com/store/apps/details?id=com.anthropic.claude',
},
}
};
function MobileQRCode({ onDone }: Props): React.ReactNode {
const [platform, setPlatform] = useState<Platform>('ios')
const [platform, setPlatform] = useState<Platform>('ios');
const [qrCodes, setQrCodes] = useState<Record<Platform, string>>({
ios: '',
android: '',
})
});
const { url } = PLATFORMS[platform]
const qrCode = qrCodes[platform]
const { url } = PLATFORMS[platform];
const qrCode = qrCodes[platform];
// Generate both QR codes upfront to avoid flicker when switching
useEffect(() => {
@@ -43,42 +43,37 @@ function MobileQRCode({ onDone }: Props): React.ReactNode {
type: 'utf8',
errorCorrectionLevel: 'L',
}),
])
setQrCodes({ ios, android })
]);
setQrCodes({ ios, android });
}
generateQRCodes().catch(() => {
// QR generation failed, leave empty
})
}, [])
});
}, []);
const handleClose = useCallback(() => {
onDone()
}, [onDone])
onDone();
}, [onDone]);
useKeybinding('confirm:no', handleClose, { context: 'Confirmation' })
useKeybinding('confirm:no', handleClose, { context: 'Confirmation' });
function handleKeyDown(e: KeyboardEvent): void {
if (e.key === 'q' || (e.ctrl && e.key === 'c')) {
e.preventDefault()
onDone()
return
e.preventDefault();
onDone();
return;
}
if (e.key === 'tab' || e.key === 'left' || e.key === 'right') {
e.preventDefault()
setPlatform(prev => (prev === 'ios' ? 'android' : 'ios'))
e.preventDefault();
setPlatform(prev => (prev === 'ios' ? 'android' : 'ios'));
}
}
const lines = qrCode.split('\n').filter(line => line.length > 0)
const lines = qrCode.split('\n').filter(line => line.length > 0);
return (
<Pane>
<Box
flexDirection="column"
tabIndex={0}
autoFocus
onKeyDown={handleKeyDown}
>
<Box flexDirection="column" tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
<Text> </Text>
<Text> </Text>
{lines.map((line, i) => (
@@ -94,10 +89,7 @@ function MobileQRCode({ onDone }: Props): React.ReactNode {
iOS
</Text>
<Text dimColor>{' / '}</Text>
<Text
bold={platform === 'android'}
underline={platform === 'android'}
>
<Text bold={platform === 'android'} underline={platform === 'android'}>
Android
</Text>
</Text>
@@ -106,11 +98,9 @@ function MobileQRCode({ onDone }: Props): React.ReactNode {
<Text dimColor>{url}</Text>
</Box>
</Pane>
)
);
}
export async function call(
onDone: LocalJSXCommandOnDone,
): Promise<React.ReactNode> {
return <MobileQRCode onDone={onDone} />
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> {
return <MobileQRCode onDone={onDone} />;
}

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -1,119 +1,96 @@
import chalk from 'chalk'
import * as React from 'react'
import type { CommandResultDisplay } from '../../commands.js'
import { ModelPicker } from '../../components/ModelPicker.js'
import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'
import chalk from 'chalk';
import * as React from 'react';
import type { CommandResultDisplay } from '../../commands.js';
import { ModelPicker } from '../../components/ModelPicker.js';
import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import { useAppState, useSetAppState } from '../../state/AppState.js'
import type { LocalJSXCommandCall } from '../../types/command.js'
import type { EffortLevel } from '../../utils/effort.js'
import { isBilledAsExtraUsage } from '../../utils/extraUsage.js'
} from '../../services/analytics/index.js';
import { useAppState, useSetAppState } from '../../state/AppState.js';
import type { LocalJSXCommandCall } from '../../types/command.js';
import type { EffortLevel } from '../../utils/effort.js';
import { isBilledAsExtraUsage } from '../../utils/extraUsage.js';
import {
clearFastModeCooldown,
isFastModeAvailable,
isFastModeEnabled,
isFastModeSupportedByModel,
} from '../../utils/fastMode.js'
import { MODEL_ALIASES } from '../../utils/model/aliases.js'
import {
checkOpus1mAccess,
checkSonnet1mAccess,
} from '../../utils/model/check1mAccess.js'
} from '../../utils/fastMode.js';
import { MODEL_ALIASES } from '../../utils/model/aliases.js';
import { checkOpus1mAccess, checkSonnet1mAccess } from '../../utils/model/check1mAccess.js';
import {
getDefaultMainLoopModelSetting,
isOpus1mMergeEnabled,
renderDefaultModelSetting,
} from '../../utils/model/model.js'
import { isModelAllowed } from '../../utils/model/modelAllowlist.js'
import { validateModel } from '../../utils/model/validateModel.js'
} from '../../utils/model/model.js';
import { isModelAllowed } from '../../utils/model/modelAllowlist.js';
import { validateModel } from '../../utils/model/validateModel.js';
function ModelPickerWrapper({
onDone,
}: {
onDone: (
result?: string,
options?: { display?: CommandResultDisplay },
) => void
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
}): React.ReactNode {
const mainLoopModel = useAppState(s => s.mainLoopModel)
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession)
const isFastMode = useAppState(s => s.fastMode)
const setAppState = useSetAppState()
const mainLoopModel = useAppState(s => s.mainLoopModel);
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession);
const isFastMode = useAppState(s => s.fastMode);
const setAppState = useSetAppState();
function handleCancel(): void {
logEvent('tengu_model_command_menu', {
action:
'cancel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
const displayModel = renderModelLabel(mainLoopModel)
action: 'cancel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
const displayModel = renderModelLabel(mainLoopModel);
onDone(`Kept model as ${chalk.bold(displayModel)}`, {
display: 'system',
})
});
}
function handleSelect(
model: string | null,
effort: EffortLevel | undefined,
): void {
function handleSelect(model: string | null, effort: EffortLevel | undefined): void {
logEvent('tengu_model_command_menu', {
action:
model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
from_model:
mainLoopModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
to_model:
model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
action: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
from_model: mainLoopModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
to_model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
setAppState(prev => ({
...prev,
mainLoopModel: model,
mainLoopModelForSession: null,
}))
}));
let message = `Set model to ${chalk.bold(renderModelLabel(model))}`
let message = `Set model to ${chalk.bold(renderModelLabel(model))}`;
if (effort !== undefined) {
message += ` with ${chalk.bold(effort)} effort`
message += ` with ${chalk.bold(effort)} effort`;
}
// Turn off fast mode if switching to unsupported model
let wasFastModeToggledOn
let wasFastModeToggledOn;
if (isFastModeEnabled()) {
clearFastModeCooldown()
clearFastModeCooldown();
if (!isFastModeSupportedByModel(model) && isFastMode) {
setAppState(prev => ({
...prev,
fastMode: false,
}))
wasFastModeToggledOn = false
}));
wasFastModeToggledOn = false;
// Do not update fast mode in settings since this is an automatic downgrade
} else if (
isFastModeSupportedByModel(model) &&
isFastModeAvailable() &&
isFastMode
) {
message += ` · Fast mode ON`
wasFastModeToggledOn = true
} else if (isFastModeSupportedByModel(model) && isFastModeAvailable() && isFastMode) {
message += ` · Fast mode ON`;
wasFastModeToggledOn = true;
}
}
if (
isBilledAsExtraUsage(
model,
wasFastModeToggledOn === true,
isOpus1mMergeEnabled(),
)
) {
message += ` · Billed as extra usage`
if (isBilledAsExtraUsage(model, wasFastModeToggledOn === true, isOpus1mMergeEnabled())) {
message += ` · Billed as extra usage`;
}
if (wasFastModeToggledOn === false) {
// Fast mode was toggled off, show suffix after extra usage billing
message += ` · Fast mode OFF`
message += ` · Fast mode OFF`;
}
onDone(message)
onDone(message);
}
return (
@@ -124,37 +101,30 @@ function ModelPickerWrapper({
onCancel={handleCancel}
isStandaloneCommand
showFastModeNotice={
isFastModeEnabled() &&
isFastMode &&
isFastModeSupportedByModel(mainLoopModel) &&
isFastModeAvailable()
isFastModeEnabled() && isFastMode && isFastModeSupportedByModel(mainLoopModel) && isFastModeAvailable()
}
/>
)
);
}
function SetModelAndClose({
args,
onDone,
}: {
args: string
onDone: (
result?: string,
options?: { display?: CommandResultDisplay },
) => void
args: string;
onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void;
}): React.ReactNode {
const isFastMode = useAppState(s => s.fastMode)
const setAppState = useSetAppState()
const model = args === 'default' ? null : args
const isFastMode = useAppState(s => s.fastMode);
const setAppState = useSetAppState();
const model = args === 'default' ? null : args;
React.useEffect(() => {
async function handleModelChange(): Promise<void> {
if (model && !isModelAllowed(model)) {
onDone(
`Model '${model}' is not available. Your organization restricts model selection.`,
{ display: 'system' },
)
return
onDone(`Model '${model}' is not available. Your organization restricts model selection.`, {
display: 'system',
});
return;
}
// @[MODEL LAUNCH]: Update check for 1M access.
@@ -162,47 +132,47 @@ function SetModelAndClose({
onDone(
`Opus 4.7 with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m`,
{ display: 'system' },
)
return
);
return;
}
if (model && isSonnet1mUnavailable(model)) {
onDone(
`Sonnet 4.6 with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m`,
{ display: 'system' },
)
return
);
return;
}
// Skip validation for default model
if (!model) {
setModel(null)
return
setModel(null);
return;
}
// Skip validation for known aliases - they're predefined and should work
if (isKnownAlias(model)) {
setModel(model)
return
setModel(model);
return;
}
// Validate and set custom model
try {
// Don't use parseUserSpecifiedModel for non-aliases since it lowercases the input
// and model names are case-sensitive
const { valid, error } = await validateModel(model)
const { valid, error } = await validateModel(model);
if (valid) {
setModel(model)
setModel(model);
} else {
onDone(error || `Model '${model}' not found`, {
display: 'system',
})
});
}
} catch (error) {
onDone(`Failed to validate model: ${(error as Error).message}`, {
display: 'system',
})
});
}
}
@@ -211,127 +181,103 @@ function SetModelAndClose({
...prev,
mainLoopModel: modelValue,
mainLoopModelForSession: null,
}))
let message = `Set model to ${chalk.bold(renderModelLabel(modelValue))}`
}));
let message = `Set model to ${chalk.bold(renderModelLabel(modelValue))}`;
let wasFastModeToggledOn
let wasFastModeToggledOn;
if (isFastModeEnabled()) {
clearFastModeCooldown()
clearFastModeCooldown();
if (!isFastModeSupportedByModel(modelValue) && isFastMode) {
setAppState(prev => ({
...prev,
fastMode: false,
}))
wasFastModeToggledOn = false
}));
wasFastModeToggledOn = false;
// Do not update fast mode in settings since this is an automatic downgrade
} else if (isFastModeSupportedByModel(modelValue) && isFastMode) {
message += ` · Fast mode ON`
wasFastModeToggledOn = true
message += ` · Fast mode ON`;
wasFastModeToggledOn = true;
}
}
if (
isBilledAsExtraUsage(
modelValue,
wasFastModeToggledOn === true,
isOpus1mMergeEnabled(),
)
) {
message += ` · Billed as extra usage`
if (isBilledAsExtraUsage(modelValue, wasFastModeToggledOn === true, isOpus1mMergeEnabled())) {
message += ` · Billed as extra usage`;
}
if (wasFastModeToggledOn === false) {
// Fast mode was toggled off, show suffix after extra usage billing
message += ` · Fast mode OFF`
message += ` · Fast mode OFF`;
}
onDone(message)
onDone(message);
}
void handleModelChange()
}, [model, onDone, setAppState])
void handleModelChange();
}, [model, onDone, setAppState]);
return null
return null;
}
function isKnownAlias(model: string): boolean {
return (MODEL_ALIASES as readonly string[]).includes(
model.toLowerCase().trim(),
)
return (MODEL_ALIASES as readonly string[]).includes(model.toLowerCase().trim());
}
function isOpus1mUnavailable(model: string): boolean {
const m = model.toLowerCase()
return (
!checkOpus1mAccess() &&
!isOpus1mMergeEnabled() &&
m.includes('opus') &&
m.includes('[1m]')
)
const m = model.toLowerCase();
return !checkOpus1mAccess() && !isOpus1mMergeEnabled() && m.includes('opus') && m.includes('[1m]');
}
function isSonnet1mUnavailable(model: string): boolean {
const m = model.toLowerCase()
const m = model.toLowerCase();
// Warn about Sonnet and Sonnet 4.6, but not Sonnet 4.5 since that had
// a different access criteria.
return (
!checkSonnet1mAccess() &&
(m.includes('sonnet[1m]') || m.includes('sonnet-4-6[1m]'))
)
return !checkSonnet1mAccess() && (m.includes('sonnet[1m]') || m.includes('sonnet-4-6[1m]'));
}
function ShowModelAndClose({
onDone,
}: {
onDone: (result?: string) => void
}): React.ReactNode {
const mainLoopModel = useAppState(s => s.mainLoopModel)
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession)
const effortValue = useAppState(s => s.effortValue)
const displayModel = renderModelLabel(mainLoopModel)
const effortInfo =
effortValue !== undefined ? ` (effort: ${effortValue})` : ''
function ShowModelAndClose({ onDone }: { onDone: (result?: string) => void }): React.ReactNode {
const mainLoopModel = useAppState(s => s.mainLoopModel);
const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession);
const effortValue = useAppState(s => s.effortValue);
const displayModel = renderModelLabel(mainLoopModel);
const effortInfo = effortValue !== undefined ? ` (effort: ${effortValue})` : '';
if (mainLoopModelForSession) {
onDone(
`Current model: ${chalk.bold(renderModelLabel(mainLoopModelForSession))} (session override from plan mode)\nBase model: ${displayModel}${effortInfo}`,
)
);
} else {
onDone(`Current model: ${displayModel}${effortInfo}`)
onDone(`Current model: ${displayModel}${effortInfo}`);
}
return null
return null;
}
export const call: LocalJSXCommandCall = async (onDone, _context, args) => {
args = args?.trim() || ''
args = args?.trim() || '';
if (COMMON_INFO_ARGS.includes(args)) {
logEvent('tengu_model_command_inline_help', {
args: args as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
return <ShowModelAndClose onDone={onDone} />
});
return <ShowModelAndClose onDone={onDone} />;
}
if (COMMON_HELP_ARGS.includes(args)) {
onDone(
'Run /model to open the model selection menu, or /model [modelName] to set the model.',
{ display: 'system' },
)
return
onDone('Run /model to open the model selection menu, or /model [modelName] to set the model.', {
display: 'system',
});
return;
}
if (args) {
logEvent('tengu_model_command_inline', {
args: args as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
return <SetModelAndClose args={args} onDone={onDone} />
});
return <SetModelAndClose args={args} onDone={onDone} />;
}
return <ModelPickerWrapper onDone={onDone} />
}
return <ModelPickerWrapper onDone={onDone} />;
};
function renderModelLabel(model: string | null): string {
const rendered = renderDefaultModelSetting(
model ?? getDefaultMainLoopModelSetting(),
)
return model === null ? `${rendered} (default)` : rendered
const rendered = renderDefaultModelSetting(model ?? getDefaultMainLoopModelSetting());
return model === null ? `${rendered} (default)` : rendered;
}

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -1,8 +1,8 @@
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js';
export async function call(onDone: LocalJSXCommandOnDone): Promise<undefined> {
onDone(
'/output-style has been deprecated. Use /config to change your output style, or set it in your settings file. Changes take effect on the next session.',
{ display: 'system' },
)
);
}

View File

@@ -1,24 +1,22 @@
import * as React from 'react'
import { Passes } from '../../components/Passes/Passes.js'
import { logEvent } from '../../services/analytics/index.js'
import { getCachedRemainingPasses } from '../../services/api/referral.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import * as React from 'react';
import { Passes } from '../../components/Passes/Passes.js';
import { logEvent } from '../../services/analytics/index.js';
import { getCachedRemainingPasses } from '../../services/api/referral.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
export async function call(
onDone: LocalJSXCommandOnDone,
): Promise<React.ReactNode> {
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> {
// Mark that user has visited /passes so we stop showing the upsell
const config = getGlobalConfig()
const isFirstVisit = !config.hasVisitedPasses
const config = getGlobalConfig();
const isFirstVisit = !config.hasVisitedPasses;
if (isFirstVisit) {
const remaining = getCachedRemainingPasses()
const remaining = getCachedRemainingPasses();
saveGlobalConfig(current => ({
...current,
hasVisitedPasses: true,
passesLastSeenRemaining: remaining ?? current.passesLastSeenRemaining,
}))
}));
}
logEvent('tengu_guest_passes_visited', { is_first_visit: isFirstVisit })
return <Passes onDone={onDone} />
logEvent('tengu_guest_passes_visited', { is_first_visit: isFirstVisit });
return <Passes onDone={onDone} />;
}

View File

@@ -1 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -1,18 +1,15 @@
import * as React from 'react'
import { PermissionRuleList } from '../../components/permissions/rules/PermissionRuleList.js'
import type { LocalJSXCommandCall } from '../../types/command.js'
import { createPermissionRetryMessage } from '../../utils/messages.js'
import * as React from 'react';
import { PermissionRuleList } from '../../components/permissions/rules/PermissionRuleList.js';
import type { LocalJSXCommandCall } from '../../types/command.js';
import { createPermissionRetryMessage } from '../../utils/messages.js';
export const call: LocalJSXCommandCall = async (onDone, context) => {
return (
<PermissionRuleList
onExit={onDone}
onRetryDenials={commands => {
context.setMessages(prev => [
...prev,
createPermissionRetryMessage(commands),
])
context.setMessages(prev => [...prev, createPermissionRetryMessage(commands)]);
}}
/>
)
}
);
};

View File

@@ -5,7 +5,9 @@ import plan from './index.js'
describe('plan bridge invocation safety', () => {
test('allows headless plan mode operations over Remote Control', () => {
expect(plan.getBridgeInvocationError?.('')).toBeUndefined()
expect(plan.getBridgeInvocationError?.('write a migration plan')).toBeUndefined()
expect(
plan.getBridgeInvocationError?.('write a migration plan'),
).toBeUndefined()
})
test('blocks /plan open over Remote Control', () => {

View File

@@ -1,24 +1,24 @@
import * as React from 'react'
import { handlePlanModeTransition } from '../../bootstrap/state.js'
import type { LocalJSXCommandContext } from '../../commands.js'
import { Box, Text } from '@anthropic/ink'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import { getExternalEditor } from '../../utils/editor.js'
import { toIDEDisplayName } from '../../utils/ide.js'
import { applyPermissionUpdate } from '../../utils/permissions/PermissionUpdate.js'
import { prepareContextForPlanMode } from '../../utils/permissions/permissionSetup.js'
import { getPlan, getPlanFilePath } from '../../utils/plans.js'
import { editFileInEditor } from '../../utils/promptEditor.js'
import { renderToString } from '../../utils/staticRender.js'
import * as React from 'react';
import { handlePlanModeTransition } from '../../bootstrap/state.js';
import type { LocalJSXCommandContext } from '../../commands.js';
import { Box, Text } from '@anthropic/ink';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import { getExternalEditor } from '../../utils/editor.js';
import { toIDEDisplayName } from '../../utils/ide.js';
import { applyPermissionUpdate } from '../../utils/permissions/PermissionUpdate.js';
import { prepareContextForPlanMode } from '../../utils/permissions/permissionSetup.js';
import { getPlan, getPlanFilePath } from '../../utils/plans.js';
import { editFileInEditor } from '../../utils/promptEditor.js';
import { renderToString } from '../../utils/staticRender.js';
function PlanDisplay({
planContent,
planPath,
editorName,
}: {
planContent: string
planPath: string
editorName: string | undefined
planContent: string;
planPath: string;
editorName: string | undefined;
}): React.ReactNode {
return (
<Box flexDirection="column">
@@ -37,7 +37,7 @@ function PlanDisplay({
</Box>
)}
</Box>
)
);
}
export async function call(
@@ -45,63 +45,58 @@ export async function call(
context: LocalJSXCommandContext,
args: string,
): Promise<React.ReactNode> {
const { getAppState, setAppState } = context
const appState = getAppState()
const currentMode = appState.toolPermissionContext.mode
const { getAppState, setAppState } = context;
const appState = getAppState();
const currentMode = appState.toolPermissionContext.mode;
// If not in plan mode, enable it
if (currentMode !== 'plan') {
handlePlanModeTransition(currentMode, 'plan')
handlePlanModeTransition(currentMode, 'plan');
setAppState(prev => ({
...prev,
toolPermissionContext: applyPermissionUpdate(
prepareContextForPlanMode(prev.toolPermissionContext),
{ type: 'setMode', mode: 'plan', destination: 'session' },
),
}))
const description = args.trim()
toolPermissionContext: applyPermissionUpdate(prepareContextForPlanMode(prev.toolPermissionContext), {
type: 'setMode',
mode: 'plan',
destination: 'session',
}),
}));
const description = args.trim();
if (description && description !== 'open') {
onDone('Enabled plan mode', { shouldQuery: true })
onDone('Enabled plan mode', { shouldQuery: true });
} else {
onDone('Enabled plan mode')
onDone('Enabled plan mode');
}
return null
return null;
}
// Already in plan mode - show the current plan
const planContent = getPlan()
const planPath = getPlanFilePath()
const planContent = getPlan();
const planPath = getPlanFilePath();
if (!planContent) {
onDone('Already in plan mode. No plan written yet.')
return null
onDone('Already in plan mode. No plan written yet.');
return null;
}
// If user typed "/plan open", open in editor
const argList = args.trim().split(/\s+/)
const argList = args.trim().split(/\s+/);
if (argList[0] === 'open') {
const result = await editFileInEditor(planPath)
const result = await editFileInEditor(planPath);
if (result.error) {
onDone(`Failed to open plan in editor: ${result.error}`)
onDone(`Failed to open plan in editor: ${result.error}`);
} else {
onDone(`Opened plan in editor: ${planPath}`)
onDone(`Opened plan in editor: ${planPath}`);
}
return null
return null;
}
const editor = getExternalEditor()
const editorName = editor ? toIDEDisplayName(editor) : undefined
const editor = getExternalEditor();
const editorName = editor ? toIDEDisplayName(editor) : undefined;
const display = (
<PlanDisplay
planContent={planContent}
planPath={planPath}
editorName={editorName}
/>
)
const display = <PlanDisplay planContent={planContent} planPath={planPath} editorName={editorName} />;
// Render to string and pass to onDone like local commands do
const output = await renderToString(display)
onDone(output)
return null
const output = await renderToString(display);
onDone(output);
return null;
}

View File

@@ -1,37 +1,34 @@
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import * as React from 'react';
import { useEffect, useRef, useState } from 'react';
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from 'src/services/analytics/index.js'
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'
import { Byline, KeyboardShortcutHint } from '@anthropic/ink'
import { Spinner } from '../../components/Spinner.js'
import TextInput from '../../components/TextInput.js'
import { Box, Text } from '@anthropic/ink'
import { toError } from '../../utils/errors.js'
import { logError } from '../../utils/log.js'
import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'
import {
addMarketplaceSource,
saveMarketplaceToSettings,
} from '../../utils/plugins/marketplaceManager.js'
import { parseMarketplaceInput } from '../../utils/plugins/parseMarketplaceInput.js'
import type { ViewState } from './types.js'
} from 'src/services/analytics/index.js';
import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js';
import { Byline, KeyboardShortcutHint } from '@anthropic/ink';
import { Spinner } from '../../components/Spinner.js';
import TextInput from '../../components/TextInput.js';
import { Box, Text } from '@anthropic/ink';
import { toError } from '../../utils/errors.js';
import { logError } from '../../utils/log.js';
import { clearAllCaches } from '../../utils/plugins/cacheUtils.js';
import { addMarketplaceSource, saveMarketplaceToSettings } from '../../utils/plugins/marketplaceManager.js';
import { parseMarketplaceInput } from '../../utils/plugins/parseMarketplaceInput.js';
import type { ViewState } from './types.js';
type Props = {
inputValue: string
setInputValue: (value: string) => void
cursorOffset: number
setCursorOffset: (offset: number) => void
error: string | null
setError: (error: string | null) => void
result: string | null
setResult: (result: string | null) => void
setViewState: (state: ViewState) => void
onAddComplete?: () => void | Promise<void>
cliMode?: boolean
}
inputValue: string;
setInputValue: (value: string) => void;
cursorOffset: number;
setCursorOffset: (offset: number) => void;
error: string | null;
setError: (error: string | null) => void;
result: string | null;
setResult: (result: string | null) => void;
setViewState: (state: ViewState) => void;
onAddComplete?: () => void | Promise<void>;
cliMode?: boolean;
};
export function AddMarketplace({
inputValue,
@@ -46,94 +43,87 @@ export function AddMarketplace({
onAddComplete,
cliMode = false,
}: Props): React.ReactNode {
const hasAttemptedAutoAdd = useRef(false)
const [isLoading, setLoading] = useState(false)
const [progressMessage, setProgressMessage] = useState<string>('')
const hasAttemptedAutoAdd = useRef(false);
const [isLoading, setLoading] = useState(false);
const [progressMessage, setProgressMessage] = useState<string>('');
const handleAdd = async () => {
const input = inputValue.trim()
const input = inputValue.trim();
if (!input) {
setError('Please enter a marketplace source')
return
setError('Please enter a marketplace source');
return;
}
const parsed = await parseMarketplaceInput(input)
const parsed = await parseMarketplaceInput(input);
if (!parsed) {
setError(
'Invalid marketplace source format. Try: owner/repo, https://..., or ./path',
)
return
setError('Invalid marketplace source format. Try: owner/repo, https://..., or ./path');
return;
}
// Check if parseMarketplaceInput returned an error
if ('error' in parsed) {
setError(parsed.error)
return
setError(parsed.error);
return;
}
setError(null)
setError(null);
try {
setLoading(true)
setProgressMessage('')
const { name, resolvedSource } = await addMarketplaceSource(
parsed,
message => {
setProgressMessage(message)
},
)
saveMarketplaceToSettings(name, { source: resolvedSource })
clearAllCaches()
setLoading(true);
setProgressMessage('');
const { name, resolvedSource } = await addMarketplaceSource(parsed, message => {
setProgressMessage(message);
});
saveMarketplaceToSettings(name, { source: resolvedSource });
clearAllCaches();
let sourceType = parsed.source
let sourceType = parsed.source;
if (parsed.source === 'github') {
sourceType =
parsed.repo as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
sourceType = parsed.repo as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS;
}
logEvent('tengu_marketplace_added', {
source_type:
sourceType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
source_type: sourceType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
});
if (onAddComplete) {
await onAddComplete()
await onAddComplete();
}
setProgressMessage('')
setLoading(false)
setProgressMessage('');
setLoading(false);
if (cliMode) {
// In CLI mode, set result to trigger completion
setResult(`Successfully added marketplace: ${name}`)
setResult(`Successfully added marketplace: ${name}`);
} else {
// In interactive mode, switch to browse view
setViewState({ type: 'browse-marketplace', targetMarketplace: name })
setViewState({ type: 'browse-marketplace', targetMarketplace: name });
}
} catch (err) {
const error = toError(err)
logError(error)
setError(error.message)
setProgressMessage('')
setLoading(false)
const error = toError(err);
logError(error);
setError(error.message);
setProgressMessage('');
setLoading(false);
if (cliMode) {
// In CLI mode, set result with error to trigger completion
setResult(`Error: ${error.message}`)
setResult(`Error: ${error.message}`);
} else {
setResult(null)
setResult(null);
}
}
}
};
// Auto-add if inputValue is provided
useEffect(() => {
if (inputValue && !hasAttemptedAutoAdd.current && !error && !result) {
hasAttemptedAutoAdd.current = true
void handleAdd()
hasAttemptedAutoAdd.current = true;
void handleAdd();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // Only run once on mount
}, []); // Only run once on mount
return (
<Box flexDirection="column">
@@ -164,9 +154,7 @@ export function AddMarketplace({
{isLoading && (
<Box marginTop={1}>
<Spinner />
<Text>
{progressMessage || 'Adding marketplace to configuration…'}
</Text>
<Text>{progressMessage || 'Adding marketplace to configuration…'}</Text>
</Box>
)}
{error && (
@@ -184,15 +172,10 @@ export function AddMarketplace({
<Text dimColor italic>
<Byline>
<KeyboardShortcutHint shortcut="Enter" action="add" />
<ConfigurableShortcutHint
action="confirm:no"
context="Settings"
fallback="Esc"
description="cancel"
/>
<ConfigurableShortcutHint action="confirm:no" context="Settings" fallback="Esc" description="cancel" />
</Byline>
</Text>
</Box>
</Box>
)
);
}

Some files were not shown because too many files have changed in this diff Show More