From f95f41b1c4512228d3d141ddec97829c57b04fec Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 27 Nov 2025 12:47:11 +0100 Subject: [PATCH] Add CLI file arguments support via @file prefix Implements ability to include files directly in the initial message using @ prefix. Features: - All @file arguments are coalesced into the first user message - Text files wrapped in content tags - Images (.jpg, .jpeg, .png, .gif, .webp) attached as base64-encoded attachments - Supports ~ expansion, relative and absolute paths - Empty files are skipped silently - Non-existent files cause immediate error with clear message - Works in interactive, --print, and --mode text/json modes - Not supported in --mode rpc (errors with clear message) Examples: pi @prompt.md @image.png "Do this" pi --print @code.ts "Review this code" pi @requirements.md @design.png "Implement this" Closes #54 --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/README.md | 57 ++++++++++- packages/coding-agent/src/main.ts | 157 +++++++++++++++++++++++++++-- 3 files changed, 211 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e2d3c82d..9824a7ba 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **CLI File Arguments (`@file`)**: Include files in your initial message using the `@` prefix (e.g., `pi @prompt.md @image.png "Do this"`). All `@file` arguments are combined into the first message. Text files are wrapped in `content` tags. Images (`.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`) are attached as base64-encoded attachments. Supports `~` expansion, relative/absolute paths. Empty files are skipped. Works in interactive, `--print`, and `--mode text/json` modes. Not supported in `--mode rpc`. ([#54](https://github.com/badlogic/pi-mono/issues/54)) + ### Fixed - **Editor Cursor Navigation**: Fixed broken up/down arrow key navigation in the editor when lines wrap. Previously, pressing up/down would move between logical lines instead of visual (wrapped) lines, causing the cursor to jump unexpectedly. Now cursor navigation is based on rendered lines. Also fixed a bug where the cursor would appear on two lines simultaneously when positioned at a wrap boundary. Added word by word navigation via Option+Left/Right or Ctrl+Left/Right. ([#61](https://github.com/badlogic/pi-mono/pull/61)) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 8e1b3339..28e32990 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -650,9 +650,58 @@ pi --session /path/to/my-session.jsonl ## CLI Options ```bash -pi [options] [messages...] +pi [options] [@files...] [messages...] ``` +### File Arguments (`@file`) + +You can include files directly in your initial message using the `@` prefix: + +```bash +# Include a text file in your prompt +pi @prompt.md "Answer the question" + +# Include multiple files +pi @requirements.md @context.txt "Summarize these" + +# Include images (vision-capable models only) +pi @screenshot.png "What's in this image?" + +# Mix text and images +pi @prompt.md @diagram.png "Explain based on the diagram" + +# Files without additional text +pi @task.md +``` + +**How it works:** +- All `@file` arguments are combined into the first user message +- Text files are wrapped in `content` tags +- Images (`.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`) are attached as base64-encoded attachments +- Paths support `~` for home directory and relative/absolute paths +- Empty files are skipped +- Non-existent files cause an immediate error + +**Examples:** +```bash +# All files go into first message, regardless of position +pi @file1.md @file2.txt "prompt" @file3.md + +# This sends: +# Message 1: file1 + file2 + file3 + "prompt" +# (Any additional plain text arguments become separate messages) + +# Home directory expansion works +pi @~/Documents/notes.md "Summarize" + +# Combine with other options +pi --print @requirements.md "List the main points" +``` + +**Limitations:** +- Not supported in `--mode rpc` (will error) +- Images require vision-capable models (e.g., Claude, GPT-4o, Gemini) + ### Options **--provider ** @@ -727,9 +776,15 @@ pi # Interactive mode with initial prompt (stays running after completion) pi "List all .ts files in src/" +# Include files in your prompt +pi @requirements.md @design.png "Implement this feature" + # Non-interactive mode (process prompt and exit) pi -p "List all .ts files in src/" +# Non-interactive with files +pi -p @code.ts "Review this code for bugs" + # JSON mode - stream all agent events (non-interactive) pi --mode json "List all .ts files in src/" diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index b4e599cb..e9edeb29 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -1,10 +1,10 @@ -import { Agent, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent-core"; +import { Agent, type Attachment, ProviderTransport, type ThinkingLevel } from "@mariozechner/pi-agent-core"; import type { Api, KnownProvider, Model } from "@mariozechner/pi-ai"; import { ProcessTerminal, TUI } from "@mariozechner/pi-tui"; import chalk from "chalk"; -import { existsSync, readFileSync } from "fs"; +import { existsSync, readFileSync, statSync } from "fs"; import { homedir } from "os"; -import { dirname, join, resolve } from "path"; +import { dirname, extname, join, resolve } from "path"; import { fileURLToPath } from "url"; import { getChangelogPath, getNewEntries, parseChangelog } from "./changelog.js"; import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js"; @@ -49,11 +49,13 @@ interface Args { models?: string[]; print?: boolean; messages: string[]; + fileArgs: string[]; } function parseArgs(args: string[]): Args { const result: Args = { messages: [], + fileArgs: [], }; for (let i = 0; i < args.length; i++) { @@ -97,6 +99,8 @@ function parseArgs(args: string[]): Args { } } else if (arg === "--print" || arg === "-p") { result.print = true; + } else if (arg.startsWith("@")) { + result.fileArgs.push(arg.slice(1)); // Remove @ prefix } else if (!arg.startsWith("-")) { result.messages.push(arg); } @@ -105,11 +109,103 @@ function parseArgs(args: string[]): Args { return result; } +/** + * Map of file extensions to MIME types for common image formats + */ +const IMAGE_MIME_TYPES: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", +}; + +/** + * Check if a file is an image based on its extension + */ +function isImageFile(filePath: string): string | null { + const ext = extname(filePath).toLowerCase(); + return IMAGE_MIME_TYPES[ext] || null; +} + +/** + * Expand ~ to home directory + */ +function expandPath(filePath: string): string { + if (filePath === "~") { + return homedir(); + } + if (filePath.startsWith("~/")) { + return homedir() + filePath.slice(1); + } + return filePath; +} + +/** + * Process @file arguments into text content and image attachments + */ +function processFileArguments(fileArgs: string[]): { textContent: string; imageAttachments: Attachment[] } { + let textContent = ""; + const imageAttachments: Attachment[] = []; + + for (const fileArg of fileArgs) { + // Expand and resolve path + const expandedPath = expandPath(fileArg); + const absolutePath = resolve(expandedPath); + + // Check if file exists + if (!existsSync(absolutePath)) { + console.error(chalk.red(`Error: File not found: ${absolutePath}`)); + process.exit(1); + } + + // Check if file is empty + const stats = statSync(absolutePath); + if (stats.size === 0) { + // Skip empty files + continue; + } + + const mimeType = isImageFile(absolutePath); + + if (mimeType) { + // Handle image file + const content = readFileSync(absolutePath); + const base64Content = content.toString("base64"); + + const attachment: Attachment = { + id: `file-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + type: "image", + fileName: absolutePath.split("/").pop() || absolutePath, + mimeType, + size: stats.size, + content: base64Content, + }; + + imageAttachments.push(attachment); + + // Add text reference to image + textContent += `\n`; + } else { + // Handle text file + try { + const content = readFileSync(absolutePath, "utf-8"); + textContent += `\n${content}\n\n`; + } catch (error: any) { + console.error(chalk.red(`Error: Could not read file ${absolutePath}: ${error.message}`)); + process.exit(1); + } + } + } + + return { textContent, imageAttachments }; +} + function printHelp() { console.log(`${chalk.bold("pi")} - AI coding assistant with read, bash, edit, write tools ${chalk.bold("Usage:")} - pi [options] [messages...] + pi [options] [@files...] [messages...] ${chalk.bold("Options:")} --provider Provider name (default: google) @@ -133,6 +229,9 @@ ${chalk.bold("Examples:")} # Interactive mode with initial prompt pi "List all .ts files in src/" + # Include files in initial message + pi @prompt.md @image.png "What color is the sky?" + # Non-interactive mode (process and exit) pi -p "List all .ts files in src/" @@ -511,6 +610,8 @@ async function runInteractiveMode( newVersion: string | null = null, scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [], initialMessages: string[] = [], + initialMessage?: string, + initialAttachments?: Attachment[], ): Promise { const renderer = new TuiRenderer( agent, @@ -533,7 +634,17 @@ async function runInteractiveMode( renderer.showWarning(modelFallbackMessage); } - // Process initial messages if provided (from CLI args) + // Process initial message with attachments if provided (from @file args) + if (initialMessage) { + try { + await agent.prompt(initialMessage, initialAttachments); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + renderer.showError(errorMessage); + } + } + + // Process remaining initial messages if provided (from CLI args) for (const message of initialMessages) { try { await agent.prompt(message); @@ -563,6 +674,8 @@ async function runSingleShotMode( _sessionManager: SessionManager, messages: string[], mode: "text" | "json", + initialMessage?: string, + initialAttachments?: Attachment[], ): Promise { if (mode === "json") { // Subscribe to all events and output as JSON @@ -572,6 +685,12 @@ async function runSingleShotMode( }); } + // Send initial message with attachments if provided + if (initialMessage) { + await agent.prompt(initialMessage, initialAttachments); + } + + // Send remaining messages for (const message of messages) { await agent.prompt(message); } @@ -631,6 +750,30 @@ export async function main(args: string[]) { return; } + // Validate: RPC mode doesn't support @file arguments + if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) { + console.error(chalk.red("Error: @file arguments are not supported in RPC mode")); + process.exit(1); + } + + // Process @file arguments if any + let initialMessage: string | undefined; + let initialAttachments: Attachment[] | undefined; + + if (parsed.fileArgs.length > 0) { + const { textContent, imageAttachments } = processFileArguments(parsed.fileArgs); + + // Combine file content with first plain text message (if any) + if (parsed.messages.length > 0) { + initialMessage = textContent + parsed.messages[0]; + parsed.messages.shift(); // Remove first message as it's been combined + } else { + initialMessage = textContent; + } + + initialAttachments = imageAttachments.length > 0 ? imageAttachments : undefined; + } + // Initialize theme (before any TUI rendering) const settingsManager = new SettingsManager(); const themeName = settingsManager.getTheme(); @@ -1001,9 +1144,11 @@ export async function main(args: string[]) { newVersion, scopedModels, parsed.messages, + initialMessage, + initialAttachments, ); } else { // Non-interactive mode (--print flag or --mode flag) - await runSingleShotMode(agent, sessionManager, parsed.messages, mode); + await runSingleShotMode(agent, sessionManager, parsed.messages, mode, initialMessage, initialAttachments); } }