mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-20 20:01:06 +00:00
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 <file name="path">content</file> 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
This commit is contained in:
parent
48df1ff259
commit
f95f41b1c4
3 changed files with 211 additions and 7 deletions
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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 `<file name="path">content</file>` 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
|
### 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))
|
- **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))
|
||||||
|
|
|
||||||
|
|
@ -650,9 +650,58 @@ pi --session /path/to/my-session.jsonl
|
||||||
## CLI Options
|
## CLI Options
|
||||||
|
|
||||||
```bash
|
```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 `<file name="path">content</file>` 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
|
### Options
|
||||||
|
|
||||||
**--provider <name>**
|
**--provider <name>**
|
||||||
|
|
@ -727,9 +776,15 @@ pi
|
||||||
# Interactive mode with initial prompt (stays running after completion)
|
# Interactive mode with initial prompt (stays running after completion)
|
||||||
pi "List all .ts files in src/"
|
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)
|
# Non-interactive mode (process prompt and exit)
|
||||||
pi -p "List all .ts files in src/"
|
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)
|
# JSON mode - stream all agent events (non-interactive)
|
||||||
pi --mode json "List all .ts files in src/"
|
pi --mode json "List all .ts files in src/"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 type { Api, KnownProvider, Model } from "@mariozechner/pi-ai";
|
||||||
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
|
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { existsSync, readFileSync } from "fs";
|
import { existsSync, readFileSync, statSync } from "fs";
|
||||||
import { homedir } from "os";
|
import { homedir } from "os";
|
||||||
import { dirname, join, resolve } from "path";
|
import { dirname, extname, join, resolve } from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { getChangelogPath, getNewEntries, parseChangelog } from "./changelog.js";
|
import { getChangelogPath, getNewEntries, parseChangelog } from "./changelog.js";
|
||||||
import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js";
|
import { findModel, getApiKeyForModel, getAvailableModels } from "./model-config.js";
|
||||||
|
|
@ -49,11 +49,13 @@ interface Args {
|
||||||
models?: string[];
|
models?: string[];
|
||||||
print?: boolean;
|
print?: boolean;
|
||||||
messages: string[];
|
messages: string[];
|
||||||
|
fileArgs: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(args: string[]): Args {
|
function parseArgs(args: string[]): Args {
|
||||||
const result: Args = {
|
const result: Args = {
|
||||||
messages: [],
|
messages: [],
|
||||||
|
fileArgs: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
|
@ -97,6 +99,8 @@ function parseArgs(args: string[]): Args {
|
||||||
}
|
}
|
||||||
} else if (arg === "--print" || arg === "-p") {
|
} else if (arg === "--print" || arg === "-p") {
|
||||||
result.print = true;
|
result.print = true;
|
||||||
|
} else if (arg.startsWith("@")) {
|
||||||
|
result.fileArgs.push(arg.slice(1)); // Remove @ prefix
|
||||||
} else if (!arg.startsWith("-")) {
|
} else if (!arg.startsWith("-")) {
|
||||||
result.messages.push(arg);
|
result.messages.push(arg);
|
||||||
}
|
}
|
||||||
|
|
@ -105,11 +109,103 @@ function parseArgs(args: string[]): Args {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map of file extensions to MIME types for common image formats
|
||||||
|
*/
|
||||||
|
const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||||
|
".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 += `<file name="${absolutePath}"></file>\n`;
|
||||||
|
} else {
|
||||||
|
// Handle text file
|
||||||
|
try {
|
||||||
|
const content = readFileSync(absolutePath, "utf-8");
|
||||||
|
textContent += `<file name="${absolutePath}">\n${content}\n</file>\n`;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(chalk.red(`Error: Could not read file ${absolutePath}: ${error.message}`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { textContent, imageAttachments };
|
||||||
|
}
|
||||||
|
|
||||||
function printHelp() {
|
function printHelp() {
|
||||||
console.log(`${chalk.bold("pi")} - AI coding assistant with read, bash, edit, write tools
|
console.log(`${chalk.bold("pi")} - AI coding assistant with read, bash, edit, write tools
|
||||||
|
|
||||||
${chalk.bold("Usage:")}
|
${chalk.bold("Usage:")}
|
||||||
pi [options] [messages...]
|
pi [options] [@files...] [messages...]
|
||||||
|
|
||||||
${chalk.bold("Options:")}
|
${chalk.bold("Options:")}
|
||||||
--provider <name> Provider name (default: google)
|
--provider <name> Provider name (default: google)
|
||||||
|
|
@ -133,6 +229,9 @@ ${chalk.bold("Examples:")}
|
||||||
# Interactive mode with initial prompt
|
# Interactive mode with initial prompt
|
||||||
pi "List all .ts files in src/"
|
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)
|
# Non-interactive mode (process and exit)
|
||||||
pi -p "List all .ts files in src/"
|
pi -p "List all .ts files in src/"
|
||||||
|
|
||||||
|
|
@ -511,6 +610,8 @@ async function runInteractiveMode(
|
||||||
newVersion: string | null = null,
|
newVersion: string | null = null,
|
||||||
scopedModels: Array<{ model: Model<Api>; thinkingLevel: ThinkingLevel }> = [],
|
scopedModels: Array<{ model: Model<Api>; thinkingLevel: ThinkingLevel }> = [],
|
||||||
initialMessages: string[] = [],
|
initialMessages: string[] = [],
|
||||||
|
initialMessage?: string,
|
||||||
|
initialAttachments?: Attachment[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const renderer = new TuiRenderer(
|
const renderer = new TuiRenderer(
|
||||||
agent,
|
agent,
|
||||||
|
|
@ -533,7 +634,17 @@ async function runInteractiveMode(
|
||||||
renderer.showWarning(modelFallbackMessage);
|
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) {
|
for (const message of initialMessages) {
|
||||||
try {
|
try {
|
||||||
await agent.prompt(message);
|
await agent.prompt(message);
|
||||||
|
|
@ -563,6 +674,8 @@ async function runSingleShotMode(
|
||||||
_sessionManager: SessionManager,
|
_sessionManager: SessionManager,
|
||||||
messages: string[],
|
messages: string[],
|
||||||
mode: "text" | "json",
|
mode: "text" | "json",
|
||||||
|
initialMessage?: string,
|
||||||
|
initialAttachments?: Attachment[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (mode === "json") {
|
if (mode === "json") {
|
||||||
// Subscribe to all events and output as 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) {
|
for (const message of messages) {
|
||||||
await agent.prompt(message);
|
await agent.prompt(message);
|
||||||
}
|
}
|
||||||
|
|
@ -631,6 +750,30 @@ export async function main(args: string[]) {
|
||||||
return;
|
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)
|
// Initialize theme (before any TUI rendering)
|
||||||
const settingsManager = new SettingsManager();
|
const settingsManager = new SettingsManager();
|
||||||
const themeName = settingsManager.getTheme();
|
const themeName = settingsManager.getTheme();
|
||||||
|
|
@ -1001,9 +1144,11 @@ export async function main(args: string[]) {
|
||||||
newVersion,
|
newVersion,
|
||||||
scopedModels,
|
scopedModels,
|
||||||
parsed.messages,
|
parsed.messages,
|
||||||
|
initialMessage,
|
||||||
|
initialAttachments,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Non-interactive mode (--print flag or --mode flag)
|
// Non-interactive mode (--print flag or --mode flag)
|
||||||
await runSingleShotMode(agent, sessionManager, parsed.messages, mode);
|
await runSingleShotMode(agent, sessionManager, parsed.messages, mode, initialMessage, initialAttachments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue