Split main-new.ts into modules: cli/args, cli/file-processor, cli/session-picker, core/system-prompt, core/model-resolver

This commit is contained in:
Mario Zechner 2025-12-09 01:20:31 +01:00
parent 109a30b265
commit 1a6a1a8acf
8 changed files with 3326 additions and 959 deletions

View file

@ -0,0 +1,197 @@
/**
* CLI argument parsing and help display
*/
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
import chalk from "chalk";
import { allTools, type ToolName } from "../core/tools/index.js";
import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, getModelsPath } from "../utils/config.js";
export type Mode = "text" | "json" | "rpc";
export interface Args {
provider?: string;
model?: string;
apiKey?: string;
systemPrompt?: string;
appendSystemPrompt?: string;
thinking?: ThinkingLevel;
continue?: boolean;
resume?: boolean;
help?: boolean;
mode?: Mode;
noSession?: boolean;
session?: string;
models?: string[];
tools?: ToolName[];
print?: boolean;
export?: string;
messages: string[];
fileArgs: string[];
}
const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
export function isValidThinkingLevel(level: string): level is ThinkingLevel {
return VALID_THINKING_LEVELS.includes(level as ThinkingLevel);
}
export function parseArgs(args: string[]): Args {
const result: Args = {
messages: [],
fileArgs: [],
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--help" || arg === "-h") {
result.help = true;
} else if (arg === "--mode" && i + 1 < args.length) {
const mode = args[++i];
if (mode === "text" || mode === "json" || mode === "rpc") {
result.mode = mode;
}
} else if (arg === "--continue" || arg === "-c") {
result.continue = true;
} else if (arg === "--resume" || arg === "-r") {
result.resume = true;
} else if (arg === "--provider" && i + 1 < args.length) {
result.provider = args[++i];
} else if (arg === "--model" && i + 1 < args.length) {
result.model = args[++i];
} else if (arg === "--api-key" && i + 1 < args.length) {
result.apiKey = args[++i];
} else if (arg === "--system-prompt" && i + 1 < args.length) {
result.systemPrompt = args[++i];
} else if (arg === "--append-system-prompt" && i + 1 < args.length) {
result.appendSystemPrompt = args[++i];
} else if (arg === "--no-session") {
result.noSession = true;
} else if (arg === "--session" && i + 1 < args.length) {
result.session = args[++i];
} else if (arg === "--models" && i + 1 < args.length) {
result.models = args[++i].split(",").map((s) => s.trim());
} else if (arg === "--tools" && i + 1 < args.length) {
const toolNames = args[++i].split(",").map((s) => s.trim());
const validTools: ToolName[] = [];
for (const name of toolNames) {
if (name in allTools) {
validTools.push(name as ToolName);
} else {
console.error(
chalk.yellow(`Warning: Unknown tool "${name}". Valid tools: ${Object.keys(allTools).join(", ")}`),
);
}
}
result.tools = validTools;
} else if (arg === "--thinking" && i + 1 < args.length) {
const level = args[++i];
if (isValidThinkingLevel(level)) {
result.thinking = level;
} else {
console.error(
chalk.yellow(
`Warning: Invalid thinking level "${level}". Valid values: ${VALID_THINKING_LEVELS.join(", ")}`,
),
);
}
} else if (arg === "--print" || arg === "-p") {
result.print = true;
} else if (arg === "--export" && i + 1 < args.length) {
result.export = args[++i];
} else if (arg.startsWith("@")) {
result.fileArgs.push(arg.slice(1)); // Remove @ prefix
} else if (!arg.startsWith("-")) {
result.messages.push(arg);
}
}
return result;
}
export function printHelp(): void {
console.log(`${chalk.bold(APP_NAME)} - AI coding assistant with read, bash, edit, write tools
${chalk.bold("Usage:")}
${APP_NAME} [options] [@files...] [messages...]
${chalk.bold("Options:")}
--provider <name> Provider name (default: google)
--model <id> Model ID (default: gemini-2.5-flash)
--api-key <key> API key (defaults to env vars)
--system-prompt <text> System prompt (default: coding assistant prompt)
--append-system-prompt <text> Append text or file contents to the system prompt
--mode <mode> Output mode: text (default), json, or rpc
--print, -p Non-interactive mode: process prompt and exit
--continue, -c Continue previous session
--resume, -r Select a session to resume
--session <path> Use specific session file
--no-session Don't save session (ephemeral)
--models <patterns> Comma-separated model patterns for quick cycling with Ctrl+P
--tools <tools> Comma-separated list of tools to enable (default: read,bash,edit,write)
Available: read, bash, edit, write, grep, find, ls
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh
--export <file> Export session file to HTML and exit
--help, -h Show this help
${chalk.bold("Examples:")}
# Interactive mode
${APP_NAME}
# Interactive mode with initial prompt
${APP_NAME} "List all .ts files in src/"
# Include files in initial message
${APP_NAME} @prompt.md @image.png "What color is the sky?"
# Non-interactive mode (process and exit)
${APP_NAME} -p "List all .ts files in src/"
# Multiple messages (interactive)
${APP_NAME} "Read package.json" "What dependencies do we have?"
# Continue previous session
${APP_NAME} --continue "What did we discuss?"
# Use different model
${APP_NAME} --provider openai --model gpt-4o-mini "Help me refactor this code"
# Limit model cycling to specific models
${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o
# Cycle models with fixed thinking levels
${APP_NAME} --models sonnet:high,haiku:low
# Start with a specific thinking level
${APP_NAME} --thinking high "Solve this complex problem"
# Read-only mode (no file modifications possible)
${APP_NAME} --tools read,grep,find,ls -p "Review the code in src/"
# Export a session file to HTML
${APP_NAME} --export ~/${CONFIG_DIR_NAME}/agent/sessions/--path--/session.jsonl
${APP_NAME} --export session.jsonl output.html
${chalk.bold("Environment Variables:")}
ANTHROPIC_API_KEY - Anthropic Claude API key
ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key)
OPENAI_API_KEY - OpenAI GPT API key
GEMINI_API_KEY - Google Gemini API key
GROQ_API_KEY - Groq API key
CEREBRAS_API_KEY - Cerebras API key
XAI_API_KEY - xAI Grok API key
OPENROUTER_API_KEY - OpenRouter API key
ZAI_API_KEY - ZAI API key
${ENV_AGENT_DIR.padEnd(23)} - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent)
${chalk.bold("Available Tools (default: read, bash, edit, write):")}
read - Read file contents
bash - Execute bash commands
edit - Edit files with find/replace
write - Write files (creates/overwrites)
grep - Search file contents (read-only, off by default)
find - Find files by glob pattern (read-only, off by default)
ls - List directory contents (read-only, off by default)
`);
}

View file

@ -0,0 +1,99 @@
/**
* Process @file CLI arguments into text content and image attachments
*/
import type { Attachment } from "@mariozechner/pi-agent-core";
import chalk from "chalk";
import { existsSync, readFileSync, statSync } from "fs";
import { homedir } from "os";
import { extname, resolve } from "path";
/** 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, returns MIME type or null */
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;
}
export interface ProcessedFiles {
textContent: string;
imageAttachments: Attachment[];
}
/** Process @file arguments into text content and image attachments */
export function processFileArguments(fileArgs: string[]): ProcessedFiles {
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: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.error(chalk.red(`Error: Could not read file ${absolutePath}: ${message}`));
process.exit(1);
}
}
}
return { textContent, imageAttachments };
}

View file

@ -0,0 +1,37 @@
/**
* TUI session selector for --resume flag
*/
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
import type { SessionManager } from "../core/session-manager.js";
import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js";
/** Show TUI session selector and return selected session path or null if cancelled */
export async function selectSession(sessionManager: SessionManager): Promise<string | null> {
return new Promise((resolve) => {
const ui = new TUI(new ProcessTerminal());
let resolved = false;
const selector = new SessionSelectorComponent(
sessionManager,
(path: string) => {
if (!resolved) {
resolved = true;
ui.stop();
resolve(path);
}
},
() => {
if (!resolved) {
resolved = true;
ui.stop();
resolve(null);
}
},
);
ui.addChild(selector);
ui.setFocus(selector.getSessionList());
ui.start();
});
}