mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 20:01:24 +00:00
Agent package + coding agent WIP, refactored web-ui prompts
This commit is contained in:
parent
4e7a340460
commit
ffc9be8867
58 changed files with 5138 additions and 2206 deletions
52
packages/coding-agent/package.json
Normal file
52
packages/coding-agent/package.json
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"name": "@mariozechner/coding-agent",
|
||||
"version": "0.5.44",
|
||||
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"coding-agent": "dist/cli.js"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
"build": "tsc -p tsconfig.build.json && chmod +x dist/cli.js",
|
||||
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
|
||||
"check": "tsc --noEmit",
|
||||
"test": "vitest --run",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-agent": "^0.5.44",
|
||||
"@mariozechner/pi-ai": "^0.5.44",
|
||||
"@mariozechner/pi-tui": "^0.5.44",
|
||||
"chalk": "^5.5.0",
|
||||
"glob": "^11.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"keywords": [
|
||||
"coding-agent",
|
||||
"ai",
|
||||
"llm",
|
||||
"cli",
|
||||
"tui",
|
||||
"agent"
|
||||
],
|
||||
"author": "Mario Zechner",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/badlogic/pi-mono.git",
|
||||
"directory": "packages/coding-agent"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
8
packages/coding-agent/src/cli.ts
Normal file
8
packages/coding-agent/src/cli.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { main } from "./main.js";
|
||||
|
||||
main(process.argv.slice(2)).catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
3
packages/coding-agent/src/index.ts
Normal file
3
packages/coding-agent/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { main } from "./main.js";
|
||||
export { SessionManager } from "./session-manager.js";
|
||||
export { bashTool, codingTools, editTool, readTool, writeTool } from "./tools/index.js";
|
||||
209
packages/coding-agent/src/main.ts
Normal file
209
packages/coding-agent/src/main.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { Agent, ProviderTransport } from "@mariozechner/pi-agent";
|
||||
import { getModel } from "@mariozechner/pi-ai";
|
||||
import chalk from "chalk";
|
||||
import { SessionManager } from "./session-manager.js";
|
||||
import { codingTools } from "./tools/index.js";
|
||||
|
||||
interface Args {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
apiKey?: string;
|
||||
systemPrompt?: string;
|
||||
continue?: boolean;
|
||||
help?: boolean;
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): Args {
|
||||
const result: Args = {
|
||||
messages: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
result.help = true;
|
||||
} else if (arg === "--continue" || arg === "-c") {
|
||||
result.continue = 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.startsWith("-")) {
|
||||
result.messages.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`${chalk.bold("coding-agent")} - AI coding assistant with read, bash, edit, write tools
|
||||
|
||||
${chalk.bold("Usage:")}
|
||||
coding-agent [options] [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)
|
||||
--continue, -c Continue previous session
|
||||
--help, -h Show this help
|
||||
|
||||
${chalk.bold("Examples:")}
|
||||
# Single message
|
||||
coding-agent "List all .ts files in src/"
|
||||
|
||||
# Multiple messages
|
||||
coding-agent "Read package.json" "What dependencies do we have?"
|
||||
|
||||
# Continue previous session
|
||||
coding-agent --continue "What did we discuss?"
|
||||
|
||||
# Use different model
|
||||
coding-agent --provider openai --model gpt-4o-mini "Help me refactor this code"
|
||||
|
||||
${chalk.bold("Environment Variables:")}
|
||||
GEMINI_API_KEY - Google Gemini API key
|
||||
OPENAI_API_KEY - OpenAI API key
|
||||
ANTHROPIC_API_KEY - Anthropic API key
|
||||
CODING_AGENT_DIR - Session storage directory (default: ~/.coding-agent)
|
||||
|
||||
${chalk.bold("Available Tools:")}
|
||||
read - Read file contents
|
||||
bash - Execute bash commands
|
||||
edit - Edit files with find/replace
|
||||
write - Write files (creates/overwrites)
|
||||
`);
|
||||
}
|
||||
|
||||
const DEFAULT_SYSTEM_PROMPT = `You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.
|
||||
|
||||
Available tools:
|
||||
- read: Read file contents
|
||||
- bash: Execute bash commands (ls, grep, find, etc.)
|
||||
- edit: Make surgical edits to files (find exact text and replace)
|
||||
- write: Create or overwrite files
|
||||
|
||||
Guidelines:
|
||||
- Always use bash tool for file operations like ls, grep, find
|
||||
- Use read to examine files before editing
|
||||
- Use edit for precise changes (old text must match exactly)
|
||||
- Use write only for new files or complete rewrites
|
||||
- Be concise in your responses
|
||||
- Show file paths clearly when working with files
|
||||
|
||||
Current directory: ${process.cwd()}`;
|
||||
|
||||
export async function main(args: string[]) {
|
||||
const parsed = parseArgs(args);
|
||||
|
||||
if (parsed.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup session manager
|
||||
const sessionManager = new SessionManager(parsed.continue);
|
||||
|
||||
// Determine provider and model
|
||||
const provider = (parsed.provider || "google") as any;
|
||||
const modelId = parsed.model || "gemini-2.5-flash";
|
||||
|
||||
// Get API key
|
||||
let apiKey = parsed.apiKey;
|
||||
if (!apiKey) {
|
||||
const envVarMap: Record<string, string> = {
|
||||
google: "GEMINI_API_KEY",
|
||||
openai: "OPENAI_API_KEY",
|
||||
anthropic: "ANTHROPIC_API_KEY",
|
||||
xai: "XAI_API_KEY",
|
||||
groq: "GROQ_API_KEY",
|
||||
cerebras: "CEREBRAS_API_KEY",
|
||||
zai: "ZAI_API_KEY",
|
||||
};
|
||||
const envVar = envVarMap[provider] || `${provider.toUpperCase()}_API_KEY`;
|
||||
apiKey = process.env[envVar];
|
||||
|
||||
if (!apiKey) {
|
||||
console.error(chalk.red(`Error: No API key found for provider "${provider}"`));
|
||||
console.error(chalk.dim(`Set ${envVar} environment variable or use --api-key flag`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Create agent
|
||||
const model = getModel(provider, modelId);
|
||||
const systemPrompt = parsed.systemPrompt || DEFAULT_SYSTEM_PROMPT;
|
||||
|
||||
const agent = new Agent({
|
||||
initialState: {
|
||||
systemPrompt,
|
||||
model,
|
||||
thinkingLevel: "off",
|
||||
tools: codingTools,
|
||||
},
|
||||
transport: new ProviderTransport({
|
||||
getApiKey: async () => apiKey!,
|
||||
}),
|
||||
});
|
||||
|
||||
// Load previous messages if continuing
|
||||
if (parsed.continue) {
|
||||
const messages = sessionManager.loadMessages();
|
||||
if (messages.length > 0) {
|
||||
console.log(chalk.dim(`Loaded ${messages.length} messages from previous session`));
|
||||
agent.replaceMessages(messages);
|
||||
}
|
||||
}
|
||||
|
||||
// Start session
|
||||
sessionManager.startSession(agent.state);
|
||||
|
||||
// Subscribe to state updates to save messages
|
||||
agent.subscribe((event) => {
|
||||
if (event.type === "state-update") {
|
||||
// Save any new messages
|
||||
const currentMessages = event.state.messages;
|
||||
const loadedMessages = sessionManager.loadMessages();
|
||||
|
||||
if (currentMessages.length > loadedMessages.length) {
|
||||
for (let i = loadedMessages.length; i < currentMessages.length; i++) {
|
||||
sessionManager.saveMessage(currentMessages[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sessionManager.saveEvent(event);
|
||||
});
|
||||
|
||||
// Process messages
|
||||
if (parsed.messages.length === 0) {
|
||||
console.log(chalk.yellow("No messages provided. Use --help for usage information."));
|
||||
console.log(chalk.dim(`Session saved to: ${sessionManager.getSessionFile()}`));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const message of parsed.messages) {
|
||||
console.log(chalk.blue(`\n> ${message}\n`));
|
||||
await agent.prompt(message);
|
||||
|
||||
// Print response
|
||||
const lastMessage = agent.state.messages[agent.state.messages.length - 1];
|
||||
if (lastMessage.role === "assistant") {
|
||||
for (const content of lastMessage.content) {
|
||||
if (content.type === "text") {
|
||||
console.log(content.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(chalk.dim(`\nSession saved to: ${sessionManager.getSessionFile()}`));
|
||||
}
|
||||
167
packages/coding-agent/src/session-manager.ts
Normal file
167
packages/coding-agent/src/session-manager.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import type { AgentEvent, AgentState } from "@mariozechner/pi-agent";
|
||||
import { randomBytes } from "crypto";
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { join, resolve } from "path";
|
||||
|
||||
function uuidv4(): string {
|
||||
const bytes = randomBytes(16);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = bytes.toString("hex");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||
}
|
||||
|
||||
export interface SessionHeader {
|
||||
type: "session";
|
||||
id: string;
|
||||
timestamp: string;
|
||||
cwd: string;
|
||||
systemPrompt: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface SessionMessageEntry {
|
||||
type: "message";
|
||||
timestamp: string;
|
||||
message: any; // AppMessage from agent state
|
||||
}
|
||||
|
||||
export interface SessionEventEntry {
|
||||
type: "event";
|
||||
timestamp: string;
|
||||
event: AgentEvent;
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
private sessionId!: string;
|
||||
private sessionFile!: string;
|
||||
private sessionDir: string;
|
||||
|
||||
constructor(continueSession: boolean = false) {
|
||||
this.sessionDir = this.getSessionDirectory();
|
||||
|
||||
if (continueSession) {
|
||||
const mostRecent = this.findMostRecentlyModifiedSession();
|
||||
if (mostRecent) {
|
||||
this.sessionFile = mostRecent;
|
||||
this.loadSessionId();
|
||||
} else {
|
||||
this.initNewSession();
|
||||
}
|
||||
} else {
|
||||
this.initNewSession();
|
||||
}
|
||||
}
|
||||
|
||||
private getSessionDirectory(): string {
|
||||
const cwd = process.cwd();
|
||||
const safePath = "--" + cwd.replace(/^\//, "").replace(/\//g, "-") + "--";
|
||||
|
||||
const configDir = resolve(process.env.CODING_AGENT_DIR || join(homedir(), ".coding-agent"));
|
||||
const sessionDir = join(configDir, "sessions", safePath);
|
||||
if (!existsSync(sessionDir)) {
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
}
|
||||
return sessionDir;
|
||||
}
|
||||
|
||||
private initNewSession(): void {
|
||||
this.sessionId = uuidv4();
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
this.sessionFile = join(this.sessionDir, `${timestamp}_${this.sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
private findMostRecentlyModifiedSession(): string | null {
|
||||
try {
|
||||
const files = readdirSync(this.sessionDir)
|
||||
.filter((f) => f.endsWith(".jsonl"))
|
||||
.map((f) => ({
|
||||
name: f,
|
||||
path: join(this.sessionDir, f),
|
||||
mtime: statSync(join(this.sessionDir, f)).mtime,
|
||||
}))
|
||||
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
return files[0]?.path || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private loadSessionId(): void {
|
||||
if (!existsSync(this.sessionFile)) return;
|
||||
|
||||
const lines = readFileSync(this.sessionFile, "utf8").trim().split("\n");
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === "session") {
|
||||
this.sessionId = entry.id;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
this.sessionId = uuidv4();
|
||||
}
|
||||
|
||||
startSession(state: AgentState): void {
|
||||
const entry: SessionHeader = {
|
||||
type: "session",
|
||||
id: this.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
systemPrompt: state.systemPrompt,
|
||||
model: `${state.model.provider}/${state.model.id}`,
|
||||
};
|
||||
appendFileSync(this.sessionFile, JSON.stringify(entry) + "\n");
|
||||
}
|
||||
|
||||
saveMessage(message: any): void {
|
||||
const entry: SessionMessageEntry = {
|
||||
type: "message",
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
};
|
||||
appendFileSync(this.sessionFile, JSON.stringify(entry) + "\n");
|
||||
}
|
||||
|
||||
saveEvent(event: AgentEvent): void {
|
||||
const entry: SessionEventEntry = {
|
||||
type: "event",
|
||||
timestamp: new Date().toISOString(),
|
||||
event,
|
||||
};
|
||||
appendFileSync(this.sessionFile, JSON.stringify(entry) + "\n");
|
||||
}
|
||||
|
||||
loadMessages(): any[] {
|
||||
if (!existsSync(this.sessionFile)) return [];
|
||||
|
||||
const messages: any[] = [];
|
||||
const lines = readFileSync(this.sessionFile, "utf8").trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === "message") {
|
||||
messages.push(entry.message);
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
getSessionId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
getSessionFile(): string {
|
||||
return this.sessionFile;
|
||||
}
|
||||
}
|
||||
37
packages/coding-agent/src/tools/bash.ts
Normal file
37
packages/coding-agent/src/tools/bash.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const bashSchema = Type.Object({
|
||||
command: Type.String({ description: "Bash command to execute" }),
|
||||
});
|
||||
|
||||
export const bashTool: AgentTool<typeof bashSchema> = {
|
||||
name: "bash",
|
||||
label: "bash",
|
||||
description:
|
||||
"Execute a bash command in the current working directory. Returns stdout and stderr. Commands run with a 30 second timeout.",
|
||||
parameters: bashSchema,
|
||||
execute: async (_toolCallId: string, { command }: { command: string }) => {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
timeout: 30000,
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB
|
||||
});
|
||||
|
||||
let output = "";
|
||||
if (stdout) output += stdout;
|
||||
if (stderr) output += stderr ? `\nSTDERR:\n${stderr}` : "";
|
||||
|
||||
return { output: output || "(no output)", details: undefined };
|
||||
} catch (error: any) {
|
||||
return {
|
||||
output: `Error executing command: ${error.message}\nSTDOUT: ${error.stdout || ""}\nSTDERR: ${error.stderr || ""}`,
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
61
packages/coding-agent/src/tools/edit.ts
Normal file
61
packages/coding-agent/src/tools/edit.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { existsSync, readFileSync, writeFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
const editSchema = Type.Object({
|
||||
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
|
||||
oldText: Type.String({ description: "Exact text to find and replace (must match exactly)" }),
|
||||
newText: Type.String({ description: "New text to replace the old text with" }),
|
||||
});
|
||||
|
||||
export const editTool: AgentTool<typeof editSchema> = {
|
||||
name: "edit",
|
||||
label: "edit",
|
||||
description:
|
||||
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
|
||||
parameters: editSchema,
|
||||
execute: async (
|
||||
_toolCallId: string,
|
||||
{ path, oldText, newText }: { path: string; oldText: string; newText: string },
|
||||
) => {
|
||||
try {
|
||||
const absolutePath = resolve(path);
|
||||
|
||||
if (!existsSync(absolutePath)) {
|
||||
return { output: `Error: File not found: ${path}`, details: undefined };
|
||||
}
|
||||
|
||||
const content = readFileSync(absolutePath, "utf-8");
|
||||
|
||||
// Check if old text exists
|
||||
if (!content.includes(oldText)) {
|
||||
return {
|
||||
output: `Error: Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Count occurrences
|
||||
const occurrences = content.split(oldText).length - 1;
|
||||
|
||||
if (occurrences > 1) {
|
||||
return {
|
||||
output: `Error: Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Perform replacement
|
||||
const newContent = content.replace(oldText, newText);
|
||||
writeFileSync(absolutePath, newContent, "utf-8");
|
||||
|
||||
return {
|
||||
output: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`,
|
||||
details: undefined,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return { output: `Error editing file: ${error.message}`, details: undefined };
|
||||
}
|
||||
},
|
||||
};
|
||||
11
packages/coding-agent/src/tools/index.ts
Normal file
11
packages/coding-agent/src/tools/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export { bashTool } from "./bash.js";
|
||||
export { editTool } from "./edit.js";
|
||||
export { readTool } from "./read.js";
|
||||
export { writeTool } from "./write.js";
|
||||
|
||||
import { bashTool } from "./bash.js";
|
||||
import { editTool } from "./edit.js";
|
||||
import { readTool } from "./read.js";
|
||||
import { writeTool } from "./write.js";
|
||||
|
||||
export const codingTools = [readTool, bashTool, editTool, writeTool];
|
||||
29
packages/coding-agent/src/tools/read.ts
Normal file
29
packages/coding-agent/src/tools/read.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
const readSchema = Type.Object({
|
||||
path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
|
||||
});
|
||||
|
||||
export const readTool: AgentTool<typeof readSchema> = {
|
||||
name: "read",
|
||||
label: "read",
|
||||
description: "Read the contents of a file. Returns the full file content as text.",
|
||||
parameters: readSchema,
|
||||
execute: async (_toolCallId: string, { path }: { path: string }) => {
|
||||
try {
|
||||
const absolutePath = resolve(path);
|
||||
|
||||
if (!existsSync(absolutePath)) {
|
||||
return { output: `Error: File not found: ${path}`, details: undefined };
|
||||
}
|
||||
|
||||
const content = readFileSync(absolutePath, "utf-8");
|
||||
return { output: content, details: undefined };
|
||||
} catch (error: any) {
|
||||
return { output: `Error reading file: ${error.message}`, details: undefined };
|
||||
}
|
||||
},
|
||||
};
|
||||
31
packages/coding-agent/src/tools/write.ts
Normal file
31
packages/coding-agent/src/tools/write.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { AgentTool } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { mkdirSync, writeFileSync } from "fs";
|
||||
import { dirname, resolve } from "path";
|
||||
|
||||
const writeSchema = Type.Object({
|
||||
path: Type.String({ description: "Path to the file to write (relative or absolute)" }),
|
||||
content: Type.String({ description: "Content to write to the file" }),
|
||||
});
|
||||
|
||||
export const writeTool: AgentTool<typeof writeSchema> = {
|
||||
name: "write",
|
||||
label: "write",
|
||||
description:
|
||||
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
|
||||
parameters: writeSchema,
|
||||
execute: async (_toolCallId: string, { path, content }: { path: string; content: string }) => {
|
||||
try {
|
||||
const absolutePath = resolve(path);
|
||||
const dir = dirname(absolutePath);
|
||||
|
||||
// Create parent directories if needed
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
writeFileSync(absolutePath, content, "utf-8");
|
||||
return { output: `Successfully wrote ${content.length} bytes to ${path}`, details: undefined };
|
||||
} catch (error: any) {
|
||||
return { output: `Error writing file: ${error.message}`, details: undefined };
|
||||
}
|
||||
},
|
||||
};
|
||||
133
packages/coding-agent/test/tools.test.ts
Normal file
133
packages/coding-agent/test/tools.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { mkdirSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { bashTool } from "../src/tools/bash.js";
|
||||
import { editTool } from "../src/tools/edit.js";
|
||||
import { readTool } from "../src/tools/read.js";
|
||||
import { writeTool } from "../src/tools/write.js";
|
||||
|
||||
describe("Coding Agent Tools", () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a unique temporary directory for each test
|
||||
testDir = join(tmpdir(), `coding-agent-test-${Date.now()}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test directory
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("read tool", () => {
|
||||
it("should read file contents", async () => {
|
||||
const testFile = join(testDir, "test.txt");
|
||||
const content = "Hello, world!";
|
||||
writeFileSync(testFile, content);
|
||||
|
||||
const result = await readTool.execute("test-call-1", { path: testFile });
|
||||
|
||||
expect(result.output).toBe(content);
|
||||
expect(result.details).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle non-existent files", async () => {
|
||||
const testFile = join(testDir, "nonexistent.txt");
|
||||
|
||||
const result = await readTool.execute("test-call-2", { path: testFile });
|
||||
|
||||
expect(result.output).toContain("Error");
|
||||
expect(result.output).toContain("File not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("write tool", () => {
|
||||
it("should write file contents", async () => {
|
||||
const testFile = join(testDir, "write-test.txt");
|
||||
const content = "Test content";
|
||||
|
||||
const result = await writeTool.execute("test-call-3", { path: testFile, content });
|
||||
|
||||
expect(result.output).toContain("Successfully wrote");
|
||||
expect(result.output).toContain(testFile);
|
||||
expect(result.details).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should create parent directories", async () => {
|
||||
const testFile = join(testDir, "nested", "dir", "test.txt");
|
||||
const content = "Nested content";
|
||||
|
||||
const result = await writeTool.execute("test-call-4", { path: testFile, content });
|
||||
|
||||
expect(result.output).toContain("Successfully wrote");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit tool", () => {
|
||||
it("should replace text in file", async () => {
|
||||
const testFile = join(testDir, "edit-test.txt");
|
||||
const originalContent = "Hello, world!";
|
||||
writeFileSync(testFile, originalContent);
|
||||
|
||||
const result = await editTool.execute("test-call-5", {
|
||||
path: testFile,
|
||||
oldText: "world",
|
||||
newText: "testing",
|
||||
});
|
||||
|
||||
expect(result.output).toContain("Successfully replaced");
|
||||
expect(result.details).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should fail if text not found", async () => {
|
||||
const testFile = join(testDir, "edit-test.txt");
|
||||
const originalContent = "Hello, world!";
|
||||
writeFileSync(testFile, originalContent);
|
||||
|
||||
const result = await editTool.execute("test-call-6", {
|
||||
path: testFile,
|
||||
oldText: "nonexistent",
|
||||
newText: "testing",
|
||||
});
|
||||
|
||||
expect(result.output).toContain("Could not find the exact text");
|
||||
});
|
||||
|
||||
it("should fail if text appears multiple times", async () => {
|
||||
const testFile = join(testDir, "edit-test.txt");
|
||||
const originalContent = "foo foo foo";
|
||||
writeFileSync(testFile, originalContent);
|
||||
|
||||
const result = await editTool.execute("test-call-7", {
|
||||
path: testFile,
|
||||
oldText: "foo",
|
||||
newText: "bar",
|
||||
});
|
||||
|
||||
expect(result.output).toContain("Found 3 occurrences");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bash tool", () => {
|
||||
it("should execute simple commands", async () => {
|
||||
const result = await bashTool.execute("test-call-8", { command: "echo 'test output'" });
|
||||
|
||||
expect(result.output).toContain("test output");
|
||||
expect(result.details).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle command errors", async () => {
|
||||
const result = await bashTool.execute("test-call-9", { command: "exit 1" });
|
||||
|
||||
expect(result.output).toContain("Command failed");
|
||||
});
|
||||
|
||||
it("should respect timeout", async () => {
|
||||
const result = await bashTool.execute("test-call-10", { command: "sleep 35" });
|
||||
|
||||
expect(result.output).toContain("Command failed");
|
||||
}, 35000);
|
||||
});
|
||||
});
|
||||
9
packages/coding-agent/tsconfig.build.json
Normal file
9
packages/coding-agent/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
9
packages/coding-agent/vitest.config.ts
Normal file
9
packages/coding-agent/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
testTimeout: 30000, // 30 seconds for API calls
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue