From 1c471bd3cb17d71665382349015ff6c0bfe0df46 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Wed, 20 Nov 2024 23:21:38 -0500 Subject: [PATCH] Add a command-line cline powered by deno --- cli/README.md | 110 ++++++++++++++++ cli/api/mod.ts | 10 ++ cli/api/providers/openrouter.ts | 147 +++++++++++++++++++++ cli/core/StandaloneAgent.ts | 164 +++++++++++++++++++++++ cli/core/prompts.ts | 120 +++++++++++++++++ cli/deno.d.ts | 40 ++++++ cli/deno.jsonc | 13 ++ cli/deno.lock | 87 ++++++++++++ cli/deps.ts | 21 +++ cli/mod.ts | 123 +++++++++++++++++ cli/tools/mod.ts | 225 ++++++++++++++++++++++++++++++++ cli/types.d.ts | 43 ++++++ 12 files changed, 1103 insertions(+) create mode 100644 cli/README.md create mode 100644 cli/api/mod.ts create mode 100644 cli/api/providers/openrouter.ts create mode 100644 cli/core/StandaloneAgent.ts create mode 100644 cli/core/prompts.ts create mode 100644 cli/deno.d.ts create mode 100644 cli/deno.jsonc create mode 100644 cli/deno.lock create mode 100644 cli/deps.ts create mode 100644 cli/mod.ts create mode 100644 cli/tools/mod.ts create mode 100644 cli/types.d.ts diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000000..ceec8cca45 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,110 @@ +# Cline CLI + +A command-line interface for Cline, powered by Deno. + +## Installation + +1. Make sure you have [Deno](https://deno.land/) installed +2. Install the CLI globally: + ```bash + cd cli + deno task install + ``` + +If you get a PATH warning during installation, add Deno's bin directory to your PATH: +```bash +echo 'export PATH="$HOME/.deno/bin:$PATH"' >> ~/.bashrc # or ~/.zshrc +source ~/.bashrc # or ~/.zshrc +``` + +## Usage + +```bash +cline [options] +``` + +### Security Model + +The CLI implements several security measures: + +1. File Operations: + - Read/write access limited to working directory (--allow-read=., --allow-write=.) + - Prevents access to files outside the project + +2. Command Execution: + - Strict allowlist of safe commands: + * npm (install, run, test, build) + * git (status, add, commit, push, pull, clone, checkout, branch) + * deno (run, test, fmt, lint, check, compile, bundle) + * ls (-l, -a, -la, -lh) + * cat, echo + - Interactive prompts for non-allowlisted commands: + * y - Run once + * n - Cancel execution + * always - Remember for session + - Clear warnings and command details shown + - Session-based memory for approved commands + +3. Required Permissions: + - --allow-read=. - Read files in working directory + - --allow-write=. - Write files in working directory + - --allow-run - Execute allowlisted commands + - --allow-net - Make API calls + - --allow-env - Access environment variables + +### Options + +- `-m, --model ` - LLM model to use (default: "anthropic/claude-3.5-sonnet") +- `-k, --key ` - OpenRouter API key (required, or set OPENROUTER_API_KEY env var) +- `-h, --help` - Display help for command + +### Examples + +Analyze code: +```bash +export OPENROUTER_API_KEY=sk-or-v1-... +cline "Analyze this codebase" +``` + +Create files: +```bash +cline "Create a React component" +``` + +Run allowed command: +```bash +cline "Run npm install" +``` + +Run non-allowlisted command (will prompt for decision): +```bash +cline "Run yarn install" +# Responds with: +# Warning: Command not in allowlist +# Command: yarn install +# Do you want to run this command? (y/n/always) +``` + +## Development + +The CLI is built with Deno. Available tasks: + +```bash +# Run in development mode +deno task dev "your task here" + +# Install globally +deno task install + +# Type check the code +deno task check +``` + +### Security Features + +- File operations restricted to working directory +- Command execution controlled by allowlist +- Interactive prompts for unknown commands +- Session-based command approval +- Clear warnings and command details +- Permission validation at runtime diff --git a/cli/api/mod.ts b/cli/api/mod.ts new file mode 100644 index 0000000000..8040c2c413 --- /dev/null +++ b/cli/api/mod.ts @@ -0,0 +1,10 @@ +import type { ApiConfiguration, ApiHandler } from "../types.d.ts"; +import { OpenRouterHandler } from "./providers/openrouter.ts"; + +// Re-export the ApiHandler interface +export type { ApiHandler }; + +export function buildApiHandler(configuration: ApiConfiguration): ApiHandler { + const { apiKey, model } = configuration; + return new OpenRouterHandler({ apiKey, model }); +} diff --git a/cli/api/providers/openrouter.ts b/cli/api/providers/openrouter.ts new file mode 100644 index 0000000000..80c6cb628d --- /dev/null +++ b/cli/api/providers/openrouter.ts @@ -0,0 +1,147 @@ +import type { ApiStream, ModelInfo, Message, TextBlock } from "../../types.d.ts"; + +interface OpenRouterOptions { + model: string; + apiKey: string; +} + +export class OpenRouterHandler { + private apiKey: string; + private model: string; + + constructor(options: OpenRouterOptions) { + this.apiKey = options.apiKey; + this.model = options.model; + } + + async *createMessage(systemPrompt: string, messages: Message[]): ApiStream { + try { + // Convert our messages to OpenRouter format + const openRouterMessages = [ + { role: "system", content: systemPrompt }, + ...messages.map(msg => ({ + role: msg.role, + content: Array.isArray(msg.content) + ? msg.content.map(c => c.text).join("\n") + : msg.content + })) + ]; + + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://github.com/mattvr/roo-cline", + "X-Title": "Cline CLI" + }, + body: JSON.stringify({ + model: this.model, + messages: openRouterMessages, + stream: true, + temperature: 0.7, + max_tokens: 4096 + }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(`OpenRouter API error: ${response.statusText}${errorData ? ` - ${JSON.stringify(errorData)}` : ""}`); + } + + if (!response.body) { + throw new Error("No response body received"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let content = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + // Add new chunk to buffer and split into lines + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + + // Process all complete lines + buffer = lines.pop() || ""; // Keep the last incomplete line in buffer + + for (const line of lines) { + if (line.trim() === "") continue; + if (line === "data: [DONE]") continue; + + if (line.startsWith("data: ")) { + try { + const data = JSON.parse(line.slice(6)); + if (data.choices?.[0]?.delta?.content) { + const text = data.choices[0].delta.content; + content += text; + yield { type: "text", text }; + } + } catch (e) { + // Ignore parse errors for incomplete chunks + continue; + } + } + } + } + + // Process any remaining content in buffer + if (buffer.trim() && buffer.startsWith("data: ")) { + try { + const data = JSON.parse(buffer.slice(6)); + if (data.choices?.[0]?.delta?.content) { + const text = data.choices[0].delta.content; + content += text; + yield { type: "text", text }; + } + } catch (e) { + // Ignore parse errors for final incomplete chunk + } + } + + // Estimate token usage (4 chars per token is a rough estimate) + const inputText = systemPrompt + messages.reduce((acc, msg) => + acc + (typeof msg.content === "string" ? + msg.content : + msg.content.reduce((a, b) => a + b.text, "")), ""); + + const inputTokens = Math.ceil(inputText.length / 4); + const outputTokens = Math.ceil(content.length / 4); + + yield { + type: "usage", + inputTokens, + outputTokens, + totalCost: this.calculateCost(inputTokens, outputTokens) + }; + + } catch (error) { + console.error("Error in OpenRouter API call:", error); + throw error; + } + } + + getModel(): { id: string; info: ModelInfo } { + return { + id: this.model, + info: { + contextWindow: 128000, // This varies by model + supportsComputerUse: true, + inputPricePerToken: 0.000002, // Approximate, varies by model + outputPricePerToken: 0.000002 + } + }; + } + + private calculateCost(inputTokens: number, outputTokens: number): number { + const { inputPricePerToken, outputPricePerToken } = this.getModel().info; + return ( + (inputTokens * (inputPricePerToken || 0)) + + (outputTokens * (outputPricePerToken || 0)) + ); + } +} diff --git a/cli/core/StandaloneAgent.ts b/cli/core/StandaloneAgent.ts new file mode 100644 index 0000000000..da2abe5750 --- /dev/null +++ b/cli/core/StandaloneAgent.ts @@ -0,0 +1,164 @@ +import { blue, red, yellow } from "../deps.ts"; +import { ApiHandler } from "../api/mod.ts"; +import { executeCommand, readFile, writeFile, searchFiles, listFiles, listCodeDefinitions } from "../tools/mod.ts"; +import type { Message, TextBlock, ToolResult } from "../types.d.ts"; + +interface AgentConfig { + api: ApiHandler; + systemPrompt: string; + workingDir: string; +} + +export class StandaloneAgent { + private api: ApiHandler; + private systemPrompt: string; + private workingDir: string; + private conversationHistory: Message[] = []; + + constructor(config: AgentConfig) { + this.api = config.api; + this.systemPrompt = config.systemPrompt; + this.workingDir = config.workingDir; + } + + async runTask(task: string): Promise { + this.conversationHistory.push({ + role: "user", + content: [{ type: "text", text: `\n${task}\n` }] + }); + + let isTaskComplete = false; + const encoder = new TextEncoder(); + + while (!isTaskComplete) { + const stream = this.api.createMessage(this.systemPrompt, this.conversationHistory); + let assistantMessage = ""; + + console.log(blue("Thinking...")); + for await (const chunk of stream) { + if (chunk.type === "text") { + assistantMessage += chunk.text; + await Deno.stdout.write(encoder.encode(chunk.text)); + } + } + + this.conversationHistory.push({ + role: "assistant", + content: [{ type: "text", text: assistantMessage }] + }); + + const toolResults = await this.executeTools(assistantMessage); + + if (toolResults.length > 0) { + this.conversationHistory.push({ + role: "user", + content: toolResults.map(result => ({ + type: "text", + text: `[${result.tool}] Result:${result.output}` + })) as TextBlock[] + }); + } else { + if (assistantMessage.includes("")) { + isTaskComplete = true; + } else { + this.conversationHistory.push({ + role: "user", + content: [{ + type: "text", + text: "You must either use available tools to accomplish the task or call attempt_completion when the task is complete." + }] + }); + } + } + } + } + + private async executeTools(message: string): Promise { + const results: ToolResult[] = []; + const toolRegex = /<(\w+)>\s*([\s\S]*?)\s*<\/\1>/g; + let match; + + while ((match = toolRegex.exec(message)) !== null) { + const [_, toolName, paramsXml] = match; + const params: Record = {}; + const paramRegex = /<(\w+)>\s*([\s\S]*?)\s*<\/\1>/g; + let paramMatch; + + while ((paramMatch = paramRegex.exec(paramsXml)) !== null) { + const [__, paramName, paramValue] = paramMatch; + params[paramName] = paramValue.trim(); + } + + let output: string; + try { + console.log(yellow(`\nExecuting: ${this.getToolDescription(toolName, params)}`)); + + switch (toolName) { + case "execute_command": + output = await executeCommand(params.command); + break; + case "read_file": + output = await readFile(this.workingDir, params.path); + break; + case "write_to_file": + output = await writeFile(this.workingDir, params.path, params.content); + break; + case "search_files": + output = await searchFiles(this.workingDir, params.path, params.regex, params.file_pattern); + break; + case "list_files": + output = await listFiles(this.workingDir, params.path, params.recursive === "true"); + break; + case "list_code_definition_names": + output = await listCodeDefinitions(this.workingDir, params.path); + break; + case "attempt_completion": + return results; + default: + console.warn(red(`Unknown tool: ${toolName}`)); + continue; + } + + results.push({ + tool: toolName, + params, + output: output || "(No output)" + }); + + break; + } catch (error) { + const errorMessage = `Error executing ${toolName}: ${error instanceof Error ? error.message : String(error)}`; + console.error(red(errorMessage)); + results.push({ + tool: toolName, + params, + output: errorMessage + }); + break; + } + } + + return results; + } + + private getToolDescription(toolName: string, params: Record): string { + switch (toolName) { + case "execute_command": + return `Running command: ${params.command}`; + case "read_file": + return `Reading file: ${params.path}`; + case "write_to_file": + return `Writing to file: ${params.path}`; + case "search_files": + return `Searching for "${params.regex}" in ${params.path}`; + case "list_files": + return `Listing files in ${params.path}`; + case "list_code_definition_names": + return `Analyzing code in ${params.path}`; + case "attempt_completion": + return "Completing task"; + default: + return toolName; + } + } +} diff --git a/cli/core/prompts.ts b/cli/core/prompts.ts new file mode 100644 index 0000000000..58653dd916 --- /dev/null +++ b/cli/core/prompts.ts @@ -0,0 +1,120 @@ +import { join } from "https://deno.land/std@0.220.1/path/mod.ts"; + +export const SYSTEM_PROMPT = async (cwd: string): Promise => { + let rulesContent = ""; + + // Load and combine rules from configuration files + const ruleFiles = ['.clinerules', '.cursorrules']; + for (const file of ruleFiles) { + const rulePath = join(cwd, file); + try { + const stat = await Deno.stat(rulePath); + if (stat.isFile) { + const content = await Deno.readTextFile(rulePath); + if (content.trim()) { + rulesContent += `\n# Rules from ${file}:\n${content.trim()}\n\n`; + } + } + } catch (err) { + // Only ignore ENOENT (file not found) errors + if (!(err instanceof Deno.errors.NotFound)) { + throw err; + } + } + } + + return `You are Cline, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices. + +==== + +TOOL USE + +You have access to tools that are executed upon approval. Use one tool per message and wait for the result before proceeding. Each tool must be used with proper XML-style formatting: + + +value1 +value2 + + +# Available Tools + +## execute_command +Description: Execute a CLI command on the system. Commands run in the current working directory: ${cwd} +Parameters: +- command: (required) The command to execute. Must be valid for the current OS. +Usage: + +command to run + + +## read_file +Description: Read contents of a file. Supports text files and automatically extracts content from PDFs/DOCXs. +Parameters: +- path: (required) Path to file (relative to ${cwd}) +Usage: + +path to file + + +## write_to_file +Description: Write content to a file. Creates directories as needed. Will overwrite existing files. +Parameters: +- path: (required) Path to write to (relative to ${cwd}) +- content: (required) Complete file content. Must include ALL parts, even unchanged sections. +Usage: + +path to file +complete file content + + +## search_files +Description: Search files using regex patterns. Shows matches with surrounding context. +Parameters: +- path: (required) Directory to search (relative to ${cwd}) +- regex: (required) Rust regex pattern to search for +- file_pattern: (optional) Glob pattern to filter files (e.g. "*.ts") +Usage: + +directory to search +pattern to search +optional file pattern + + +## list_code_definition_names +Description: List code definitions (classes, functions, etc.) in source files. +Parameters: +- path: (required) Directory to analyze (relative to ${cwd}) +Usage: + +directory to analyze + + +## attempt_completion +Description: Signal task completion and present results. +Parameters: +- result: (required) Description of completed work +- command: (optional) Command to demonstrate result +Usage: + +description of completed work +optional demo command + + +# Guidelines + +1. Use one tool at a time and wait for results +2. Provide complete file content when using write_to_file +3. Be direct and technical in responses +4. Present final results using attempt_completion +5. Do not make assumptions about command success +6. Do not make up commands that don't exist + +# Rules + +- Current working directory is: ${cwd} +- Cannot cd to different directories +- Must wait for confirmation after each tool use +- Must provide complete file content when writing files +- Be direct and technical, not conversational +- Do not end messages with questions${rulesContent}`; +}; diff --git a/cli/deno.d.ts b/cli/deno.d.ts new file mode 100644 index 0000000000..61bf76d376 --- /dev/null +++ b/cli/deno.d.ts @@ -0,0 +1,40 @@ +declare namespace Deno { + export const args: string[]; + export function exit(code?: number): never; + export const env: { + get(key: string): string | undefined; + }; + export function cwd(): string; + export function readTextFile(path: string): Promise; + export function writeTextFile(path: string, data: string): Promise; + export function mkdir(path: string, options?: { recursive?: boolean }): Promise; + export function readDir(path: string): AsyncIterable<{ + name: string; + isFile: boolean; + isDirectory: boolean; + }>; + export function stat(path: string): Promise<{ + isFile: boolean; + isDirectory: boolean; + }>; + export class Command { + constructor(cmd: string, options?: { + args?: string[]; + stdout?: "piped"; + stderr?: "piped"; + }); + output(): Promise<{ + stdout: Uint8Array; + stderr: Uint8Array; + }>; + } + export const permissions: { + query(desc: { name: string; path?: string }): Promise<{ state: "granted" | "denied" }>; + }; + export const errors: { + PermissionDenied: typeof Error; + }; + export const stdout: { + write(data: Uint8Array): Promise; + }; +} diff --git a/cli/deno.jsonc b/cli/deno.jsonc new file mode 100644 index 0000000000..8947075132 --- /dev/null +++ b/cli/deno.jsonc @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "allowJs": true, + "strict": true, + "lib": ["deno.ns", "dom"] + }, + "tasks": { + "start": "deno run --allow-read=. mod.ts", + "dev": "deno run --allow-read=. mod.ts", + "install": "deno install --allow-read --allow-write --allow-net --allow-env --allow-run --global --name cline mod.ts", + "check": "deno check mod.ts" + } +} diff --git a/cli/deno.lock b/cli/deno.lock new file mode 100644 index 0000000000..e6d142f1be --- /dev/null +++ b/cli/deno.lock @@ -0,0 +1,87 @@ +{ + "version": "4", + "remote": { + "https://deno.land/std@0.220.1/assert/assert.ts": "bec068b2fccdd434c138a555b19a2c2393b71dfaada02b7d568a01541e67cdc5", + "https://deno.land/std@0.220.1/assert/assert_exists.ts": "24a7bf965e634f909242cd09fbaf38bde6b791128ece08e33ab08586a7cc55c9", + "https://deno.land/std@0.220.1/assert/assertion_error.ts": "9f689a101ee586c4ce92f52fa7ddd362e86434ffdf1f848e45987dc7689976b8", + "https://deno.land/std@0.220.1/flags/mod.ts": "9f13f3a49c54618277ac49195af934f1c7d235731bcf80fd33b8b234e6839ce9", + "https://deno.land/std@0.220.1/fmt/colors.ts": "d239d84620b921ea520125d778947881f62c50e78deef2657073840b8af9559a", + "https://deno.land/std@0.220.1/path/_common/assert_path.ts": "dbdd757a465b690b2cc72fc5fb7698c51507dec6bfafce4ca500c46b76ff7bd8", + "https://deno.land/std@0.220.1/path/_common/basename.ts": "569744855bc8445f3a56087fd2aed56bdad39da971a8d92b138c9913aecc5fa2", + "https://deno.land/std@0.220.1/path/_common/common.ts": "ef73c2860694775fe8ffcbcdd387f9f97c7a656febf0daa8c73b56f4d8a7bd4c", + "https://deno.land/std@0.220.1/path/_common/constants.ts": "dc5f8057159f4b48cd304eb3027e42f1148cf4df1fb4240774d3492b5d12ac0c", + "https://deno.land/std@0.220.1/path/_common/dirname.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8", + "https://deno.land/std@0.220.1/path/_common/format.ts": "92500e91ea5de21c97f5fe91e178bae62af524b72d5fcd246d6d60ae4bcada8b", + "https://deno.land/std@0.220.1/path/_common/from_file_url.ts": "d672bdeebc11bf80e99bf266f886c70963107bdd31134c4e249eef51133ceccf", + "https://deno.land/std@0.220.1/path/_common/glob_to_reg_exp.ts": "6cac16d5c2dc23af7d66348a7ce430e5de4e70b0eede074bdbcf4903f4374d8d", + "https://deno.land/std@0.220.1/path/_common/normalize.ts": "684df4aa71a04bbcc346c692c8485594fc8a90b9408dfbc26ff32cf3e0c98cc8", + "https://deno.land/std@0.220.1/path/_common/normalize_string.ts": "33edef773c2a8e242761f731adeb2bd6d683e9c69e4e3d0092985bede74f4ac3", + "https://deno.land/std@0.220.1/path/_common/relative.ts": "faa2753d9b32320ed4ada0733261e3357c186e5705678d9dd08b97527deae607", + "https://deno.land/std@0.220.1/path/_common/strip_trailing_separators.ts": "7024a93447efcdcfeaa9339a98fa63ef9d53de363f1fbe9858970f1bba02655a", + "https://deno.land/std@0.220.1/path/_common/to_file_url.ts": "7f76adbc83ece1bba173e6e98a27c647712cab773d3f8cbe0398b74afc817883", + "https://deno.land/std@0.220.1/path/_interface.ts": "a1419fcf45c0ceb8acdccc94394e3e94f99e18cfd32d509aab514c8841799600", + "https://deno.land/std@0.220.1/path/_os.ts": "8fb9b90fb6b753bd8c77cfd8a33c2ff6c5f5bc185f50de8ca4ac6a05710b2c15", + "https://deno.land/std@0.220.1/path/basename.ts": "5d341aadb7ada266e2280561692c165771d071c98746fcb66da928870cd47668", + "https://deno.land/std@0.220.1/path/common.ts": "03e52e22882402c986fe97ca3b5bb4263c2aa811c515ce84584b23bac4cc2643", + "https://deno.land/std@0.220.1/path/constants.ts": "0c206169ca104938ede9da48ac952de288f23343304a1c3cb6ec7625e7325f36", + "https://deno.land/std@0.220.1/path/dirname.ts": "85bd955bf31d62c9aafdd7ff561c4b5fb587d11a9a5a45e2b01aedffa4238a7c", + "https://deno.land/std@0.220.1/path/extname.ts": "593303db8ae8c865cbd9ceec6e55d4b9ac5410c1e276bfd3131916591b954441", + "https://deno.land/std@0.220.1/path/format.ts": "42a2f3201343df77061207e6aaf78c95bafce7f711dcb7fe1e5840311c505778", + "https://deno.land/std@0.220.1/path/from_file_url.ts": "911833ae4fd10a1c84f6271f36151ab785955849117dc48c6e43b929504ee069", + "https://deno.land/std@0.220.1/path/glob_to_regexp.ts": "7f30f0a21439cadfdae1be1bf370880b415e676097fda584a63ce319053b5972", + "https://deno.land/std@0.220.1/path/is_absolute.ts": "4791afc8bfd0c87f0526eaa616b0d16e7b3ab6a65b62942e50eac68de4ef67d7", + "https://deno.land/std@0.220.1/path/is_glob.ts": "a65f6195d3058c3050ab905705891b412ff942a292bcbaa1a807a74439a14141", + "https://deno.land/std@0.220.1/path/join.ts": "ae2ec5ca44c7e84a235fd532e4a0116bfb1f2368b394db1c4fb75e3c0f26a33a", + "https://deno.land/std@0.220.1/path/join_globs.ts": "5b3bf248b93247194f94fa6947b612ab9d3abd571ca8386cf7789038545e54a0", + "https://deno.land/std@0.220.1/path/mod.ts": "2821a1bb3a4148a0ffe79c92aa41aa9319fef73c6d6f5178f52b2c720d3eb02d", + "https://deno.land/std@0.220.1/path/normalize.ts": "4155743ccceeed319b350c1e62e931600272fad8ad00c417b91df093867a8352", + "https://deno.land/std@0.220.1/path/normalize_glob.ts": "cc89a77a7d3b1d01053b9dcd59462b75482b11e9068ae6c754b5cf5d794b374f", + "https://deno.land/std@0.220.1/path/parse.ts": "65e8e285f1a63b714e19ef24b68f56e76934c3df0b6e65fd440d3991f4f8aefb", + "https://deno.land/std@0.220.1/path/posix/_util.ts": "1e3937da30f080bfc99fe45d7ed23c47dd8585c5e473b2d771380d3a6937cf9d", + "https://deno.land/std@0.220.1/path/posix/basename.ts": "d2fa5fbbb1c5a3ab8b9326458a8d4ceac77580961b3739cd5bfd1d3541a3e5f0", + "https://deno.land/std@0.220.1/path/posix/common.ts": "26f60ccc8b2cac3e1613000c23ac5a7d392715d479e5be413473a37903a2b5d4", + "https://deno.land/std@0.220.1/path/posix/constants.ts": "93481efb98cdffa4c719c22a0182b994e5a6aed3047e1962f6c2c75b7592bef1", + "https://deno.land/std@0.220.1/path/posix/dirname.ts": "76cd348ffe92345711409f88d4d8561d8645353ac215c8e9c80140069bf42f00", + "https://deno.land/std@0.220.1/path/posix/extname.ts": "e398c1d9d1908d3756a7ed94199fcd169e79466dd88feffd2f47ce0abf9d61d2", + "https://deno.land/std@0.220.1/path/posix/format.ts": "185e9ee2091a42dd39e2a3b8e4925370ee8407572cee1ae52838aed96310c5c1", + "https://deno.land/std@0.220.1/path/posix/from_file_url.ts": "951aee3a2c46fd0ed488899d024c6352b59154c70552e90885ed0c2ab699bc40", + "https://deno.land/std@0.220.1/path/posix/glob_to_regexp.ts": "76f012fcdb22c04b633f536c0b9644d100861bea36e9da56a94b9c589a742e8f", + "https://deno.land/std@0.220.1/path/posix/is_absolute.ts": "cebe561ad0ae294f0ce0365a1879dcfca8abd872821519b4fcc8d8967f888ede", + "https://deno.land/std@0.220.1/path/posix/is_glob.ts": "8a8b08c08bf731acf2c1232218f1f45a11131bc01de81e5f803450a5914434b9", + "https://deno.land/std@0.220.1/path/posix/join.ts": "7fc2cb3716aa1b863e990baf30b101d768db479e70b7313b4866a088db016f63", + "https://deno.land/std@0.220.1/path/posix/join_globs.ts": "a9475b44645feddceb484ee0498e456f4add112e181cb94042cdc6d47d1cdd25", + "https://deno.land/std@0.220.1/path/posix/mod.ts": "2301fc1c54a28b349e20656f68a85f75befa0ee9b6cd75bfac3da5aca9c3f604", + "https://deno.land/std@0.220.1/path/posix/normalize.ts": "baeb49816a8299f90a0237d214cef46f00ba3e95c0d2ceb74205a6a584b58a91", + "https://deno.land/std@0.220.1/path/posix/normalize_glob.ts": "9c87a829b6c0f445d03b3ecadc14492e2864c3ebb966f4cea41e98326e4435c6", + "https://deno.land/std@0.220.1/path/posix/parse.ts": "0b1fc4cb890dbb699ec1d2c232d274843b4a7142e1ad976b69fe51c954eb6080", + "https://deno.land/std@0.220.1/path/posix/relative.ts": "3907d6eda41f0ff723d336125a1ad4349112cd4d48f693859980314d5b9da31c", + "https://deno.land/std@0.220.1/path/posix/resolve.ts": "08b699cfeee10cb6857ccab38fa4b2ec703b0ea33e8e69964f29d02a2d5257cf", + "https://deno.land/std@0.220.1/path/posix/to_file_url.ts": "7aa752ba66a35049e0e4a4be5a0a31ac6b645257d2e031142abb1854de250aaf", + "https://deno.land/std@0.220.1/path/posix/to_namespaced_path.ts": "28b216b3c76f892a4dca9734ff1cc0045d135532bfd9c435ae4858bfa5a2ebf0", + "https://deno.land/std@0.220.1/path/relative.ts": "ab739d727180ed8727e34ed71d976912461d98e2b76de3d3de834c1066667add", + "https://deno.land/std@0.220.1/path/resolve.ts": "a6f977bdb4272e79d8d0ed4333e3d71367cc3926acf15ac271f1d059c8494d8d", + "https://deno.land/std@0.220.1/path/to_file_url.ts": "88f049b769bce411e2d2db5bd9e6fd9a185a5fbd6b9f5ad8f52bef517c4ece1b", + "https://deno.land/std@0.220.1/path/to_namespaced_path.ts": "b706a4103b104cfadc09600a5f838c2ba94dbcdb642344557122dda444526e40", + "https://deno.land/std@0.220.1/path/windows/_util.ts": "d5f47363e5293fced22c984550d5e70e98e266cc3f31769e1710511803d04808", + "https://deno.land/std@0.220.1/path/windows/basename.ts": "e2dbf31d1d6385bfab1ce38c333aa290b6d7ae9e0ecb8234a654e583cf22f8fe", + "https://deno.land/std@0.220.1/path/windows/common.ts": "26f60ccc8b2cac3e1613000c23ac5a7d392715d479e5be413473a37903a2b5d4", + "https://deno.land/std@0.220.1/path/windows/constants.ts": "5afaac0a1f67b68b0a380a4ef391bf59feb55856aa8c60dfc01bd3b6abb813f5", + "https://deno.land/std@0.220.1/path/windows/dirname.ts": "33e421be5a5558a1346a48e74c330b8e560be7424ed7684ea03c12c21b627bc9", + "https://deno.land/std@0.220.1/path/windows/extname.ts": "165a61b00d781257fda1e9606a48c78b06815385e7d703232548dbfc95346bef", + "https://deno.land/std@0.220.1/path/windows/format.ts": "bbb5ecf379305b472b1082cd2fdc010e44a0020030414974d6029be9ad52aeb6", + "https://deno.land/std@0.220.1/path/windows/from_file_url.ts": "ced2d587b6dff18f963f269d745c4a599cf82b0c4007356bd957cb4cb52efc01", + "https://deno.land/std@0.220.1/path/windows/glob_to_regexp.ts": "e45f1f89bf3fc36f94ab7b3b9d0026729829fabc486c77f414caebef3b7304f8", + "https://deno.land/std@0.220.1/path/windows/is_absolute.ts": "4a8f6853f8598cf91a835f41abed42112cebab09478b072e4beb00ec81f8ca8a", + "https://deno.land/std@0.220.1/path/windows/is_glob.ts": "8a8b08c08bf731acf2c1232218f1f45a11131bc01de81e5f803450a5914434b9", + "https://deno.land/std@0.220.1/path/windows/join.ts": "8d03530ab89195185103b7da9dfc6327af13eabdcd44c7c63e42e27808f50ecf", + "https://deno.land/std@0.220.1/path/windows/join_globs.ts": "a9475b44645feddceb484ee0498e456f4add112e181cb94042cdc6d47d1cdd25", + "https://deno.land/std@0.220.1/path/windows/mod.ts": "2301fc1c54a28b349e20656f68a85f75befa0ee9b6cd75bfac3da5aca9c3f604", + "https://deno.land/std@0.220.1/path/windows/normalize.ts": "78126170ab917f0ca355a9af9e65ad6bfa5be14d574c5fb09bb1920f52577780", + "https://deno.land/std@0.220.1/path/windows/normalize_glob.ts": "9c87a829b6c0f445d03b3ecadc14492e2864c3ebb966f4cea41e98326e4435c6", + "https://deno.land/std@0.220.1/path/windows/parse.ts": "dbdfe2bc6db482d755b5f63f7207cd019240fcac02ad2efa582adf67ff10553a", + "https://deno.land/std@0.220.1/path/windows/relative.ts": "3e1abc7977ee6cc0db2730d1f9cb38be87b0ce4806759d271a70e4997fc638d7", + "https://deno.land/std@0.220.1/path/windows/resolve.ts": "8dae1dadfed9d46ff46cc337c9525c0c7d959fb400a6308f34595c45bdca1972", + "https://deno.land/std@0.220.1/path/windows/to_file_url.ts": "40e560ee4854fe5a3d4d12976cef2f4e8914125c81b11f1108e127934ced502e", + "https://deno.land/std@0.220.1/path/windows/to_namespaced_path.ts": "4ffa4fb6fae321448d5fe810b3ca741d84df4d7897e61ee29be961a6aac89a4c" + } +} diff --git a/cli/deps.ts b/cli/deps.ts new file mode 100644 index 0000000000..fc8dc02014 --- /dev/null +++ b/cli/deps.ts @@ -0,0 +1,21 @@ +// Re-export standard library dependencies +export { parse } from "https://deno.land/std@0.220.1/flags/mod.ts"; +export { + blue, + red, + gray, + yellow, + bold, +} from "https://deno.land/std@0.220.1/fmt/colors.ts"; +export { + join, + dirname, +} from "https://deno.land/std@0.220.1/path/mod.ts"; + +// Export types +export type { + ApiHandler, + AgentConfig, + OperationMode, + ToolResponse, +} from "./types.d.ts"; diff --git a/cli/mod.ts b/cli/mod.ts new file mode 100644 index 0000000000..5521cdd913 --- /dev/null +++ b/cli/mod.ts @@ -0,0 +1,123 @@ +#!/usr/bin/env -S deno run --allow-read=. --allow-write=. --allow-run --allow-net --allow-env + +import { parse } from "./deps.ts"; +import { blue, red, gray, yellow, bold } from "./deps.ts"; +import { buildApiHandler } from "./api/mod.ts"; +import { StandaloneAgent } from "./core/StandaloneAgent.ts"; +import { SYSTEM_PROMPT } from "./core/prompts.ts"; +import type { ApiHandler, AgentConfig } from "./types.d.ts"; + +// Parse command line arguments +const args = parse(Deno.args, { + string: ["model", "key"], + boolean: ["help"], + alias: { + m: "model", + k: "key", + h: "help" + }, + default: { + model: "anthropic/claude-3.5-sonnet" + }, +}); + +if (args.help || Deno.args.length === 0) { + console.log(blue("\nCline - AI Coding Assistant\n")); + + console.log("Usage:"); + console.log(" cline [options]\n"); + + console.log("Required Permissions:"); + console.log(" --allow-read=. Read files in working directory"); + console.log(" --allow-write=. Write files in working directory"); + console.log(" --allow-run Execute commands (with interactive prompts)\n"); + console.log(" --allow-net Make API calls"); + console.log(" --allow-env Access environment variables\n"); + + console.log("Pre-approved Commands:"); + console.log(" npm - Package management (install, run, test, build)"); + console.log(" git - Version control (status, add, commit, push, pull, clone)"); + console.log(" deno - Deno runtime (run, test, fmt, lint, check)"); + console.log(" ls - List directory contents"); + console.log(" cat - Show file contents"); + console.log(" echo - Print text"); + console.log(" find - Search for files"); + console.log("\nOther commands will prompt for confirmation before execution.\n"); + + console.log("Options:"); + console.log(" -m, --model LLM model to use (default: \"anthropic/claude-3.5-sonnet\")"); + console.log(" -k, --key OpenRouter API key (or set OPENROUTER_API_KEY env var)"); + console.log(" -h, --help Display help for command\n"); + + console.log("Examples:"); + console.log(gray(" # Run pre-approved command")); + console.log(" cline \"Run npm install\"\n"); + + console.log(gray(" # Run command that requires confirmation")); + console.log(" cline \"Run yarn install\"\n"); + + Deno.exit(0); +} + +// Verify required permissions +const requiredPermissions = [ + { name: "read", path: "." }, + { name: "write", path: "." }, + { name: "run" }, + { name: "net" }, + { name: "env" } +] as const; + +for (const permission of requiredPermissions) { + const status = await Deno.permissions.query(permission); + if (status.state !== "granted") { + console.error(red(`Error: Missing required permission`)); + console.error(yellow(`Hint: Run with the following permissions:`)); + console.error(yellow(` deno run ${requiredPermissions.map(p => + "path" in p ? `--allow-${p.name}=${p.path}` : `--allow-${p.name}` + ).join(" ")} cli/mod.ts ...\n`)); + Deno.exit(1); + } +} + +const task = args._[0] as string; +const apiKey = args.key || Deno.env.get("OPENROUTER_API_KEY"); + +if (!apiKey) { + console.error(red("Error: OpenRouter API key is required. Set it with --key or OPENROUTER_API_KEY env var")); + console.error(yellow("Get your API key from: https://openrouter.ai/keys")); + Deno.exit(1); +} + +try { + const workingDir = Deno.cwd(); + + // Initialize API handler + const apiHandler = buildApiHandler({ + model: args.model, + apiKey + }); + + // Create agent instance + const agent = new StandaloneAgent({ + api: apiHandler, + systemPrompt: await SYSTEM_PROMPT(workingDir), + workingDir + }); + + // Run the task + console.log(blue(`\nStarting task: ${bold(task)}`)); + console.log(gray(`Working directory: ${workingDir}`)); + console.log(gray(`Model: ${args.model}`)); + console.log(gray("---\n")); + + await agent.runTask(task); + +} catch (error) { + if (error instanceof Error) { + console.error(red(`\nError: ${error.message}`)); + } else { + console.error(red("\nAn unknown error occurred")); + } + Deno.exit(1); +} diff --git a/cli/tools/mod.ts b/cli/tools/mod.ts new file mode 100644 index 0000000000..e2fbcd007b --- /dev/null +++ b/cli/tools/mod.ts @@ -0,0 +1,225 @@ +/// +import { join, dirname } from "https://deno.land/std@0.220.1/path/mod.ts"; +import { red, yellow, green } from "https://deno.land/std@0.220.1/fmt/colors.ts"; +import type { ToolResponse } from "../types.d.ts"; + +interface CommandConfig { + desc: string; + args: readonly string[]; +} + +// Define allowed commands and their descriptions +const ALLOWED_COMMANDS: Record = { + 'npm': { + desc: "Node package manager", + args: ["install", "run", "test", "build"] + }, + 'git': { + desc: "Version control", + args: ["status", "add", "commit", "push", "pull", "clone", "checkout", "branch"] + }, + 'deno': { + desc: "Deno runtime", + args: ["run", "test", "fmt", "lint", "check", "compile", "bundle"] + }, + 'ls': { + desc: "List directory contents", + args: ["-l", "-a", "-la", "-lh"] + }, + 'cat': { + desc: "Show file contents", + args: [] + }, + 'echo': { + desc: "Print text", + args: [] + } +}; + +// Track commands that have been allowed for this session +const alwaysAllowedCommands = new Set(); + +function isCommandAllowed(command: string): boolean { + // Split command into parts + const parts = command.trim().split(/\s+/); + if (parts.length === 0) return false; + + // Get base command + const baseCmd = parts[0]; + if (!(baseCmd in ALLOWED_COMMANDS)) return false; + + // If command has arguments, check if they're allowed + if (parts.length > 1 && ALLOWED_COMMANDS[baseCmd].args.length > 0) { + const arg = parts[1]; + return ALLOWED_COMMANDS[baseCmd].args.includes(arg); + } + + return true; +} + +async function promptForCommand(command: string): Promise { + // Check if command has been previously allowed + if (alwaysAllowedCommands.has(command)) { + console.log(yellow("\nWarning: Running previously allowed command:"), red(command)); + return true; + } + + console.log(yellow("\nWarning: Command not in allowlist")); + console.log("Command:", red(command)); + console.log("\nAllowed commands:"); + Object.entries(ALLOWED_COMMANDS).forEach(([cmd, { desc, args }]) => { + console.log(` ${green(cmd)}: ${desc}`); + if (args.length) { + console.log(` Arguments: ${args.join(", ")}`); + } + }); + + const answer = prompt("\nDo you want to run this command? (y/n/always) "); + if (answer?.toLowerCase() === 'always') { + alwaysAllowedCommands.add(command); + return true; + } + return answer?.toLowerCase() === 'y'; +} + +export async function executeCommand(command: string): Promise { + try { + // Check if command is allowed + if (!isCommandAllowed(command)) { + // Prompt user for confirmation + const shouldRun = await promptForCommand(command); + if (!shouldRun) { + return "Command execution cancelled by user"; + } + console.log(yellow("\nProceeding with command execution...")); + } + + const process = new Deno.Command("sh", { + args: ["-c", command], + stdout: "piped", + stderr: "piped", + }); + const { stdout, stderr } = await process.output(); + const decoder = new TextDecoder(); + return decoder.decode(stdout) + (stderr.length ? `\nStderr:\n${decoder.decode(stderr)}` : ""); + } catch (error) { + return `Error executing command: ${error instanceof Error ? error.message : String(error)}`; + } +} + +export async function readFile(workingDir: string, relativePath: string): Promise { + try { + const fullPath = join(workingDir, relativePath); + const content = await Deno.readTextFile(fullPath); + return content; + } catch (error) { + return `Error reading file: ${error instanceof Error ? error.message : String(error)}`; + } +} + +export async function writeFile(workingDir: string, relativePath: string, content: string): Promise { + try { + const fullPath = join(workingDir, relativePath); + await Deno.mkdir(dirname(fullPath), { recursive: true }); + await Deno.writeTextFile(fullPath, content); + return `Successfully wrote to ${relativePath}`; + } catch (error) { + return `Error writing file: ${error instanceof Error ? error.message : String(error)}`; + } +} + +export async function searchFiles( + workingDir: string, + searchPath: string, + regex: string, + filePattern?: string +): Promise { + try { + const fullPath = join(workingDir, searchPath); + const results: string[] = []; + + const regexObj = new RegExp(regex, "g"); + const patternObj = filePattern ? new RegExp(filePattern) : null; + + for await (const entry of Deno.readDir(fullPath)) { + if (entry.isFile && (!patternObj || patternObj.test(entry.name))) { + const filePath = join(fullPath, entry.name); + const content = await Deno.readTextFile(filePath); + const matches = content.match(regexObj); + if (matches) { + results.push(`File: ${entry.name}\nMatches:\n${matches.join("\n")}\n`); + } + } + } + + return results.join("\n") || "No matches found"; + } catch (error) { + return `Error searching files: ${error instanceof Error ? error.message : String(error)}`; + } +} + +export async function listFiles(workingDir: string, relativePath: string, recursive: boolean): Promise { + try { + const fullPath = join(workingDir, relativePath); + const files: string[] = []; + + async function* walkDir(dir: string): AsyncGenerator { + for await (const entry of Deno.readDir(dir)) { + const entryPath = join(dir, entry.name); + if (entry.isFile) { + yield entryPath.replace(fullPath + "/", ""); + } else if (recursive && entry.isDirectory) { + yield* walkDir(entryPath); + } + } + } + + for await (const file of walkDir(fullPath)) { + files.push(file); + } + + return files.join("\n") || "No files found"; + } catch (error) { + return `Error listing files: ${error instanceof Error ? error.message : String(error)}`; + } +} + +export async function listCodeDefinitions(workingDir: string, relativePath: string): Promise { + try { + const fullPath = join(workingDir, relativePath); + const content = await Deno.readTextFile(fullPath); + + // Basic regex patterns for common code definitions + const patterns = { + function: /(?:function|const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?:=\s*(?:function|\([^)]*\)\s*=>)|[({])/g, + class: /class\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g, + method: /(?:async\s+)?([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\([^)]*\)\s*{/g, + }; + + const definitions: Record = { + functions: [], + classes: [], + methods: [], + }; + + let match; + + while ((match = patterns.function.exec(content)) !== null) { + definitions.functions.push(match[1]); + } + + while ((match = patterns.class.exec(content)) !== null) { + definitions.classes.push(match[1]); + } + + while ((match = patterns.method.exec(content)) !== null) { + definitions.methods.push(match[1]); + } + + return Object.entries(definitions) + .map(([type, names]) => `${type}:\n${names.join("\n")}`) + .join("\n\n"); + } catch (error) { + return `Error listing code definitions: ${error instanceof Error ? error.message : String(error)}`; + } +} diff --git a/cli/types.d.ts b/cli/types.d.ts new file mode 100644 index 0000000000..ca814430f0 --- /dev/null +++ b/cli/types.d.ts @@ -0,0 +1,43 @@ +export interface ApiHandler { + sendMessage(message: string): Promise; + createMessage(systemPrompt: string, history: Message[]): AsyncIterable; +} + +export interface AgentConfig { + api: ApiHandler; + systemPrompt: string; + workingDir: string; + debug?: boolean; +} + +export type ToolResponse = string; + +export interface Message { + role: "user" | "assistant"; + content: TextBlock[]; +} + +export interface TextBlock { + type: "text"; + text: string; +} + +export interface ToolResult { + tool: string; + params: Record; + output: string; +} + +export interface MessageChunk { + type: "text"; + text: string; +} + +export interface UsageBlock { + type: "usage"; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +}