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