From f3bec2a39568e1fc5f63b0a814e943fa39de3a95 Mon Sep 17 00:00:00 2001 From: Jon Samp Date: Sun, 12 Jul 2026 10:35:34 -0500 Subject: [PATCH 01/13] [eas-cli] Add hidden `eas chat` command Ask the Expo dashboard AI assistant about your account and EAS projects from the terminal. Streams the reply, supports interactive multi-turn follow-ups, `--project`/`--account` scoping, `--non-interactive`, and `--json` for agent-friendly structured output. Calls the website's `/api/chat` route using the CLI session secret; command is hidden while the backend feature gate rolls out. --- CHANGELOG.md | 1 + .../src/chat/__tests__/chatClient.test.ts | 157 ++++++++++ packages/eas-cli/src/chat/chatClient.ts | 288 ++++++++++++++++++ .../src/commands/__tests__/chat.test.ts | 166 ++++++++++ packages/eas-cli/src/commands/chat.ts | 177 +++++++++++ 5 files changed, 789 insertions(+) create mode 100644 packages/eas-cli/src/chat/__tests__/chatClient.test.ts create mode 100644 packages/eas-cli/src/chat/chatClient.ts create mode 100644 packages/eas-cli/src/commands/__tests__/chat.test.ts create mode 100644 packages/eas-cli/src/commands/chat.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 05e9705f0b..c245810de1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features +- [eas-cli] Add hidden `eas chat` command to ask the Expo dashboard AI assistant about your account and EAS projects from the terminal. Supports interactive multi-turn follow-ups, `--project`/`-p` to focus on a specific project, `--account`/`-a` scoping, `--non-interactive`, and `--json` for agent-friendly structured output. ([#0000](https://github.com/expo/eas-cli/pull/0000) by [@jonsamp](https://github.com/jonsamp)) - [eas-cli] Improve `eas workflow:create`: add a `--template` flag, generate a placeholder workflow when a file name is passed, use shorter default file names (`build.yml`, `update.yml`, `deploy.yml`), configure EAS Build and EAS Update automatically when the chosen template requires them, set default app identifiers without prompting for the development build and deploy templates, install `expo-dev-client` during development build setup, and tighten the generated comments and next steps. ([#3943](https://github.com/expo/eas-cli/pull/3943) by [@jonsamp](https://github.com/jonsamp)) - [eas-cli] `eas integrations:posthog:dashboard` now opens PostHog via a signed-in link, skipping the login prompt. ([#3975](https://github.com/expo/eas-cli/pull/3975) by [@gwdp](https://github.com/gwdp)) 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..55d4fa4d25 --- /dev/null +++ b/packages/eas-cli/src/chat/__tests__/chatClient.test.ts @@ -0,0 +1,157 @@ +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: 'tool-input-available', + toolCallId: 't1', + toolName: 'get_latest_builds', + input: { limit: 1 }, + }, + { type: 'tool-output-available', toolCallId: 't1', output: { builds: ['b1'] } }, + { type: 'text-delta', delta: 'Done.' }, + { 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, + }, + ]); + }); + + 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'); + expect(capturedBody.messages[0].parts[0]).toEqual({ 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/chatClient.ts b/packages/eas-cli/src/chat/chatClient.ts new file mode 100644 index 0000000000..72783f9ea4 --- /dev/null +++ b/packages/eas-cli/src/chat/chatClient.ts @@ -0,0 +1,288 @@ +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'; + +/** + * 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, ' ')}`; +} + +type UIMessageStreamFrame = { + type: string; + delta?: string; + toolCallId?: string; + toolName?: string; + 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: 'user' | 'assistant'; + parts: { type: 'text'; text: string }[]; +}; + +export function makeUserMessage(text: string): ChatMessage { + return { id: uuidv4(), role: 'user', parts: [{ type: 'text', text }] }; +} + +export function makeAssistantMessage(text: string): ChatMessage { + return { id: uuidv4(), role: 'assistant', parts: [{ type: 'text', text }] }; +} + +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[]; +}; + +/** + * 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 }), + }); + } catch (error) { + throw toFriendlyChatError(error); + } + + if (!response.body) { + throw new Error('The chat service returned an empty response.'); + } + + const spinner = stream ? ora('Thinking…').start() : undefined; + const toolCallsById = new Map(); + const announcedTools = new Set(); + let fullText = ''; + let streamingText = false; + let errorText: string | undefined; + let buffer = ''; + + const announceTool = (toolName: string): void => { + if (!stream || announcedTools.has(toolName)) { + return; + } + announcedTools.add(toolName); + const label = describeTool(toolName); + if (streamingText) { + process.stdout.write(chalk.dim(`\n(${label})\n`)); + } else if (spinner) { + spinner.text = `${label}…`; + } + }; + + const handleFrame = (frame: UIMessageStreamFrame): void => { + switch (frame.type) { + case 'text-delta': { + const delta = typeof frame.delta === 'string' ? frame.delta : ''; + if (!delta) { + return; + } + fullText += delta; + if (stream) { + if (!streamingText) { + spinner?.stop(); + streamingText = true; + } + process.stdout.write(delta); + } + 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, + }); + announceTool(toolName); + break; + } + case 'tool-output-available': { + const { toolCallId } = frame; + if (!toolCallId) { + return; + } + const existing = toolCallsById.get(toolCallId); + if (existing) { + existing.output = frame.output; + } + break; + } + case 'tool-output-error': { + const { toolCallId } = frame; + if (toolCallId && toolCallsById.has(toolCallId) && typeof frame.errorText === 'string') { + toolCallsById.get(toolCallId)!.errorText = frame.errorText; + } + break; + } + case 'error': { + if (typeof frame.errorText === 'string') { + errorText = frame.errorText; + } + break; + } + } + }; + + try { + for await (const chunk of response.body) { + buffer += chunk.toString(); + 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 { + if (spinner?.isSpinning) { + spinner.stop(); + } + } + + if (errorText) { + throw new Error(errorText); + } + + const toolCalls = [...toolCallsById.values()]; + + 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 }; +} + +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/commands/__tests__/chat.test.ts b/packages/eas-cli/src/commands/__tests__/chat.test.ts new file mode 100644 index 0000000000..b0686b1d45 --- /dev/null +++ b/packages/eas-cli/src/commands/__tests__/chat.test.ts @@ -0,0 +1,166 @@ +import { getMockOclifConfig } from '../../__tests__/commands/utils'; +import { ChatResult, streamChatResponseAsync } from '../../chat/chatClient'; +import * as flagsModule from '../../commandUtils/flags'; +import { AppQuery } from '../../graphql/queries/AppQuery'; +import { promptAsync } from '../../prompts'; +import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; +import Chat from '../chat'; + +jest.mock('../../chat/chatClient', () => ({ + ...jest.requireActual('../../chat/chatClient'), + streamChatResponseAsync: jest.fn(), +})); +jest.mock('../../log'); +jest.mock('../../prompts'); +jest.mock('../../utils/json'); +jest.mock('../../graphql/queries/AppQuery', () => ({ + AppQuery: { byFullNameAsync: jest.fn() }, +})); + +const mockStreamChatResponseAsync = jest.mocked(streamChatResponseAsync); +const mockPromptAsync = jest.mocked(promptAsync); +const mockAppByFullNameAsync = jest.mocked(AppQuery.byFullNameAsync); +const mockEnableJsonOutput = jest.mocked(enableJsonOutput); +const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput); + +const emptyResult: ChatResult = { text: 'ok', toolCalls: [] }; + +describe(Chat, () => { + const mockConfig = getMockOclifConfig(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + mockStreamChatResponseAsync.mockResolvedValue(emptyResult); + }); + + 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].text).toBe('how are my builds?'); + expect(mockPromptAsync).not.toHaveBeenCalled(); + }); + + it('prefers the --account flag over the primary account', async () => { + const command = createCommand(['hi', '--account', 'other-account', '--non-interactive']); + await command.runAsync(); + + expect(mockStreamChatResponseAsync.mock.calls[0][0].accountName).toBe('other-account'); + }); + + 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].text).toBe( + '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 exits', async () => { + jest + .spyOn(flagsModule, 'resolveNonInteractiveAndJsonFlags') + .mockReturnValue({ json: false, nonInteractive: false }); + mockStreamChatResponseAsync.mockResolvedValue({ text: 'answer', toolCalls: [] }); + mockPromptAsync + .mockResolvedValueOnce({ reply: 'and my updates?' }) + .mockResolvedValueOnce({ reply: '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[1].parts[0].text).toBe('answer'); + expect(secondCall.messages[2].role).toBe('user'); + expect(secondCall.messages[2].parts[0].text).toBe('and my updates?'); + }); + + it('emits structured JSON and does not stream or prompt when --json is passed', async () => { + mockStreamChatResponseAsync.mockResolvedValue({ + text: 'Your latest build passed.', + toolCalls: [{ 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(mockPromptAsync).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..ecc3fc5718 --- /dev/null +++ b/packages/eas-cli/src/commands/chat.ts @@ -0,0 +1,177 @@ +import { Args, Flags } from '@oclif/core'; +import chalk from 'chalk'; + +import { + ChatMessage, + makeAssistantMessage, + makeUserMessage, + streamChatResponseAsync, +} from '../chat/chatClient'; +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 { promptAsync } from '../prompts'; +import { Actor } from '../user/User'; +import { enableJsonOutput, printJsonOnlyOutput } from '../utils/json'; + +const EXIT_WORDS = new Set(['exit', 'quit', 'q']); + +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; + if (flags.project) { + const resolved = await resolveProjectAsync(graphqlClient, flags.project, accountName); + accountName = resolved.accountName; + projectLabel = resolved.label; + } + + const firstMessageText = projectLabel + ? `Regarding the EAS project ${projectLabel}: ${args.message}` + : args.message; + 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(`Project: ${projectLabel}`)); + } + Log.log(chalk.dim(`> ${args.message}`)); + if (!nonInteractive) { + Log.log(chalk.dim('Type a reply to continue the conversation, or "exit" to quit.')); + } + Log.newLine(); + + for (;;) { + const result = await streamChatResponseAsync({ + messages: [...messages], + accountName, + sessionSecret, + stream: true, + }); + messages.push(makeAssistantMessage(result.text)); + + if (nonInteractive) { + break; + } + + Log.newLine(); + const { reply } = await promptAsync({ + type: 'text', + name: 'reply', + message: 'Reply', + }); + const trimmedReply = reply?.trim(); + if (!trimmedReply || EXIT_WORDS.has(trimmedReply.toLowerCase())) { + Log.log(chalk.dim('Ending chat.')); + break; + } + Log.newLine(); + messages.push(makeUserMessage(trimmedReply)); + } + } +} + +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 }; +} From effddeb23098ee6aef7d7f5945d88fdc48aa8a53 Mon Sep 17 00:00:00 2001 From: Jon Samp Date: Sun, 12 Jul 2026 10:36:15 -0500 Subject: [PATCH 02/13] [eas-cli] Update changelog PR link for `eas chat` --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c245810de1..0814f3cbde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features -- [eas-cli] Add hidden `eas chat` command to ask the Expo dashboard AI assistant about your account and EAS projects from the terminal. Supports interactive multi-turn follow-ups, `--project`/`-p` to focus on a specific project, `--account`/`-a` scoping, `--non-interactive`, and `--json` for agent-friendly structured output. ([#0000](https://github.com/expo/eas-cli/pull/0000) by [@jonsamp](https://github.com/jonsamp)) +- [eas-cli] Add hidden `eas chat` command to ask the Expo dashboard AI assistant about your account and EAS projects from the terminal. Supports interactive multi-turn follow-ups, `--project`/`-p` to focus on a specific project, `--account`/`-a` scoping, `--non-interactive`, and `--json` for agent-friendly structured output. ([#4005](https://github.com/expo/eas-cli/pull/4005) by [@jonsamp](https://github.com/jonsamp)) - [eas-cli] Improve `eas workflow:create`: add a `--template` flag, generate a placeholder workflow when a file name is passed, use shorter default file names (`build.yml`, `update.yml`, `deploy.yml`), configure EAS Build and EAS Update automatically when the chosen template requires them, set default app identifiers without prompting for the development build and deploy templates, install `expo-dev-client` during development build setup, and tighten the generated comments and next steps. ([#3943](https://github.com/expo/eas-cli/pull/3943) by [@jonsamp](https://github.com/jonsamp)) - [eas-cli] `eas integrations:posthog:dashboard` now opens PostHog via a signed-in link, skipping the login prompt. ([#3975](https://github.com/expo/eas-cli/pull/3975) by [@gwdp](https://github.com/gwdp)) From 0709c7822c277509c07d02d1889d9f05dab1324d Mon Sep 17 00:00:00 2001 From: Jon Samp Date: Sun, 12 Jul 2026 11:34:58 -0500 Subject: [PATCH 03/13] [eas-cli] Render markdown and steer output for `eas chat` Render the streamed assistant reply as ANSI (bold, inline code, headings, bullets, links) one line at a time. Prepend a client-side system message steering the assistant to write terminal-friendly answers and include expo.dev links for referenced artifacts (builds, updates, etc.). --- .../src/chat/__tests__/chatClient.test.ts | 7 +- .../src/chat/__tests__/renderMarkdown.test.ts | 62 +++++++++++++ packages/eas-cli/src/chat/chatClient.ts | 55 +++++++++++- packages/eas-cli/src/chat/renderMarkdown.ts | 86 +++++++++++++++++++ 4 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 packages/eas-cli/src/chat/__tests__/renderMarkdown.test.ts create mode 100644 packages/eas-cli/src/chat/renderMarkdown.ts diff --git a/packages/eas-cli/src/chat/__tests__/chatClient.test.ts b/packages/eas-cli/src/chat/__tests__/chatClient.test.ts index 55d4fa4d25..a9676db564 100644 --- a/packages/eas-cli/src/chat/__tests__/chatClient.test.ts +++ b/packages/eas-cli/src/chat/__tests__/chatClient.test.ts @@ -126,7 +126,12 @@ describe(streamChatResponseAsync, () => { 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'); - expect(capturedBody.messages[0].parts[0]).toEqual({ type: 'text', text: 'hello' }); + // 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 () => { 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..7d87bb1823 --- /dev/null +++ b/packages/eas-cli/src/chat/__tests__/renderMarkdown.test.ts @@ -0,0 +1,62 @@ +import chalk from 'chalk'; + +import { + createMarkdownRenderState, + renderInlineMarkdown, + renderMarkdownLine, +} 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` + ); + }); +}); diff --git a/packages/eas-cli/src/chat/chatClient.ts b/packages/eas-cli/src/chat/chatClient.ts index 72783f9ea4..baa7b86990 100644 --- a/packages/eas-cli/src/chat/chatClient.ts +++ b/packages/eas-cli/src/chat/chatClient.ts @@ -5,6 +5,23 @@ import { getExpoWebsiteBaseUrl } from '../api'; import fetch, { RequestError } from '../fetch'; import Log from '../log'; import { ora } from '../ora'; +import { + MarkdownRenderState, + createMarkdownRenderState, + renderMarkdownLine, +} 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 @@ -67,10 +84,14 @@ type UIMessageStreamFrame = { /** A single turn in the conversation, in the AI SDK `UIMessage` shape the endpoint expects. */ export type ChatMessage = { id: string; - role: 'user' | 'assistant'; + role: 'system' | 'user' | 'assistant'; parts: { type: 'text'; text: string }[]; }; +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 }] }; } @@ -123,7 +144,7 @@ export async function streamChatResponseAsync({ cookie: `${getWebsiteAuthCookieName()}=${encodeURIComponent(sessionSecret)}`, ...(accountName ? { 'x-account-name': accountName } : {}), }, - body: JSON.stringify({ messages }), + body: JSON.stringify({ messages: [makeSystemMessage(CHAT_SYSTEM_GUIDANCE), ...messages] }), }); } catch (error) { throw toFriendlyChatError(error); @@ -136,10 +157,33 @@ export async function streamChatResponseAsync({ const spinner = stream ? ora('Thinking…').start() : undefined; const toolCallsById = new Map(); const announcedTools = new Set(); + const markdownState: MarkdownRenderState = createMarkdownRenderState(); let fullText = ''; let streamingText = false; let errorText: string | undefined; let buffer = ''; + let displayBuffer = ''; + + // 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) { + process.stdout.write(`${rendered}\n`); + } + } + if (flushRemainder && displayBuffer.length > 0) { + const rendered = renderMarkdownLine(displayBuffer, markdownState); + displayBuffer = ''; + if (rendered !== null) { + process.stdout.write(rendered); + } + } + }; const announceTool = (toolName: string): void => { if (!stream || announcedTools.has(toolName)) { @@ -148,6 +192,7 @@ export async function streamChatResponseAsync({ announcedTools.add(toolName); const label = describeTool(toolName); if (streamingText) { + flushDisplayLines(true); process.stdout.write(chalk.dim(`\n(${label})\n`)); } else if (spinner) { spinner.text = `${label}…`; @@ -167,7 +212,8 @@ export async function streamChatResponseAsync({ spinner?.stop(); streamingText = true; } - process.stdout.write(delta); + displayBuffer += delta; + flushDisplayLines(false); } break; } @@ -241,6 +287,9 @@ export async function streamChatResponseAsync({ if (spinner?.isSpinning) { spinner.stop(); } + if (stream) { + flushDisplayLines(true); + } } if (errorText) { diff --git a/packages/eas-cli/src/chat/renderMarkdown.ts b/packages/eas-cli/src/chat/renderMarkdown.ts new file mode 100644 index 0000000000..09963593f6 --- /dev/null +++ b/packages/eas-cli/src/chat/renderMarkdown.ts @@ -0,0 +1,86 @@ +import chalk from 'chalk'; + +/** + * 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) + ) + ); +} From 19140ad91ccc4b63c03a72f4dd58d7c5e8c8ebf2 Mon Sep 17 00:00:00 2001 From: Jon Samp Date: Sun, 12 Jul 2026 12:36:04 -0500 Subject: [PATCH 04/13] [eas-cli] Add readline REPL with history and slash commands to `eas chat` Replace the per-turn prompt with a reusable Node readline interface: arrow-key history across turns and /help, /clear, /exit slash commands, all in the normal scrolling buffer (no new dependency). --- CHANGELOG.md | 2 +- .../src/chat/__tests__/replInput.test.ts | 38 +++++++ packages/eas-cli/src/chat/replInput.ts | 64 ++++++++++++ .../src/commands/__tests__/chat.test.ts | 79 ++++++++++++--- packages/eas-cli/src/commands/chat.ts | 99 ++++++++++++++----- 5 files changed, 243 insertions(+), 39 deletions(-) create mode 100644 packages/eas-cli/src/chat/__tests__/replInput.test.ts create mode 100644 packages/eas-cli/src/chat/replInput.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0814f3cbde..32b9bcb08c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features -- [eas-cli] Add hidden `eas chat` command to ask the Expo dashboard AI assistant about your account and EAS projects from the terminal. Supports interactive multi-turn follow-ups, `--project`/`-p` to focus on a specific project, `--account`/`-a` scoping, `--non-interactive`, and `--json` for agent-friendly structured output. ([#4005](https://github.com/expo/eas-cli/pull/4005) by [@jonsamp](https://github.com/jonsamp)) +- [eas-cli] Add hidden `eas chat` command to ask the Expo dashboard AI assistant about your account and EAS projects from the terminal. Supports an interactive multi-turn REPL (arrow-key history, `/help`, `/clear`, `/exit`), rendered markdown output, `--project`/`-p` to focus on a specific project, `--account`/`-a` scoping, `--non-interactive`, and `--json` for agent-friendly structured output. ([#4005](https://github.com/expo/eas-cli/pull/4005) by [@jonsamp](https://github.com/jonsamp)) - [eas-cli] Improve `eas workflow:create`: add a `--template` flag, generate a placeholder workflow when a file name is passed, use shorter default file names (`build.yml`, `update.yml`, `deploy.yml`), configure EAS Build and EAS Update automatically when the chosen template requires them, set default app identifiers without prompting for the development build and deploy templates, install `expo-dev-client` during development build setup, and tighten the generated comments and next steps. ([#3943](https://github.com/expo/eas-cli/pull/3943) by [@jonsamp](https://github.com/jonsamp)) - [eas-cli] `eas integrations:posthog:dashboard` now opens PostHog via a signed-in link, skipping the login prompt. ([#3975](https://github.com/expo/eas-cli/pull/3975) by [@gwdp](https://github.com/gwdp)) 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..f5e92741c9 --- /dev/null +++ b/packages/eas-cli/src/chat/__tests__/replInput.test.ts @@ -0,0 +1,38 @@ +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('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/replInput.ts b/packages/eas-cli/src/chat/replInput.ts new file mode 100644 index 0000000000..bb69d4ccb3 --- /dev/null +++ b/packages/eas-cli/src/chat/replInput.ts @@ -0,0 +1,64 @@ +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; +}): ChatReplInput { + const rl = readline.createInterface({ + input: options?.input ?? process.stdin, + output: options?.output ?? process.stdout, + terminal: options?.terminal ?? true, + historySize: 100, + }); + + 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; + } + 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 index b0686b1d45..417cc1d345 100644 --- a/packages/eas-cli/src/commands/__tests__/chat.test.ts +++ b/packages/eas-cli/src/commands/__tests__/chat.test.ts @@ -1,8 +1,8 @@ import { getMockOclifConfig } from '../../__tests__/commands/utils'; import { ChatResult, streamChatResponseAsync } from '../../chat/chatClient'; +import { ChatReplInput, createChatReplInput } from '../../chat/replInput'; import * as flagsModule from '../../commandUtils/flags'; import { AppQuery } from '../../graphql/queries/AppQuery'; -import { promptAsync } from '../../prompts'; import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json'; import Chat from '../chat'; @@ -10,21 +10,40 @@ jest.mock('../../chat/chatClient', () => ({ ...jest.requireActual('../../chat/chatClient'), streamChatResponseAsync: jest.fn(), })); +jest.mock('../../chat/replInput'); jest.mock('../../log'); -jest.mock('../../prompts'); jest.mock('../../utils/json'); jest.mock('../../graphql/queries/AppQuery', () => ({ AppQuery: { byFullNameAsync: jest.fn() }, })); const mockStreamChatResponseAsync = jest.mocked(streamChatResponseAsync); -const mockPromptAsync = jest.mocked(promptAsync); +const mockCreateChatReplInput = jest.mocked(createChatReplInput); const mockAppByFullNameAsync = jest.mocked(AppQuery.byFullNameAsync); const mockEnableJsonOutput = jest.mocked(enableJsonOutput); const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput); const emptyResult: ChatResult = { text: 'ok', toolCalls: [] }; +/** 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(); @@ -71,7 +90,7 @@ describe(Chat, () => { expect(call.stream).toBe(true); expect(call.messages).toHaveLength(1); expect(call.messages[0].parts[0].text).toBe('how are my builds?'); - expect(mockPromptAsync).not.toHaveBeenCalled(); + expect(mockCreateChatReplInput).not.toHaveBeenCalled(); }); it('prefers the --account flag over the primary account', async () => { @@ -111,14 +130,10 @@ describe(Chat, () => { await expect(command.runAsync()).rejects.toThrow(/@acme\/ghost was not found/); }); - it('continues the conversation with follow-up replies until the user exits', async () => { - jest - .spyOn(flagsModule, 'resolveNonInteractiveAndJsonFlags') - .mockReturnValue({ json: false, nonInteractive: false }); + it('continues the conversation with follow-up replies until the user types /exit', async () => { + forceInteractive(); mockStreamChatResponseAsync.mockResolvedValue({ text: 'answer', toolCalls: [] }); - mockPromptAsync - .mockResolvedValueOnce({ reply: 'and my updates?' }) - .mockResolvedValueOnce({ reply: 'exit' }); + const input = mockReplInput(['and my updates?', '/exit']); const command = createCommand(['how are my builds?']); await command.runAsync(); @@ -127,9 +142,47 @@ describe(Chat, () => { const secondCall = mockStreamChatResponseAsync.mock.calls[1][0]; expect(secondCall.messages).toHaveLength(3); expect(secondCall.messages[1].role).toBe('assistant'); - expect(secondCall.messages[1].parts[0].text).toBe('answer'); expect(secondCall.messages[2].role).toBe('user'); expect(secondCall.messages[2].parts[0].text).toBe('and my updates?'); + expect(input.close).toHaveBeenCalled(); + }); + + 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({ text: 'answer', toolCalls: [] }); + 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].text).toBe('fresh question'); + }); + + it('ignores unknown slash commands and keeps prompting', async () => { + forceInteractive(); + mockStreamChatResponseAsync.mockResolvedValue({ text: 'answer', toolCalls: [] }); + 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].text).toBe( + 'a real question' + ); }); it('emits structured JSON and does not stream or prompt when --json is passed', async () => { @@ -143,7 +196,7 @@ describe(Chat, () => { expect(mockEnableJsonOutput).toHaveBeenCalled(); expect(mockStreamChatResponseAsync.mock.calls[0][0].stream).toBe(false); - expect(mockPromptAsync).not.toHaveBeenCalled(); + expect(mockCreateChatReplInput).not.toHaveBeenCalled(); expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({ message: 'how are my builds?', account: 'my-account', diff --git a/packages/eas-cli/src/commands/chat.ts b/packages/eas-cli/src/commands/chat.ts index ecc3fc5718..ac560e8ce3 100644 --- a/packages/eas-cli/src/commands/chat.ts +++ b/packages/eas-cli/src/commands/chat.ts @@ -7,6 +7,7 @@ import { makeUserMessage, streamChatResponseAsync, } from '../chat/chatClient'; +import { ChatReplInput, createChatReplInput } from '../chat/replInput'; import EasCommand from '../commandUtils/EasCommand'; import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; import { @@ -15,11 +16,16 @@ import { } from '../commandUtils/flags'; import { AppQuery } from '../graphql/queries/AppQuery'; import Log from '../log'; -import { promptAsync } from '../prompts'; import { Actor } from '../user/User'; import { enableJsonOutput, printJsonOnlyOutput } from '../utils/json'; const EXIT_WORDS = new Set(['exit', 'quit', 'q']); +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'; @@ -103,37 +109,80 @@ export default class Chat extends EasCommand { } Log.log(chalk.dim(`> ${args.message}`)); if (!nonInteractive) { - Log.log(chalk.dim('Type a reply to continue the conversation, or "exit" to quit.')); + Log.log(chalk.dim('Type a message to continue. Use /help for commands, or /exit to quit.')); } Log.newLine(); - for (;;) { - const result = await streamChatResponseAsync({ - messages: [...messages], - accountName, - sessionSecret, - stream: true, - }); - messages.push(makeAssistantMessage(result.text)); - - if (nonInteractive) { - break; + const input = nonInteractive ? undefined : createChatReplInput(); + try { + for (;;) { + const result = await streamChatResponseAsync({ + messages: [...messages], + accountName, + sessionSecret, + stream: true, + }); + messages.push(makeAssistantMessage(result.text)); + + if (!input) { + break; + } + + const nextMessage = await readNextUserMessageAsync(input, messages); + if (nextMessage === null) { + Log.log(chalk.dim('Ending chat.')); + break; + } + messages.push(makeUserMessage(nextMessage)); } + } finally { + input?.close(); + } + } +} - Log.newLine(); - const { reply } = await promptAsync({ - type: 'text', - name: 'reply', - message: 'Reply', - }); - const trimmedReply = reply?.trim(); - if (!trimmedReply || EXIT_WORDS.has(trimmedReply.toLowerCase())) { - Log.log(chalk.dim('Ending chat.')); - break; +/** + * 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[] +): Promise { + for (;;) { + Log.newLine(); + const line = await input.askAsync(`${chalk.bold.cyan('You')} ${chalk.dim('›')} `); + 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; } - Log.newLine(); - messages.push(makeUserMessage(trimmedReply)); + if (['clear', 'new', 'reset'].includes(command)) { + messages.length = 0; + 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 trimmed; } } From facc8d34f84b65ba75a7ede9f3abd2d42f4275da Mon Sep 17 00:00:00 2001 From: Jon Samp Date: Sun, 12 Jul 2026 12:43:28 -0500 Subject: [PATCH 05/13] [eas-cli] Fix `eas chat` interactive prompt exiting immediately The ora spinner leaves stdin paused when it stops, so the readline prompt had nothing keeping the event loop alive and the process exited the moment the prompt appeared. Resume stdin before each prompt and stop the spinner from grabbing stdin (discardStdin: false). --- .../eas-cli/src/chat/__tests__/replInput.test.ts | 13 +++++++++++++ packages/eas-cli/src/chat/chatClient.ts | 4 +++- packages/eas-cli/src/chat/replInput.ts | 6 +++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/eas-cli/src/chat/__tests__/replInput.test.ts b/packages/eas-cli/src/chat/__tests__/replInput.test.ts index f5e92741c9..6f4a53288e 100644 --- a/packages/eas-cli/src/chat/__tests__/replInput.test.ts +++ b/packages/eas-cli/src/chat/__tests__/replInput.test.ts @@ -15,6 +15,19 @@ describe(createChatReplInput, () => { 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(); diff --git a/packages/eas-cli/src/chat/chatClient.ts b/packages/eas-cli/src/chat/chatClient.ts index baa7b86990..7a9d8ae401 100644 --- a/packages/eas-cli/src/chat/chatClient.ts +++ b/packages/eas-cli/src/chat/chatClient.ts @@ -154,7 +154,9 @@ export async function streamChatResponseAsync({ throw new Error('The chat service returned an empty response.'); } - const spinner = stream ? ora('Thinking…').start() : undefined; + // discardStdin: false so the spinner does not pause stdin, which the interactive readline prompt + // relies on staying open between turns. + const spinner = stream ? ora({ text: 'Thinking…', discardStdin: false }).start() : undefined; const toolCallsById = new Map(); const announcedTools = new Set(); const markdownState: MarkdownRenderState = createMarkdownRenderState(); diff --git a/packages/eas-cli/src/chat/replInput.ts b/packages/eas-cli/src/chat/replInput.ts index bb69d4ccb3..c04b1afc6d 100644 --- a/packages/eas-cli/src/chat/replInput.ts +++ b/packages/eas-cli/src/chat/replInput.ts @@ -16,8 +16,9 @@ export function createChatReplInput(options?: { output?: NodeJS.WritableStream; terminal?: boolean; }): ChatReplInput { + const inputStream = options?.input ?? process.stdin; const rl = readline.createInterface({ - input: options?.input ?? process.stdin, + input: inputStream, output: options?.output ?? process.stdout, terminal: options?.terminal ?? true, historySize: 100, @@ -37,6 +38,9 @@ export function createChatReplInput(options?: { 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 => { From a6078864814a5644adc1be4e301ab406aa7f953a Mon Sep 17 00:00:00 2001 From: Jon Samp Date: Sun, 12 Jul 2026 12:51:29 -0500 Subject: [PATCH 06/13] [eas-cli] Auto-scope `eas chat` to the current directory's project When run inside a linked EAS project and neither --project nor --account is given, chat now focuses on that project automatically. Detection is silent and read-only: it never prompts, links, or creates a config, and falls back to account scope when the directory is not a linked project. --- packages/eas-cli/src/chat/detectProject.ts | 52 +++++++++++++++++++ .../src/commands/__tests__/chat.test.ts | 34 +++++++++++- packages/eas-cli/src/commands/chat.ts | 18 ++++++- 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 packages/eas-cli/src/chat/detectProject.ts 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/commands/__tests__/chat.test.ts b/packages/eas-cli/src/commands/__tests__/chat.test.ts index 417cc1d345..5c13baa40e 100644 --- a/packages/eas-cli/src/commands/__tests__/chat.test.ts +++ b/packages/eas-cli/src/commands/__tests__/chat.test.ts @@ -1,5 +1,6 @@ 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'; @@ -11,6 +12,7 @@ jest.mock('../../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', () => ({ @@ -19,6 +21,7 @@ jest.mock('../../graphql/queries/AppQuery', () => ({ 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); @@ -51,6 +54,7 @@ describe(Chat, () => { jest.clearAllMocks(); jest.restoreAllMocks(); mockStreamChatResponseAsync.mockResolvedValue(emptyResult); + mockDetectCurrentProjectAsync.mockResolvedValue(null); }); function createCommand( @@ -93,11 +97,39 @@ describe(Chat, () => { expect(mockCreateChatReplInput).not.toHaveBeenCalled(); }); - it('prefers the --account flag over the primary account', async () => { + 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].text).toBe( + '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 () => { diff --git a/packages/eas-cli/src/commands/chat.ts b/packages/eas-cli/src/commands/chat.ts index ac560e8ce3..d1e49cd779 100644 --- a/packages/eas-cli/src/commands/chat.ts +++ b/packages/eas-cli/src/commands/chat.ts @@ -7,6 +7,7 @@ import { 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'; @@ -76,10 +77,19 @@ export default class Chat extends EasCommand { 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 = projectLabel @@ -105,7 +115,13 @@ export default class Chat extends EasCommand { } if (projectLabel) { - Log.log(chalk.dim(`Project: ${projectLabel}`)); + Log.log( + chalk.dim( + projectDetectedFromDirectory + ? `Project: ${projectLabel} (from current directory; use --account to widen)` + : `Project: ${projectLabel}` + ) + ); } Log.log(chalk.dim(`> ${args.message}`)); if (!nonInteractive) { From 10f72b448658036538ddf8754d778d39d439ef9a Mon Sep 17 00:00:00 2001 From: Jon Samp Date: Sun, 12 Jul 2026 12:55:20 -0500 Subject: [PATCH 07/13] [eas-cli] Seed `eas chat` history with the initial command-line message The first message comes from argv, not the readline prompt, so Up-arrow had nothing to recall at the first follow-up. Pass it as readline's initial history. --- packages/eas-cli/src/chat/replInput.ts | 3 +++ packages/eas-cli/src/commands/__tests__/chat.test.ts | 4 ++++ packages/eas-cli/src/commands/chat.ts | 3 ++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/eas-cli/src/chat/replInput.ts b/packages/eas-cli/src/chat/replInput.ts index c04b1afc6d..9488847f35 100644 --- a/packages/eas-cli/src/chat/replInput.ts +++ b/packages/eas-cli/src/chat/replInput.ts @@ -15,6 +15,8 @@ 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({ @@ -22,6 +24,7 @@ export function createChatReplInput(options?: { output: options?.output ?? process.stdout, terminal: options?.terminal ?? true, historySize: 100, + history: options?.history, }); let closed = false; diff --git a/packages/eas-cli/src/commands/__tests__/chat.test.ts b/packages/eas-cli/src/commands/__tests__/chat.test.ts index 5c13baa40e..0ccc1d5e02 100644 --- a/packages/eas-cli/src/commands/__tests__/chat.test.ts +++ b/packages/eas-cli/src/commands/__tests__/chat.test.ts @@ -177,6 +177,10 @@ describe(Chat, () => { expect(secondCall.messages[2].role).toBe('user'); expect(secondCall.messages[2].parts[0].text).toBe('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('exits when the input stream closes (Ctrl-D)', async () => { diff --git a/packages/eas-cli/src/commands/chat.ts b/packages/eas-cli/src/commands/chat.ts index d1e49cd779..7f43c6e169 100644 --- a/packages/eas-cli/src/commands/chat.ts +++ b/packages/eas-cli/src/commands/chat.ts @@ -129,7 +129,8 @@ export default class Chat extends EasCommand { } Log.newLine(); - const input = nonInteractive ? undefined : createChatReplInput(); + // 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({ From b01f9e2d320a1705337c73b46f0479709922f7d6 Mon Sep 17 00:00:00 2001 From: Jon Samp Date: Sun, 12 Jul 2026 13:02:12 -0500 Subject: [PATCH 08/13] [eas-cli] Label and align `eas chat` speakers (Chat > / Expo >) Prefix the assistant reply with a magenta "Expo > " label and indent its continuation lines to match, and label the user's turn "Chat > " (both 4-char names so the two align). --- packages/eas-cli/src/chat/chatClient.ts | 21 ++++++++++++++++++--- packages/eas-cli/src/commands/chat.ts | 6 ++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/eas-cli/src/chat/chatClient.ts b/packages/eas-cli/src/chat/chatClient.ts index 7a9d8ae401..c20b7158b2 100644 --- a/packages/eas-cli/src/chat/chatClient.ts +++ b/packages/eas-cli/src/chat/chatClient.ts @@ -71,6 +71,12 @@ 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); + type UIMessageStreamFrame = { type: string; delta?: string; @@ -165,6 +171,15 @@ export async function streamChatResponseAsync({ let errorText: string | undefined; let buffer = ''; let displayBuffer = ''; + let wroteAssistantLine = false; + + // 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. + const writeAssistantLine = (rendered: string, withNewline: boolean): void => { + const prefix = wroteAssistantLine ? ASSISTANT_INDENT : ASSISTANT_LABEL; + wroteAssistantLine = true; + process.stdout.write(withNewline ? `${prefix}${rendered}\n` : `${prefix}${rendered}`); + }; // 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. @@ -175,14 +190,14 @@ export async function streamChatResponseAsync({ displayBuffer = displayBuffer.slice(newlineIndex + 1); const rendered = renderMarkdownLine(rawLine, markdownState); if (rendered !== null) { - process.stdout.write(`${rendered}\n`); + writeAssistantLine(rendered, true); } } if (flushRemainder && displayBuffer.length > 0) { const rendered = renderMarkdownLine(displayBuffer, markdownState); displayBuffer = ''; if (rendered !== null) { - process.stdout.write(rendered); + writeAssistantLine(rendered, false); } } }; @@ -195,7 +210,7 @@ export async function streamChatResponseAsync({ const label = describeTool(toolName); if (streamingText) { flushDisplayLines(true); - process.stdout.write(chalk.dim(`\n(${label})\n`)); + process.stdout.write(chalk.dim(`\n${ASSISTANT_INDENT}(${label})\n`)); } else if (spinner) { spinner.text = `${label}…`; } diff --git a/packages/eas-cli/src/commands/chat.ts b/packages/eas-cli/src/commands/chat.ts index 7f43c6e169..1af2c8ad43 100644 --- a/packages/eas-cli/src/commands/chat.ts +++ b/packages/eas-cli/src/commands/chat.ts @@ -21,6 +21,8 @@ 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', @@ -123,7 +125,7 @@ export default class Chat extends EasCommand { ) ); } - Log.log(chalk.dim(`> ${args.message}`)); + Log.log(`${USER_LABEL}${args.message}`); if (!nonInteractive) { Log.log(chalk.dim('Type a message to continue. Use /help for commands, or /exit to quit.')); } @@ -168,7 +170,7 @@ async function readNextUserMessageAsync( ): Promise { for (;;) { Log.newLine(); - const line = await input.askAsync(`${chalk.bold.cyan('You')} ${chalk.dim('›')} `); + const line = await input.askAsync(USER_LABEL); if (line === null) { return null; } From fd0b8a2a92a3a324e62237b8e80501f804c1b7ac Mon Sep 17 00:00:00 2001 From: Jon Samp Date: Sun, 12 Jul 2026 13:09:15 -0500 Subject: [PATCH 09/13] [eas-cli] Wrap `eas chat` reply to terminal width and indent the spinner Wrap each rendered line to the terminal width minus the indent (ANSI-aware, hard-breaking long URLs) and re-apply the indent to every wrapped piece, so terminal soft-wrapping no longer drops wrapped text to column 0. Also indent the ora spinner to align with the reply body. --- .../src/chat/__tests__/renderMarkdown.test.ts | 26 +++++++++++++++++++ packages/eas-cli/src/chat/chatClient.ts | 24 ++++++++++++----- packages/eas-cli/src/chat/renderMarkdown.ts | 13 ++++++++++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/eas-cli/src/chat/__tests__/renderMarkdown.test.ts b/packages/eas-cli/src/chat/__tests__/renderMarkdown.test.ts index 7d87bb1823..cb3afcde8d 100644 --- a/packages/eas-cli/src/chat/__tests__/renderMarkdown.test.ts +++ b/packages/eas-cli/src/chat/__tests__/renderMarkdown.test.ts @@ -4,6 +4,7 @@ import { createMarkdownRenderState, renderInlineMarkdown, renderMarkdownLine, + wrapToWidth, } from '../renderMarkdown'; describe(renderInlineMarkdown, () => { @@ -60,3 +61,28 @@ describe(renderMarkdownLine, () => { ); }); }); + +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/chatClient.ts b/packages/eas-cli/src/chat/chatClient.ts index c20b7158b2..fcdb92a3cd 100644 --- a/packages/eas-cli/src/chat/chatClient.ts +++ b/packages/eas-cli/src/chat/chatClient.ts @@ -9,6 +9,7 @@ import { MarkdownRenderState, createMarkdownRenderState, renderMarkdownLine, + wrapToWidth, } from './renderMarkdown'; /** @@ -161,8 +162,11 @@ export async function streamChatResponseAsync({ } // discardStdin: false so the spinner does not pause stdin, which the interactive readline prompt - // relies on staying open between turns. - const spinner = stream ? ora({ text: 'Thinking…', discardStdin: false }).start() : undefined; + // relies on staying open between turns. indent aligns the spinner (and tool status) with the + // "Expo > " reply body. + const spinner = stream + ? ora({ text: 'Thinking…', discardStdin: false, indent: ASSISTANT_INDENT.length }).start() + : undefined; const toolCallsById = new Map(); const announcedTools = new Set(); const markdownState: MarkdownRenderState = createMarkdownRenderState(); @@ -174,11 +178,19 @@ export async function streamChatResponseAsync({ let wroteAssistantLine = false; // 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. + // 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 prefix = wroteAssistantLine ? ASSISTANT_INDENT : ASSISTANT_LABEL; - wroteAssistantLine = true; - process.stdout.write(withNewline ? `${prefix}${rendered}\n` : `${prefix}${rendered}`); + 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 diff --git a/packages/eas-cli/src/chat/renderMarkdown.ts b/packages/eas-cli/src/chat/renderMarkdown.ts index 09963593f6..07a3cff64d 100644 --- a/packages/eas-cli/src/chat/renderMarkdown.ts +++ b/packages/eas-cli/src/chat/renderMarkdown.ts @@ -1,4 +1,17 @@ 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 From cea9d4bbdc9bc6e32c7f73020a727784c8502dd0 Mon Sep 17 00:00:00 2001 From: Jon Samp Date: Sun, 12 Jul 2026 13:34:23 -0500 Subject: [PATCH 10/13] [eas-cli] Fix `eas chat` reply indent after spinner and reorder intro The indented ora spinner leaves the cursor at its indent column when it stops (ora's clear() ends with cursorTo(indent)), which pushed the whole reply right by the indent. Reset the cursor to column 0 after stopping. Also move the commands hint above the echoed message and shorten it. --- packages/eas-cli/src/chat/chatClient.ts | 18 ++++++++++++++---- packages/eas-cli/src/commands/chat.ts | 4 ++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/eas-cli/src/chat/chatClient.ts b/packages/eas-cli/src/chat/chatClient.ts index fcdb92a3cd..fc9c534de9 100644 --- a/packages/eas-cli/src/chat/chatClient.ts +++ b/packages/eas-cli/src/chat/chatClient.ts @@ -167,6 +167,18 @@ export async function streamChatResponseAsync({ const spinner = stream ? ora({ text: 'Thinking…', discardStdin: false, indent: ASSISTANT_INDENT.length }).start() : undefined; + + // ora leaves the cursor at its indent column after clearing on stop (its clear() ends with + // cursorTo(indent)), which would push the reply right by the indent. Reset to the line start. + const stopSpinner = (): void => { + if (!spinner) { + return; + } + spinner.stop(); + if (process.stdout.isTTY) { + process.stdout.write('\r'); + } + }; const toolCallsById = new Map(); const announcedTools = new Set(); const markdownState: MarkdownRenderState = createMarkdownRenderState(); @@ -238,7 +250,7 @@ export async function streamChatResponseAsync({ fullText += delta; if (stream) { if (!streamingText) { - spinner?.stop(); + stopSpinner(); streamingText = true; } displayBuffer += delta; @@ -313,9 +325,7 @@ export async function streamChatResponseAsync({ } } } finally { - if (spinner?.isSpinning) { - spinner.stop(); - } + stopSpinner(); if (stream) { flushDisplayLines(true); } diff --git a/packages/eas-cli/src/commands/chat.ts b/packages/eas-cli/src/commands/chat.ts index 1af2c8ad43..d1b8cbb912 100644 --- a/packages/eas-cli/src/commands/chat.ts +++ b/packages/eas-cli/src/commands/chat.ts @@ -125,10 +125,10 @@ export default class Chat extends EasCommand { ) ); } - Log.log(`${USER_LABEL}${args.message}`); if (!nonInteractive) { - Log.log(chalk.dim('Type a message to continue. Use /help for commands, or /exit to quit.')); + 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. From 7c75c277b45970e4dec16b8db1947a5542452663 Mon Sep 17 00:00:00 2001 From: Jon Samp Date: Sun, 12 Jul 2026 13:39:50 -0500 Subject: [PATCH 11/13] [eas-cli] Render `eas chat` spinner with the Expo label prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use ora's prefixText to show "Expo > Thinking…" from the first frame instead of the indent option, which rendered unindented for a tick then snapped right. The spinner now reads as the reply and is cleared/replaced by the streamed text. --- packages/eas-cli/src/chat/chatClient.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/eas-cli/src/chat/chatClient.ts b/packages/eas-cli/src/chat/chatClient.ts index fc9c534de9..a375df3ca0 100644 --- a/packages/eas-cli/src/chat/chatClient.ts +++ b/packages/eas-cli/src/chat/chatClient.ts @@ -77,6 +77,8 @@ function describeTool(toolName: string): string { 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; @@ -162,14 +164,13 @@ export async function streamChatResponseAsync({ } // discardStdin: false so the spinner does not pause stdin, which the interactive readline prompt - // relies on staying open between turns. indent aligns the spinner (and tool status) with the - // "Expo > " reply body. + // 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, indent: ASSISTANT_INDENT.length }).start() + ? ora({ text: 'Thinking…', discardStdin: false, prefixText: ASSISTANT_SPINNER_PREFIX }).start() : undefined; - // ora leaves the cursor at its indent column after clearing on stop (its clear() ends with - // cursorTo(indent)), which would push the reply right by the indent. Reset to the line start. + // Reset the cursor to the line start after clearing the spinner, so the reply begins at column 0. const stopSpinner = (): void => { if (!spinner) { return; From 30f82f0dfb2c22070cf7627bf4492fd2143bef34 Mon Sep 17 00:00:00 2001 From: Jon Samp Date: Sun, 12 Jul 2026 14:22:17 -0500 Subject: [PATCH 12/13] [eas-cli] Decode `eas chat` stream with StringDecoder to avoid splitting multi-byte characters --- packages/eas-cli/src/chat/chatClient.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/eas-cli/src/chat/chatClient.ts b/packages/eas-cli/src/chat/chatClient.ts index a375df3ca0..bed0b692df 100644 --- a/packages/eas-cli/src/chat/chatClient.ts +++ b/packages/eas-cli/src/chat/chatClient.ts @@ -1,3 +1,5 @@ +import { StringDecoder } from 'node:string_decoder'; + import chalk from 'chalk'; import { v4 as uuidv4 } from 'uuid'; @@ -302,9 +304,12 @@ export async function streamChatResponseAsync({ } }; + // 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 += chunk.toString(); + buffer += decoder.write(chunk as Buffer); let newlineIndex: number; while ((newlineIndex = buffer.indexOf('\n')) !== -1) { const line = buffer.slice(0, newlineIndex).trim(); From f28692de13e1c799ac411c91092654a33b0caa99 Mon Sep 17 00:00:00 2001 From: Jon Samp Date: Thu, 16 Jul 2026 06:55:28 -0500 Subject: [PATCH 13/13] [eas-cli] Remove CHANGELOG entry for hidden `eas chat` command --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32b9bcb08c..05e9705f0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,6 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features -- [eas-cli] Add hidden `eas chat` command to ask the Expo dashboard AI assistant about your account and EAS projects from the terminal. Supports an interactive multi-turn REPL (arrow-key history, `/help`, `/clear`, `/exit`), rendered markdown output, `--project`/`-p` to focus on a specific project, `--account`/`-a` scoping, `--non-interactive`, and `--json` for agent-friendly structured output. ([#4005](https://github.com/expo/eas-cli/pull/4005) by [@jonsamp](https://github.com/jonsamp)) - [eas-cli] Improve `eas workflow:create`: add a `--template` flag, generate a placeholder workflow when a file name is passed, use shorter default file names (`build.yml`, `update.yml`, `deploy.yml`), configure EAS Build and EAS Update automatically when the chosen template requires them, set default app identifiers without prompting for the development build and deploy templates, install `expo-dev-client` during development build setup, and tighten the generated comments and next steps. ([#3943](https://github.com/expo/eas-cli/pull/3943) by [@jonsamp](https://github.com/jonsamp)) - [eas-cli] `eas integrations:posthog:dashboard` now opens PostHog via a signed-in link, skipping the login prompt. ([#3975](https://github.com/expo/eas-cli/pull/3975) by [@gwdp](https://github.com/gwdp))