diff --git a/packages/eas-cli/src/chat/__tests__/chatClient.test.ts b/packages/eas-cli/src/chat/__tests__/chatClient.test.ts new file mode 100644 index 0000000000..43ef8fa00b --- /dev/null +++ b/packages/eas-cli/src/chat/__tests__/chatClient.test.ts @@ -0,0 +1,183 @@ +import nock from 'nock'; + +import { makeUserMessage, streamChatResponseAsync } from '../chatClient'; + +jest.mock('../../log'); +jest.mock('../../ora', () => ({ + ora: () => { + const spinner = { + isSpinning: true, + text: '', + start: () => spinner, + stop: () => { + spinner.isSpinning = false; + return spinner; + }, + }; + return spinner; + }, +})); + +const WEBSITE_ORIGIN = 'https://expo.dev'; + +function sseBody(frames: object[]): string { + return [...frames.map(frame => `data: ${JSON.stringify(frame)}`), 'data: [DONE]', ''].join( + '\n\n' + ); +} + +describe(streamChatResponseAsync, () => { + let writeSpy: jest.SpyInstance; + + beforeEach(() => { + nock.cleanAll(); + writeSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true); + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + it('streams text deltas to stdout and returns the full text', async () => { + nock(WEBSITE_ORIGIN) + .post('/api/chat') + .reply( + 200, + sseBody([ + { type: 'start' }, + { type: 'tool-input-available', toolCallId: 't1', toolName: 'get_latest_builds' }, + { type: 'text-start', id: '0' }, + { type: 'text-delta', id: '0', delta: 'Your latest ' }, + { type: 'text-delta', id: '0', delta: 'build passed.' }, + { type: 'finish' }, + ]), + { 'content-type': 'text/event-stream' } + ); + + const result = await streamChatResponseAsync({ + messages: [makeUserMessage('how are my builds?')], + accountName: 'my-account', + sessionSecret: '{"id":"session-id","version":"1"}', + stream: true, + }); + + const written = writeSpy.mock.calls.map(call => call[0]).join(''); + expect(written).toContain('Your latest '); + expect(written).toContain('build passed.'); + expect(result.text).toBe('Your latest build passed.'); + }); + + it('captures tool calls with inputs and outputs without writing to stdout when not streaming', async () => { + nock(WEBSITE_ORIGIN) + .post('/api/chat') + .reply( + 200, + sseBody([ + { type: 'start-step' }, + { + type: 'tool-input-available', + toolCallId: 't1', + toolName: 'get_latest_builds', + input: { limit: 1 }, + }, + { type: 'tool-output-available', toolCallId: 't1', output: { builds: ['b1'] } }, + { type: 'finish-step' }, + { type: 'start-step' }, + { type: 'text-delta', delta: 'Done.' }, + { type: 'finish-step' }, + { type: 'finish' }, + ]) + ); + + const result = await streamChatResponseAsync({ + messages: [makeUserMessage('builds?')], + accountName: 'my-account', + sessionSecret: '{"id":"abc","version":"1"}', + stream: false, + }); + + expect(writeSpy).not.toHaveBeenCalled(); + expect(result.text).toBe('Done.'); + expect(result.toolCalls).toEqual([ + { + toolName: 'get_latest_builds', + input: { limit: 1 }, + output: { builds: ['b1'] }, + errorText: undefined, + }, + ]); + expect(result.assistantMessage).toEqual( + expect.objectContaining({ + role: 'assistant', + parts: [ + { type: 'step-start' }, + { + type: 'tool-get_latest_builds', + toolCallId: 't1', + state: 'output-available', + input: { limit: 1 }, + output: { builds: ['b1'] }, + }, + { type: 'step-start' }, + { type: 'text', text: 'Done.' }, + ], + }) + ); + }); + + it('sends the session secret as a cookie and the account as a header', async () => { + let capturedHeaders: Record = {}; + let capturedBody: any; + nock(WEBSITE_ORIGIN) + .post('/api/chat') + .reply(function (_uri, body) { + capturedHeaders = this.req.headers; + capturedBody = body; + return [200, sseBody([{ type: 'text-delta', delta: 'hi' }])]; + }); + + await streamChatResponseAsync({ + messages: [makeUserMessage('hello')], + accountName: 'my-account', + sessionSecret: '{"id":"abc","version":"1"}', + stream: false, + }); + + const cookie = [capturedHeaders.cookie].flat().join('; '); + expect(cookie).toContain('io.expo.auth.sessionSecret='); + expect(cookie).toContain(encodeURIComponent('{"id":"abc","version":"1"}')); + expect([capturedHeaders['x-account-name']].flat()[0]).toBe('my-account'); + // A steering system message is prepended, followed by the caller's messages. + expect(capturedBody.messages[0].role).toBe('system'); + expect(capturedBody.messages[0].parts[0].text).toContain('https://expo.dev'); + expect(capturedBody.messages[1]).toEqual( + expect.objectContaining({ role: 'user', parts: [{ type: 'text', text: 'hello' }] }) + ); + }); + + it('maps a 429 response to a usage-limit error', async () => { + nock(WEBSITE_ORIGIN).post('/api/chat').reply(429, 'Chat usage limit reached'); + + await expect( + streamChatResponseAsync({ + messages: [makeUserMessage('hello')], + accountName: 'my-account', + sessionSecret: '{"id":"abc","version":"1"}', + stream: false, + }) + ).rejects.toThrow(/usage limit/); + }); + + it('maps a 403 response to a not-enabled error', async () => { + nock(WEBSITE_ORIGIN).post('/api/chat').reply(403, 'Forbidden'); + + await expect( + streamChatResponseAsync({ + messages: [makeUserMessage('hello')], + accountName: 'my-account', + sessionSecret: '{"id":"abc","version":"1"}', + stream: false, + }) + ).rejects.toThrow(/not enabled/); + }); +}); diff --git a/packages/eas-cli/src/chat/__tests__/renderMarkdown.test.ts b/packages/eas-cli/src/chat/__tests__/renderMarkdown.test.ts new file mode 100644 index 0000000000..cb3afcde8d --- /dev/null +++ b/packages/eas-cli/src/chat/__tests__/renderMarkdown.test.ts @@ -0,0 +1,88 @@ +import chalk from 'chalk'; + +import { + createMarkdownRenderState, + renderInlineMarkdown, + renderMarkdownLine, + wrapToWidth, +} from '../renderMarkdown'; + +describe(renderInlineMarkdown, () => { + it('renders bold with chalk.bold', () => { + expect(renderInlineMarkdown('a **bold** word')).toBe(`a ${chalk.bold('bold')} word`); + expect(renderInlineMarkdown('a __bold__ word')).toBe(`a ${chalk.bold('bold')} word`); + }); + + it('renders inline code with chalk.cyan', () => { + expect(renderInlineMarkdown('run `eas build` now')).toBe(`run ${chalk.cyan('eas build')} now`); + }); + + it('renders italic with chalk.italic', () => { + expect(renderInlineMarkdown('an *emphasized* word')).toBe( + `an ${chalk.italic('emphasized')} word` + ); + }); + + it('does not treat underscores inside a word as italic', () => { + expect(renderInlineMarkdown('my_project_name')).toBe('my_project_name'); + }); + + it('renders links as underlined label with dimmed url', () => { + expect(renderInlineMarkdown('see [build](https://expo.dev/b/1)')).toBe( + `see ${chalk.underline('build')} ${chalk.dim('(https://expo.dev/b/1)')}` + ); + }); +}); + +describe(renderMarkdownLine, () => { + it('renders headings as bold and strips the marker', () => { + const state = createMarkdownRenderState(); + expect(renderMarkdownLine('## Builds', state)).toBe(chalk.bold('Builds')); + }); + + it('renders bullet lines with a bullet glyph', () => { + const state = createMarkdownRenderState(); + expect(renderMarkdownLine('- first', state)).toBe(`${chalk.dim('•')} first`); + }); + + it('drops code fences and dims code block contents', () => { + const state = createMarkdownRenderState(); + expect(renderMarkdownLine('```ts', state)).toBeNull(); + expect(state.inCodeBlock).toBe(true); + expect(renderMarkdownLine('const x = 1;', state)).toBe(chalk.gray('const x = 1;')); + expect(renderMarkdownLine('```', state)).toBeNull(); + expect(state.inCodeBlock).toBe(false); + }); + + it('applies inline styling to plain lines', () => { + const state = createMarkdownRenderState(); + expect(renderMarkdownLine('the **latest** build', state)).toBe( + `the ${chalk.bold('latest')} build` + ); + }); +}); + +describe(wrapToWidth, () => { + it('word-wraps to the given width', () => { + const segments = wrapToWidth('one two three four five', 12); + expect(segments.length).toBeGreaterThan(1); + for (const segment of segments) { + expect(segment.length).toBeLessThanOrEqual(12); + } + expect(segments.join(' ').replace(/\s+/g, ' ').trim()).toBe('one two three four five'); + }); + + it('hard-breaks long unbreakable tokens like URLs', () => { + const url = 'https://expo.dev/accounts/acme/projects/mobile/builds/cb7c9bdd-56e6-42b8'; + const segments = wrapToWidth(url, 20); + expect(segments.length).toBeGreaterThan(1); + for (const segment of segments) { + expect(segment.length).toBeLessThanOrEqual(20); + } + expect(segments.join('')).toBe(url); + }); + + it('returns the text unchanged when the width is too small to wrap into', () => { + expect(wrapToWidth('anything at all here', 4)).toEqual(['anything at all here']); + }); +}); diff --git a/packages/eas-cli/src/chat/__tests__/replInput.test.ts b/packages/eas-cli/src/chat/__tests__/replInput.test.ts new file mode 100644 index 0000000000..6f4a53288e --- /dev/null +++ b/packages/eas-cli/src/chat/__tests__/replInput.test.ts @@ -0,0 +1,51 @@ +import { PassThrough } from 'node:stream'; + +import { createChatReplInput } from '../replInput'; + +describe(createChatReplInput, () => { + it('resolves the typed line', async () => { + const input = new PassThrough(); + const output = new PassThrough(); + const repl = createChatReplInput({ input, output, terminal: false }); + + const pending = repl.askAsync('> '); + input.write('how are my builds?\n'); + + expect(await pending).toBe('how are my builds?'); + repl.close(); + }); + + it('reads input even when the stream was left paused (e.g. by a spinner)', async () => { + const input = new PassThrough(); + const output = new PassThrough(); + const repl = createChatReplInput({ input, output, terminal: false }); + input.pause(); + + const pending = repl.askAsync('> '); + input.write('hello\n'); + + expect(await pending).toBe('hello'); + repl.close(); + }); + + it('resolves null when the input stream ends', async () => { + const input = new PassThrough(); + const output = new PassThrough(); + const repl = createChatReplInput({ input, output, terminal: false }); + + const pending = repl.askAsync('> '); + input.end(); + + expect(await pending).toBeNull(); + repl.close(); + }); + + it('resolves null immediately once closed', async () => { + const input = new PassThrough(); + const output = new PassThrough(); + const repl = createChatReplInput({ input, output, terminal: false }); + repl.close(); + + expect(await repl.askAsync('> ')).toBeNull(); + }); +}); diff --git a/packages/eas-cli/src/chat/chatClient.ts b/packages/eas-cli/src/chat/chatClient.ts new file mode 100644 index 0000000000..2752bd4c63 --- /dev/null +++ b/packages/eas-cli/src/chat/chatClient.ts @@ -0,0 +1,495 @@ +import { StringDecoder } from 'node:string_decoder'; + +import chalk from 'chalk'; +import { v4 as uuidv4 } from 'uuid'; + +import { getExpoWebsiteBaseUrl } from '../api'; +import fetch, { RequestError } from '../fetch'; +import Log from '../log'; +import { ora } from '../ora'; +import { + MarkdownRenderState, + createMarkdownRenderState, + renderMarkdownLine, + wrapToWidth, +} from './renderMarkdown'; + +/** + * Client-side steering sent as a leading system message on every request. The server owns the base + * system prompt (which we cannot change), so this only adds guidance that is specific to running in + * a terminal instead of the web dashboard. + */ +const CHAT_SYSTEM_GUIDANCE = [ + 'You are being used through the EAS CLI in a terminal, not the web dashboard.', + 'The user cannot see the interactive tables the dashboard renders from tool results, so include the important details directly in your text answer instead of assuming a table is shown.', + 'Whenever you reference a specific artifact (a build, update, submission, deployment, workflow run, channel, or branch), include its direct https://expo.dev link so the user can open it.', + 'Use only simple markdown: bold, inline code, and bullet or numbered lists.', +].join(' '); + +/** + * The Expo dashboard AI chat lives in a Next.js API route on the website (`/api/chat`), not on the + * GraphQL API server. It authenticates by reading the session secret from a cookie named after the + * website's auth storage key and forwarding it to the GraphQL API as the `expo-session` header + * (see `getAuthStorageKey` / `createServerGraphQLClient` in the website). We mirror that cookie name + * here so the CLI's stored session secret authenticates exactly like a browser session would. + */ +function getWebsiteAuthCookieName(): string { + if (process.env.EXPO_STAGING) { + return 'staging.expo.auth.sessionSecret'; + } else if (process.env.EXPO_LOCAL) { + return 'local.expo.auth.sessionSecret'; + } else { + return 'io.expo.auth.sessionSecret'; + } +} + +/** + * Human-readable labels for the assistant's tools, shown while it works so the terminal user has + * the same transparency the dashboard gives with its structured tables. + */ +const TOOL_LABELS: Record = { + get_projects: 'Looking up your projects', + get_latest_builds: 'Looking up builds', + get_build: 'Looking up a build', + get_build_logs: 'Reading build logs', + get_activity: 'Looking up recent activity', + get_updates: 'Looking up updates', + get_workflow_runs: 'Looking up workflow runs', + get_workflow_run: 'Looking up a workflow run', + get_workflow_run_logs: 'Reading workflow logs', + get_submissions: 'Looking up submissions', + get_channels: 'Looking up channels', + get_branches: 'Looking up branches', + get_worker_deployments: 'Looking up deployments', + get_environment_variables: 'Looking up environment variables', + get_account_members: 'Looking up account members', + get_usage: 'Looking up usage', + get_usage_history: 'Looking up usage history', + get_invoices: 'Looking up invoices', + search_expo_docs: 'Searching Expo docs', + navigate: 'Finding the right page', +}; + +function describeTool(toolName: string): string { + return TOOL_LABELS[toolName] ?? `Using ${toolName.replace(/_/g, ' ')}`; +} + +// The assistant's reply is labeled "Expo > " and continuation lines are indented to align under it, +// mirroring the "Chat > " prompt the user types into. +const ASSISTANT_LABEL_TEXT = 'Expo'; +const ASSISTANT_LABEL = `${chalk.bold.magenta(ASSISTANT_LABEL_TEXT)}${chalk.dim(' > ')}`; +const ASSISTANT_INDENT = ' '.repeat(`${ASSISTANT_LABEL_TEXT} > `.length); +// ora renders "prefixText + ' ' + spinner", so this omits the trailing space to read "Expo > ⠋". +const ASSISTANT_SPINNER_PREFIX = `${chalk.bold.magenta(ASSISTANT_LABEL_TEXT)}${chalk.dim(' >')}`; + +type UIMessageStreamFrame = { + type: string; + id?: string; + delta?: string; + toolCallId?: string; + toolName?: string; + input?: unknown; + output?: unknown; + errorText?: string; +}; + +type ChatMessagePart = + | { type: 'text'; text: string } + | { type: 'step-start' } + | { + type: `tool-${string}`; + toolCallId: string; + state: 'input-streaming' | 'input-available' | 'output-available' | 'output-error'; + input: unknown; + output?: unknown; + errorText?: string; + }; + +/** A single turn in the conversation, in the AI SDK `UIMessage` shape the endpoint expects. */ +export type ChatMessage = { + id: string; + role: 'system' | 'user' | 'assistant'; + parts: ChatMessagePart[]; +}; + +function makeSystemMessage(text: string): ChatMessage { + return { id: uuidv4(), role: 'system', parts: [{ type: 'text', text }] }; +} + +export function makeUserMessage(text: string): ChatMessage { + return { id: uuidv4(), role: 'user', parts: [{ type: 'text', text }] }; +} + +function makeAssistantMessage(parts: ChatMessagePart[]): ChatMessage { + return { id: uuidv4(), role: 'assistant', parts }; +} + +export type ChatToolCall = { + toolName: string; + input?: unknown; + output?: unknown; + errorText?: string; +}; + +export type ChatResult = { + /** The assistant's full text answer (may be empty when it only returned tool data). */ + text: string; + /** Tools the assistant invoked, in call order, with their inputs and outputs. */ + toolCalls: ChatToolCall[]; + /** Complete UI message, including tool results, to retain in the next request's history. */ + assistantMessage: ChatMessage; +}; + +/** + * Sends the conversation so far to the dashboard AI chat endpoint and returns the assistant's reply. + * The full `messages` history is sent every turn (the endpoint is stateless for model context), so + * the caller appends each assistant reply and follow-up before calling again. + * + * When `stream` is true, the assistant's text is written to stdout incrementally and tool calls are + * surfaced as brief status lines. When false (e.g. `--json` mode), nothing is written to stdout and + * the full result is returned for the caller to serialize. + */ +export async function streamChatResponseAsync({ + messages, + accountName, + sessionSecret, + stream, +}: { + messages: ChatMessage[]; + accountName: string | undefined; + sessionSecret: string; + stream: boolean; +}): Promise { + let response; + try { + response = await fetch(`${getExpoWebsiteBaseUrl()}/api/chat`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'text/event-stream', + cookie: `${getWebsiteAuthCookieName()}=${encodeURIComponent(sessionSecret)}`, + ...(accountName ? { 'x-account-name': accountName } : {}), + }, + body: JSON.stringify({ messages: [makeSystemMessage(CHAT_SYSTEM_GUIDANCE), ...messages] }), + }); + } catch (error) { + throw toFriendlyChatError(error); + } + + if (!response.body) { + throw new Error('The chat service returned an empty response.'); + } + + // discardStdin: false so the spinner does not pause stdin, which the interactive readline prompt + // relies on staying open between turns. prefixText renders the "Expo >" label before the spinner + // ("Expo > ⠋ Thinking…"), so it is already aligned from the first frame and reads as the reply. + const spinner = stream + ? ora({ text: 'Thinking…', discardStdin: false, prefixText: ASSISTANT_SPINNER_PREFIX }).start() + : undefined; + + // Reset the cursor to the line start after clearing the spinner, so the reply begins at column 0. + const stopSpinner = (): void => { + if (!spinner) { + return; + } + spinner.stop(); + if (process.stdout.isTTY) { + process.stdout.write('\r'); + } + }; + const toolCallsById = new Map(); + const toolMessagePartsById = new Map>(); + const textMessagePartsById = new Map>(); + const assistantMessageParts: ChatMessagePart[] = []; + const announcedTools = new Set(); + const markdownState: MarkdownRenderState = createMarkdownRenderState(); + let fullText = ''; + let streamingText = false; + let errorText: string | undefined; + let buffer = ''; + let displayBuffer = ''; + let wroteAssistantLine = false; + let currentTextMessagePart: Extract | undefined; + + const getOrCreateToolMessagePart = ( + toolCallId: string, + toolName: string + ): Extract => { + const existing = toolMessagePartsById.get(toolCallId); + if (existing) { + return existing; + } + const part: Extract = { + type: `tool-${toolName}`, + toolCallId, + state: 'input-streaming', + input: undefined, + }; + toolMessagePartsById.set(toolCallId, part); + assistantMessageParts.push(part); + return part; + }; + + const getOrCreateTextMessagePart = ( + id: string | undefined + ): Extract => { + const existing = id ? textMessagePartsById.get(id) : currentTextMessagePart; + if (existing) { + return existing; + } + const part: Extract = { type: 'text', text: '' }; + if (id) { + textMessagePartsById.set(id, part); + } + currentTextMessagePart = part; + assistantMessageParts.push(part); + return part; + }; + + // Prefixes the first written line with the "Expo > " label and every following line with a matching + // indent, so the whole reply lines up under the label. Long lines are wrapped to the terminal + // width (minus the indent) so terminal soft-wrapping does not drop wrapped text back to column 0. + const writeAssistantLine = (rendered: string, withNewline: boolean): void => { + const columns = process.stdout.columns ?? 0; + const width = columns > 0 ? columns - ASSISTANT_INDENT.length : 0; + const segments = wrapToWidth(rendered, width); + segments.forEach((segment, index) => { + const prefix = wroteAssistantLine ? ASSISTANT_INDENT : ASSISTANT_LABEL; + wroteAssistantLine = true; + const isLastSegment = index === segments.length - 1; + const needsNewline = !isLastSegment || withNewline; + process.stdout.write(`${prefix}${segment}${needsNewline ? '\n' : ''}`); + }); + }; + + // Render markdown one completed line at a time: the assistant streams token by token, but markdown + // markers (e.g. **bold**) can span several tokens, so we can only style a line once it is whole. + const flushDisplayLines = (flushRemainder: boolean): void => { + let newlineIndex: number; + while ((newlineIndex = displayBuffer.indexOf('\n')) !== -1) { + const rawLine = displayBuffer.slice(0, newlineIndex); + displayBuffer = displayBuffer.slice(newlineIndex + 1); + const rendered = renderMarkdownLine(rawLine, markdownState); + if (rendered !== null) { + writeAssistantLine(rendered, true); + } + } + if (flushRemainder && displayBuffer.length > 0) { + const rendered = renderMarkdownLine(displayBuffer, markdownState); + displayBuffer = ''; + if (rendered !== null) { + writeAssistantLine(rendered, false); + } + } + }; + + const announceTool = (toolName: string): void => { + if (!stream || announcedTools.has(toolName)) { + return; + } + announcedTools.add(toolName); + const label = describeTool(toolName); + if (streamingText) { + flushDisplayLines(true); + process.stdout.write(chalk.dim(`\n${ASSISTANT_INDENT}(${label})\n`)); + } else if (spinner) { + spinner.text = `${label}…`; + } + }; + + const handleFrame = (frame: UIMessageStreamFrame): void => { + switch (frame.type) { + case 'start-step': { + assistantMessageParts.push({ type: 'step-start' }); + currentTextMessagePart = undefined; + break; + } + case 'text-start': { + currentTextMessagePart = getOrCreateTextMessagePart(frame.id); + break; + } + case 'text-delta': { + const delta = typeof frame.delta === 'string' ? frame.delta : ''; + if (!delta) { + return; + } + fullText += delta; + getOrCreateTextMessagePart(frame.id).text += delta; + if (stream) { + if (!streamingText) { + stopSpinner(); + streamingText = true; + } + displayBuffer += delta; + flushDisplayLines(false); + } + break; + } + case 'text-end': { + currentTextMessagePart = undefined; + break; + } + case 'tool-input-start': + case 'tool-input-available': { + const { toolCallId, toolName } = frame; + if (!toolCallId || !toolName) { + return; + } + const existing = toolCallsById.get(toolCallId); + toolCallsById.set(toolCallId, { + toolName, + input: frame.input ?? existing?.input, + output: existing?.output, + errorText: existing?.errorText, + }); + const toolPart = getOrCreateToolMessagePart(toolCallId, toolName); + if (frame.type === 'tool-input-available') { + toolPart.state = 'input-available'; + toolPart.input = frame.input; + delete toolPart.output; + delete toolPart.errorText; + } + announceTool(toolName); + break; + } + case 'tool-input-error': { + const { toolCallId, toolName } = frame; + if (!toolCallId || !toolName || typeof frame.errorText !== 'string') { + return; + } + toolCallsById.set(toolCallId, { + toolName, + input: frame.input, + errorText: frame.errorText, + }); + const toolPart = getOrCreateToolMessagePart(toolCallId, toolName); + toolPart.state = 'output-error'; + toolPart.input = frame.input; + delete toolPart.output; + toolPart.errorText = frame.errorText; + announceTool(toolName); + break; + } + case 'tool-output-available': { + const { toolCallId } = frame; + if (!toolCallId) { + return; + } + const existing = toolCallsById.get(toolCallId); + if (existing) { + existing.output = frame.output; + delete existing.errorText; + const toolPart = getOrCreateToolMessagePart(toolCallId, existing.toolName); + toolPart.state = 'output-available'; + toolPart.output = frame.output; + delete toolPart.errorText; + } + break; + } + case 'tool-output-error': { + const { toolCallId } = frame; + if (toolCallId && toolCallsById.has(toolCallId) && typeof frame.errorText === 'string') { + const toolCall = toolCallsById.get(toolCallId)!; + delete toolCall.output; + toolCall.errorText = frame.errorText; + const toolPart = getOrCreateToolMessagePart(toolCallId, toolCall.toolName); + toolPart.state = 'output-error'; + delete toolPart.output; + toolPart.errorText = frame.errorText; + } + break; + } + case 'error': { + if (typeof frame.errorText === 'string') { + errorText = frame.errorText; + } + break; + } + } + }; + + // Decode incrementally so a multi-byte character split across two chunks is not corrupted: the + // decoder holds an incomplete trailing byte sequence until the next chunk completes it. + const decoder = new StringDecoder('utf8'); + try { + for await (const chunk of response.body) { + buffer += decoder.write(chunk as Buffer); + let newlineIndex: number; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + if (!line.startsWith('data:')) { + continue; + } + const data = line.slice('data:'.length).trim(); + if (!data || data === '[DONE]') { + continue; + } + let frame: UIMessageStreamFrame; + try { + frame = JSON.parse(data); + } catch { + continue; + } + handleFrame(frame); + } + } + } finally { + stopSpinner(); + if (stream) { + flushDisplayLines(true); + } + } + + if (errorText) { + throw new Error(errorText); + } + + const toolCalls = [...toolCallsById.values()]; + const assistantMessage = makeAssistantMessage( + assistantMessageParts.filter( + part => + part.type === 'text' || + part.type === 'step-start' || + part.state === 'output-available' || + part.state === 'output-error' + ) + ); + + if (stream) { + if (fullText.length > 0) { + Log.newLine(); + } else { + Log.log( + chalk.dim( + toolCalls.length > 0 + ? 'The assistant looked up your data but did not return a text response. Try asking a more specific question.' + : 'The assistant did not return a response.' + ) + ); + } + } + + return { text: fullText, toolCalls, assistantMessage }; +} + +function toFriendlyChatError(error: unknown): Error { + if (error instanceof RequestError) { + const status = error.response.status; + if (status === 401) { + return new Error('Your Expo session is not valid for chat. Run `eas login` and try again.'); + } + if (status === 403) { + return new Error( + 'Chat is not enabled for this account yet. It is still rolling out. If you think you should have access, reach out to Expo.' + ); + } + if (status === 429) { + return new Error( + 'You have reached your chat usage limit. Try again later or upgrade your plan for a larger allowance.' + ); + } + return new Error(`The chat request failed (HTTP ${status}).`); + } + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/packages/eas-cli/src/chat/detectProject.ts b/packages/eas-cli/src/chat/detectProject.ts new file mode 100644 index 0000000000..9645f602cc --- /dev/null +++ b/packages/eas-cli/src/chat/detectProject.ts @@ -0,0 +1,52 @@ +import { getConfigFilePaths } from '@expo/config'; + +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { findProjectDirAndVerifyProjectSetupAsync } from '../commandUtils/context/contextUtils/findProjectDirAndVerifyProjectSetupAsync'; +import { AppQuery } from '../graphql/queries/AppQuery'; +import { getPrivateExpoConfigAsync } from '../project/expoConfig'; + +/** + * Best-effort detection of the EAS project for the current directory, used to auto-scope `eas chat`. + * + * This is intentionally silent and read-only: it never prompts, never creates or links a project, + * and returns `null` (rather than throwing) when the directory is not an already-linked EAS project. + * A missing project just means the chat stays account-scoped. + */ +export async function detectCurrentProjectAsync( + graphqlClient: ExpoGraphqlClient +): Promise<{ accountName: string; label: string } | null> { + let projectDir: string; + try { + projectDir = await findProjectDirAndVerifyProjectSetupAsync(); + } catch { + return null; + } + if (!projectDir) { + return null; + } + + // Only read the config if one already exists; getPrivateExpoConfigAsync would otherwise create an + // app.json, which chat must never do. + const configPaths = getConfigFilePaths(projectDir); + if (!configPaths.staticConfigPath && !configPaths.dynamicConfigPath) { + return null; + } + + let projectId: unknown; + try { + const exp = await getPrivateExpoConfigAsync(projectDir); + projectId = exp.extra?.eas?.projectId; + } catch { + return null; + } + if (typeof projectId !== 'string' || !projectId) { + return null; + } + + try { + const app = await AppQuery.byIdAsync(graphqlClient, projectId); + return { accountName: app.ownerAccount.name, label: app.fullName }; + } catch { + return null; + } +} diff --git a/packages/eas-cli/src/chat/renderMarkdown.ts b/packages/eas-cli/src/chat/renderMarkdown.ts new file mode 100644 index 0000000000..07a3cff64d --- /dev/null +++ b/packages/eas-cli/src/chat/renderMarkdown.ts @@ -0,0 +1,99 @@ +import chalk from 'chalk'; +import wrapAnsi from 'wrap-ansi'; + +/** + * Wraps a (possibly ANSI-styled) line to `width` visible columns, returning one string per physical + * line. `hard` breaks tokens longer than the width (e.g. URLs) so they wrap too. Returns the line + * unchanged when the width is too small to wrap into (e.g. output is not a terminal). + */ +export function wrapToWidth(text: string, width: number): string[] { + if (width < 10) { + return [text]; + } + return wrapAnsi(text, width, { hard: true, trim: false }).split('\n'); +} + +/** + * Minimal, streaming-friendly markdown-to-ANSI rendering for the terminal. The assistant streams + * markdown token by token, so rendering happens one completed line at a time (markers never span + * lines except fenced code blocks, which we track with `MarkdownRenderState`). + * + * This intentionally supports only the common elements the assistant produces; it is not a full + * CommonMark implementation. + */ +export type MarkdownRenderState = { inCodeBlock: boolean }; + +export function createMarkdownRenderState(): MarkdownRenderState { + return { inCodeBlock: false }; +} + +const FENCE_RE = /^\s*```/; +const HEADING_RE = /^(#{1,6})\s+(.*)$/; +const BLOCKQUOTE_RE = /^\s*>\s?(.*)$/; +const HORIZONTAL_RULE_RE = /^\s*([-*_])\1{2,}\s*$/; +const BULLET_RE = /^(\s*)[-*+]\s+(.*)$/; +const ORDERED_RE = /^(\s*)(\d+)\.\s+(.*)$/; + +/** + * Renders a single line of markdown to an ANSI-styled string. Returns `null` when the line should + * be dropped from the output (the ``` fence delimiters). + */ +export function renderMarkdownLine(line: string, state: MarkdownRenderState): string | null { + if (FENCE_RE.test(line)) { + state.inCodeBlock = !state.inCodeBlock; + return null; + } + if (state.inCodeBlock) { + return chalk.gray(line); + } + + const heading = line.match(HEADING_RE); + if (heading) { + return chalk.bold(renderInlineMarkdown(heading[2])); + } + + if (HORIZONTAL_RULE_RE.test(line)) { + return chalk.dim('─'.repeat(24)); + } + + const blockquote = line.match(BLOCKQUOTE_RE); + if (blockquote) { + return chalk.dim(`│ ${renderInlineMarkdown(blockquote[1])}`); + } + + const bullet = line.match(BULLET_RE); + if (bullet) { + return `${bullet[1]}${chalk.dim('•')} ${renderInlineMarkdown(bullet[2])}`; + } + + const ordered = line.match(ORDERED_RE); + if (ordered) { + return `${ordered[1]}${chalk.dim(`${ordered[2]}.`)} ${renderInlineMarkdown(ordered[3])}`; + } + + return renderInlineMarkdown(line); +} + +/** + * Applies inline markdown styling (code spans, links, bold, italic) to a single line's text. + */ +export function renderInlineMarkdown(text: string): string { + return ( + text + // Inline code first so its contents are not re-styled. + .replace(/`([^`]+)`/g, (_match, code) => chalk.cyan(code)) + // Links: [label](url) -> underlined label with a dimmed url. + .replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + (_match, label, url) => `${chalk.underline(label)} ${chalk.dim(`(${url})`)}` + ) + // Bold: **text** or __text__. + .replace(/(\*\*|__)(.+?)\1/g, (_match, _marker, content) => chalk.bold(content)) + // Italic: *text* (not part of **) ... + .replace(/(? chalk.italic(content)) + // ... and _text_ (only when the underscores are not inside a word, e.g. a_b_c). + .replace(/(? + chalk.italic(content) + ) + ); +} diff --git a/packages/eas-cli/src/chat/replInput.ts b/packages/eas-cli/src/chat/replInput.ts new file mode 100644 index 0000000000..9488847f35 --- /dev/null +++ b/packages/eas-cli/src/chat/replInput.ts @@ -0,0 +1,71 @@ +import readline from 'node:readline'; + +/** + * A minimal line-editor for the interactive chat loop, built on Node's built-in `readline`. Reusing + * a single interface across turns gives arrow-key history for free. It stays in the normal scrolling + * terminal buffer (no alternate screen), so the transcript remains selectable and scrollable. + */ +export type ChatReplInput = { + /** Prompts for a line. Resolves the entered text, or `null` when the input closes (Ctrl-D/Ctrl-C). */ + askAsync(prompt: string): Promise; + close(): void; +}; + +export function createChatReplInput(options?: { + input?: NodeJS.ReadableStream; + output?: NodeJS.WritableStream; + terminal?: boolean; + /** Initial arrow-key history (most recent first), e.g. the message passed on the command line. */ + history?: string[]; +}): ChatReplInput { + const inputStream = options?.input ?? process.stdin; + const rl = readline.createInterface({ + input: inputStream, + output: options?.output ?? process.stdout, + terminal: options?.terminal ?? true, + historySize: 100, + history: options?.history, + }); + + let closed = false; + rl.on('close', () => { + closed = true; + }); + // Ctrl-C at the prompt ends the session cleanly instead of leaving the terminal in a raw state. + rl.on('SIGINT', () => { + rl.close(); + }); + + return { + async askAsync(prompt: string): Promise { + if (closed) { + return null; + } + // A spinner (ora) or a previous reader can leave stdin paused; resuming it recovers input and + // keeps the event loop alive while we wait, so the prompt does not exit immediately. + inputStream.resume(); + return await new Promise(resolve => { + let settled = false; + const onClose = (): void => { + if (!settled) { + settled = true; + resolve(null); + } + }; + rl.once('close', onClose); + rl.question(prompt, answer => { + if (!settled) { + settled = true; + rl.removeListener('close', onClose); + resolve(answer); + } + }); + }); + }, + close(): void { + if (!closed) { + rl.close(); + } + }, + }; +} diff --git a/packages/eas-cli/src/commands/__tests__/chat.test.ts b/packages/eas-cli/src/commands/__tests__/chat.test.ts new file mode 100644 index 0000000000..42701a4b69 --- /dev/null +++ b/packages/eas-cli/src/commands/__tests__/chat.test.ts @@ -0,0 +1,315 @@ +import { getMockOclifConfig } from '../../__tests__/commands/utils'; +import { ChatResult, streamChatResponseAsync } from '../../chat/chatClient'; +import { detectCurrentProjectAsync } from '../../chat/detectProject'; +import { ChatReplInput, createChatReplInput } from '../../chat/replInput'; +import * as flagsModule from '../../commandUtils/flags'; +import { AppQuery } from '../../graphql/queries/AppQuery'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; +import Chat from '../chat'; + +jest.mock('../../chat/chatClient', () => ({ + ...jest.requireActual('../../chat/chatClient'), + streamChatResponseAsync: jest.fn(), +})); +jest.mock('../../chat/replInput'); +jest.mock('../../chat/detectProject'); +jest.mock('../../log'); +jest.mock('../../utils/json'); +jest.mock('../../graphql/queries/AppQuery', () => ({ + AppQuery: { byFullNameAsync: jest.fn() }, +})); + +const mockStreamChatResponseAsync = jest.mocked(streamChatResponseAsync); +const mockCreateChatReplInput = jest.mocked(createChatReplInput); +const mockDetectCurrentProjectAsync = jest.mocked(detectCurrentProjectAsync); +const mockAppByFullNameAsync = jest.mocked(AppQuery.byFullNameAsync); +const mockEnableJsonOutput = jest.mocked(enableJsonOutput); +const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput); + +function makeChatResult(text: string, toolCalls: ChatResult['toolCalls'] = []): ChatResult { + return { + text, + toolCalls, + assistantMessage: { + id: 'assistant-message', + role: 'assistant', + parts: [{ type: 'text', text }], + }, + }; +} + +const emptyResult = makeChatResult('ok'); + +/** Builds a ChatReplInput whose askAsync yields the given lines in order, then `null`. */ +function mockReplInput(lines: (string | null)[]): { askAsync: jest.Mock; close: jest.Mock } { + const askAsync = jest.fn(); + for (const line of lines) { + askAsync.mockResolvedValueOnce(line); + } + askAsync.mockResolvedValue(null); + const input = { askAsync, close: jest.fn() }; + mockCreateChatReplInput.mockReturnValue(input as ChatReplInput); + return input; +} + +/** Forces interactive mode (jest runs without a TTY, which otherwise defaults to non-interactive). */ +function forceInteractive(): void { + jest + .spyOn(flagsModule, 'resolveNonInteractiveAndJsonFlags') + .mockReturnValue({ json: false, nonInteractive: false }); +} + +describe(Chat, () => { + const mockConfig = getMockOclifConfig(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + mockStreamChatResponseAsync.mockResolvedValue(emptyResult); + mockDetectCurrentProjectAsync.mockResolvedValue(null); + }); + + function createCommand( + argv: string[], + { + sessionSecret = '{"id":"session-id","version":"1"}', + primaryAccountName = 'my-account', + }: { sessionSecret?: string | null; primaryAccountName?: string } = {} + ): Chat { + const command = new Chat(argv, mockConfig); + // @ts-expect-error getContextAsync is protected + jest.spyOn(command, 'getContextAsync').mockResolvedValue({ + loggedIn: { + actor: { + __typename: 'User', + id: 'user-id', + primaryAccount: { id: 'account-id', name: primaryAccountName }, + accounts: [{ id: 'account-id', name: primaryAccountName }], + }, + graphqlClient: {}, + authenticationInfo: sessionSecret + ? { accessToken: null, sessionSecret } + : { accessToken: 'token', sessionSecret: null }, + }, + }); + return command; + } + + it('sends the message for the primary account and does not prompt in non-interactive mode', async () => { + const command = createCommand(['how are my builds?', '--non-interactive']); + await command.runAsync(); + + expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1); + const call = mockStreamChatResponseAsync.mock.calls[0][0]; + expect(call.accountName).toBe('my-account'); + expect(call.sessionSecret).toBe('{"id":"session-id","version":"1"}'); + expect(call.stream).toBe(true); + expect(call.messages).toHaveLength(1); + expect(call.messages[0].parts[0]).toEqual({ type: 'text', text: 'how are my builds?' }); + expect(mockCreateChatReplInput).not.toHaveBeenCalled(); + }); + + it('prefers the --account flag over the primary account and skips project auto-detection', async () => { + const command = createCommand(['hi', '--account', 'other-account', '--non-interactive']); + await command.runAsync(); + + expect(mockStreamChatResponseAsync.mock.calls[0][0].accountName).toBe('other-account'); + expect(mockDetectCurrentProjectAsync).not.toHaveBeenCalled(); + }); + + it('auto-scopes to the current directory project when no scope flags are given', async () => { + mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' }); + + const command = createCommand(['is my build ok?', '--non-interactive']); + await command.runAsync(); + + const call = mockStreamChatResponseAsync.mock.calls[0][0]; + expect(call.accountName).toBe('acme'); + expect(call.messages[0].parts[0]).toEqual({ + type: 'text', + text: 'Regarding the EAS project @acme/mobile: is my build ok?', + }); + }); + + it('does not auto-detect when --project is given', async () => { + mockAppByFullNameAsync.mockResolvedValue({ + id: 'app1', + fullName: '@acme/mobile', + slug: 'mobile', + ownerAccount: { id: 'acc', name: 'acme' }, + } as any); + + const command = createCommand(['hi', '--project', 'acme/mobile', '--non-interactive']); + await command.runAsync(); + + expect(mockDetectCurrentProjectAsync).not.toHaveBeenCalled(); + }); + + it('resolves --project to its owner account and frames the message', async () => { + mockAppByFullNameAsync.mockResolvedValue({ + id: 'app1', + fullName: '@acme/mobile', + slug: 'mobile', + ownerAccount: { id: 'acc', name: 'acme' }, + } as any); + + const command = createCommand([ + 'is my build ok?', + '--project', + 'acme/mobile', + '--non-interactive', + ]); + await command.runAsync(); + + expect(mockAppByFullNameAsync).toHaveBeenCalledWith(expect.anything(), '@acme/mobile'); + const call = mockStreamChatResponseAsync.mock.calls[0][0]; + expect(call.accountName).toBe('acme'); + expect(call.messages[0].parts[0]).toEqual({ + type: 'text', + text: 'Regarding the EAS project @acme/mobile: is my build ok?', + }); + }); + + it('throws a friendly error when --project cannot be found', async () => { + mockAppByFullNameAsync.mockRejectedValue(new Error('not found')); + const command = createCommand(['hi', '--project', 'acme/ghost', '--non-interactive']); + await expect(command.runAsync()).rejects.toThrow(/@acme\/ghost was not found/); + }); + + it('continues the conversation with follow-up replies until the user types /exit', async () => { + forceInteractive(); + mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer')); + const input = mockReplInput(['and my updates?', '/exit']); + + const command = createCommand(['how are my builds?']); + await command.runAsync(); + + expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2); + const secondCall = mockStreamChatResponseAsync.mock.calls[1][0]; + expect(secondCall.messages).toHaveLength(3); + expect(secondCall.messages[1].role).toBe('assistant'); + expect(secondCall.messages[2].role).toBe('user'); + expect(secondCall.messages[2].parts[0]).toEqual({ type: 'text', text: 'and my updates?' }); + expect(input.close).toHaveBeenCalled(); + // The command-line message seeds history so Up recalls it at the first prompt. + expect(mockCreateChatReplInput).toHaveBeenCalledWith( + expect.objectContaining({ history: ['how are my builds?'] }) + ); + }); + + it('retains tool results in the conversation history for follow-up questions', async () => { + forceInteractive(); + const result = makeChatResult('The latest build passed.', [ + { toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: ['b1'] } }, + ]); + result.assistantMessage.parts = [ + { type: 'step-start' }, + { + type: 'tool-get_latest_builds', + toolCallId: 't1', + state: 'output-available', + input: { limit: 1 }, + output: { builds: ['b1'] }, + }, + { type: 'step-start' }, + { type: 'text', text: result.text }, + ]; + mockStreamChatResponseAsync.mockResolvedValue(result); + mockReplInput(['what SDK did it use?', '/exit']); + + const command = createCommand(['how are my builds?']); + await command.runAsync(); + + const secondCall = mockStreamChatResponseAsync.mock.calls[1][0]; + expect(secondCall.messages[1]).toEqual(result.assistantMessage); + }); + + it('exits when the input stream closes (Ctrl-D)', async () => { + forceInteractive(); + mockReplInput([null]); + + const command = createCommand(['how are my builds?']); + await command.runAsync(); + + expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(1); + }); + + it('starts a new conversation with /clear', async () => { + forceInteractive(); + mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer')); + mockReplInput(['/clear', 'fresh question', '/exit']); + + const command = createCommand(['first question']); + await command.runAsync(); + + expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2); + const secondCall = mockStreamChatResponseAsync.mock.calls[1][0]; + expect(secondCall.messages).toHaveLength(1); + expect(secondCall.messages[0].parts[0]).toEqual({ type: 'text', text: 'fresh question' }); + }); + + it('restores project scope after /clear', async () => { + forceInteractive(); + mockDetectCurrentProjectAsync.mockResolvedValue({ accountName: 'acme', label: '@acme/mobile' }); + mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer')); + mockReplInput(['/clear', 'fresh question', '/exit']); + + const command = createCommand(['first question']); + await command.runAsync(); + + const secondCall = mockStreamChatResponseAsync.mock.calls[1][0]; + expect(secondCall.messages).toHaveLength(1); + expect(secondCall.messages[0].parts[0]).toEqual({ + type: 'text', + text: 'Regarding the EAS project @acme/mobile: fresh question', + }); + }); + + it('ignores unknown slash commands and keeps prompting', async () => { + forceInteractive(); + mockStreamChatResponseAsync.mockResolvedValue(makeChatResult('answer')); + mockReplInput(['/bogus', 'a real question', '/exit']); + + const command = createCommand(['first question']); + await command.runAsync(); + + expect(mockStreamChatResponseAsync).toHaveBeenCalledTimes(2); + expect(mockStreamChatResponseAsync.mock.calls[1][0].messages[2].parts[0]).toEqual({ + type: 'text', + text: 'a real question', + }); + }); + + it('emits structured JSON and does not stream or prompt when --json is passed', async () => { + mockStreamChatResponseAsync.mockResolvedValue( + makeChatResult('Your latest build passed.', [ + { toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: [] } }, + ]) + ); + + const command = createCommand(['how are my builds?', '--json']); + await command.runAsync(); + + expect(mockEnableJsonOutput).toHaveBeenCalled(); + expect(mockStreamChatResponseAsync.mock.calls[0][0].stream).toBe(false); + expect(mockCreateChatReplInput).not.toHaveBeenCalled(); + expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({ + message: 'how are my builds?', + account: 'my-account', + project: null, + response: 'Your latest build passed.', + toolCalls: [{ toolName: 'get_latest_builds', input: { limit: 1 }, output: { builds: [] } }], + }); + }); + + it('throws when authenticated with an access token instead of a session secret', async () => { + const command = createCommand(['hi', '--non-interactive'], { sessionSecret: null }); + await expect(command.runAsync()).rejects.toThrow(/requires an interactive login/); + expect(mockStreamChatResponseAsync).not.toHaveBeenCalled(); + }); + + it('requires a message argument', async () => { + const command = createCommand(['--non-interactive']); + await expect(command.runAsync()).rejects.toThrow(); + }); +}); diff --git a/packages/eas-cli/src/commands/chat.ts b/packages/eas-cli/src/commands/chat.ts new file mode 100644 index 0000000000..3e7f2bdcf3 --- /dev/null +++ b/packages/eas-cli/src/commands/chat.ts @@ -0,0 +1,245 @@ +import { Args, Flags } from '@oclif/core'; +import chalk from 'chalk'; + +import { ChatMessage, makeUserMessage, streamChatResponseAsync } from '../chat/chatClient'; +import { detectCurrentProjectAsync } from '../chat/detectProject'; +import { ChatReplInput, createChatReplInput } from '../chat/replInput'; +import EasCommand from '../commandUtils/EasCommand'; +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { + EasNonInteractiveAndJsonFlags, + resolveNonInteractiveAndJsonFlags, +} from '../commandUtils/flags'; +import { AppQuery } from '../graphql/queries/AppQuery'; +import Log from '../log'; +import { Actor } from '../user/User'; +import { enableJsonOutput, printJsonOnlyOutput } from '../utils/json'; + +const EXIT_WORDS = new Set(['exit', 'quit', 'q']); +// "Chat > " labels the user's turn; it is 4 chars + " > " to line up with the assistant's "Expo > ". +const USER_LABEL = `${chalk.bold.cyan('Chat')}${chalk.dim(' > ')}`; +const CHAT_HELP = [ + 'Commands:', + ' /help Show this help', + ' /clear Start a new conversation', + ' /exit Quit', +].join('\n'); + +export default class Chat extends EasCommand { + static override description = 'ask an AI assistant about your Expo account and EAS projects'; + + static override hidden = true; + + static override args = { + message: Args.string({ + required: true, + description: 'Message to send to the assistant', + }), + }; + + static override flags = { + account: Flags.string({ + char: 'a', + description: 'Account to scope the conversation to (defaults to your primary account)', + }), + project: Flags.string({ + char: 'p', + description: + 'Project to focus the conversation on, as @account-name/project-slug or a project slug', + }), + ...EasNonInteractiveAndJsonFlags, + }; + + static override contextDefinition = { + ...this.ContextOptions.LoggedIn, + }; + + async runAsync(): Promise { + const { args, flags } = await this.parse(Chat); + const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); + if (json) { + enableJsonOutput(); + } + + const { + loggedIn: { actor, authenticationInfo, graphqlClient }, + } = await this.getContextAsync(Chat, { nonInteractive }); + + if (!authenticationInfo.sessionSecret) { + throw new Error( + '`eas chat` requires an interactive login. Run `eas login` first. Chat does not support EXPO_TOKEN access tokens yet.' + ); + } + const { sessionSecret } = authenticationInfo; + + let accountName = flags.account ?? getDefaultAccountName(actor); + let projectLabel: string | undefined; + let projectDetectedFromDirectory = false; + if (flags.project) { + const resolved = await resolveProjectAsync(graphqlClient, flags.project, accountName); + accountName = resolved.accountName; + projectLabel = resolved.label; + } else if (!flags.account) { + // No explicit scope given: focus on the current directory's project when there is one. + const detected = await detectCurrentProjectAsync(graphqlClient); + if (detected) { + accountName = detected.accountName; + projectLabel = detected.label; + projectDetectedFromDirectory = true; + } + } + + const firstMessageText = scopeMessageToProject(args.message, projectLabel); + const messages: ChatMessage[] = [makeUserMessage(firstMessageText)]; + + if (json) { + const result = await streamChatResponseAsync({ + messages: [...messages], + accountName, + sessionSecret, + stream: false, + }); + printJsonOnlyOutput({ + message: args.message, + account: accountName ?? null, + project: projectLabel ?? null, + response: result.text, + toolCalls: result.toolCalls, + }); + return; + } + + if (projectLabel) { + Log.log( + chalk.dim( + projectDetectedFromDirectory + ? `Project: ${projectLabel} (from current directory; use --account to widen)` + : `Project: ${projectLabel}` + ) + ); + } + if (!nonInteractive) { + Log.log(chalk.dim('Use /help for commands, or /exit to quit.')); + } + Log.log(`${USER_LABEL}${args.message}`); + Log.newLine(); + + // Seed history with the message from the command line so Up recalls it at the first prompt. + const input = nonInteractive ? undefined : createChatReplInput({ history: [args.message] }); + try { + for (;;) { + const result = await streamChatResponseAsync({ + messages: [...messages], + accountName, + sessionSecret, + stream: true, + }); + messages.push(result.assistantMessage); + + if (!input) { + break; + } + + const nextMessage = await readNextUserMessageAsync(input, messages, projectLabel); + if (nextMessage === null) { + Log.log(chalk.dim('Ending chat.')); + break; + } + messages.push(makeUserMessage(nextMessage)); + } + } finally { + input?.close(); + } + } +} + +/** + * Prompts for the next message, handling slash commands. Returns the message text to send, or `null` + * to end the chat. `messages` is mutated in place by conversation-affecting commands (e.g. /clear). + */ +async function readNextUserMessageAsync( + input: ChatReplInput, + messages: ChatMessage[], + projectLabel: string | undefined +): Promise { + let shouldRestoreProjectScope = false; + for (;;) { + Log.newLine(); + const line = await input.askAsync(USER_LABEL); + if (line === null) { + return null; + } + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + if (EXIT_WORDS.has(trimmed.toLowerCase())) { + return null; + } + + if (trimmed.startsWith('/')) { + const command = trimmed.slice(1).split(/\s+/)[0].toLowerCase(); + if (['exit', 'quit', 'q'].includes(command)) { + return null; + } + if (['clear', 'new', 'reset'].includes(command)) { + messages.length = 0; + shouldRestoreProjectScope = true; + Log.log(chalk.dim('Started a new conversation.')); + continue; + } + if (['help', 'h', '?'].includes(command)) { + Log.log(chalk.dim(CHAT_HELP)); + continue; + } + Log.warn(`Unknown command "/${command}". Type /help for commands.`); + continue; + } + + Log.newLine(); + return shouldRestoreProjectScope ? scopeMessageToProject(trimmed, projectLabel) : trimmed; + } +} + +function scopeMessageToProject(message: string, projectLabel: string | undefined): string { + return projectLabel ? `Regarding the EAS project ${projectLabel}: ${message}` : message; +} + +function getDefaultAccountName(actor: Actor): string | undefined { + if ('primaryAccount' in actor && actor.primaryAccount) { + return actor.primaryAccount.name; + } + return actor.accounts[0]?.name ?? undefined; +} + +async function resolveProjectAsync( + graphqlClient: ExpoGraphqlClient, + projectReference: string, + fallbackAccountName: string | undefined +): Promise<{ accountName: string; label: string }> { + const parsed = parseProjectReference(projectReference); + const accountForLookup = parsed.account ?? fallbackAccountName; + if (!accountForLookup) { + throw new Error( + `Could not determine which account owns project "${projectReference}". Pass it as @account-name/project-slug.` + ); + } + + const fullName = `@${accountForLookup}/${parsed.slug}`; + let app; + try { + app = await AppQuery.byFullNameAsync(graphqlClient, fullName); + } catch { + throw new Error(`Project ${fullName} was not found or you do not have access to it.`); + } + return { accountName: app.ownerAccount.name, label: app.fullName }; +} + +function parseProjectReference(reference: string): { account?: string; slug: string } { + const cleaned = reference.startsWith('@') ? reference.slice(1) : reference; + const parts = cleaned.split('/'); + if (parts.length === 2 && parts[0] && parts[1]) { + return { account: parts[0], slug: parts[1] }; + } + return { slug: cleaned }; +}