From 791dfb81da74c7f97b16845713bc247fafcc2bcf Mon Sep 17 00:00:00 2001 From: bytecii Date: Mon, 9 Mar 2026 20:51:51 -0700 Subject: [PATCH 1/6] feat: support cli --- CONTRIBUTING.md | 48 +++- backend/app/controller/chat_controller.py | 30 ++- backend/app/model/chat.py | 28 +- cli/.env.example | 5 + cli/README.md | 99 +++++++ cli/package.json | 30 +++ cli/src/client.ts | 217 +++++++++++++++ cli/src/collapsible.ts | 71 +++++ cli/src/config.ts | 116 ++++++++ cli/src/index.ts | 155 +++++++++++ cli/src/renderer.ts | 163 ++++++++++++ cli/src/repl.ts | 309 ++++++++++++++++++++++ cli/src/timer.ts | 35 +++ cli/src/types.ts | 77 ++++++ cli/src/vendor.d.ts | 20 ++ cli/tsconfig.json | 14 + 16 files changed, 1389 insertions(+), 28 deletions(-) create mode 100644 cli/.env.example create mode 100644 cli/README.md create mode 100644 cli/package.json create mode 100644 cli/src/client.ts create mode 100644 cli/src/collapsible.ts create mode 100644 cli/src/config.ts create mode 100644 cli/src/index.ts create mode 100644 cli/src/renderer.ts create mode 100644 cli/src/repl.ts create mode 100644 cli/src/timer.ts create mode 100644 cli/src/types.ts create mode 100644 cli/src/vendor.d.ts create mode 100644 cli/tsconfig.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8439666e2..a50049bf2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -259,25 +259,57 @@ Our aim is to make the developer setup as straightforward as possible. If you en ## Quick Start šŸš€ +### 1. Frontend Setup + ```bash git clone https://github.com/eigent-ai/eigent.git cd eigent npm install -npm run dev +``` + +### 2. Backend Setup + +```bash +cd server + +# Start Docker services (PostgreSQL and Redis) +docker compose -f docker-compose.dev.yml up -d + +# Copy and configure environment variables +cp .env.example .env +# Edit .env and uncomment/set the following: +# database_url=postgresql://postgres:123456@localhost:5432/eigent +# redis_url=redis://localhost:6379/0 +``` + +### 3. Run Database Migrations -# In a separate terminal, start the backend server +```bash cd server -docker compose up -d -# Stream the logs if you needed -docker compose logs -f +uv run alembic upgrade head ``` -To run the application locally in developer mode: +### 4. Start the Backend Server -1. Configure `.env.development`: +```bash +cd server +uv run uvicorn main:api --host 0.0.0.0 --port 3001 --reload +``` + +### 5. Start the Frontend Dev Server + +```bash +# In a separate terminal, from the project root +npm run dev +``` + +### 6. Configure Frontend Proxy + +Configure `.env.development`: - Set `VITE_USE_LOCAL_PROXY=true` - Set `VITE_PROXY_URL=http://localhost:3001` -1. Go to the settings to specify your model key and model type. + +Then go to the settings to specify your model key and model type. ## Common Actions šŸ”„ diff --git a/backend/app/controller/chat_controller.py b/backend/app/controller/chat_controller.py index 87e42a70a..99fe44089 100644 --- a/backend/app/controller/chat_controller.py +++ b/backend/app/controller/chat_controller.py @@ -186,6 +186,10 @@ async def post(data: Chat, request: Request): os.environ["file_save_path"] = data.file_save_path() os.environ["browser_port"] = str(data.browser_port) + + # Store workspace on task_lock so follow-up tasks preserve it + if data.workspace: + task_lock.workspace = data.workspace os.environ["OPENAI_API_KEY"] = data.api_key os.environ["OPENAI_API_BASE_URL"] = ( data.api_url or "https://api.openai.com/v1" @@ -290,18 +294,26 @@ def improve(id: str, data: SupplementChat): # Get current environment values needed to construct new path current_email = None - # Extract email from current file_save_path if available - current_file_save_path = os.environ.get("file_save_path", "") - if current_file_save_path: - path_parts = Path(current_file_save_path).parts - if len(path_parts) >= 3 and "eigent" in path_parts: - eigent_index = path_parts.index("eigent") - if eigent_index + 1 < len(path_parts): - current_email = path_parts[eigent_index + 1] + # If using a custom workspace, keep it as-is + if hasattr(task_lock, "workspace") and task_lock.workspace: + new_folder_path = Path(task_lock.workspace) + new_folder_path.mkdir(parents=True, exist_ok=True) + task_lock.new_folder_path = new_folder_path + os.environ["file_save_path"] = str(new_folder_path) + current_email = "__workspace__" + else: + # Extract email from current file_save_path if available + current_file_save_path = os.environ.get("file_save_path", "") + if current_file_save_path: + path_parts = Path(current_file_save_path).parts + if len(path_parts) >= 3 and "eigent" in path_parts: + eigent_index = path_parts.index("eigent") + if eigent_index + 1 < len(path_parts): + current_email = path_parts[eigent_index + 1] # If we have the necessary info, update # the file_save_path - if current_email and id: + if current_email and current_email != "__workspace__" and id: # Create new path using the existing # pattern: email/project_{id}/task_{id} new_folder_path = ( diff --git a/backend/app/model/chat.py b/backend/app/model/chat.py index 8f13a5aa4..f8d5281d4 100644 --- a/backend/app/model/chat.py +++ b/backend/app/model/chat.py @@ -78,6 +78,9 @@ class Chat(BaseModel): search_config: dict[str, str] | None = None # User identifier for user-specific skill configurations user_id: str | None = None + # Optional workspace directory — when set, agents work in this + # directory instead of the default ~/eigent//... path + workspace: str | None = None @field_validator("model_type") @classmethod @@ -119,17 +122,20 @@ def is_cloud(self): return self.api_url is not None and "44.247.171.124" in self.api_url def file_save_path(self, path: str | None = None): - email = re.sub(r'[\\/*?:"<>|\s]', "_", self.email.split("@")[0]).strip( - "." - ) - # Use project-based structure: project_{project_id}/task_{task_id} - save_path = ( - Path.home() - / "eigent" - / email - / f"project_{self.project_id}" - / f"task_{self.task_id}" - ) + if self.workspace: + save_path = Path(self.workspace) + else: + email = re.sub( + r'[\\/*?:"<>|\s]', "_", self.email.split("@")[0] + ).strip(".") + # Use project-based structure: project_{project_id}/task_{task_id} + save_path = ( + Path.home() + / "eigent" + / email + / f"project_{self.project_id}" + / f"task_{self.task_id}" + ) if path is not None: save_path = save_path / path save_path.mkdir(parents=True, exist_ok=True) diff --git a/cli/.env.example b/cli/.env.example new file mode 100644 index 000000000..b64debf7b --- /dev/null +++ b/cli/.env.example @@ -0,0 +1,5 @@ +EIGENT_API_URL=http://localhost:5001 +EIGENT_API_KEY=sk-... +EIGENT_MODEL_PLATFORM=anthropic +EIGENT_MODEL_TYPE=claude-sonnet-4-20250514 +EIGENT_EMAIL=cli@eigent.ai diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 000000000..a230d54ca --- /dev/null +++ b/cli/README.md @@ -0,0 +1,99 @@ +# Eigent CLI + +Command-line interface for the Eigent AI coding agent. + +## Prerequisites + +- Node.js >= 18 +- Eigent backend running (default: `http://localhost:5001`) + +## Installation + +```bash +# From the repo root +cd cli +npm install +npm run build +npm install -g . +``` + +After installation, the `eigent` command is available globally. + +## Configuration + +Run the config wizard to set your API key, model, and backend URL: + +```bash +eigent config +``` + +Config is stored at `~/.eigent/cli-config.json`. + +You can also use environment variables (or `.env.development` at the repo root): + +``` +EIGENT_API_URL=http://localhost:5001 +EIGENT_API_KEY=sk-... +EIGENT_MODEL_PLATFORM=openai +EIGENT_MODEL_TYPE=gpt-4o +EIGENT_EMAIL=you@example.com +``` + +## Usage + +### Interactive mode (REPL) + +```bash +eigent +``` + +### One-shot mode + +```bash +eigent "search for YC W2026 companies and save one to w26.json" +``` + +### CLI options + +``` +eigent [question] Ask a question (one-shot mode) +eigent config Configure API key, model, backend URL +eigent --help Show help +eigent --version Show version +``` + +### REPL commands + +| Command | Description | +|----------------------|--------------------------------------| +| `/new` | Start a new conversation | +| `/project` | Show current project ID | +| `/workspace` | Show current workspace path | +| `/workspace ` | Change workspace directory | +| `/stop` | Stop the current task | +| `/quit` or `/exit` | Exit the CLI | +| `Ctrl+C` | Stop streaming / exit | +| `Ctrl+E` | Expand last collapsed output | + +## Development + +```bash +# Watch mode (auto-rebuild on changes) +npm run dev + +# Manual build +npm run build + +# Run without global install +node dist/index.js +``` + +## Updating + +After pulling changes: + +```bash +cd cli +npm run build +npm install -g . +``` diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 000000000..1c8b08c90 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,30 @@ +{ + "name": "@eigent/cli", + "version": "0.1.0", + "description": "Eigent AI coding agent CLI", + "type": "module", + "bin": { + "eigent": "./dist/index.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "start": "node dist/index.js" + }, + "dependencies": { + "chalk": "^5.4.1", + "commander": "^13.1.0", + "dotenv": "^17.3.1", + "marked": "^15.0.7", + "marked-terminal": "^7.3.0", + "uuid": "^11.1.0" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "@types/uuid": "^10.0.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/cli/src/client.ts b/cli/src/client.ts new file mode 100644 index 000000000..0ef19288c --- /dev/null +++ b/cli/src/client.ts @@ -0,0 +1,217 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { v4 as uuidv4 } from 'uuid'; +import type { ChatPayload, CliConfig, SSEEvent } from './types.js'; + +export class EigentClient { + private config: CliConfig; + private base: string; + private projectId: string; + private abortController: AbortController | null = null; + + constructor(config: CliConfig) { + this.config = config; + this.base = config.apiUrl.replace(/\/+$/, ''); + this.projectId = uuidv4(); + } + + getProjectId(): string { + return this.projectId; + } + + /** Reset project ID for a new conversation. */ + newSession(): void { + this.projectId = uuidv4(); + } + + /** + * Abort the current SSE connection. Call this after you're done + * consuming events to prevent the process from hanging. + */ + abort(): void { + this.abortController?.abort(); + this.abortController = null; + } + + async *startChat( + question: string, + workspace?: string + ): AsyncGenerator { + const taskId = uuidv4(); + const payload: ChatPayload = { + project_id: this.projectId, + task_id: taskId, + question, + email: this.config.email, + model_platform: this.config.modelPlatform, + model_type: this.config.modelType, + api_key: this.config.apiKey, + api_url: null, + language: 'en', + allow_local_system: true, + attaches: [], + browser_port: 9222, + installed_mcp: { mcpServers: {} }, + env_path: null, + search_config: null, + new_agents: [], + summary_prompt: '', + workspace: workspace ? `${workspace}/project_${this.projectId}` : null, + }; + + yield* this.streamSSE(`${this.base}/chat`, 'POST', payload); + } + + async improveChat(question: string): Promise { + const taskId = uuidv4(); + const url = `${this.base}/chat/${this.projectId}`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ question, task_id: taskId }), + }); + if (!res.ok) { + throw new Error( + `Failed to send follow-up: ${res.status} ${res.statusText}` + ); + } + } + + async confirmAndStartTask( + tasks: { id: string; content: string }[] + ): Promise { + // PUT /task/{id} — update task plan + const putUrl = `${this.base}/task/${this.projectId}`; + const putRes = await fetch(putUrl, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task: tasks }), + }); + if (!putRes.ok) { + throw new Error( + `Failed to update task: ${putRes.status} ${putRes.statusText}` + ); + } + + // POST /task/{id}/start — trigger execution + const startUrl = `${this.base}/task/${this.projectId}/start`; + const startRes = await fetch(startUrl, { method: 'POST' }); + if (!startRes.ok) { + throw new Error( + `Failed to start task: ${startRes.status} ${startRes.statusText}` + ); + } + } + + async humanReply(agent: string, reply: string): Promise { + const url = `${this.base}/chat/${this.projectId}/human-reply`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agent, reply }), + }); + if (!res.ok) { + throw new Error( + `Failed to send human reply: ${res.status} ${res.statusText}` + ); + } + } + + async stopChat(): Promise { + this.abortController?.abort(); + try { + await fetch(`${this.base}/chat/${this.projectId}`, { + method: 'DELETE', + }); + } catch { + // Ignore errors on stop + } + } + + async skipTask(): Promise { + this.abortController?.abort(); + try { + await fetch(`${this.base}/chat/${this.projectId}/skip-task`, { + method: 'POST', + }); + } catch { + // Ignore errors on skip + } + } + + async checkHealth(): Promise { + try { + const res = await fetch(`${this.base}/health`, { + signal: AbortSignal.timeout(3000), + }); + return res.ok; + } catch { + return false; + } + } + + private async *streamSSE( + url: string, + method: string, + body?: unknown + ): AsyncGenerator { + this.abortController = new AbortController(); + + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + signal: this.abortController.signal, + }); + + if (!res.ok) { + throw new Error(`Backend error: ${res.status} ${res.statusText}`); + } + + if (!res.body) { + throw new Error('No response body'); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith('data: ')) continue; + const json = trimmed.slice(6); + try { + const event: SSEEvent = JSON.parse(json); + yield event; + } catch { + // Skip malformed SSE lines + } + } + } + } catch (err: any) { + if (err.name === 'AbortError') return; + throw err; + } + } +} diff --git a/cli/src/collapsible.ts b/cli/src/collapsible.ts new file mode 100644 index 000000000..469447a91 --- /dev/null +++ b/cli/src/collapsible.ts @@ -0,0 +1,71 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import chalk from 'chalk'; + +export interface CollapsibleBlock { + id: number; + fullOutput: string; + previewLines: number; + expanded: boolean; +} + +const LINE_THRESHOLD = 15; +const PREVIEW_LINES = 5; + +let blocks: CollapsibleBlock[] = []; +let nextId = 0; + +export function shouldCollapse(output: string): boolean { + return output.split('\n').length > LINE_THRESHOLD; +} + +export function addCollapsibleBlock(fullOutput: string): CollapsibleBlock { + const block: CollapsibleBlock = { + id: nextId++, + fullOutput, + previewLines: PREVIEW_LINES, + expanded: false, + }; + blocks.push(block); + return block; +} + +export function renderCollapsed(block: CollapsibleBlock): string { + const lines = block.fullOutput.split('\n'); + const preview = lines.slice(0, block.previewLines).join('\n'); + const remaining = lines.length - block.previewLines; + return ( + preview + + '\n' + + chalk.dim.italic(` ... ${remaining} more lines (Ctrl+E to expand)`) + ); +} + +export function getLastCollapsedBlock(): CollapsibleBlock | null { + for (let i = blocks.length - 1; i >= 0; i--) { + if (!blocks[i].expanded) return blocks[i]; + } + return null; +} + +export function expandBlock(block: CollapsibleBlock): string { + block.expanded = true; + return block.fullOutput; +} + +export function clearBlocks(): void { + blocks = []; + nextId = 0; +} diff --git a/cli/src/config.ts b/cli/src/config.ts new file mode 100644 index 000000000..bec0fd481 --- /dev/null +++ b/cli/src/config.ts @@ -0,0 +1,116 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import dotenv from 'dotenv'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import readline from 'node:readline'; +import type { CliConfig } from './types.js'; + +const CONFIG_DIR = path.join(os.homedir(), '.eigent'); +const CONFIG_FILE = path.join(CONFIG_DIR, 'cli-config.json'); + +const DEFAULTS: CliConfig = { + apiUrl: 'http://localhost:5001', + apiKey: '', + modelPlatform: 'openai', + modelType: 'gpt-4o', + email: 'cli@eigent.ai', + workspace: process.cwd(), +}; + +/** + * Find and load .env.development by walking up from cwd to find the repo root. + */ +function loadDotEnv(): void { + let dir = process.cwd(); + while (dir !== path.dirname(dir)) { + const envFile = path.join(dir, '.env.development'); + if (fs.existsSync(envFile)) { + dotenv.config({ path: envFile, quiet: true } as any); + return; + } + dir = path.dirname(dir); + } +} + +export function loadConfig(): CliConfig { + // Load .env.development first (won't override existing env vars) + loadDotEnv(); + + const config = { ...DEFAULTS }; + + // Load from file + if (fs.existsSync(CONFIG_FILE)) { + try { + const stored = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')); + Object.assign(config, stored); + } catch { + // Ignore invalid config file + } + } + + // Env vars override file config + if (process.env.EIGENT_API_URL) config.apiUrl = process.env.EIGENT_API_URL; + if (process.env.EIGENT_API_KEY) config.apiKey = process.env.EIGENT_API_KEY; + if (process.env.EIGENT_MODEL_PLATFORM) + config.modelPlatform = process.env.EIGENT_MODEL_PLATFORM; + if (process.env.EIGENT_MODEL_TYPE) + config.modelType = process.env.EIGENT_MODEL_TYPE; + if (process.env.EIGENT_EMAIL) config.email = process.env.EIGENT_EMAIL; + if (process.env.EIGENT_WORKSPACE) + config.workspace = process.env.EIGENT_WORKSPACE; + + return config; +} + +export function saveConfig(config: Partial): void { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + const existing = loadConfig(); + const merged = { ...existing, ...config }; + fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n'); +} + +function ask( + rl: readline.Interface, + question: string, + defaultVal: string +): Promise { + return new Promise((resolve) => { + rl.question(`${question} [${defaultVal}]: `, (answer) => { + resolve(answer.trim() || defaultVal); + }); + }); +} + +export async function runConfigWizard(): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + const current = loadConfig(); + + console.log('\nEigent CLI Configuration\n'); + + const apiUrl = await ask(rl, 'Backend URL', current.apiUrl); + const apiKey = await ask(rl, 'API Key', current.apiKey || 'sk-...'); + const modelPlatform = await ask(rl, 'Model Platform', current.modelPlatform); + const modelType = await ask(rl, 'Model Type', current.modelType); + const email = await ask(rl, 'Email', current.email); + + saveConfig({ apiUrl, apiKey, modelPlatform, modelType, email }); + console.log(`\nConfig saved to ${CONFIG_FILE}\n`); + rl.close(); +} diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 000000000..a80f3bd18 --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,155 @@ +#!/usr/bin/env node +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import chalk from 'chalk'; +import { Command } from 'commander'; +import { + addCollapsibleBlock, + clearBlocks, + renderCollapsed, + shouldCollapse, +} from './collapsible.js'; +import { loadConfig, runConfigWizard } from './config.js'; +import { startRepl } from './repl.js'; +import { TaskTimer } from './timer.js'; + +const program = new Command(); + +program + .name('eigent') + .description('Eigent AI coding agent CLI') + .version('0.1.0'); + +program + .command('config') + .description('Configure API key, model, and backend URL') + .action(async () => { + await runConfigWizard(); + }); + +program + .argument( + '[question]', + 'Question to ask (starts interactive mode if omitted)' + ) + .option('--api-url ', 'Backend URL') + .option('--api-key ', 'API key') + .option('--model ', 'Model type (e.g. gpt-4o)') + .option('--platform ', 'Model platform (e.g. openai)') + .action( + async (question: string | undefined, opts: Record) => { + const config = loadConfig(); + + // CLI flags override config + if (opts.apiUrl) config.apiUrl = opts.apiUrl; + if (opts.apiKey) config.apiKey = opts.apiKey; + if (opts.model) config.modelType = opts.model; + if (opts.platform) config.modelPlatform = opts.platform; + + if (!config.apiKey) { + console.log('No API key configured. Run: eigent config'); + process.exit(1); + } + + if (question) { + // One-shot mode: send question, print result, exit + const { EigentClient } = await import('./client.js'); + const { renderEvent } = await import('./renderer.js'); + const client = new EigentClient(config); + + const healthy = await client.checkHealth(); + if (!healthy) { + console.error(`Cannot connect to backend at ${config.apiUrl}`); + process.exit(1); + } + + // Styled user input echo + console.log(chalk.bold.cyan('\u276F ') + chalk.bold(question)); + console.log(); + + const timer = new TaskTimer(); + timer.start(); + clearBlocks(); + + const { AgentStep } = await import('./types.js'); + let hasConfirmed = false; + for await (const event of client.startChat( + question, + config.workspace + )) { + const rendered = renderEvent(event); + if (rendered.output) { + if (rendered.raw) { + process.stdout.write(rendered.output); + } else if ( + rendered.collapsible && + shouldCollapse(rendered.output) + ) { + const block = addCollapsibleBlock(rendered.output); + console.log(renderCollapsed(block)); + } else { + console.log(rendered.output); + } + } + + // Auto-confirm task plan to start execution (only once) + if (event.step === AgentStep.TO_SUB_TASKS && !hasConfirmed) { + const subTasks = event.data.sub_tasks || event.data.task || []; + const tasks = flattenTasks(subTasks); + if (tasks.length > 0) { + hasConfirmed = true; + try { + await client.confirmAndStartTask(tasks); + console.log( + chalk.green('> Auto-confirmed task plan, starting...') + ); + } catch (err: any) { + console.error( + `Failed to auto-start: ${(err as Error).message}` + ); + } + } + } + + if (rendered.isEnd || rendered.isWaitConfirm) { + client.abort(); + break; + } + } + + console.log(chalk.dim(`\n\u2733 Completed in ${timer.format()}`)); + } else { + // Interactive REPL mode + await startRepl(config); + } + } + ); + +/** Flatten a tree of sub_tasks into a flat list of {id, content}. */ +function flattenTasks(tasks: any[]): { id: string; content: string }[] { + const result: { id: string; content: string }[] = []; + for (const t of tasks) { + result.push({ + id: t.id || String(result.length + 1), + content: t.content || String(t), + }); + if (Array.isArray(t.subtasks) && t.subtasks.length > 0) { + result.push(...flattenTasks(t.subtasks)); + } + } + return result; +} + +program.parse(); diff --git a/cli/src/renderer.ts b/cli/src/renderer.ts new file mode 100644 index 000000000..056f18f47 --- /dev/null +++ b/cli/src/renderer.ts @@ -0,0 +1,163 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import chalk from 'chalk'; +import { Marked } from 'marked'; +import { markedTerminal } from 'marked-terminal'; +import { AgentStep, type SSEEvent } from './types.js'; + +const marked = new Marked(markedTerminal() as any); + +function renderMarkdown(text: string): string { + try { + return (marked.parse(text) as string).trimEnd(); + } catch { + return text; + } +} + +function truncate(text: string, maxLen: number = 200): string { + if (text.length <= maxLen) return text; + return text.slice(0, maxLen) + '...'; +} + +export interface RenderResult { + output: string | null; + /** If true, write output with process.stdout.write (no trailing newline) */ + raw: boolean; + /** If true, output may be long and should be collapsed if over threshold */ + collapsible: boolean; + needsHumanReply: { agent: string; question: string } | null; + isEnd: boolean; + isWaitConfirm: boolean; +} + +export function renderEvent(event: SSEEvent): RenderResult { + const result: RenderResult = { + output: null, + raw: false, + collapsible: false, + needsHumanReply: null, + isEnd: false, + isWaitConfirm: false, + }; + + const { step, data } = event; + + switch (step) { + // --- Visible events (user-facing) --- + + case AgentStep.CONFIRMED: + result.output = chalk.dim('> Processing...'); + break; + + case AgentStep.TASK_STATE: { + const state = data.state || ''; + const taskId = data.task_id || ''; + const stateIcon = + state === 'completed' ? 'āœ“' : state === 'failed' ? 'āœ—' : 'ā—'; + const stateColor = + state === 'completed' + ? chalk.green + : state === 'failed' + ? chalk.red + : chalk.yellow; + result.output = stateColor(`[Task ${taskId}] ${stateIcon} ${state}`); + if (data.result) { + result.output += '\n' + renderMarkdown(String(data.result)); + result.collapsible = true; + } + break; + } + + case AgentStep.WRITE_FILE: + result.output = chalk.magenta( + `[File] Wrote ${data.path || data.file_path || 'unknown'}` + ); + break; + + case AgentStep.ASK: + result.output = chalk.cyan( + `\n[Question from ${data.agent || 'agent'}]\n${data.question || data.content || ''}` + ); + result.needsHumanReply = { + agent: data.agent || '', + question: data.question || data.content || '', + }; + break; + + case AgentStep.WAIT_CONFIRM: { + const content = data.content || ''; + if (content) { + result.output = '\n' + renderMarkdown(content); + result.collapsible = true; + } + result.isWaitConfirm = true; + break; + } + + case AgentStep.END: { + const endMsg = + typeof data === 'string' ? data : data.message || data.result || ''; + const summaryMatch = String(endMsg).match(/(.*?)<\/summary>/s); + const summary = summaryMatch ? summaryMatch[1] : ''; + result.output = chalk.green('\nāœ“ Task completed'); + if (summary) { + result.output += '\n' + chalk.dim(summary); + } + result.isEnd = true; + break; + } + + case AgentStep.ERROR: + result.output = chalk.red( + `\n[Error] ${data.message || JSON.stringify(data)}` + ); + break; + + case AgentStep.BUDGET_NOT_ENOUGH: + result.output = chalk.red( + '\n[Warning] Budget/credits exhausted. Task paused.' + ); + break; + + case AgentStep.CONTEXT_TOO_LONG: + result.output = chalk.red( + '\n[Warning] Context too long. Please start a new conversation.' + ); + break; + + // --- Silent events (hidden from output) --- + + case AgentStep.DECOMPOSE_TEXT: + case AgentStep.TO_SUB_TASKS: + case AgentStep.CREATE_AGENT: + case AgentStep.ACTIVATE_AGENT: + case AgentStep.DEACTIVATE_AGENT: + case AgentStep.ACTIVATE_TOOLKIT: + case AgentStep.DEACTIVATE_TOOLKIT: + case AgentStep.ASSIGN_TASK: + case AgentStep.NEW_TASK_STATE: + case AgentStep.TERMINAL: + case AgentStep.NOTICE: + case AgentStep.SYNC: + // Hidden — only final results matter + break; + + default: + break; + } + + return result; +} diff --git a/cli/src/repl.ts b/cli/src/repl.ts new file mode 100644 index 000000000..7ffaf12a0 --- /dev/null +++ b/cli/src/repl.ts @@ -0,0 +1,309 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import chalk from 'chalk'; +import fs from 'node:fs'; +import path from 'node:path'; +import readline from 'node:readline'; +import { EigentClient } from './client.js'; +import { + addCollapsibleBlock, + clearBlocks, + expandBlock, + getLastCollapsedBlock, + renderCollapsed, + shouldCollapse, +} from './collapsible.js'; +import { renderEvent } from './renderer.js'; +import { TaskTimer } from './timer.js'; +import type { CliConfig, SSEEvent } from './types.js'; +import { AgentStep } from './types.js'; + +const PROMPT = chalk.bold.cyan('\u276F '); + +function showPrompt(rl: readline.Interface): void { + const cols = process.stdout.columns || 80; + console.log(chalk.dim('\u2500'.repeat(Math.min(cols, 80)))); + rl.prompt(); +} + +function shortenPath(p: string): string { + const home = process.env.HOME || process.env.USERPROFILE || ''; + if (home && p.startsWith(home)) return '~' + p.slice(home.length); + return p; +} + +export async function startRepl(config: CliConfig): Promise { + const client = new EigentClient(config); + let isStreaming = false; + let workspace = config.workspace; + // Persistent SSE stream — stays alive across turns + let stream: AsyncGenerator | null = null; + + // Check backend health + const healthy = await client.checkHealth(); + if (!healthy) { + console.log(chalk.red(`Cannot connect to backend at ${config.apiUrl}`)); + console.log(chalk.dim('Make sure the eigent backend is running.')); + process.exit(1); + } + + console.log(); + console.log( + chalk.bold.cyan(' ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—') + ); + console.log( + chalk.bold.cyan(' ā–ˆā–ˆā•”ā•ā•ā•ā•ā•ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā•ā•ā• ā–ˆā–ˆā•”ā•ā•ā•ā•ā•ā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•‘ā•šā•ā•ā–ˆā–ˆā•”ā•ā•ā•') + ); + console.log( + chalk.bold.cyan(' ā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•‘ā–ˆā–ˆā•‘ ā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•”ā–ˆā–ˆā•— ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘') + ); + console.log( + chalk.bold.cyan(' ā–ˆā–ˆā•”ā•ā•ā• ā–ˆā–ˆā•‘ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā• ā–ˆā–ˆā•‘ā•šā–ˆā–ˆā•—ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘') + ); + console.log( + chalk.bold.cyan(' ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā•‘ā•šā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•”ā•ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā•‘ ā•šā–ˆā–ˆā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘') + ); + console.log( + chalk.bold.cyan(' ā•šā•ā•ā•ā•ā•ā•ā•ā•šā•ā• ā•šā•ā•ā•ā•ā•ā• ā•šā•ā•ā•ā•ā•ā•ā•ā•šā•ā• ā•šā•ā•ā•ā• ā•šā•ā•') + ); + console.log(); + console.log( + chalk.dim( + ` v0.1.0 | ${config.modelPlatform}/${config.modelType} | ${config.apiUrl}` + ) + ); + console.log(chalk.dim(` workspace: ${workspace}`)); + console.log( + chalk.dim(' /quit, /new, /project, /workspace , Ctrl+C to stop') + ); + console.log(); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: PROMPT, + }); + + // Keypress handler for Ctrl+E (expand collapsed output) + process.stdin.on('keypress', (_str: string, key: readline.Key) => { + if (!isStreaming && key && key.ctrl && key.name === 'e') { + const block = getLastCollapsedBlock(); + if (block) { + console.log(chalk.dim('\n--- Expanded output ---')); + console.log(expandBlock(block)); + console.log(chalk.dim('--- End expanded output ---')); + showPrompt(rl); + } + } + }); + + // Handle Ctrl+C + rl.on('SIGINT', async () => { + if (isStreaming) { + console.log(chalk.yellow('\nStopping task...')); + await client.skipTask(); + stream = null; + isStreaming = false; + showPrompt(rl); + } else { + console.log(chalk.dim('\nGoodbye!')); + client.abort(); + rl.close(); + process.exit(0); + } + }); + + async function handleMessage(input: string): Promise { + const trimmed = input.trim(); + if (!trimmed) { + showPrompt(rl); + return; + } + + // Slash commands + if (trimmed === '/quit' || trimmed === '/exit') { + console.log(chalk.dim('Goodbye!')); + client.abort(); + rl.close(); + process.exit(0); + } + if (trimmed === '/stop') { + await client.stopChat(); + stream = null; + console.log(chalk.yellow('Task stopped.')); + showPrompt(rl); + return; + } + if (trimmed === '/new') { + client.abort(); + stream = null; + client.newSession(); + console.log(chalk.dim('Starting new conversation.')); + showPrompt(rl); + return; + } + if (trimmed === '/project') { + console.log(chalk.dim(`Current project: ${client.getProjectId()}`)); + showPrompt(rl); + return; + } + if (trimmed === '/workspace') { + console.log(chalk.dim(`Current workspace: ${workspace}`)); + showPrompt(rl); + return; + } + if (trimmed.startsWith('/workspace ')) { + const newPath = trimmed.slice('/workspace '.length).trim(); + if (!newPath) { + console.log(chalk.dim(`Current workspace: ${workspace}`)); + } else { + const expanded = newPath.startsWith('~/') + ? path.join(process.env.HOME || '', newPath.slice(1)) + : newPath === '~' + ? process.env.HOME || '' + : newPath; + const resolved = path.resolve(expanded); + if (fs.existsSync(resolved)) { + workspace = resolved; + console.log(chalk.green(`Workspace set to: ${workspace}`)); + } else { + console.log(chalk.red(`Path does not exist: ${resolved}`)); + } + } + showPrompt(rl); + return; + } + + // Overwrite readline echo with styled user input + process.stdout.write('\x1B[1A\x1B[2K'); + console.log(chalk.bold.cyan('\u276F ') + chalk.bold(trimmed)); + console.log(); + + const timer = new TaskTimer(); + timer.start(); + clearBlocks(); + + isStreaming = true; + let hasConfirmed = false; + + try { + if (!stream) { + // First message — open persistent SSE connection + stream = client.startChat(trimmed, workspace); + } else { + // Follow-up — POST to improve endpoint, events come through existing stream + await client.improveChat(trimmed); + } + + // Consume events using manual .next() — for-await-of would close the + // generator on break, making it impossible to resume for follow-ups. + while (true) { + const { value: event, done } = await stream.next(); + if (done || !event) break; + + const rendered = renderEvent(event); + + if (rendered.output) { + if (rendered.raw) { + process.stdout.write(rendered.output); + } else if (rendered.collapsible && shouldCollapse(rendered.output)) { + const block = addCollapsibleBlock(rendered.output); + console.log(renderCollapsed(block)); + } else { + console.log(rendered.output); + } + } + + // Auto-confirm task plan to start execution (only once per turn) + if (event.step === AgentStep.TO_SUB_TASKS && !hasConfirmed) { + const subTasks = event.data.sub_tasks || event.data.task || []; + const tasks = flattenTasks(subTasks); + if (tasks.length > 0) { + hasConfirmed = true; + try { + await client.confirmAndStartTask(tasks); + console.log( + chalk.green('> Auto-confirmed task plan, starting...') + ); + } catch (err: any) { + console.log(chalk.red(`Failed to auto-start: ${err.message}`)); + } + } + } + + // Handle HITL: agent asking a question + if (rendered.needsHumanReply) { + const reply = await askUser(rl, chalk.cyan('Your reply: ')); + await client.humanReply(rendered.needsHumanReply.agent, reply); + } + + if (rendered.isWaitConfirm) { + // Stop consuming, but DON'T close — stream stays alive for follow-ups + break; + } + if (rendered.isEnd) { + // Conversation over — tear down stream, reset for next conversation + client.abort(); + stream = null; + client.newSession(); + break; + } + } + } catch (err: any) { + if (err.name !== 'AbortError') { + console.log(chalk.red(`Error: ${err.message}`)); + } + // Stream is broken — reset + stream = null; + } + + isStreaming = false; + console.log(chalk.dim(`\n\u2733 Completed in ${timer.format()}`)); + showPrompt(rl); + } + + rl.on('line', (input) => { + handleMessage(input).catch((err) => { + console.error(chalk.red(`Unexpected error: ${err.message}`)); + stream = null; + showPrompt(rl); + }); + }); + + showPrompt(rl); +} + +function askUser(rl: readline.Interface, prompt: string): Promise { + return new Promise((resolve) => { + rl.question(prompt, (answer) => { + resolve(answer.trim()); + }); + }); +} + +/** Flatten a tree of sub_tasks into a flat list of {id, content}. */ +function flattenTasks(tasks: any[]): { id: string; content: string }[] { + const result: { id: string; content: string }[] = []; + for (const t of tasks) { + result.push({ + id: t.id || String(result.length + 1), + content: t.content || String(t), + }); + if (Array.isArray(t.subtasks) && t.subtasks.length > 0) { + result.push(...flattenTasks(t.subtasks)); + } + } + return result; +} diff --git a/cli/src/timer.ts b/cli/src/timer.ts new file mode 100644 index 000000000..17c7a97da --- /dev/null +++ b/cli/src/timer.ts @@ -0,0 +1,35 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +export class TaskTimer { + private startTime = 0; + + start(): void { + this.startTime = Date.now(); + } + + elapsed(): number { + return Date.now() - this.startTime; + } + + format(): string { + const totalSeconds = Math.floor(this.elapsed() / 1000); + if (totalSeconds < 60) { + return `${totalSeconds}s`; + } + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}m ${seconds}s`; + } +} diff --git a/cli/src/types.ts b/cli/src/types.ts new file mode 100644 index 000000000..56eb5c81a --- /dev/null +++ b/cli/src/types.ts @@ -0,0 +1,77 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +// SSE event step types (matches backend sse_json step values) +export const AgentStep = { + CONFIRMED: 'confirmed', + NEW_TASK_STATE: 'new_task_state', + END: 'end', + WAIT_CONFIRM: 'wait_confirm', + DECOMPOSE_TEXT: 'decompose_text', + TO_SUB_TASKS: 'to_sub_tasks', + CREATE_AGENT: 'create_agent', + TASK_STATE: 'task_state', + ACTIVATE_AGENT: 'activate_agent', + DEACTIVATE_AGENT: 'deactivate_agent', + ASSIGN_TASK: 'assign_task', + ACTIVATE_TOOLKIT: 'activate_toolkit', + DEACTIVATE_TOOLKIT: 'deactivate_toolkit', + TERMINAL: 'terminal', + WRITE_FILE: 'write_file', + BUDGET_NOT_ENOUGH: 'budget_not_enough', + CONTEXT_TOO_LONG: 'context_too_long', + ERROR: 'error', + ADD_TASK: 'add_task', + REMOVE_TASK: 'remove_task', + NOTICE: 'notice', + ASK: 'ask', + SYNC: 'sync', +} as const; + +export type AgentStepType = (typeof AgentStep)[keyof typeof AgentStep]; + +export interface SSEEvent { + step: AgentStepType; + data: Record; +} + +export interface CliConfig { + apiUrl: string; + apiKey: string; + modelPlatform: string; + modelType: string; + email: string; + workspace: string; +} + +export interface ChatPayload { + project_id: string; + task_id: string; + question: string; + email: string; + model_platform: string; + model_type: string; + api_key: string; + api_url: string | null; + language: string; + allow_local_system: boolean; + attaches: string[]; + browser_port: number; + installed_mcp: { mcpServers: Record }; + env_path: string | null; + search_config: Record | null; + new_agents: never[]; + summary_prompt: string; + workspace: string | null; +} diff --git a/cli/src/vendor.d.ts b/cli/src/vendor.d.ts new file mode 100644 index 000000000..e1110f309 --- /dev/null +++ b/cli/src/vendor.d.ts @@ -0,0 +1,20 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +declare module 'marked-terminal' { + import type { MarkedExtension } from 'marked'; + export function markedTerminal( + options?: Record + ): MarkedExtension; +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 000000000..486fed81c --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src/**/*"] +} From 310391049cbcdd6ab91fe3adb580179d8ea1bbd3 Mon Sep 17 00:00:00 2001 From: bytecii Date: Mon, 9 Mar 2026 20:57:23 -0700 Subject: [PATCH 2/6] feat: support cli --- CONTRIBUTING.md | 45 ++++----------------------------------------- 1 file changed, 4 insertions(+), 41 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a50049bf2..69dea99b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -259,57 +259,20 @@ Our aim is to make the developer setup as straightforward as possible. If you en ## Quick Start šŸš€ -### 1. Frontend Setup +### Local Development ```bash git clone https://github.com/eigent-ai/eigent.git cd eigent npm install -``` - -### 2. Backend Setup - -```bash -cd server - -# Start Docker services (PostgreSQL and Redis) -docker compose -f docker-compose.dev.yml up -d - -# Copy and configure environment variables -cp .env.example .env -# Edit .env and uncomment/set the following: -# database_url=postgresql://postgres:123456@localhost:5432/eigent -# redis_url=redis://localhost:6379/0 -``` - -### 3. Run Database Migrations - -```bash -cd server -uv run alembic upgrade head -``` - -### 4. Start the Backend Server - -```bash -cd server -uv run uvicorn main:api --host 0.0.0.0 --port 3001 --reload -``` - -### 5. Start the Frontend Dev Server - -```bash -# In a separate terminal, from the project root npm run dev ``` -### 6. Configure Frontend Proxy +Then go to settings to specify your model key and model type. -Configure `.env.development`: - - Set `VITE_USE_LOCAL_PROXY=true` - - Set `VITE_PROXY_URL=http://localhost:3001` +### CLI -Then go to the settings to specify your model key and model type. +See [cli/README.md](cli/README.md) for CLI installation and usage. ## Common Actions šŸ”„ From b3ce01104e4a5c72ebd59904c72c2af3d16ab417 Mon Sep 17 00:00:00 2001 From: bytecii Date: Mon, 9 Mar 2026 22:08:58 -0700 Subject: [PATCH 3/6] update --- cli/.env.example | 6 ++++-- cli/src/client.ts | 2 +- cli/src/config.ts | 3 +++ cli/src/types.ts | 1 + 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/cli/.env.example b/cli/.env.example index b64debf7b..9b0bdcaa9 100644 --- a/cli/.env.example +++ b/cli/.env.example @@ -1,5 +1,7 @@ EIGENT_API_URL=http://localhost:5001 EIGENT_API_KEY=sk-... -EIGENT_MODEL_PLATFORM=anthropic -EIGENT_MODEL_TYPE=claude-sonnet-4-20250514 +EIGENT_MODEL_PLATFORM=openai +EIGENT_MODEL_TYPE=gpt-4o EIGENT_EMAIL=cli@eigent.ai +# Optional: custom OpenAI-compatible endpoint +# EIGENT_API_ENDPOINT=https://api.openai.com/v1 diff --git a/cli/src/client.ts b/cli/src/client.ts index 0ef19288c..d4c3e28c4 100644 --- a/cli/src/client.ts +++ b/cli/src/client.ts @@ -58,7 +58,7 @@ export class EigentClient { model_platform: this.config.modelPlatform, model_type: this.config.modelType, api_key: this.config.apiKey, - api_url: null, + api_url: this.config.apiEndpoint || null, language: 'en', allow_local_system: true, attaches: [], diff --git a/cli/src/config.ts b/cli/src/config.ts index bec0fd481..5dd9ecd6d 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -29,6 +29,7 @@ const DEFAULTS: CliConfig = { modelType: 'gpt-4o', email: 'cli@eigent.ai', workspace: process.cwd(), + apiEndpoint: null, }; /** @@ -72,6 +73,8 @@ export function loadConfig(): CliConfig { if (process.env.EIGENT_EMAIL) config.email = process.env.EIGENT_EMAIL; if (process.env.EIGENT_WORKSPACE) config.workspace = process.env.EIGENT_WORKSPACE; + if (process.env.EIGENT_API_ENDPOINT) + config.apiEndpoint = process.env.EIGENT_API_ENDPOINT; return config; } diff --git a/cli/src/types.ts b/cli/src/types.ts index 56eb5c81a..0f973d88f 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -53,6 +53,7 @@ export interface CliConfig { modelType: string; email: string; workspace: string; + apiEndpoint: string | null; } export interface ChatPayload { From a20acaba6bae10f1ba73fd0592f7109bb908bc88 Mon Sep 17 00:00:00 2001 From: bytecii Date: Mon, 9 Mar 2026 22:14:46 -0700 Subject: [PATCH 4/6] update --- cli/src/repl.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cli/src/repl.ts b/cli/src/repl.ts index 7ffaf12a0..67b721681 100644 --- a/cli/src/repl.ts +++ b/cli/src/repl.ts @@ -150,7 +150,7 @@ export async function startRepl(config: CliConfig): Promise { client.abort(); stream = null; client.newSession(); - console.log(chalk.dim('Starting new conversation.')); + console.log(chalk.dim('Starting new project.')); showPrompt(rl); return; } @@ -189,7 +189,6 @@ export async function startRepl(config: CliConfig): Promise { // Overwrite readline echo with styled user input process.stdout.write('\x1B[1A\x1B[2K'); console.log(chalk.bold.cyan('\u276F ') + chalk.bold(trimmed)); - console.log(); const timer = new TaskTimer(); timer.start(); From a5175eb7b08d0cf92d0758466ea38d654a32a209 Mon Sep 17 00:00:00 2001 From: bytecii Date: Tue, 10 Mar 2026 00:06:00 -0700 Subject: [PATCH 5/6] update --- cli/src/repl.ts | 315 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 279 insertions(+), 36 deletions(-) diff --git a/cli/src/repl.ts b/cli/src/repl.ts index 67b721681..d20b00054 100644 --- a/cli/src/repl.ts +++ b/cli/src/repl.ts @@ -31,6 +31,16 @@ import type { CliConfig, SSEEvent } from './types.js'; import { AgentStep } from './types.js'; const PROMPT = chalk.bold.cyan('\u276F '); +const INPUT_BG = chalk.bgHex('#2a2a3a'); +const SPINNER_FRAMES = ['ā ‹', 'ā ™', 'ā ¹', 'ā ø', 'ā ¼', 'ā “', 'ā ¦', 'ā §', 'ā ‡', 'ā ']; + +const SLASH_COMMANDS = [ + { name: '/new', description: 'Start a new project' }, + { name: '/workspace', description: 'Show or set workspace path' }, + { name: '/project', description: 'Show current project ID' }, + { name: '/stop', description: 'Stop the current task' }, + { name: '/quit', description: 'Exit the CLI' }, +]; function showPrompt(rl: readline.Interface): void { const cols = process.stdout.columns || 80; @@ -38,6 +48,17 @@ function showPrompt(rl: readline.Interface): void { rl.prompt(); } +/** Overwrite readline echo with styled input (background color strip). */ +function echoInput(text: string): void { + const cols = process.stdout.columns || 80; + const prefix = '\u276F '; + const padding = Math.max(0, cols - prefix.length - text.length); + process.stdout.write('\x1B[1A\x1B[2K'); + console.log( + INPUT_BG(chalk.bold.cyan(prefix) + chalk.bold(text) + ' '.repeat(padding)) + ); +} + function shortenPath(p: string): string { const home = process.env.HOME || process.env.USERPROFILE || ''; if (home && p.startsWith(home)) return '~' + p.slice(home.length); @@ -59,36 +80,40 @@ export async function startRepl(config: CliConfig): Promise { process.exit(1); } - console.log(); - console.log( - chalk.bold.cyan(' ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—') - ); - console.log( - chalk.bold.cyan(' ā–ˆā–ˆā•”ā•ā•ā•ā•ā•ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā•ā•ā• ā–ˆā–ˆā•”ā•ā•ā•ā•ā•ā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•‘ā•šā•ā•ā–ˆā–ˆā•”ā•ā•ā•') - ); - console.log( - chalk.bold.cyan(' ā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•‘ā–ˆā–ˆā•‘ ā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•”ā–ˆā–ˆā•— ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘') - ); - console.log( - chalk.bold.cyan(' ā–ˆā–ˆā•”ā•ā•ā• ā–ˆā–ˆā•‘ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā• ā–ˆā–ˆā•‘ā•šā–ˆā–ˆā•—ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘') - ); - console.log( - chalk.bold.cyan(' ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā•‘ā•šā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•”ā•ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā•‘ ā•šā–ˆā–ˆā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘') - ); - console.log( - chalk.bold.cyan(' ā•šā•ā•ā•ā•ā•ā•ā•ā•šā•ā• ā•šā•ā•ā•ā•ā•ā• ā•šā•ā•ā•ā•ā•ā•ā•ā•šā•ā• ā•šā•ā•ā•ā• ā•šā•ā•') - ); - console.log(); - console.log( - chalk.dim( - ` v0.1.0 | ${config.modelPlatform}/${config.modelType} | ${config.apiUrl}` - ) - ); - console.log(chalk.dim(` workspace: ${workspace}`)); - console.log( - chalk.dim(' /quit, /new, /project, /workspace , Ctrl+C to stop') - ); - console.log(); + function showBanner(): void { + console.log(); + console.log( + chalk.bold.cyan(' ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—') + ); + console.log( + chalk.bold.cyan(' ā–ˆā–ˆā•”ā•ā•ā•ā•ā•ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā•ā•ā• ā–ˆā–ˆā•”ā•ā•ā•ā•ā•ā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•‘ā•šā•ā•ā–ˆā–ˆā•”ā•ā•ā•') + ); + console.log( + chalk.bold.cyan(' ā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•‘ā–ˆā–ˆā•‘ ā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā–ˆā–ˆā–ˆā•— ā–ˆā–ˆā•”ā–ˆā–ˆā•— ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘') + ); + console.log( + chalk.bold.cyan(' ā–ˆā–ˆā•”ā•ā•ā• ā–ˆā–ˆā•‘ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘ā–ˆā–ˆā•”ā•ā•ā• ā–ˆā–ˆā•‘ā•šā–ˆā–ˆā•—ā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘') + ); + console.log( + chalk.bold.cyan(' ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā•‘ā•šā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•”ā•ā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā–ˆā•—ā–ˆā–ˆā•‘ ā•šā–ˆā–ˆā–ˆā–ˆā•‘ ā–ˆā–ˆā•‘') + ); + console.log( + chalk.bold.cyan(' ā•šā•ā•ā•ā•ā•ā•ā•ā•šā•ā• ā•šā•ā•ā•ā•ā•ā• ā•šā•ā•ā•ā•ā•ā•ā•ā•šā•ā• ā•šā•ā•ā•ā• ā•šā•ā•') + ); + console.log(); + console.log( + chalk.dim( + ` v0.1.0 | ${config.modelPlatform}/${config.modelType} | ${config.apiUrl}` + ) + ); + console.log(chalk.dim(` workspace: ${workspace}`)); + console.log( + chalk.dim(' /quit, /new, /project, /workspace , Ctrl+C to stop') + ); + console.log(); + } + + showBanner(); const rl = readline.createInterface({ input: process.stdin, @@ -96,7 +121,8 @@ export async function startRepl(config: CliConfig): Promise { prompt: PROMPT, }); - // Keypress handler for Ctrl+E (expand collapsed output) + // Keypress handler for Ctrl+E and "/" command picker trigger + let pickerActive = false; process.stdin.on('keypress', (_str: string, key: readline.Key) => { if (!isStreaming && key && key.ctrl && key.name === 'e') { const block = getLastCollapsedBlock(); @@ -107,6 +133,52 @@ export async function startRepl(config: CliConfig): Promise { showPrompt(rl); } } + + // "/" typed on empty line → immediately open command picker + const currentLine = (rl as any).line as string | undefined; + if (_str === '/' && currentLine === '/' && !pickerActive && !isStreaming) { + pickerActive = true; + + // Overwrite readline's echoed "āÆ /" with styled version + process.stdout.write('\r\x1B[2K'); + const cols = process.stdout.columns || 80; + const pfx = '\u276F '; + const pad = Math.max(0, cols - pfx.length - 1); + console.log( + INPUT_BG(chalk.bold.cyan(pfx) + chalk.bold('/') + ' '.repeat(pad)) + ); + + // Open picker (it handles stdin takeover internally) + pickCommand('') + .then(async (picked) => { + pickerActive = false; + // Clear readline's internal buffer so it doesn't have stale "/" + (rl as any).line = ''; + (rl as any).cursor = 0; + + if (picked) { + // Overwrite the "āÆ /" line with the picked command + process.stdout.write('\x1B[1A\x1B[2K'); + const pad2 = Math.max(0, cols - pfx.length - picked.length); + console.log( + INPUT_BG( + chalk.bold.cyan(pfx) + chalk.bold(picked) + ' '.repeat(pad2) + ) + ); + await handleMessage(picked, true); + } else { + // Cancelled — erase the styled "/" line, show clean prompt + process.stdout.write('\x1B[1A\x1B[2K'); + showPrompt(rl); + } + }) + .catch(() => { + pickerActive = false; + (rl as any).line = ''; + (rl as any).cursor = 0; + showPrompt(rl); + }); + } }); // Handle Ctrl+C @@ -125,13 +197,164 @@ export async function startRepl(config: CliConfig): Promise { } }); - async function handleMessage(input: string): Promise { + /** + * Interactive command picker using raw stdin data events. + * Bypasses readline/keypress entirely — parses ANSI escape sequences + * from raw bytes for maximum reliability. + */ + function pickCommand(initialFilter: string): Promise { + return new Promise((resolve) => { + let selected = 0; + let filter = initialFilter; + let lastDrawn = 0; + let resolved = false; + + function getFiltered() { + return SLASH_COMMANDS.filter((c) => c.name.startsWith('/' + filter)); + } + + function drawMenu(): void { + if (lastDrawn > 0) { + process.stdout.write(`\x1B[${lastDrawn}A\x1B[J`); + } + const filtered = getFiltered(); + if (filtered.length === 0) { + lastDrawn = 0; + } else { + for (let i = 0; i < filtered.length; i++) { + const cmd = filtered[i]; + if (i === selected) { + console.log( + ` ${chalk.cyan('\u25B8')} ${chalk.cyan.bold(cmd.name.padEnd(13))}${cmd.description}` + ); + } else { + console.log( + ` ${chalk.dim(cmd.name.padEnd(13))}${chalk.dim(cmd.description)}` + ); + } + } + lastDrawn = filtered.length; + } + } + + function updateInputLine(): void { + const cols = process.stdout.columns || 80; + const text = '/' + filter; + const pfx = '\u276F '; + const pad = Math.max(0, cols - pfx.length - text.length); + // Move up past menu + input line, clear everything below + process.stdout.write(`\x1B[${lastDrawn + 1}A\x1B[J`); + console.log( + INPUT_BG(chalk.bold.cyan(pfx) + chalk.bold(text) + ' '.repeat(pad)) + ); + lastDrawn = 0; + drawMenu(); + } + + function finish(result: string | null): void { + if (resolved) return; + resolved = true; + process.stdin.removeListener('data', onData); + if (lastDrawn > 0) { + process.stdout.write(`\x1B[${lastDrawn}A\x1B[J`); + } + // Restore all saved listeners and resume readline + for (const fn of savedDataListeners) { + process.stdin.on('data', fn as (...args: any[]) => void); + } + for (const fn of savedKpListeners) { + process.stdin.on('keypress', fn as (...args: any[]) => void); + } + rl.resume(); + resolve(result); + } + + function onData(buf: Buffer): void { + const s = buf.toString(); + const filtered = getFiltered(); + + if (s === '\x1B[A' || s === '\x1BOA') { + // Arrow up + if (filtered.length > 0) { + selected = (selected - 1 + filtered.length) % filtered.length; + drawMenu(); + } + } else if (s === '\x1B[B' || s === '\x1BOB') { + // Arrow down + if (filtered.length > 0) { + selected = (selected + 1) % filtered.length; + drawMenu(); + } + } else if (s === '\r' || s === '\n') { + // Enter + if (filtered.length > 0 && selected < filtered.length) { + finish(filtered[selected].name); + } else if (filter.length > 0) { + // No match — return typed text as plain input + finish('/' + filter); + } else { + finish(null); + } + } else if (s === '\x1B') { + // Escape + finish(null); + } else if (s === '\x03') { + // Ctrl+C + finish(null); + } else if (s === '\x7F' || s === '\b') { + // Backspace + if (filter.length > 0) { + filter = filter.slice(0, -1); + selected = 0; + updateInputLine(); + } else { + finish(null); + } + } else if (s.length === 1 && s >= ' ') { + // Regular character — type to filter + filter += s; + selected = 0; + updateInputLine(); + } + } + + // Save and remove ALL data + keypress listeners to prevent + // emitKeypressEvents interference with our raw data handler + const savedKpListeners = process.stdin.rawListeners('keypress').slice(); + const savedDataListeners = process.stdin.rawListeners('data').slice(); + process.stdin.removeAllListeners('keypress'); + process.stdin.removeAllListeners('data'); + + // Pause readline (releases stdin), then take raw control + rl.pause(); + if (process.stdin.isTTY && process.stdin.setRawMode) { + process.stdin.setRawMode(true); + } + + // Register handler before resume to avoid missing events + process.stdin.on('data', onData); + process.stdin.resume(); + + // Draw initial menu + drawMenu(); + }); + } + + async function handleMessage( + input: string, + fromPicker = false + ): Promise { const trimmed = input.trim(); if (!trimmed) { showPrompt(rl); return; } + // Overwrite readline echo with styled input (bg color strip) + if (!fromPicker) { + echoInput(trimmed); + } + // Slash commands if (trimmed === '/quit' || trimmed === '/exit') { console.log(chalk.dim('Goodbye!')); @@ -150,6 +373,8 @@ export async function startRepl(config: CliConfig): Promise { client.abort(); stream = null; client.newSession(); + console.clear(); + showBanner(); console.log(chalk.dim('Starting new project.')); showPrompt(rl); return; @@ -186,10 +411,6 @@ export async function startRepl(config: CliConfig): Promise { return; } - // Overwrite readline echo with styled user input - process.stdout.write('\x1B[1A\x1B[2K'); - console.log(chalk.bold.cyan('\u276F ') + chalk.bold(trimmed)); - const timer = new TaskTimer(); timer.start(); clearBlocks(); @@ -197,6 +418,24 @@ export async function startRepl(config: CliConfig): Promise { isStreaming = true; let hasConfirmed = false; + // Spinner while waiting for first response + let spinnerIdx = 0; + const spinner = setInterval(() => { + const frame = chalk.cyan( + SPINNER_FRAMES[spinnerIdx % SPINNER_FRAMES.length] + ); + process.stdout.write(`\r\x1B[2K ${frame} ${chalk.dim('Thinking...')}`); + spinnerIdx++; + }, 80); + let spinnerCleared = false; + function clearSpinner(): void { + if (!spinnerCleared) { + spinnerCleared = true; + clearInterval(spinner); + process.stdout.write('\r\x1B[2K'); + } + } + try { if (!stream) { // First message — open persistent SSE connection @@ -212,6 +451,7 @@ export async function startRepl(config: CliConfig): Promise { const { value: event, done } = await stream.next(); if (done || !event) break; + clearSpinner(); const rendered = renderEvent(event); if (rendered.output) { @@ -261,6 +501,7 @@ export async function startRepl(config: CliConfig): Promise { } } } catch (err: any) { + clearSpinner(); if (err.name !== 'AbortError') { console.log(chalk.red(`Error: ${err.message}`)); } @@ -268,12 +509,14 @@ export async function startRepl(config: CliConfig): Promise { stream = null; } + clearSpinner(); isStreaming = false; console.log(chalk.dim(`\n\u2733 Completed in ${timer.format()}`)); showPrompt(rl); } rl.on('line', (input) => { + if (pickerActive) return; // Picker is handling input handleMessage(input).catch((err) => { console.error(chalk.red(`Unexpected error: ${err.message}`)); stream = null; From 15e9512e0412f3af7134440014dda8b3d708ea64 Mon Sep 17 00:00:00 2001 From: bytecii Date: Tue, 10 Mar 2026 00:17:06 -0700 Subject: [PATCH 6/6] update --- backend/app/controller/chat_controller.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/app/controller/chat_controller.py b/backend/app/controller/chat_controller.py index 99fe44089..e9fc62bbb 100644 --- a/backend/app/controller/chat_controller.py +++ b/backend/app/controller/chat_controller.py @@ -311,8 +311,10 @@ def improve(id: str, data: SupplementChat): if eigent_index + 1 < len(path_parts): current_email = path_parts[eigent_index + 1] - # If we have the necessary info, update - # the file_save_path + # Only rewrite file_save_path for web-app users (real email). + # CLI (dev mode only) sets email to "__workspace__" so files + # stay in the local workspace directory instead of the + # ~/eigent/{email}/... structure used by the web app. if current_email and current_email != "__workspace__" and id: # Create new path using the existing # pattern: email/project_{id}/task_{id}