From b3fea2b7c08922755ff876d2c323bf6765e4a989 Mon Sep 17 00:00:00 2001 From: munish-shah Date: Tue, 10 Feb 2026 14:46:26 -0600 Subject: [PATCH 1/2] fix: update create-agent SKILL.md with working implementation - Fix SDK version from ^0.1.2 to ^0.8.0 (required for getItemsStream) - Switch from getTextStream() to getItemsStream() for tool execution - Use SDK tool() helper instead of manual tool definitions - Fix empty catch blocks that silently swallowed errors - Fix tool completion indicator logic in CLI - Add maxToolRounds configuration --- skills/create-agent/SKILL.md | 1229 +++++++++++++++++----------------- 1 file changed, 607 insertions(+), 622 deletions(-) diff --git a/skills/create-agent/SKILL.md b/skills/create-agent/SKILL.md index 1a5286f..681a112 100644 --- a/skills/create-agent/SKILL.md +++ b/skills/create-agent/SKILL.md @@ -1,67 +1,122 @@ --- name: create-agent -description: Bootstrap a modular AI agent with OpenRouter SDK, extensible hooks, and optional Ink TUI +description: Bootstrap a CLI coding agent with OpenRouter SDK callModel() API, Zod tools, and Ink TUI metadata: - version: 0.0.0 + version: 0.3.0 homepage: https://openrouter.ai --- -# Build a Modular AI Agent with OpenRouter +# Build a Modular AI Coding Agent with OpenRouter -This skill helps you create a **modular AI agent** with: +This skill helps you create a **terminal-based coding agent**. Unlike simple chatbots, this agent can: -- **Standalone Agent Core** - Runs independently, extensible via hooks -- **OpenRouter SDK** - Unified access to 300+ language models -- **Optional Ink TUI** - Beautiful terminal UI (separate from agent logic) +- **Read & Write Files** - Modify your codebase directly +- **Edit Files** - Targeted find-and-replace edits +- **Execute Shell Commands** - Run tests, installs, and builds +- **Browse the Web** - Fetch documentation and search the internet +- **Look Good** - Includes a modern Ink-based Terminal UI (TUI) +- **OpenRouter SDK callModel()** - Automatic tool execution with items-based streaming ## Architecture ``` -┌─────────────────────────────────────────────────────┐ -│ Your Application │ -├─────────────────────────────────────────────────────┤ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ Ink TUI │ │ HTTP API │ │ Discord │ │ -│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ -│ │ │ │ │ -│ └────────────────┼────────────────┘ │ -│ ▼ │ -│ ┌───────────────────────┐ │ -│ │ Agent Core │ │ -│ │ (hooks & lifecycle) │ │ -│ └───────────┬───────────┘ │ -│ ▼ │ -│ ┌───────────────────────┐ │ -│ │ OpenRouter SDK │ │ -│ └───────────────────────┘ │ -└─────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────┐ +│ cli.tsx (TUI) │ +│ Ink React components + events │ +├─────────────────────────────────────────────────┤ +│ agent.ts (Core) │ +│ OpenRouter SDK callModel() + getItemsStream() │ +│ EventEmitter for UI hooks │ +├─────────────────────────────────────────────────┤ +│ tools.ts (Tools) │ +│ 7 Zod-schema tools with callbacks │ +│ list_files, read_file, edit_file, write_file, │ +│ run_command, fetch_web_page, web_search │ +└─────────────────────────────────────────────────┘ ``` +**How it works:** +1. Agent calls `callModel()` with tools +2. SDK automatically validates args with Zod, executes tools, sends results back to model +3. SDK repeats until the model stops calling tools +4. `getItemsStream()` streams all items (messages, tool calls, results) to the TUI +5. Tool callbacks emit events for the UI's tool indicators + ## Prerequisites +> [!IMPORTANT] +> **Node.js 18.x or 20.x required.** + Get an OpenRouter API key at: https://openrouter.ai/settings/keys -⚠️ **Security:** Never commit API keys. Use environment variables. +> [!CAUTION] +> **Dependency Safety Rules (MUST follow):** +> - Use ONLY the exact dependency versions listed in the package.json below +> - Do NOT add `cheerio`, `undici`, or any package with native bindings — these frequently break across Node versions +> - For HTML parsing, use the built-in regex-based `htmlToText()` helper in tools.ts — no external HTML parser needed +> - After `npm install`, verify zero errors by running `npm start` before considering setup complete ## Project Setup ### Step 1: Initialize Project ```bash -mkdir my-agent && cd my-agent +mkdir my-coding-agent && cd my-coding-agent npm init -y npm pkg set type="module" +mkdir src +``` + +### Step 2: Create package.json + +Replace your `package.json` with this exact content for guaranteed compatibility: + +```json +{ + "name": "my-coding-agent", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "tsx src/cli.tsx", + "start:headless": "tsx src/headless.ts", + "dev": "tsx watch src/cli.tsx" + }, + "dependencies": { + "@openrouter/sdk": "^0.8.0", + "dotenv": "^16.4.5", + "eventemitter3": "^5.0.4", + "glob": "^10.4.5", + "ink": "^4.4.1", + "react": "^18.3.1", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "@types/react": "^18.3.28", + "tsx": "^4.21.0", + "typescript": "^5.5.0" + } +} ``` -### Step 2: Install Dependencies +Then install: ```bash -npm install @openrouter/sdk zod eventemitter3 -npm install ink react # Optional: only for TUI -npm install -D typescript @types/react tsx +npm install ``` -### Step 3: Create tsconfig.json +### Step 3: Create .env file + +Create a `.env` file in the project root with your API key: + +``` +OPENROUTER_API_KEY=your-key-here +``` + +> [!CAUTION] +> Add `.env` to your `.gitignore` to avoid committing secrets! + +### Step 4: Create tsconfig.json ```json { @@ -79,177 +134,163 @@ npm install -D typescript @types/react tsx } ``` -### Step 4: Add Scripts to package.json - -```json -{ - "scripts": { - "start": "tsx src/cli.tsx", - "start:headless": "tsx src/headless.ts", - "dev": "tsx watch src/cli.tsx" - } -} -``` - ## File Structure -```bash +``` src/ -├── agent.ts # Standalone agent core with hooks -├── tools.ts # Tool definitions -├── cli.tsx # Ink TUI (optional interface) -└── headless.ts # Headless usage example +├── agent.ts # Core: callModel() with automatic tool execution & event emitter +├── tools.ts # 7 Zod-schema tools with callbacks for UI events +├── cli.tsx # Ink TUI: ASCII banner, streaming, tool indicators +└── headless.ts # readline-based CLI mode ``` -## Step 1: Agent Core with Hooks +--- + +## Implementation -Create `src/agent.ts` - the standalone agent that can run anywhere: +### Step 5: Create src/agent.ts + +The agent uses `callModel()` which handles the entire tool execution loop internally. No manual while loop needed — the SDK validates tool args with Zod, executes tools, and re-queries the model automatically. We use `getItemsStream()` to see all items (messages, tool calls, results) as they happen. ```typescript -import { OpenRouter, tool, stepCountIs } from '@openrouter/sdk'; -import type { Tool, StopCondition, StreamableOutputItem } from '@openrouter/sdk'; +import { OpenRouter } from '@openrouter/sdk'; import { EventEmitter } from 'eventemitter3'; -import { z } from 'zod'; +import { createTools } from './tools.js'; // Message types export interface Message { - role: 'user' | 'assistant' | 'system'; + role: 'user' | 'assistant'; content: string; } -// Agent events for hooks (items-based streaming model) +// Agent events export interface AgentEvents { 'message:user': (message: Message) => void; 'message:assistant': (message: Message) => void; - 'item:update': (item: StreamableOutputItem) => void; // Items emitted with same ID, replace by ID 'stream:start': () => void; 'stream:delta': (delta: string, accumulated: string) => void; 'stream:end': (fullText: string) => void; 'tool:call': (name: string, args: unknown) => void; 'tool:result': (name: string, result: unknown) => void; - 'reasoning:update': (text: string) => void; // Extended thinking content 'error': (error: Error) => void; 'thinking:start': () => void; 'thinking:end': () => void; } - // Agent configuration export interface AgentConfig { apiKey: string; model?: string; instructions?: string; - tools?: Tool[]; - maxSteps?: number; + maxToolRounds?: number; } -// The Agent class - runs independently of any UI +// The Agent class - uses OpenRouter SDK callModel() with automatic tool execution export class Agent extends EventEmitter { private client: OpenRouter; - private messages: Message[] = []; - private config: Required> & { apiKey: string }; + private history: Message[] = []; + private tools: ReturnType; + private config: { + model: string; + instructions: string; + maxToolRounds: number; + }; constructor(config: AgentConfig) { super(); this.client = new OpenRouter({ apiKey: config.apiKey }); this.config = { - apiKey: config.apiKey, - model: config.model ?? 'openrouter/auto', - instructions: config.instructions ?? 'You are a helpful assistant.', - tools: config.tools ?? [], - maxSteps: config.maxSteps ?? 5, + model: config.model ?? 'openai/gpt-4o', + instructions: config.instructions ?? 'You are a skilled coding assistant.', + maxToolRounds: config.maxToolRounds ?? 50, }; + // Create tools with event callbacks wired to this agent's emitter + this.tools = createTools({ + onCall: (name, args) => this.emit('tool:call', name, args), + onResult: (name, result) => this.emit('tool:result', name, result), + }); } - // Get conversation history getMessages(): Message[] { - return [...this.messages]; + return [...this.history]; } - // Clear conversation clearHistory(): void { - this.messages = []; + this.history = []; } - // Add a system message setInstructions(instructions: string): void { this.config.instructions = instructions; } - // Register additional tools at runtime - addTool(newTool: Tool): void { - this.config.tools.push(newTool); - } - - // Send a message and get streaming response using items-based model - // Items are emitted multiple times with the same ID but progressively updated content - // Replace items by their ID rather than accumulating chunks async send(content: string): Promise { const userMessage: Message = { role: 'user', content }; - this.messages.push(userMessage); + this.history.push(userMessage); this.emit('message:user', userMessage); this.emit('thinking:start'); + this.emit('stream:start'); try { + // callModel() handles the entire tool execution loop const result = this.client.callModel({ model: this.config.model, instructions: this.config.instructions, - input: this.messages.map((m) => ({ role: m.role, content: m.content })), - tools: this.config.tools.length > 0 ? this.config.tools : undefined, - stopWhen: [stepCountIs(this.config.maxSteps)], + input: this.history.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content })), + tools: this.tools, }); - this.emit('stream:start'); + // Use getItemsStream() to see ALL items (messages, tool calls, etc.) + // Items are emitted multiple times with same ID but progressively updated content let fullText = ''; + const seenToolCalls = new Set(); + const completedToolCalls = new Set(); - // Use getItemsStream() for items-based streaming (recommended) - // Each item emission is complete - replace by ID, don't accumulate for await (const item of result.getItemsStream()) { - // Emit the item for UI state management (use Map keyed by item.id) - this.emit('item:update', item); - - switch (item.type) { - case 'message': - // Message items contain progressively updated content - const textContent = item.content?.find((c: { type: string }) => c.type === 'output_text'); - if (textContent && 'text' in textContent) { - const newText = textContent.text; - if (newText !== fullText) { - const delta = newText.slice(fullText.length); - fullText = newText; - this.emit('stream:delta', delta, fullText); - } + if (item.type === 'function_call') { + // Tool call item - emit events for UI indicators + const callId = item.callId; + if (!seenToolCalls.has(callId)) { + seenToolCalls.add(callId); + // Parse the arguments safely + let args: unknown = {}; + try { + args = item.arguments ? JSON.parse(item.arguments) : {}; + } catch { + args = { raw: item.arguments }; } - break; - case 'function_call': - // Function call arguments stream progressively - if (item.status === 'completed') { - this.emit('tool:call', item.name, JSON.parse(item.arguments || '{}')); + this.emit('tool:call', item.name, args); + } + } else if (item.type === 'function_call_output') { + // Tool result - mark as complete + const callId = (item as { callId?: string }).callId; + if (callId && !completedToolCalls.has(callId)) { + completedToolCalls.add(callId); + this.emit('tool:result', 'tool', (item as { output?: string }).output); + } + } else if (item.type === 'message') { + // Message item - extract text content and stream it + const messageItem = item as { content?: Array<{ type: string; text?: string }> }; + if (messageItem.content) { + let currentText = ''; + for (const part of messageItem.content) { + if (part.type === 'output_text' && part.text) { + currentText += part.text; + } } - break; - case 'function_call_output': - this.emit('tool:result', item.callId, item.output); - break; - case 'reasoning': - // Extended thinking/reasoning content - const reasoningText = item.content?.find((c: { type: string }) => c.type === 'reasoning_text'); - if (reasoningText && 'text' in reasoningText) { - this.emit('reasoning:update', reasoningText.text); + // Emit delta for new text + if (currentText.length > fullText.length) { + const delta = currentText.slice(fullText.length); + fullText = currentText; + this.emit('stream:delta', delta, fullText); } - break; - // Additional item types: web_search_call, file_search_call, image_generation_call + } } } - // Get final text if streaming didn't capture it - if (!fullText) { - fullText = await result.getText(); - } - this.emit('stream:end', fullText); const assistantMessage: Message = { role: 'assistant', content: fullText }; - this.messages.push(assistantMessage); + this.history.push(assistantMessage); this.emit('message:assistant', assistantMessage); return fullText; @@ -261,209 +302,350 @@ export class Agent extends EventEmitter { this.emit('thinking:end'); } } - - // Send without streaming (simpler for programmatic use) - async sendSync(content: string): Promise { - const userMessage: Message = { role: 'user', content }; - this.messages.push(userMessage); - this.emit('message:user', userMessage); - - try { - const result = this.client.callModel({ - model: this.config.model, - instructions: this.config.instructions, - input: this.messages.map((m) => ({ role: m.role, content: m.content })), - tools: this.config.tools.length > 0 ? this.config.tools : undefined, - stopWhen: [stepCountIs(this.config.maxSteps)], - }); - - const fullText = await result.getText(); - const assistantMessage: Message = { role: 'assistant', content: fullText }; - this.messages.push(assistantMessage); - this.emit('message:assistant', assistantMessage); - - return fullText; - } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)); - this.emit('error', error); - throw error; - } - } } -// Factory function for easy creation +// Factory function export function createAgent(config: AgentConfig): Agent { return new Agent(config); } ``` -## Step 2: Define Tools - -Create `src/tools.ts`: - -```typescript -import { tool } from '@openrouter/sdk'; -import { z } from 'zod'; - -export const timeTool = tool({ - name: 'get_current_time', - description: 'Get the current date and time', - inputSchema: z.object({ - timezone: z.string().optional().describe('Timezone (e.g., "UTC", "America/New_York")'), - }), - execute: async ({ timezone }) => { - return { - time: new Date().toLocaleString('en-US', { timeZone: timezone || 'UTC' }), - timezone: timezone || 'UTC', - }; - }, -}); - -export const calculatorTool = tool({ - name: 'calculate', - description: 'Perform mathematical calculations', - inputSchema: z.object({ - expression: z.string().describe('Math expression (e.g., "2 + 2", "sqrt(16)")'), - }), - execute: async ({ expression }) => { - // Simple safe eval for basic math - const sanitized = expression.replace(/[^0-9+\-*/().\s]/g, ''); - const result = Function(`"use strict"; return (${sanitized})`)(); - return { expression, result }; - }, -}); - -export const defaultTools = [timeTool, calculatorTool]; -``` +--- -## Step 3: Headless Usage (No UI) +### Step 6: Create src/tools.ts -Create `src/headless.ts` - use the agent programmatically: +Tools use Zod schemas for type-safe parameters. The SDK automatically validates args against the schema before calling `execute`. Callbacks notify the UI about tool activity. ```typescript -import { createAgent } from './agent.js'; -import { defaultTools } from './tools.js'; - -async function main() { - const agent = createAgent({ - apiKey: process.env.OPENROUTER_API_KEY!, - model: 'openrouter/auto', - instructions: 'You are a helpful assistant with access to tools.', - tools: defaultTools, - }); - - // Hook into events - agent.on('thinking:start', () => console.log('\n🤔 Thinking...')); - agent.on('tool:call', (name, args) => console.log(`🔧 Using ${name}:`, args)); - agent.on('stream:delta', (delta) => process.stdout.write(delta)); - agent.on('stream:end', () => console.log('\n')); - agent.on('error', (err) => console.error('❌ Error:', err.message)); +import { z } from 'zod/v4'; +import { tool } from '@openrouter/sdk'; +import * as fs from 'fs/promises'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { glob } from 'glob'; - // Interactive loop - const readline = await import('readline'); - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); +const execAsync = promisify(exec); - console.log('Agent ready. Type your message (Ctrl+C to exit):\n'); +// Callbacks for tool event notifications +export interface ToolCallbacks { + onCall?: (name: string, args: unknown) => void; + onResult?: (name: string, result: unknown) => void; +} - const prompt = () => { - rl.question('You: ', async (input) => { - if (!input.trim()) { - prompt(); - return; - } - await agent.send(input); - prompt(); - }); - }; +// Lightweight HTML-to-text helper (no external dependencies) +function htmlToText(html: string): string { + return html + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim(); +} - prompt(); +function extractTitle(html: string): string { + const match = html.match(/]*>([\s\S]*?)<\/title>/i); + return match ? match[1].trim() : ''; } -main().catch(console.error); +// Create all tools using the SDK's tool() helper +// Returns properly typed Tool[] for OpenRouter SDK callModel() +export function createTools(callbacks?: ToolCallbacks) { + const call = (name: string, args: unknown) => callbacks?.onCall?.(name, args); + const done = (name: string, result: T): T => { callbacks?.onResult?.(name, result); return result; }; + + return [ + tool({ + name: 'list_files', + description: 'List files in a directory to understand project structure', + inputSchema: z.object({ + path: z.string().default('.').describe('Directory to search'), + recursive: z.boolean().default(false).describe('List recursively'), + }), + execute: async (params) => { + call('list_files', params); + try { + const files = await glob(params.recursive ? '**/*' : '*', { + cwd: params.path, + nodir: false, + ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**'], + }); + return done('list_files', { files: files.slice(0, 100), count: files.length }); + } catch (e) { + return done('list_files', { error: e instanceof Error ? e.message : String(e) }); + } + }, + }), + + tool({ + name: 'read_file', + description: 'Read the contents of a specific file', + inputSchema: z.object({ + filepath: z.string().describe('Path to the file to read'), + }), + execute: async (params) => { + call('read_file', params); + try { + const content = await fs.readFile(params.filepath, 'utf-8'); + return done('read_file', { filepath: params.filepath, content, length: content.length }); + } catch (e) { + return done('read_file', { error: `Failed to read ${params.filepath}: ${e instanceof Error ? e.message : e}` }); + } + }, + }), + + tool({ + name: 'edit_file', + description: 'Make targeted edits to a file by replacing specific text. Use this instead of write_file when you only need to change part of a file.', + inputSchema: z.object({ + filepath: z.string().describe('Path to the file to edit'), + old_string: z.string().describe('The exact text to find and replace (must match exactly)'), + new_string: z.string().describe('The replacement text'), + replace_all: z.boolean().default(false).describe('Replace all occurrences instead of just the first'), + }), + execute: async (params) => { + call('edit_file', params); + try { + const content = await fs.readFile(params.filepath, 'utf-8'); + if (!content.includes(params.old_string)) { + return done('edit_file', { error: `old_string not found in ${params.filepath}. Make sure it matches exactly, including whitespace and indentation.` }); + } + const occurrences = content.split(params.old_string).length - 1; + if (occurrences > 1 && !params.replace_all) { + return done('edit_file', { error: `old_string found ${occurrences} times in ${params.filepath}. Provide more context to make it unique, or set replace_all to true.` }); + } + const updated = params.replace_all + ? content.replaceAll(params.old_string, params.new_string) + : content.replace(params.old_string, params.new_string); + await fs.writeFile(params.filepath, updated, 'utf-8'); + return done('edit_file', { success: true, filepath: params.filepath, replacements: params.replace_all ? occurrences : 1 }); + } catch (e) { + return done('edit_file', { error: `Failed to edit ${params.filepath}: ${e instanceof Error ? e.message : e}` }); + } + }, + }), + + tool({ + name: 'write_file', + description: 'Write content to a file. Overwrites existing content.', + inputSchema: z.object({ + filepath: z.string().describe('Path to the file to write'), + content: z.string().describe('Content to write'), + }), + execute: async (params) => { + call('write_file', params); + try { + await fs.writeFile(params.filepath, params.content, 'utf-8'); + return done('write_file', { success: true, filepath: params.filepath, bytesWritten: params.content.length }); + } catch (e) { + return done('write_file', { error: `Failed to write ${params.filepath}: ${e instanceof Error ? e.message : e}` }); + } + }, + }), + + tool({ + name: 'run_command', + description: 'Execute a shell command (e.g., git, npm test, ls)', + inputSchema: z.object({ + command: z.string().describe('The shell command to execute'), + }), + execute: async (params) => { + call('run_command', params); + try { + const { stdout, stderr } = await execAsync(params.command); + return done('run_command', { command: params.command, stdout: stdout.trim().slice(0, 2000), stderr: stderr.trim().slice(0, 500) }); + } catch (e) { + const err = e as { message?: string; stdout?: string; stderr?: string }; + return done('run_command', { error: 'Command failed', message: err.message, stdout: err.stdout?.slice(0, 1000) }); + } + }, + }), + + tool({ + name: 'fetch_web_page', + description: 'Fetch text content from a URL (useful for reading docs)', + inputSchema: z.object({ + url: z.string().describe('URL to fetch'), + }), + execute: async (params) => { + call('fetch_web_page', params); + try { + const res = await fetch(params.url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const html = await res.text(); + const title = extractTitle(html); + const text = htmlToText(html).slice(0, 4000); + return done('fetch_web_page', { url: params.url, title, content: text }); + } catch (e) { + return done('fetch_web_page', { error: `Failed to fetch ${params.url}: ${e instanceof Error ? e.message : e}` }); + } + }, + }), + + tool({ + name: 'web_search', + description: 'Search the internet for information using DuckDuckGo. Returns titles, URLs, and snippets.', + inputSchema: z.object({ + query: z.string().describe('The search query'), + num_results: z.number().default(5).describe('Number of results (max 10)'), + }), + execute: async (params) => { + call('web_search', params); + const numResults = Math.min(params.num_results, 10); + try { + const searchUrl = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(params.query)}`; + const res = await fetch(searchUrl, { + headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const html = await res.text(); + const results: Array<{ title: string; url: string; snippet: string }> = []; + const resultBlocks = html.split(/class="result\s/); + for (let i = 1; i < resultBlocks.length && results.length < numResults; i++) { + const block = resultBlocks[i]; + const titleMatch = block.match(/class="result__a"[^>]*>([^<]+)]*>([\s\S]*?)<\/a>/); + const title = titleMatch ? titleMatch[1].trim() : ''; + let url = hrefMatch ? hrefMatch[1] : ''; + if (url.startsWith('//')) url = `https:${url}`; + const snippet = snippetMatch ? htmlToText(snippetMatch[1]).slice(0, 200) : ''; + if (title && url) results.push({ title, url, snippet }); + } + return done('web_search', { query: params.query, results, count: results.length }); + } catch (e) { + return done('web_search', { error: `Search failed: ${e instanceof Error ? e.message : e}` }); + } + }, + }), + ]; +} ``` -Run headless: `OPENROUTER_API_KEY=sk-or-... npm run start:headless` +--- -## Step 4: Ink TUI (Optional Interface) +### Step 7: Create src/cli.tsx -Create `src/cli.tsx` - a beautiful terminal UI that uses the agent with items-based streaming: +A polished terminal interface with ASCII art banner, streaming text, and tool call indicators. -```tsx +```typescript +import 'dotenv/config'; import React, { useState, useEffect, useCallback } from 'react'; -import { render, Box, Text, useInput, useApp } from 'ink'; -import type { StreamableOutputItem } from '@openrouter/sdk'; -import { createAgent, type Agent, type Message } from './agent.js'; -import { defaultTools } from './tools.js'; +import { render, Box, Text, useInput, useApp, useStdout } from 'ink'; +import { createAgent, type Message } from './agent.js'; +// ═══════════════════════════════════════════════════════════════════════════════ +// Simple markdown cleaner for terminal display +// ═══════════════════════════════════════════════════════════════════════════════ +function formatMarkdown(text: string): string { + return text + // Headers: ## Header → ▓ Header + .replace(/^### (.+)$/gm, '\n░ $1') + .replace(/^## (.+)$/gm, '\n▓ $1') + .replace(/^# (.+)$/gm, '\n█ $1 █') + // Bold: **text** → text (remove markers) + .replace(/\*\*([^*]+)\*\*/g, '$1') + // Italic: *text* or _text_ → text + .replace(/\*([^*]+)\*/g, '$1') + .replace(/_([^_]+)_/g, '$1') + // Bullet points: * item or - item → • item + .replace(/^[\s]*[-*]\s+/gm, ' • ') + // Code blocks: ```code``` → just the code + .replace(/```[\w]*\n?([\s\S]*?)```/g, '\n$1\n') + // Inline code: `code` → code + .replace(/`([^`]+)`/g, '$1') + // Links: [text](url) → text + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + // Clean up extra newlines + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// ASCII Banner +// ═══════════════════════════════════════════════════════════════════════════════ +const BANNER = ` + ██████╗ ██████╗ ███████╗███╗ ██╗██████╗ ██████╗ ██╗ ██╗████████╗███████╗██████╗ +██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔══██╗██╔═══██╗██║ ██║╚══██╔══╝██╔════╝██╔══██╗ +██║ ██║██████╔╝█████╗ ██╔██╗ ██║██████╔╝██║ ██║██║ ██║ ██║ █████╗ ██████╔╝ +██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██╔══██╗██║ ██║██║ ██║ ██║ ██╔══╝ ██╔══██╗ +╚██████╔╝██║ ███████╗██║ ╚████║██║ ██║╚██████╔╝╚██████╔╝ ██║ ███████╗██║ ██║ + ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═╝`; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Initialization +// ═══════════════════════════════════════════════════════════════════════════════ +if (!process.env.OPENROUTER_API_KEY) { + console.error('\n\x1b[31m✖ Error: OPENROUTER_API_KEY required\x1b[0m'); + console.error('\x1b[90mCreate a .env file with: OPENROUTER_API_KEY=your-key-here\x1b[0m\n'); + process.exit(1); +} -// Initialize agent (runs independently of UI) const agent = createAgent({ - apiKey: process.env.OPENROUTER_API_KEY!, - model: 'openrouter/auto', - instructions: 'You are a helpful assistant. Be concise.', - tools: defaultTools, + apiKey: process.env.OPENROUTER_API_KEY, + model: 'openai/gpt-4o', + instructions: `You are an autonomous coding agent. You can read files, write code, run commands, search the web, and read documentation. + +IMPORTANT: After using tools, ALWAYS provide a summary of what you found and your insights. Don't just call tools and stop - synthesize the information into a helpful response. + +When presenting information: +- Use markdown formatting for readability +- Use bullet points for lists +- Use code blocks for code +- Be concise but thorough`, + maxToolRounds: 50, }); -function ChatMessage({ message }: { message: Message }) { - const isUser = message.role === 'user'; +// ═══════════════════════════════════════════════════════════════════════════════ +// Types +// ═══════════════════════════════════════════════════════════════════════════════ +interface ToolCallInfo { name: string; status: 'running' | 'complete'; args?: string; } +interface DisplayMessage { role: 'user' | 'assistant'; content: string; timestamp: Date; } + +// ═══════════════════════════════════════════════════════════════════════════════ +// Components +// ═══════════════════════════════════════════════════════════════════════════════ +function Header() { + const { stdout } = useStdout(); + const width = stdout?.columns ?? 100; + + if (width >= 95) { + return ( + + {BANNER} + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Coding Agent + + Model: + gpt-4o + + Press + ESC + to exit + + + ); + } return ( - - {isUser ? '▶ You' : '◀ Assistant'} - - {message.content} + ◆ OPENROUTER Coding Agent + ───────────────────────────────────────── ); } -// Render streaming items by type using the items-based pattern -function ItemRenderer({ item }: { item: StreamableOutputItem }) { - switch (item.type) { - case 'message': { - const textContent = item.content?.find((c: { type: string }) => c.type === 'output_text'); - const text = textContent && 'text' in textContent ? textContent.text : ''; - return ( - - ◀ Assistant - {text} - {item.status !== 'completed' && } - - ); - } - case 'function_call': - return ( - - {item.status === 'completed' ? ' ✓' : ' 🔧'} {item.name} - {item.status === 'in_progress' && '...'} - - ); - case 'reasoning': { - const reasoningText = item.content?.find((c: { type: string }) => c.type === 'reasoning_text'); - const text = reasoningText && 'text' in reasoningText ? reasoningText.text : ''; - return ( - - 💭 Thinking - {text} - - ); - } - default: - return null; - } -} - -function InputField({ - value, - onChange, - onSubmit, - disabled, -}: { - value: string; - onChange: (v: string) => void; - onSubmit: () => void; - disabled: boolean; +function InputBox({ value, onChange, onSubmit, disabled }: { + value: string; onChange: (v: string) => void; onSubmit: () => void; disabled: boolean; }) { useInput((input, key) => { if (disabled) return; @@ -471,98 +653,116 @@ function InputField({ else if (key.backspace || key.delete) onChange(value.slice(0, -1)); else if (input && !key.ctrl && !key.meta) onChange(value + input); }); - return ( - - {'> '} + + {value} - {disabled ? ' ···' : '█'} + {!disabled && } + {disabled && thinking...} + + ); +} + +function ToolCallDisplay({ tools }: { tools: ToolCallInfo[] }) { + if (!tools.length) return null; + return ( + + {tools.map((tc, i) => ( + + + {tc.status === 'complete' ? '✓' : '⚡'} + + {tc.name} + {tc.args && {tc.args.slice(0, 40)}...} + + ))} ); } +function MessageDisplay({ msg }: { msg: DisplayMessage }) { + const isUser = msg.role === 'user'; + const time = msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + // Apply markdown formatting to assistant messages only + const displayContent = isUser ? msg.content : formatMarkdown(msg.content); + return ( + + + {isUser ? '● You' : '◆ Assistant'} + {time} + + {displayContent} + + ); +} + +function StatusBar({ status }: { status: string }) { + return {status}; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Main App +// ═══════════════════════════════════════════════════════════════════════════════ function App() { const { exit } = useApp(); - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [isLoading, setIsLoading] = useState(false); - // Use Map keyed by item ID for efficient React state updates (items-based pattern) - const [items, setItems] = useState>(new Map()); + const [toolCalls, setToolCalls] = useState([]); + const [streamingText, setStreamingText] = useState(''); + const [error, setError] = useState(null); + const [status, setStatus] = useState('Ready'); - useInput((_, key) => { - if (key.escape) exit(); - }); + useInput((_, key) => { if (key.escape) exit(); }); - // Subscribe to agent events using items-based streaming useEffect(() => { - const onThinkingStart = () => { - setIsLoading(true); - setItems(new Map()); // Clear items for new response + const onStart = () => { setIsLoading(true); setToolCalls([]); setStreamingText(''); setError(null); setStatus('Processing...'); }; + const onDelta = (_d: string, acc: string) => { setStreamingText(acc); setStatus('Streaming...'); }; + const onToolCall = (name: string, args: unknown) => { + setToolCalls(prev => [...prev, { name, status: 'running', args: JSON.stringify(args) }]); + setStatus(`Running ${name}...`); }; - - // Items-based streaming: replace items by ID, don't accumulate - const onItemUpdate = (item: StreamableOutputItem) => { - setItems((prev) => new Map(prev).set(item.id, item)); - }; - - const onMessageAssistant = () => { - setMessages(agent.getMessages()); - setItems(new Map()); // Clear streaming items - setIsLoading(false); + const onToolResult = () => setToolCalls(prev => prev.map(tc => tc.status === 'running' ? { ...tc, status: 'complete' as const } : tc)); + const onDone = () => { + setMessages(agent.getMessages().map(m => ({ role: m.role as 'user' | 'assistant', content: m.content, timestamp: new Date() }))); + setToolCalls([]); setStreamingText(''); setIsLoading(false); setStatus('Ready'); }; + const onError = (err: Error) => { setError(err.message); setIsLoading(false); setStatus('Error'); }; - const onError = (err: Error) => { - setIsLoading(false); - }; - - agent.on('thinking:start', onThinkingStart); - agent.on('item:update', onItemUpdate); - agent.on('message:assistant', onMessageAssistant); + agent.on('thinking:start', onStart); + agent.on('stream:delta', onDelta); + agent.on('tool:call', onToolCall); + agent.on('tool:result', onToolResult); + agent.on('message:assistant', onDone); agent.on('error', onError); - - return () => { - agent.off('thinking:start', onThinkingStart); - agent.off('item:update', onItemUpdate); - agent.off('message:assistant', onMessageAssistant); - agent.off('error', onError); - }; + return () => { agent.off('thinking:start', onStart); agent.off('stream:delta', onDelta); agent.off('tool:call', onToolCall); agent.off('tool:result', onToolResult); agent.off('message:assistant', onDone); agent.off('error', onError); }; }, []); - const sendMessage = useCallback(async () => { + const send = useCallback(async () => { if (!input.trim() || isLoading) return; const text = input.trim(); setInput(''); - setMessages((prev) => [...prev, { role: 'user', content: text }]); - await agent.send(text); + setMessages(prev => [...prev, { role: 'user', content: text, timestamp: new Date() }]); + try { await agent.send(text); } catch (e) { setError(e instanceof Error ? e.message : String(e)); setIsLoading(false); } }, [input, isLoading]); return ( - - - 🤖 OpenRouter Agent - (Esc to exit) - - - - {/* Render completed messages */} - {messages.map((msg, i) => ( - - ))} - - {/* Render streaming items by type (items-based pattern) */} - {Array.from(items.values()).map((item) => ( - - ))} - - - - + +
+ + {messages.map((msg, i) => )} + + {streamingText && isLoading && ( + + ◆ Assistant (streaming...) + {streamingText} + + )} + {isLoading && !streamingText && !toolCalls.length && Thinking...} + {error && ✖ Error: {error}} + + ); } @@ -570,283 +770,68 @@ function App() { render(); ``` -Run TUI: `OPENROUTER_API_KEY=sk-or-... npm start` - -## Understanding Items-Based Streaming - -The OpenRouter SDK uses an **items-based streaming model** - a key paradigm where items are emitted multiple times with the same ID but progressively updated content. Instead of accumulating chunks, you **replace items by their ID**. - -### How It Works - -Each iteration of `getItemsStream()` yields a complete item with updated content: - -```typescript -// Iteration 1: Partial message -{ id: "msg_123", type: "message", content: [{ type: "output_text", text: "Hello" }] } - -// Iteration 2: Updated message (replace, don't append) -{ id: "msg_123", type: "message", content: [{ type: "output_text", text: "Hello world" }] } -``` - -For function calls, arguments stream progressively: - -```typescript -// Iteration 1: Partial arguments -{ id: "call_456", type: "function_call", name: "get_weather", arguments: "{\"q" } - -// Iteration 2: Complete arguments -{ id: "call_456", type: "function_call", name: "get_weather", arguments: "{\"query\": \"Paris\"}", status: "completed" } -``` - -### Why Items Are Better - -**Traditional (accumulation required):** -```typescript -let text = ''; -for await (const chunk of result.getTextStream()) { - text += chunk; // Manual accumulation - updateUI(text); -} -``` - -**Items (complete replacement):** -```typescript -const items = new Map(); -for await (const item of result.getItemsStream()) { - items.set(item.id, item); // Replace by ID - updateUI(items); -} -``` - -Benefits: -- **No manual chunk management** - each item is complete -- **Handles concurrent outputs** - function calls and messages can stream in parallel -- **Full TypeScript inference** for all item types -- **Natural Map-based state** works perfectly with React/UI frameworks - -## Extending the Agent - -### Add Custom Hooks - -```typescript -const agent = createAgent({ apiKey: '...' }); - -// Log all events -agent.on('message:user', (msg) => { - saveToDatabase('user', msg.content); -}); - -agent.on('message:assistant', (msg) => { - saveToDatabase('assistant', msg.content); - sendWebhook('new_message', msg); -}); - -agent.on('tool:call', (name, args) => { - analytics.track('tool_used', { name, args }); -}); - -agent.on('error', (err) => { - errorReporting.capture(err); -}); -``` - -### Use with HTTP Server - -```typescript -import express from 'express'; -import { createAgent } from './agent.js'; - -const app = express(); -app.use(express.json()); - -// One agent per session (store in memory or Redis) -const sessions = new Map(); - -app.post('/chat', async (req, res) => { - const { sessionId, message } = req.body; - - let agent = sessions.get(sessionId); - if (!agent) { - agent = createAgent({ apiKey: process.env.OPENROUTER_API_KEY! }); - sessions.set(sessionId, agent); - } - - const response = await agent.sendSync(message); - res.json({ response, history: agent.getMessages() }); -}); +--- -app.listen(3000); -``` +### Step 8: Create src/headless.ts -### Use with Discord +Useful for CI/CD pipelines or API integration. ```typescript -import { Client, GatewayIntentBits } from 'discord.js'; +import 'dotenv/config'; import { createAgent } from './agent.js'; +import * as readline from 'readline'; -const discord = new Client({ - intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages], -}); - -const agents = new Map(); - -discord.on('messageCreate', async (msg) => { - if (msg.author.bot) return; - - let agent = agents.get(msg.channelId); - if (!agent) { - agent = createAgent({ apiKey: process.env.OPENROUTER_API_KEY! }); - agents.set(msg.channelId, agent); +async function main() { + if (!process.env.OPENROUTER_API_KEY) { + console.error('Error: OPENROUTER_API_KEY environment variable is required'); + process.exit(1); } - const response = await agent.sendSync(msg.content); - await msg.reply(response); -}); - -discord.login(process.env.DISCORD_TOKEN); -``` - -## Agent API Reference - -### Constructor Options - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| apiKey | string | required | OpenRouter API key | -| model | string | 'openrouter/auto' | Model to use | -| instructions | string | 'You are a helpful assistant.' | System prompt | -| tools | Tool[] | [] | Available tools | -| maxSteps | number | 5 | Max agentic loop iterations | - -### Methods - -| Method | Returns | Description | -|--------|---------|-------------| -| `send(content)` | Promise | Send message with streaming | -| `sendSync(content)` | Promise | Send message without streaming | -| `getMessages()` | Message[] | Get conversation history | -| `clearHistory()` | void | Clear conversation | -| `setInstructions(text)` | void | Update system prompt | -| `addTool(tool)` | void | Add tool at runtime | - -### Events - -| Event | Payload | Description | -|-------|---------|-------------| -| `message:user` | Message | User message added | -| `message:assistant` | Message | Assistant response complete | -| `item:update` | StreamableOutputItem | Item emitted (replace by ID, don't accumulate) | -| `stream:start` | - | Streaming started | -| `stream:delta` | (delta, accumulated) | New text chunk | -| `stream:end` | fullText | Streaming complete | -| `tool:call` | (name, args) | Tool being called | -| `tool:result` | (name, result) | Tool returned result | -| `reasoning:update` | text | Extended thinking content | -| `thinking:start` | - | Agent processing | -| `thinking:end` | - | Agent done processing | -| `error` | Error | Error occurred | - -### Item Types (from getItemsStream) - -The SDK uses an items-based streaming model where items are emitted multiple times with the same ID but progressively updated content. Replace items by their ID rather than accumulating chunks. - -| Type | Purpose | -|------|---------| -| `message` | Assistant text responses | -| `function_call` | Tool invocations with streaming arguments | -| `function_call_output` | Results from executed tools | -| `reasoning` | Extended thinking content | -| `web_search_call` | Web search operations | -| `file_search_call` | File search operations | -| `image_generation_call` | Image generation operations | - -## Discovering Models - -**Do not hardcode model IDs** - they change frequently. Use the models API: - -### Fetch Available Models - -```typescript -interface OpenRouterModel { - id: string; - name: string; - description?: string; - context_length: number; - pricing: { prompt: string; completion: string }; - top_provider?: { is_moderated: boolean }; -} - -async function fetchModels(): Promise { - const res = await fetch('https://openrouter.ai/api/v1/models'); - const data = await res.json(); - return data.data; -} - -// Find models by criteria -async function findModels(filter: { - author?: string; // e.g., 'anthropic', 'openai', 'google' - minContext?: number; // e.g., 100000 for 100k context - maxPromptPrice?: number; // e.g., 0.001 for cheap models -}): Promise { - const models = await fetchModels(); - - return models.filter((m) => { - if (filter.author && !m.id.startsWith(filter.author + '/')) return false; - if (filter.minContext && m.context_length < filter.minContext) return false; - if (filter.maxPromptPrice) { - const price = parseFloat(m.pricing.prompt); - if (price > filter.maxPromptPrice) return false; - } - return true; + const agent = createAgent({ + apiKey: process.env.OPENROUTER_API_KEY, + model: 'openai/gpt-4o', + instructions: 'You are a capable coding agent. You can inspect files, write code, run tests, and browse the web.', + maxToolRounds: 50, }); -} - -// Example: Get latest Claude models -const claudeModels = await findModels({ author: 'anthropic' }); -console.log(claudeModels.map((m) => m.id)); - -// Example: Get models with 100k+ context -const longContextModels = await findModels({ minContext: 100000 }); -// Example: Get cheap models -const cheapModels = await findModels({ maxPromptPrice: 0.0005 }); -``` + agent.on('thinking:start', () => console.log('\n🤔 Thinking...')); + agent.on('tool:call', (name, args) => console.log(`🔧 Using ${name}:`, JSON.stringify(args))); + agent.on('tool:result', (name) => console.log(` ✅ Result from ${name}`)); + agent.on('stream:delta', (delta) => process.stdout.write(delta)); + agent.on('stream:end', () => console.log('\n')); + agent.on('error', (err) => console.error('❌ Error:', err.message)); -### Dynamic Model Selection in Agent + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + console.log('🤖 OpenRouter Coding Agent (Headless Mode)\nType your message (Ctrl+C to exit):\n'); -```typescript -// Create agent with dynamic model selection -const models = await fetchModels(); -const bestModel = models.find((m) => m.id.includes('claude')) || models[0]; + const prompt = () => { + rl.question('You: ', async (input) => { + if (!input.trim()) { prompt(); return; } + try { await agent.send(input); } catch (error) { console.error('Error:', error); } + prompt(); + }); + }; + prompt(); +} -const agent = createAgent({ - apiKey: process.env.OPENROUTER_API_KEY!, - model: bestModel.id, // Use discovered model - instructions: 'You are a helpful assistant.', -}); +main().catch(console.error); ``` -### Using openrouter/auto - -For simplicity, use `openrouter/auto` which automatically selects the best -available model for your request: - -```typescript -const agent = createAgent({ - apiKey: process.env.OPENROUTER_API_KEY!, - model: 'openrouter/auto', // Auto-selects best model -}); -``` +--- -### Models API Reference +## Running the Agent -- **Endpoint**: `GET https://openrouter.ai/api/v1/models` -- **Response**: `{ data: OpenRouterModel[] }` -- **Browse models**: https://openrouter.ai/models +1. **Add your API key to `.env`:** + ``` + OPENROUTER_API_KEY=sk-or-v1-xxxxx + ``` -## Resources +2. **Run the TUI:** + ```bash + npm start + ``` -- OpenRouter Docs: https://openrouter.ai/docs -- Models API: https://openrouter.ai/api/v1/models -- Ink Docs: https://github.com/vadimdemedes/ink -- Get API Key: https://openrouter.ai/settings/keys +3. **Or run in headless mode:** + ```bash + npm run start:headless + ``` From b620e3e301e5816c0cc1acec4816ec7f6a5f8503 Mon Sep 17 00:00:00 2001 From: munish-shah Date: Mon, 16 Feb 2026 22:02:31 -0600 Subject: [PATCH 2/2] Address review feedback: flatten branching, use openrouter/auto with model picker, dynamic deps, Node LTS, generic agent framing, THEME constant for terminal readability --- skills/create-agent/SKILL.md | 811 ++++++++++++++++++++++++----------- 1 file changed, 564 insertions(+), 247 deletions(-) diff --git a/skills/create-agent/SKILL.md b/skills/create-agent/SKILL.md index 681a112..5a13638 100644 --- a/skills/create-agent/SKILL.md +++ b/skills/create-agent/SKILL.md @@ -1,14 +1,14 @@ --- name: create-agent -description: Bootstrap a CLI coding agent with OpenRouter SDK callModel() API, Zod tools, and Ink TUI +description: Bootstrap a modular AI agent with OpenRouter SDK callModel() API, Zod tools, and Ink TUI metadata: - version: 0.3.0 + version: 0.4.0 homepage: https://openrouter.ai --- -# Build a Modular AI Coding Agent with OpenRouter +# Build a Modular AI Agent with OpenRouter -This skill helps you create a **terminal-based coding agent**. Unlike simple chatbots, this agent can: +This skill helps you create a **terminal-based agent**. Unlike simple chatbots, this agent can: - **Read & Write Files** - Modify your codebase directly - **Edit Files** - Targeted find-and-replace edits @@ -21,14 +21,17 @@ This skill helps you create a **terminal-based coding agent**. Unlike simple cha ``` ┌─────────────────────────────────────────────────┐ -│ cli.tsx (TUI) │ -│ Ink React components + events │ +│ cli.tsx (TUI) / headless.ts (CLI) │ +│ UI layer + user interaction │ ├─────────────────────────────────────────────────┤ -│ agent.ts (Core) │ +│ config.ts (Setup) │ +│ Env validation, shared agent instance │ +├─────────────────────────────────────────────────┤ +│ agent.ts (Core) │ │ OpenRouter SDK callModel() + getItemsStream() │ │ EventEmitter for UI hooks │ ├─────────────────────────────────────────────────┤ -│ tools.ts (Tools) │ +│ tools.ts (Tools) │ │ 7 Zod-schema tools with callbacks │ │ list_files, read_file, edit_file, write_file, │ │ run_command, fetch_web_page, web_search │ @@ -36,22 +39,27 @@ This skill helps you create a **terminal-based coding agent**. Unlike simple cha ``` **How it works:** -1. Agent calls `callModel()` with tools -2. SDK automatically validates args with Zod, executes tools, sends results back to model -3. SDK repeats until the model stops calling tools -4. `getItemsStream()` streams all items (messages, tool calls, results) to the TUI -5. Tool callbacks emit events for the UI's tool indicators + +1. `config.ts` validates the API key and creates a shared agent instance +2. Agent calls `callModel()` with tools +3. SDK automatically validates args with Zod, executes tools, sends results back to model +4. SDK repeats until the model stops calling tools +5. `getItemsStream()` streams all items (messages, tool calls, results) to the TUI +6. Tool callbacks emit events for the UI's tool indicators ## Prerequisites > [!IMPORTANT] -> **Node.js 18.x or 20.x required.** +> **Node.js LTS (latest stable even-numbered release) required.** +> Run `node -v` to check. If below the current LTS, upgrade via [nodejs.org](https://nodejs.org) or `nvm install --lts && nvm alias default lts/*`. +> All dependencies use unpinned ranges (`npm install` fetches latest compatible versions), so no version numbers in this skill will go stale. Get an OpenRouter API key at: https://openrouter.ai/settings/keys > [!CAUTION] > **Dependency Safety Rules (MUST follow):** -> - Use ONLY the exact dependency versions listed in the package.json below +> +> - Use the dependencies listed in the package.json below > - Do NOT add `cheerio`, `undici`, or any package with native bindings — these frequently break across Node versions > - For HTML parsing, use the built-in regex-based `htmlToText()` helper in tools.ts — no external HTML parser needed > - After `npm install`, verify zero errors by running `npm start` before considering setup complete @@ -60,59 +68,67 @@ Get an OpenRouter API key at: https://openrouter.ai/settings/keys ### Step 1: Initialize Project +Verify Node.js LTS is available, then scaffold: + ```bash -mkdir my-coding-agent && cd my-coding-agent +node -v # must be current LTS (even-numbered: 20, 22, 24, …) +mkdir my-agent && cd my-agent npm init -y npm pkg set type="module" mkdir src +echo -e 'node_modules/\ndist/\n.env\n.DS_Store' > .gitignore ``` -### Step 2: Create package.json +### Step 2: Install Dependencies -Replace your `package.json` with this exact content for guaranteed compatibility: +Install runtime dependencies (no version pinning — always fetches latest compatible): -```json -{ - "name": "my-coding-agent", - "version": "1.0.0", - "type": "module", - "scripts": { - "start": "tsx src/cli.tsx", - "start:headless": "tsx src/headless.ts", - "dev": "tsx watch src/cli.tsx" - }, - "dependencies": { - "@openrouter/sdk": "^0.8.0", - "dotenv": "^16.4.5", - "eventemitter3": "^5.0.4", - "glob": "^10.4.5", - "ink": "^4.4.1", - "react": "^18.3.1", - "zod": "^3.25.0" - }, - "devDependencies": { - "@types/node": "^20.14.0", - "@types/react": "^18.3.28", - "tsx": "^4.21.0", - "typescript": "^5.5.0" - } -} +```bash +npm install @openrouter/sdk dotenv eventemitter3 glob ink react zod ``` -Then install: +| Package | Purpose | npm | +| ----------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `@openrouter/sdk` | LLM API client with streaming & tool-call support | [npmjs.com/package/@openrouter/sdk](https://www.npmjs.com/package/@openrouter/sdk) | +| `dotenv` | Loads `.env` vars into `process.env` | [npmjs.com/package/dotenv](https://www.npmjs.com/package/dotenv) | +| `eventemitter3` | Lightweight event bus for agent ↔ UI communication | [npmjs.com/package/eventemitter3](https://www.npmjs.com/package/eventemitter3) | +| `glob` | File-pattern matching for tool implementations | [npmjs.com/package/glob](https://www.npmjs.com/package/glob) | +| `ink` | React-based terminal UI framework | [npmjs.com/package/ink](https://www.npmjs.com/package/ink) | +| `react` | Required peer dependency for Ink | [npmjs.com/package/react](https://www.npmjs.com/package/react) | +| `zod` | Schema validation used for defining tool parameters | [npmjs.com/package/zod](https://www.npmjs.com/package/zod) | + +Install dev dependencies: ```bash -npm install +npm install -D @types/node @types/react tsx typescript +``` + +| Package | Purpose | npm | +| -------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `@types/node` | TypeScript types for Node.js APIs | [npmjs.com/package/@types/node](https://www.npmjs.com/package/@types/node) | +| `@types/react` | TypeScript types for React (needed by Ink) | [npmjs.com/package/@types/react](https://www.npmjs.com/package/@types/react) | +| `tsx` | TypeScript execution engine (runs `.ts`/`.tsx` directly) | [npmjs.com/package/tsx](https://www.npmjs.com/package/tsx) | +| `typescript` | TypeScript compiler | [npmjs.com/package/typescript](https://www.npmjs.com/package/typescript) | + +Then add the scripts to `package.json`: + +```bash +npm pkg set scripts.start="tsx src/cli.tsx" +npm pkg set scripts.start:headless="tsx src/headless.ts" +npm pkg set scripts.dev="tsx watch src/cli.tsx" ``` ### Step 3: Create .env file -Create a `.env` file in the project root with your API key: +Create a `.env` file in the project root with your API key and (optionally) a model: ``` OPENROUTER_API_KEY=your-key-here +MODEL=openrouter/auto ``` +`MODEL` is optional — it defaults to `openrouter/auto` (smart routing). Set it to any tool-capable model ID from [openrouter.ai/models](https://openrouter.ai/models). + > [!CAUTION] > Add `.env` to your `.gitignore` to avoid committing secrets! @@ -139,6 +155,7 @@ OPENROUTER_API_KEY=your-key-here ``` src/ ├── agent.ts # Core: callModel() with automatic tool execution & event emitter +├── config.ts # Shared setup: env validation, agent initialization ├── tools.ts # 7 Zod-schema tools with callbacks for UI events ├── cli.tsx # Ink TUI: ASCII banner, streaming, tool indicators └── headless.ts # readline-based CLI mode @@ -150,42 +167,52 @@ src/ ### Step 5: Create src/agent.ts -The agent uses `callModel()` which handles the entire tool execution loop internally. No manual while loop needed — the SDK validates tool args with Zod, executes tools, and re-queries the model automatically. We use `getItemsStream()` to see all items (messages, tool calls, results) as they happen. +The agent uses `callModel()` which handles the entire tool execution loop internally. No manual while loop needed — the SDK validates tool args with Zod, executes tools, and re-queries the model automatically. We consume `getItemsStream()` to drive the full loop and extract text deltas for streaming. Tool callbacks emit events to the UI during execution. ```typescript -import { OpenRouter } from '@openrouter/sdk'; +import { OpenRouter, stepCountIs } from '@openrouter/sdk'; import { EventEmitter } from 'eventemitter3'; import { createTools } from './tools.js'; -// Message types +// ─── Types ─────────────────────────────────────────────────────────────────── + export interface Message { role: 'user' | 'assistant'; content: string; } -// Agent events +export interface ModelInfo { + id: string; + name: string; + contextLength: number | null; + promptPricing: string; + completionPricing: string; +} + export interface AgentEvents { 'message:user': (message: Message) => void; 'message:assistant': (message: Message) => void; 'stream:start': () => void; 'stream:delta': (delta: string, accumulated: string) => void; 'stream:end': (fullText: string) => void; - 'tool:call': (name: string, args: unknown) => void; - 'tool:result': (name: string, result: unknown) => void; - 'error': (error: Error) => void; + 'tool:call': (name: string, args: Record) => void; + 'tool:result': (name: string, result: Record) => void; + 'model:changed': (modelId: string) => void; + error: (error: Error) => void; 'thinking:start': () => void; 'thinking:end': () => void; } -// Agent configuration export interface AgentConfig { apiKey: string; + /** Model ID from OpenRouter (e.g. "anthropic/claude-sonnet-4"). Defaults to "openrouter/auto". */ model?: string; instructions?: string; maxToolRounds?: number; } -// The Agent class - uses OpenRouter SDK callModel() with automatic tool execution +export const DEFAULT_MODEL = 'openrouter/auto'; + export class Agent extends EventEmitter { private client: OpenRouter; private history: Message[] = []; @@ -200,17 +227,25 @@ export class Agent extends EventEmitter { super(); this.client = new OpenRouter({ apiKey: config.apiKey }); this.config = { - model: config.model ?? 'openai/gpt-4o', - instructions: config.instructions ?? 'You are a skilled coding assistant.', + model: config.model ?? DEFAULT_MODEL, + instructions: config.instructions ?? 'You are a skilled AI assistant.', maxToolRounds: config.maxToolRounds ?? 50, }; - // Create tools with event callbacks wired to this agent's emitter + // Tool callbacks are the single source of truth for tool events. + // No duplicate emissions from the stream — callbacks fire during execute(). this.tools = createTools({ - onCall: (name, args) => this.emit('tool:call', name, args), - onResult: (name, result) => this.emit('tool:result', name, result), + onCall: (name, args) => + this.emit('tool:call', name, args as Record), + onResult: (name, result) => + this.emit('tool:result', name, result as Record), }); } + /** The model ID currently in use (useful for displaying in UI). */ + get model(): string { + return this.config.model; + } + getMessages(): Message[] { return [...this.history]; } @@ -223,6 +258,41 @@ export class Agent extends EventEmitter { this.config.instructions = instructions; } + setModel(modelId: string): void { + this.config.model = modelId; + this.emit('model:changed', modelId); + } + + async listToolCapableModels(): Promise { + const response = await this.client.models.list({ + supportedParameters: 'tools', + }); + + const models = response.data.map((m) => ({ + id: m.id, + name: m.name, + contextLength: m.contextLength, + promptPricing: m.pricing.prompt, + completionPricing: m.pricing.completion, + })); + + // Provider priority order (earlier = higher priority) + const providerOrder = ['anthropic', 'openai', 'google', 'meta-llama', 'x-ai', 'deepseek', 'mistralai']; + + const getProviderPriority = (id: string): number => { + const provider = id.split('/')[0]; + const index = providerOrder.indexOf(provider); + return index === -1 ? providerOrder.length : index; + }; + + // Sort by: provider priority first, then alphabetically within each provider + return models.sort((a, b) => { + const priorityDiff = getProviderPriority(a.id) - getProviderPriority(b.id); + if (priorityDiff !== 0) return priorityDiff; + return a.name.localeCompare(b.name); + }); + } + async send(content: string): Promise { const userMessage: Message = { role: 'user', content }; this.history.push(userMessage); @@ -231,65 +301,44 @@ export class Agent extends EventEmitter { this.emit('stream:start'); try { - // callModel() handles the entire tool execution loop + // callModel() handles the entire tool execution loop automatically. + // Tool callbacks emit tool:call / tool:result events during execution. + // We consume getItemsStream() to drive the full loop (including tool rounds) + // and extract text deltas from 'message' items for streaming to the UI. const result = this.client.callModel({ model: this.config.model, instructions: this.config.instructions, - input: this.history.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content })), + input: this.history.map((m) => ({ + role: m.role, + content: m.content, + })), tools: this.tools, + stopWhen: stepCountIs(this.config.maxToolRounds), }); - // Use getItemsStream() to see ALL items (messages, tool calls, etc.) - // Items are emitted multiple times with same ID but progressively updated content + // getItemsStream() drives the full tool execution loop. + // We only extract text here — tool events come from callbacks. let fullText = ''; - const seenToolCalls = new Set(); - const completedToolCalls = new Set(); - for await (const item of result.getItemsStream()) { - if (item.type === 'function_call') { - // Tool call item - emit events for UI indicators - const callId = item.callId; - if (!seenToolCalls.has(callId)) { - seenToolCalls.add(callId); - // Parse the arguments safely - let args: unknown = {}; - try { - args = item.arguments ? JSON.parse(item.arguments) : {}; - } catch { - args = { raw: item.arguments }; - } - this.emit('tool:call', item.name, args); - } - } else if (item.type === 'function_call_output') { - // Tool result - mark as complete - const callId = (item as { callId?: string }).callId; - if (callId && !completedToolCalls.has(callId)) { - completedToolCalls.add(callId); - this.emit('tool:result', 'tool', (item as { output?: string }).output); - } - } else if (item.type === 'message') { - // Message item - extract text content and stream it - const messageItem = item as { content?: Array<{ type: string; text?: string }> }; - if (messageItem.content) { - let currentText = ''; - for (const part of messageItem.content) { - if (part.type === 'output_text' && part.text) { - currentText += part.text; - } - } - // Emit delta for new text - if (currentText.length > fullText.length) { - const delta = currentText.slice(fullText.length); - fullText = currentText; - this.emit('stream:delta', delta, fullText); - } - } - } + if (item.type !== 'message') continue; + const parts = (item as { content?: Array<{ type: string; text?: string }> }).content; + if (!parts) continue; + const currentText = parts + .filter((p) => p.type === 'output_text' && p.text) + .map((p) => p.text!) + .join(''); + if (currentText.length <= fullText.length) continue; + const delta = currentText.slice(fullText.length); + fullText = currentText; + this.emit('stream:delta', delta, fullText); } this.emit('stream:end', fullText); - const assistantMessage: Message = { role: 'assistant', content: fullText }; + const assistantMessage: Message = { + role: 'assistant', + content: fullText, + }; this.history.push(assistantMessage); this.emit('message:assistant', assistantMessage); @@ -304,7 +353,6 @@ export class Agent extends EventEmitter { } } -// Factory function export function createAgent(config: AgentConfig): Agent { return new Agent(config); } @@ -320,12 +368,20 @@ Tools use Zod schemas for type-safe parameters. The SDK automatically validates import { z } from 'zod/v4'; import { tool } from '@openrouter/sdk'; import * as fs from 'fs/promises'; +import { dirname } from 'path'; import { exec } from 'child_process'; import { promisify } from 'util'; import { glob } from 'glob'; const execAsync = promisify(exec); +const COMMAND_TIMEOUT_MS = 30_000; +const FETCH_TIMEOUT_MS = 15_000; + +function toErrorMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + // Callbacks for tool event notifications export interface ToolCallbacks { onCall?: (name: string, args: unknown) => void; @@ -360,7 +416,10 @@ function extractTitle(html: string): string { // Returns properly typed Tool[] for OpenRouter SDK callModel() export function createTools(callbacks?: ToolCallbacks) { const call = (name: string, args: unknown) => callbacks?.onCall?.(name, args); - const done = (name: string, result: T): T => { callbacks?.onResult?.(name, result); return result; }; + const done = (name: string, result: T): T => { + callbacks?.onResult?.(name, result); + return result; + }; return [ tool({ @@ -378,9 +437,12 @@ export function createTools(callbacks?: ToolCallbacks) { nodir: false, ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**'], }); - return done('list_files', { files: files.slice(0, 100), count: files.length }); + return done('list_files', { + files: files.slice(0, 100), + count: files.length, + }); } catch (e) { - return done('list_files', { error: e instanceof Error ? e.message : String(e) }); + return done('list_files', { error: toErrorMessage(e) }); } }, }), @@ -395,47 +457,69 @@ export function createTools(callbacks?: ToolCallbacks) { call('read_file', params); try { const content = await fs.readFile(params.filepath, 'utf-8'); - return done('read_file', { filepath: params.filepath, content, length: content.length }); + return done('read_file', { + filepath: params.filepath, + content, + length: content.length, + }); } catch (e) { - return done('read_file', { error: `Failed to read ${params.filepath}: ${e instanceof Error ? e.message : e}` }); + return done('read_file', { + error: `Failed to read ${params.filepath}: ${toErrorMessage(e)}`, + }); } }, }), tool({ name: 'edit_file', - description: 'Make targeted edits to a file by replacing specific text. Use this instead of write_file when you only need to change part of a file.', + description: + 'Make targeted edits to a file by replacing specific text. Use this instead of write_file when you only need to change part of a file.', inputSchema: z.object({ filepath: z.string().describe('Path to the file to edit'), - old_string: z.string().describe('The exact text to find and replace (must match exactly)'), + old_string: z + .string() + .describe('The exact text to find and replace (must match exactly)'), new_string: z.string().describe('The replacement text'), - replace_all: z.boolean().default(false).describe('Replace all occurrences instead of just the first'), + replace_all: z + .boolean() + .default(false) + .describe('Replace all occurrences instead of just the first'), }), execute: async (params) => { call('edit_file', params); try { const content = await fs.readFile(params.filepath, 'utf-8'); if (!content.includes(params.old_string)) { - return done('edit_file', { error: `old_string not found in ${params.filepath}. Make sure it matches exactly, including whitespace and indentation.` }); + return done('edit_file', { + error: `old_string not found in ${params.filepath}. Make sure it matches exactly, including whitespace and indentation.`, + }); } const occurrences = content.split(params.old_string).length - 1; if (occurrences > 1 && !params.replace_all) { - return done('edit_file', { error: `old_string found ${occurrences} times in ${params.filepath}. Provide more context to make it unique, or set replace_all to true.` }); + return done('edit_file', { + error: `old_string found ${occurrences} times in ${params.filepath}. Provide more context to make it unique, or set replace_all to true.`, + }); } const updated = params.replace_all ? content.replaceAll(params.old_string, params.new_string) : content.replace(params.old_string, params.new_string); await fs.writeFile(params.filepath, updated, 'utf-8'); - return done('edit_file', { success: true, filepath: params.filepath, replacements: params.replace_all ? occurrences : 1 }); + return done('edit_file', { + success: true, + filepath: params.filepath, + replacements: params.replace_all ? occurrences : 1, + }); } catch (e) { - return done('edit_file', { error: `Failed to edit ${params.filepath}: ${e instanceof Error ? e.message : e}` }); + return done('edit_file', { + error: `Failed to edit ${params.filepath}: ${toErrorMessage(e)}`, + }); } }, }), tool({ name: 'write_file', - description: 'Write content to a file. Overwrites existing content.', + description: 'Write content to a file (creates parent directories if needed). Overwrites existing content.', inputSchema: z.object({ filepath: z.string().describe('Path to the file to write'), content: z.string().describe('Content to write'), @@ -443,10 +527,17 @@ export function createTools(callbacks?: ToolCallbacks) { execute: async (params) => { call('write_file', params); try { + await fs.mkdir(dirname(params.filepath), { recursive: true }); await fs.writeFile(params.filepath, params.content, 'utf-8'); - return done('write_file', { success: true, filepath: params.filepath, bytesWritten: params.content.length }); + return done('write_file', { + success: true, + filepath: params.filepath, + size: Buffer.byteLength(params.content, 'utf-8'), + }); } catch (e) { - return done('write_file', { error: `Failed to write ${params.filepath}: ${e instanceof Error ? e.message : e}` }); + return done('write_file', { + error: `Failed to write ${params.filepath}: ${toErrorMessage(e)}`, + }); } }, }), @@ -460,11 +551,22 @@ export function createTools(callbacks?: ToolCallbacks) { execute: async (params) => { call('run_command', params); try { - const { stdout, stderr } = await execAsync(params.command); - return done('run_command', { command: params.command, stdout: stdout.trim().slice(0, 2000), stderr: stderr.trim().slice(0, 500) }); + const { stdout, stderr } = await execAsync(params.command, { + timeout: COMMAND_TIMEOUT_MS, + }); + return done('run_command', { + command: params.command, + stdout: stdout.trim().slice(0, 2000), + stderr: stderr.trim().slice(0, 500), + }); } catch (e) { - const err = e as { message?: string; stdout?: string; stderr?: string }; - return done('run_command', { error: 'Command failed', message: err.message, stdout: err.stdout?.slice(0, 1000) }); + const err = e as Error & { stdout?: string; stderr?: string }; + return done('run_command', { + error: 'Command failed', + message: err.message, + stdout: err.stdout?.slice(0, 1000), + stderr: err.stderr?.slice(0, 500), + }); } }, }), @@ -478,51 +580,87 @@ export function createTools(callbacks?: ToolCallbacks) { execute: async (params) => { call('fetch_web_page', params); try { - const res = await fetch(params.url); + const controller = new AbortController(); + setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const res = await fetch(params.url, { signal: controller.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const html = await res.text(); const title = extractTitle(html); const text = htmlToText(html).slice(0, 4000); - return done('fetch_web_page', { url: params.url, title, content: text }); + return done('fetch_web_page', { + url: params.url, + title, + content: text, + }); } catch (e) { - return done('fetch_web_page', { error: `Failed to fetch ${params.url}: ${e instanceof Error ? e.message : e}` }); + return done('fetch_web_page', { + error: `Failed to fetch ${params.url}: ${toErrorMessage(e)}`, + }); } }, }), tool({ name: 'web_search', - description: 'Search the internet for information using DuckDuckGo. Returns titles, URLs, and snippets.', + description: + 'Search the internet for information using DuckDuckGo. Returns titles, URLs, and snippets.', inputSchema: z.object({ query: z.string().describe('The search query'), - num_results: z.number().default(5).describe('Number of results (max 10)'), + num_results: z + .number() + .default(5) + .describe('Number of results (max 10)'), }), execute: async (params) => { call('web_search', params); const numResults = Math.min(params.num_results, 10); try { const searchUrl = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(params.query)}`; + const controller = new AbortController(); + setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); const res = await fetch(searchUrl, { - headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' }, + signal: controller.signal, + headers: { + 'User-Agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', + }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const html = await res.text(); - const results: Array<{ title: string; url: string; snippet: string }> = []; + const results: Array<{ + title: string; + url: string; + snippet: string; + }> = []; const resultBlocks = html.split(/class="result\s/); - for (let i = 1; i < resultBlocks.length && results.length < numResults; i++) { + for ( + let i = 1; + i < resultBlocks.length && results.length < numResults; + i++ + ) { const block = resultBlocks[i]; const titleMatch = block.match(/class="result__a"[^>]*>([^<]+)]*>([\s\S]*?)<\/a>/); + const snippetMatch = block.match( + /class="result__snippet"[^>]*>([\s\S]*?)<\/a>/, + ); const title = titleMatch ? titleMatch[1].trim() : ''; let url = hrefMatch ? hrefMatch[1] : ''; if (url.startsWith('//')) url = `https:${url}`; - const snippet = snippetMatch ? htmlToText(snippetMatch[1]).slice(0, 200) : ''; + const snippet = snippetMatch + ? htmlToText(snippetMatch[1]).slice(0, 200) + : ''; if (title && url) results.push({ title, url, snippet }); } - return done('web_search', { query: params.query, results, count: results.length }); + return done('web_search', { + query: params.query, + results, + count: results.length, + }); } catch (e) { - return done('web_search', { error: `Search failed: ${e instanceof Error ? e.message : e}` }); + return done('web_search', { + error: `Search failed: ${toErrorMessage(e)}`, + }); } }, }), @@ -532,38 +670,65 @@ export function createTools(callbacks?: ToolCallbacks) { --- -### Step 7: Create src/cli.tsx +### Step 7: Create src/config.ts -A polished terminal interface with ASCII art banner, streaming text, and tool call indicators. +Shared setup module imported by both entry points. This is the single source of truth for API key validation, system instructions, and agent initialization — avoiding duplication between `cli.tsx` and `headless.ts`. ```typescript import 'dotenv/config'; +import { createAgent } from './agent.js'; + +if (!process.env.OPENROUTER_API_KEY) { + console.error('Error: OPENROUTER_API_KEY environment variable is required'); + console.error('Create a .env file with: OPENROUTER_API_KEY=your-key-here'); + process.exit(1); +} + +// SKILL INSTRUCTION: Replace with a system prompt tailored to the user's agent name and purpose. +export const agent = createAgent({ + apiKey: process.env.OPENROUTER_API_KEY, + model: process.env.MODEL, + instructions: `You are a helpful AI Agent with access to tools for working with files, running commands, writing code, and browsing the web. Use these tools when they're helpful for the task at hand, but you're not limited to just using tools - you can also engage in conversation, answer questions, provide advice, or help with any other request. + +When using tools, always provide context and synthesis - don't just call tools silently. Explain what you're doing and why.`, +}); +``` + +--- + +### Step 8: Create src/cli.tsx + +A polished terminal interface with ASCII art banner, streaming text, and tool call indicators. + +````typescript import React, { useState, useEffect, useCallback } from 'react'; import { render, Box, Text, useInput, useApp, useStdout } from 'ink'; -import { createAgent, type Message } from './agent.js'; +import { DEFAULT_MODEL, type ModelInfo } from './agent.js'; +import { agent } from './config.js'; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Theme — centralised so every muted/secondary colour is easy to tune +// ═══════════════════════════════════════════════════════════════════════════════ +const THEME = { + muted: '#999999', // secondary text, separators, labels + mutedDim: '#777777' // even less prominent (tool args, pagination) +} as const; + // ═══════════════════════════════════════════════════════════════════════════════ // Simple markdown cleaner for terminal display // ═══════════════════════════════════════════════════════════════════════════════ function formatMarkdown(text: string): string { return text - // Headers: ## Header → ▓ Header .replace(/^### (.+)$/gm, '\n░ $1') .replace(/^## (.+)$/gm, '\n▓ $1') .replace(/^# (.+)$/gm, '\n█ $1 █') - // Bold: **text** → text (remove markers) .replace(/\*\*([^*]+)\*\*/g, '$1') - // Italic: *text* or _text_ → text .replace(/\*([^*]+)\*/g, '$1') .replace(/_([^_]+)_/g, '$1') - // Bullet points: * item or - item → • item .replace(/^[\s]*[-*]\s+/gm, ' • ') - // Code blocks: ```code``` → just the code .replace(/```[\w]*\n?([\s\S]*?)```/g, '\n$1\n') - // Inline code: `code` → code .replace(/`([^`]+)`/g, '$1') - // Links: [text](url) → text .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') - // Clean up extra newlines .replace(/\n{3,}/g, '\n\n') .trim(); } @@ -571,37 +736,14 @@ function formatMarkdown(text: string): string { // ═══════════════════════════════════════════════════════════════════════════════ // ASCII Banner // ═══════════════════════════════════════════════════════════════════════════════ -const BANNER = ` - ██████╗ ██████╗ ███████╗███╗ ██╗██████╗ ██████╗ ██╗ ██╗████████╗███████╗██████╗ -██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔══██╗██╔═══██╗██║ ██║╚══██╔══╝██╔════╝██╔══██╗ -██║ ██║██████╔╝█████╗ ██╔██╗ ██║██████╔╝██║ ██║██║ ██║ ██║ █████╗ ██████╔╝ -██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██╔══██╗██║ ██║██║ ██║ ██║ ██╔══╝ ██╔══██╗ -╚██████╔╝██║ ███████╗██║ ╚████║██║ ██║╚██████╔╝╚██████╔╝ ██║ ███████╗██║ ██║ - ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═╝`; - -// ═══════════════════════════════════════════════════════════════════════════════ -// Initialization -// ═══════════════════════════════════════════════════════════════════════════════ -if (!process.env.OPENROUTER_API_KEY) { - console.error('\n\x1b[31m✖ Error: OPENROUTER_API_KEY required\x1b[0m'); - console.error('\x1b[90mCreate a .env file with: OPENROUTER_API_KEY=your-key-here\x1b[0m\n'); - process.exit(1); -} - -const agent = createAgent({ - apiKey: process.env.OPENROUTER_API_KEY, - model: 'openai/gpt-4o', - instructions: `You are an autonomous coding agent. You can read files, write code, run commands, search the web, and read documentation. - -IMPORTANT: After using tools, ALWAYS provide a summary of what you found and your insights. Don't just call tools and stop - synthesize the information into a helpful response. - -When presenting information: -- Use markdown formatting for readability -- Use bullet points for lists -- Use code blocks for code -- Be concise but thorough`, - maxToolRounds: 50, -}); +// SKILL INSTRUCTION: Ask the user for an ASCII art banner for their agent. +// If they provide one, paste it into the template string below. +// If they don't have one, generate one with: npx -y figlet -f "ANSI Shadow" "AGENT NAME" +// Then paste the output directly into the template literal below (no trimStart). +// The figlet output includes leading spaces for alignment — preserve them exactly. +// If figlet is unavailable, use a simple text fallback: +// const BANNER = `◆ MY AGENT`; +const BANNER = ``; // ═══════════════════════════════════════════════════════════════════════════════ // Types @@ -612,34 +754,42 @@ interface DisplayMessage { role: 'user' | 'assistant'; content: string; timestam // ═══════════════════════════════════════════════════════════════════════════════ // Components // ═══════════════════════════════════════════════════════════════════════════════ -function Header() { +// SKILL INSTRUCTION: Replace "MY AGENT" with the user's agent name in Header and MessageDisplay. +function Header({ currentModel }: { currentModel: string }) { const { stdout } = useStdout(); const width = stdout?.columns ?? 100; + const bannerWidth = BANNER.split('\n').reduce((max, line) => Math.max(max, line.length), 0); - if (width >= 95) { + if (width >= bannerWidth + 2) { return ( {BANNER} - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + {'━'.repeat(Math.min(width - 2, bannerWidth))} - Coding Agent - - Model: - gpt-4o + Model: + {currentModel} + + Type + /model + to switch - Press + Press ESC - to exit + to exit ); } return ( - ◆ OPENROUTER Coding Agent - ───────────────────────────────────────── + ◆ MY AGENT + + Model: + {currentModel} + + {'─'.repeat(Math.min(width - 2, 40))} ); } @@ -654,11 +804,13 @@ function InputBox({ value, onChange, onSubmit, disabled }: { else if (input && !key.ctrl && !key.meta) onChange(value + input); }); return ( - - - {value} - {!disabled && } - {disabled && thinking...} + + + + {value} + {!disabled && } + {disabled && thinking...} + ); } @@ -672,8 +824,8 @@ function ToolCallDisplay({ tools }: { tools: ToolCallInfo[] }) { {tc.status === 'complete' ? '✓' : '⚡'} - {tc.name} - {tc.args && {tc.args.slice(0, 40)}...} + {tc.name} + {tc.args && {tc.args.slice(0, 40)}...} ))} @@ -683,13 +835,12 @@ function ToolCallDisplay({ tools }: { tools: ToolCallInfo[] }) { function MessageDisplay({ msg }: { msg: DisplayMessage }) { const isUser = msg.role === 'user'; const time = msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - // Apply markdown formatting to assistant messages only const displayContent = isUser ? msg.content : formatMarkdown(msg.content); return ( - {isUser ? '● You' : '◆ Assistant'} - {time} + {isUser ? '● You' : '◆ MY AGENT'} + {time} {displayContent} @@ -697,7 +848,64 @@ function MessageDisplay({ msg }: { msg: DisplayMessage }) { } function StatusBar({ status }: { status: string }) { - return {status}; + return {status}; +} + +function ModelPicker({ models, currentModel, loading, onSelect, onCancel }: { + models: ModelInfo[] | null; + currentModel: string; + loading: boolean; + onSelect: (modelId: string) => void; + onCancel: () => void; +}) { + const [index, setIndex] = useState(0); + const items: ModelInfo[] = models + ? [{ id: DEFAULT_MODEL, name: 'Default (openrouter/auto)', contextLength: null, promptPricing: '0', completionPricing: '0' }, ...models] + : []; + + useInput((_, key) => { + if (loading) return; + if (key.escape) { onCancel(); return; } + if (key.return && items.length > 0) { onSelect(items[index].id); return; } + if (key.upArrow) setIndex(i => Math.max(0, i - 1)); + if (key.downArrow) setIndex(i => Math.min(items.length - 1, i + 1)); + }); + + if (loading) { + return ( + + Fetching tool-capable models... + + ); + } + + const WINDOW = 15; + const start = Math.max(0, Math.min(index - Math.floor(WINDOW / 2), items.length - WINDOW)); + const visible = items.slice(start, start + WINDOW); + + return ( + + Select Model (↑↓ + Enter, ESC to cancel) + + {visible.map((m, i) => { + const realIndex = start + i; + const isHighlighted = realIndex === index; + const isActive = m.id === currentModel; + const ctx = m.contextLength ? ` (${(m.contextLength / 1000).toFixed(0)}k)` : ''; + return ( + + + {isHighlighted ? '❯ ' : ' '}{isActive ? '✓ ' : ' '}{m.name}{ctx} + + + ); + })} + + {items.length > WINDOW && ( + ({index + 1}/{items.length}) + )} + + ); } // ═══════════════════════════════════════════════════════════════════════════════ @@ -712,8 +920,12 @@ function App() { const [streamingText, setStreamingText] = useState(''); const [error, setError] = useState(null); const [status, setStatus] = useState('Ready'); + const [currentModel, setCurrentModel] = useState(agent.model); + const [showModelPicker, setShowModelPicker] = useState(false); + const [models, setModels] = useState(null); + const [modelLoading, setModelLoading] = useState(false); - useInput((_, key) => { if (key.escape) exit(); }); + useInput((_, key) => { if (key.escape && !showModelPicker) exit(); }); useEffect(() => { const onStart = () => { setIsLoading(true); setToolCalls([]); setStreamingText(''); setError(null); setStatus('Processing...'); }; @@ -724,90 +936,193 @@ function App() { }; const onToolResult = () => setToolCalls(prev => prev.map(tc => tc.status === 'running' ? { ...tc, status: 'complete' as const } : tc)); const onDone = () => { - setMessages(agent.getMessages().map(m => ({ role: m.role as 'user' | 'assistant', content: m.content, timestamp: new Date() }))); + setMessages(agent.getMessages().map(m => ({ role: m.role, content: m.content, timestamp: new Date() }))); setToolCalls([]); setStreamingText(''); setIsLoading(false); setStatus('Ready'); }; - const onError = (err: Error) => { setError(err.message); setIsLoading(false); setStatus('Error'); }; + const onModelChanged = (modelId: string) => setCurrentModel(modelId); agent.on('thinking:start', onStart); agent.on('stream:delta', onDelta); agent.on('tool:call', onToolCall); agent.on('tool:result', onToolResult); agent.on('message:assistant', onDone); - agent.on('error', onError); - return () => { agent.off('thinking:start', onStart); agent.off('stream:delta', onDelta); agent.off('tool:call', onToolCall); agent.off('tool:result', onToolResult); agent.off('message:assistant', onDone); agent.off('error', onError); }; + agent.on('model:changed', onModelChanged); + return () => { + agent.off('thinking:start', onStart); + agent.off('stream:delta', onDelta); + agent.off('tool:call', onToolCall); + agent.off('tool:result', onToolResult); + agent.off('message:assistant', onDone); + agent.off('model:changed', onModelChanged); + }; }, []); + const openModelPicker = useCallback(async () => { + setShowModelPicker(true); + if (!models) { + setModelLoading(true); + try { + const list = await agent.listToolCapableModels(); + setModels(list); + } catch (e) { + setError(`Failed to fetch models: ${e instanceof Error ? e.message : String(e)}`); + setShowModelPicker(false); + } finally { + setModelLoading(false); + } + } + }, [models]); + const send = useCallback(async () => { if (!input.trim() || isLoading) return; const text = input.trim(); setInput(''); + if (text === '/model') { + openModelPicker(); + return; + } setMessages(prev => [...prev, { role: 'user', content: text, timestamp: new Date() }]); - try { await agent.send(text); } catch (e) { setError(e instanceof Error ? e.message : String(e)); setIsLoading(false); } - }, [input, isLoading]); + try { + await agent.send(text); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setIsLoading(false); + } + }, [input, isLoading, openModelPicker]); return ( -
+
{messages.map((msg, i) => )} {streamingText && isLoading && ( - ◆ Assistant (streaming...) + {/* SKILL INSTRUCTION: Replace "MY AGENT" with the user's agent name */} + ◆ MY AGENT (streaming...) {streamingText} )} - {isLoading && !streamingText && !toolCalls.length && Thinking...} + {isLoading && !streamingText && !toolCalls.length && Thinking...} {error && ✖ Error: {error}} - - + {showModelPicker ? ( + { agent.setModel(id); setShowModelPicker(false); }} + onCancel={() => setShowModelPicker(false)} + /> + ) : ( + <> + + + + )} ); } render(); -``` +```` --- -### Step 8: Create src/headless.ts +### Step 9: Create src/headless.ts Useful for CI/CD pipelines or API integration. ```typescript -import 'dotenv/config'; -import { createAgent } from './agent.js'; +import { DEFAULT_MODEL, type ModelInfo } from './agent.js'; +import { agent } from './config.js'; import * as readline from 'readline'; -async function main() { - if (!process.env.OPENROUTER_API_KEY) { - console.error('Error: OPENROUTER_API_KEY environment variable is required'); - process.exit(1); +let cachedModels: ModelInfo[] | null = null; + +async function handleModelCommand(rl: readline.Interface): Promise { + console.log('\nFetching tool-capable models...'); + if (!cachedModels) { + cachedModels = await agent.listToolCapableModels(); } - const agent = createAgent({ - apiKey: process.env.OPENROUTER_API_KEY, - model: 'openai/gpt-4o', - instructions: 'You are a capable coding agent. You can inspect files, write code, run tests, and browse the web.', - maxToolRounds: 50, + console.log(`\n [0] Default (${DEFAULT_MODEL})`); + cachedModels.forEach((m, i) => { + const ctx = m.contextLength + ? `${(m.contextLength / 1000).toFixed(0)}k ctx` + : ''; + console.log(` [${i + 1}] ${m.name} (${m.id})${ctx ? ` — ${ctx}` : ''}`); }); + console.log(`\nCurrent: ${agent.model}`); + + return new Promise((resolve) => { + rl.question('\nSelect model number (or Enter to cancel): ', (answer) => { + const trimmed = answer.trim(); + if (trimmed === '') { + console.log('Cancelled.\n'); + resolve(); + return; + } + const num = parseInt(trimmed, 10); + if (isNaN(num) || num < 0 || !cachedModels || num > cachedModels.length) { + console.log('Invalid selection.\n'); + resolve(); + return; + } + if (num === 0) { + agent.setModel(DEFAULT_MODEL); + console.log(`Switched to: Default (${DEFAULT_MODEL})\n`); + } else { + const selected = cachedModels[num - 1]; + agent.setModel(selected.id); + console.log(`Switched to: ${selected.name} (${selected.id})\n`); + } + resolve(); + }); + }); +} +async function main() { agent.on('thinking:start', () => console.log('\n🤔 Thinking...')); - agent.on('tool:call', (name, args) => console.log(`🔧 Using ${name}:`, JSON.stringify(args))); + agent.on('tool:call', (name, args) => + console.log(`🔧 Using ${name}:`, JSON.stringify(args)), + ); agent.on('tool:result', (name) => console.log(` ✅ Result from ${name}`)); agent.on('stream:delta', (delta) => process.stdout.write(delta)); agent.on('stream:end', () => console.log('\n')); - agent.on('error', (err) => console.error('❌ Error:', err.message)); - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - console.log('🤖 OpenRouter Coding Agent (Headless Mode)\nType your message (Ctrl+C to exit):\n'); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + console.log( + // SKILL INSTRUCTION: Replace with the user's agent name + '🤖 AI Agent (Headless Mode)\nType your message (Ctrl+C to exit):\nCommands: /model\n', + ); const prompt = () => { rl.question('You: ', async (input) => { - if (!input.trim()) { prompt(); return; } - try { await agent.send(input); } catch (error) { console.error('Error:', error); } + if (!input.trim()) { + prompt(); + return; + } + if (input.trim() === '/model') { + try { + await handleModelCommand(rl); + } catch (e) { + console.error( + 'Failed to list models:', + e instanceof Error ? e.message : String(e), + ); + } + prompt(); + return; + } + try { + await agent.send(input); + } catch (e) { + console.error('Error:', e instanceof Error ? e.message : String(e)); + } prompt(); }); }; @@ -821,17 +1136,19 @@ main().catch(console.error); ## Running the Agent -1. **Add your API key to `.env`:** - ``` - OPENROUTER_API_KEY=sk-or-v1-xxxxx - ``` +```bash +npm start # TUI mode +npm run start:headless # Headless mode +``` + +### Choosing a Model + +By default the agent uses `openrouter/auto` (smart routing). Override via the `MODEL` environment variable: + +```bash +MODEL=anthropic/claude-sonnet-4 npm start +``` -2. **Run the TUI:** - ```bash - npm start - ``` +Or set it in `.env`. Browse available models at [openrouter.ai/models](https://openrouter.ai/models). -3. **Or run in headless mode:** - ```bash - npm run start:headless - ``` +Use the `/model` command at runtime to switch between tool-capable models.