From b04904e1f62172d5508a8e2a770f35683470e33d Mon Sep 17 00:00:00 2001 From: Amolith Date: Sat, 7 Feb 2026 18:39:58 -0700 Subject: [PATCH] refactor: improve UX messaging in CLI and tools - More descriptive error messages with examples for invalid model format and missing config - Clearer, more consistent tool descriptions across all agent tools - Comprehensive help text with version, commands, usage, options, and config sections - Add commit.gpgsign=false to test setups to prevent GPG signing issues - Minor formatting consistency (e.g., 'KB' without space) --- src/agent/model-resolver.ts | 6 +++--- src/agent/runner.ts | 2 +- src/agent/tools/find.ts | 4 ++-- src/agent/tools/git/blame.ts | 2 +- src/agent/tools/git/diff.ts | 2 +- src/agent/tools/git/log.ts | 2 +- src/agent/tools/git/refs.ts | 2 +- src/agent/tools/git/show.ts | 2 +- src/agent/tools/grep.ts | 4 ++-- src/agent/tools/ls.ts | 2 +- src/agent/tools/read.ts | 6 +++--- src/agent/tools/web-fetch.ts | 6 +++--- src/agent/tools/web-search.ts | 4 ++-- src/cli/commands/web.ts | 4 ++-- src/cli/index.ts | 29 ++++++++++++++++++++++++----- test/git-log-validation.test.ts | 1 + test/git-tools.test.ts | 1 + test/workspace-cleanup.test.ts | 2 +- 18 files changed, 51 insertions(+), 30 deletions(-) diff --git a/src/agent/model-resolver.ts b/src/agent/model-resolver.ts index b989a347cb0b9e6a01c7da45af94543b82f3627a..92d7788058c8d010201fa48baf11a98d3454602f 100644 --- a/src/agent/model-resolver.ts +++ b/src/agent/model-resolver.ts @@ -12,14 +12,14 @@ export function resolveModel( ): Model { const colonIndex = modelString.indexOf(":"); if (colonIndex === -1) { - throw new ConfigError("Model must be in provider:model format"); + throw new ConfigError(`Invalid model format: "${modelString}". Expected provider:model (e.g. anthropic:claude-sonnet-4-20250514)`); } const provider = modelString.slice(0, colonIndex); const modelName = modelString.slice(colonIndex + 1); if (!provider || !modelName) { - throw new ConfigError("Model must be in provider:model format"); + throw new ConfigError(`Invalid model format: "${modelString}". Expected provider:model (e.g. anthropic:claude-sonnet-4-20250514)`); } // Handle custom models @@ -34,7 +34,7 @@ export function resolveModel( function resolveCustomModel(modelName: string, config: RumiloConfig): Model { if (!config.custom_models) { throw new ConfigError( - `No custom models configured. Use 'custom:' prefix only with custom model definitions in config.`, + `No custom models defined in config. Add a [custom_models.${modelName}] section to use custom:${modelName}`, ); } diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 2445e26681e29728e6a849ccf03e178d8c4418af..2ab1d37537fac23b05934ef16551be288d2ced05 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -80,7 +80,7 @@ export async function runAgent(query: string, options: AgentRunOptions): Promise .trim(); if (text === undefined || text === "") { - throw new AgentError("Agent returned no text response"); + throw new AgentError("Agent completed without producing a text response"); } const requestCount = agent.state.messages.filter((msg) => msg.role === "assistant").length; diff --git a/src/agent/tools/find.ts b/src/agent/tools/find.ts index 0266686fd5a386fda7cb0e45b1a0c92082f8e3ed..73aec6f85aaeb0ed64ecfa0a70df6e3b0f3706ab 100644 --- a/src/agent/tools/find.ts +++ b/src/agent/tools/find.ts @@ -17,12 +17,12 @@ const FindSchema = Type.Object({ export const createFindTool = (workspacePath: string): AgentTool => { const fdResult = spawnSync("which", ["fd"], { encoding: "utf-8" }); const fdPath = fdResult.stdout?.trim(); - if (!fdPath) throw new ToolInputError("fd is not available"); + if (!fdPath) throw new ToolInputError("find requires fd to be installed"); return { name: "find", label: "Find Files", - description: `Search for files by glob pattern using fd. Returns up to ${DEFAULT_LIMIT} results and ${DEFAULT_MAX_BYTES / 1024} KB of output. Respects .gitignore.`, + description: `Search for files matching a glob pattern. Returns up to ${DEFAULT_LIMIT} results and ${DEFAULT_MAX_BYTES / 1024}KB of output. Respects .gitignore.`, parameters: FindSchema as any, execute: async (_toolCallId: string, params: any) => { const searchDir: string = params.path || "."; diff --git a/src/agent/tools/git/blame.ts b/src/agent/tools/git/blame.ts index 3c3c6c24dcfbfecaf8dc483d6b9753a1e78cdde2..6ee4e2463778dc46a4f24492767b0bc4e01061b0 100644 --- a/src/agent/tools/git/blame.ts +++ b/src/agent/tools/git/blame.ts @@ -15,7 +15,7 @@ const BlameSchema = Type.Object({ export const createGitBlameTool = (workspacePath: string): AgentTool => ({ name: "git_blame", label: "Git Blame", - description: "Blame a file to see commit attribution.", + description: "Show line-by-line commit attribution for a file.", parameters: BlameSchema as any, execute: async (_toolCallId: string, params: any) => { if (!String(params.path ?? "").trim()) { diff --git a/src/agent/tools/git/diff.ts b/src/agent/tools/git/diff.ts index 528c87d90806b14a5288de686c9ae0077c92730b..9d8a0abbf9af4e93ad728d4923d2c809287890e3 100644 --- a/src/agent/tools/git/diff.ts +++ b/src/agent/tools/git/diff.ts @@ -17,7 +17,7 @@ const DiffSchema = Type.Object({ export const createGitDiffTool = (workspacePath: string): AgentTool => ({ name: "git_diff", label: "Git Diff", - description: "Show diff between refs or working tree.", + description: "Show diff between refs, or between a ref and the working tree. Omit both refs for unstaged changes.", parameters: DiffSchema as any, execute: async (_toolCallId: string, params: any) => { const git = simpleGit(workspacePath); diff --git a/src/agent/tools/git/log.ts b/src/agent/tools/git/log.ts index 68ab169085338fe1e83f4323247094f38c8e79e1..a8aa9080e4488cf9dca4f776446ba528a1d15a32 100644 --- a/src/agent/tools/git/log.ts +++ b/src/agent/tools/git/log.ts @@ -21,7 +21,7 @@ const LogSchema = Type.Object({ export const createGitLogTool = (workspacePath: string): AgentTool => ({ name: "git_log", label: "Git Log", - description: "View commit history. Supports filtering by path, author, date range, and count.", + description: "View commit history with optional path, author, and date filters.", parameters: LogSchema as any, execute: async (_toolCallId: string, params: any) => { const git = simpleGit(workspacePath); diff --git a/src/agent/tools/git/refs.ts b/src/agent/tools/git/refs.ts index 362548b3aba689bb717bd6dfd5f1d216102f5b90..a8807d3bb25a6c2cc93a246e7a133e465d67285a 100644 --- a/src/agent/tools/git/refs.ts +++ b/src/agent/tools/git/refs.ts @@ -18,7 +18,7 @@ const RefsSchema = Type.Object({ export const createGitRefsTool = (workspacePath: string): AgentTool => ({ name: "git_refs", label: "Git Refs", - description: "List branches or tags.", + description: "List branches, tags, or remotes.", parameters: RefsSchema as any, execute: async (_toolCallId: string, params: any) => { const git = simpleGit(workspacePath); diff --git a/src/agent/tools/git/show.ts b/src/agent/tools/git/show.ts index 0df8a79c9dc07baf242aed9988e02764a4ebdacd..f8af474850e6d35bc150d62c3ba1a336e8bb4ee2 100644 --- a/src/agent/tools/git/show.ts +++ b/src/agent/tools/git/show.ts @@ -15,7 +15,7 @@ const ShowSchema = Type.Object({ export const createGitShowTool = (workspacePath: string): AgentTool => ({ name: "git_show", label: "Git Show", - description: "Show details for a commit or object.", + description: "Show commit message and diff for a given ref.", parameters: ShowSchema as any, execute: async (_toolCallId: string, params: any) => { if (!String(params.ref ?? "").trim()) { diff --git a/src/agent/tools/grep.ts b/src/agent/tools/grep.ts index 66b4f52edfbf4a001df0d9f94357480fd9736c28..aed496233bb09216917b7b8e26226cd487ba00bc 100644 --- a/src/agent/tools/grep.ts +++ b/src/agent/tools/grep.ts @@ -31,14 +31,14 @@ const GrepSchema = Type.Object({ export const createGrepTool = (workspacePath: string): AgentTool => { const rgResult = spawnSync("which", ["rg"], { encoding: "utf-8" }); if (rgResult.status !== 0) { - throw new ToolInputError("ripgrep (rg) is not available"); + throw new ToolInputError("grep requires ripgrep (rg) to be installed"); } const rgPath = rgResult.stdout.trim(); return { name: "grep", label: "Grep", - description: `Search file contents for a pattern using ripgrep. Returns up to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Respects .gitignore.`, + description: `Search file contents for a pattern. Returns up to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB, whichever is reached first. Lines over ${GREP_MAX_LINE_LENGTH} chars are truncated. Respects .gitignore.`, parameters: GrepSchema as any, execute: async (_toolCallId: string, params: any) => { const searchDir: string | undefined = params.path; diff --git a/src/agent/tools/ls.ts b/src/agent/tools/ls.ts index 4fbd84fe9a3c8335168d38d47d9a34a59b699174..ab0d5bb62e5c1dcf95ac60184dfb51c29edc3d0c 100644 --- a/src/agent/tools/ls.ts +++ b/src/agent/tools/ls.ts @@ -15,7 +15,7 @@ const LsSchema = Type.Object({ export const createLsTool = (workspacePath: string): AgentTool => ({ name: "ls", label: "List Directory", - description: `List directory contents. Returns up to ${DEFAULT_LIMIT} entries and ${DEFAULT_MAX_BYTES / 1024} KB of output.`, + description: `List directory contents. Directories are suffixed with /. Returns up to ${DEFAULT_LIMIT} entries and ${DEFAULT_MAX_BYTES / 1024}KB of output.`, parameters: LsSchema as any, execute: async (_toolCallId: string, params: any) => { const resolved = resolveToCwd(params.path || ".", workspacePath); diff --git a/src/agent/tools/read.ts b/src/agent/tools/read.ts index 2a6dfd00f29e608190dafa26de79b690d43f40dd..199bf4e7429473d36c691276f03d827aa3a74cc4 100644 --- a/src/agent/tools/read.ts +++ b/src/agent/tools/read.ts @@ -8,7 +8,7 @@ import { ToolInputError } from "../../util/errors.js"; const MAX_READ_BYTES = 5 * 1024 * 1024; const ReadSchema = Type.Object({ - path: Type.String({ description: "Path relative to workspace root" }), + path: Type.String({ description: "File path relative to workspace root" }), offset: Type.Optional(Type.Number({ description: "1-based starting line (default: 1)" })), limit: Type.Optional(Type.Number({ description: "Maximum lines to return" })), }); @@ -16,7 +16,7 @@ const ReadSchema = Type.Object({ export const createReadTool = (workspacePath: string): AgentTool => ({ name: "read", label: "Read File", - description: `Read a file's contents. Output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files.`, + description: `Read a file's contents. Returns up to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB, whichever is reached first. Use offset and limit to paginate large files.`, parameters: ReadSchema as any, execute: async (_toolCallId: string, params: any) => { const absolutePath = resolveReadPath(params.path, workspacePath); @@ -24,7 +24,7 @@ export const createReadTool = (workspacePath: string): AgentTool => ({ const fileStats = await stat(absolutePath); if (fileStats.size > MAX_READ_BYTES) { - throw new ToolInputError(`File exceeds 5MB limit: ${params.path}`); + throw new ToolInputError(`File too large (>5MB): ${params.path}`); } const raw = await readFile(absolutePath, "utf8"); diff --git a/src/agent/tools/web-fetch.ts b/src/agent/tools/web-fetch.ts index 202a0675d94221be779041cd39e8512283d7c84e..ca663a5a2aa80a6e36ca9a074f4d220682fc2599 100644 --- a/src/agent/tools/web-fetch.ts +++ b/src/agent/tools/web-fetch.ts @@ -10,18 +10,18 @@ export interface WebFetchResult { const FetchSchema = Type.Object({ url: Type.String({ description: "URL to fetch" }), - nocache: Type.Optional(Type.Boolean({ description: "Force fresh fetch" })), + nocache: Type.Optional(Type.Boolean({ description: "Bypass cache and fetch fresh content" })), }); export function createWebFetchTool(apiKey: string): AgentTool { return { name: "web_fetch", label: "Web Fetch", - description: "Fetch a URL and return markdown using Tabstack.", + description: "Fetch a URL and return its content as markdown.", parameters: FetchSchema as any, execute: async (_toolCallId: string, params: any) => { if (!apiKey) { - throw new ToolInputError("Missing Tabstack API key"); + throw new ToolInputError("Web fetch is not configured"); } const client = new Tabstack({ apiKey }); diff --git a/src/agent/tools/web-search.ts b/src/agent/tools/web-search.ts index ac7b10be61726720c6d3ddd3b7045cd250cea483..abed56581d20cffc56ef38c85ba0d90120cc9a9d 100644 --- a/src/agent/tools/web-search.ts +++ b/src/agent/tools/web-search.ts @@ -10,11 +10,11 @@ const SearchSchema = Type.Object({ export const createWebSearchTool = (sessionToken: string): AgentTool => ({ name: "web_search", label: "Web Search", - description: "Search the web using Kagi (session token required).", + description: "Search the web. Returns structured results with titles, URLs, and snippets.", parameters: SearchSchema as any, execute: async (_toolCallId: string, params: any) => { if (!sessionToken) { - throw new ToolInputError("Missing Kagi session token"); + throw new ToolInputError("Web search is not configured"); } try { diff --git a/src/cli/commands/web.ts b/src/cli/commands/web.ts index 1e86b935e953d51553ac144073c35b91e3485ef8..547d9e0f15b6d204263546c9e45e325e624efca5 100644 --- a/src/cli/commands/web.ts +++ b/src/cli/commands/web.ts @@ -45,10 +45,10 @@ export async function runWebCommand(options: WebCommandOptions): Promise { process.env["TABSTACK_API_KEY"]; if (!kagiSession) { - throw new ConfigError("Missing Kagi session token (set KAGI_SESSION_TOKEN or config)"); + throw new ConfigError("Web search requires KAGI_SESSION_TOKEN (set via environment or config)"); } if (!tabstackKey) { - throw new ConfigError("Missing Tabstack API key (set TABSTACK_API_KEY or config)"); + throw new ConfigError("Web fetch requires TABSTACK_API_KEY (set via environment or config)"); } let systemPrompt = WEB_SYSTEM_PROMPT; diff --git a/src/cli/index.ts b/src/cli/index.ts index dde880ae1185ea2e5680e6ea4667751fe5717ffe..8d53ea2d3e49840b076b2777e7ae375ddb620b5a 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -28,8 +28,27 @@ async function main() { } if (!actualCommand || actualCommand === "help" || actualCommand === "--help" || actualCommand === "-h" || options["help"]) { - console.log("rumilo web [-u URL] [--model ] [--verbose] [--no-cleanup]"); - console.log("rumilo repo -u [--ref ] [--full] [--model ] [--verbose] [--no-cleanup]"); + console.log(`rumilo v${VERSION} — dispatch AI research subagents + +Commands: + web Search the web and synthesize an answer + repo Clone and explore a git repository + +Usage: + rumilo web [-u ] [options] + rumilo repo -u [options] + +Options: + -u Seed URL to pre-fetch (web) or repository to clone (repo) + --model Override the default model + --ref Checkout a specific ref after cloning (repo only) + --full Full clone instead of shallow (repo only) + --verbose Show tool calls and results on stderr + --no-cleanup Preserve the workspace directory after exit + -v, --version Print version and exit + +Configuration: + $XDG_CONFIG_HOME/rumilo/config.toml`); process.exit(0); } @@ -37,7 +56,7 @@ async function main() { if (command === "web") { const query = positional.join(" "); if (!query) { - throw new RumiloError("Missing query", "CLI_ERROR"); + throw new RumiloError("Missing query. Usage: rumilo web ", "CLI_ERROR"); } await runWebCommand({ @@ -54,10 +73,10 @@ async function main() { const query = positional.join(" "); const uri = options["uri"] ? String(options["uri"]) : undefined; if (!uri) { - throw new RumiloError("Missing repo URI", "CLI_ERROR"); + throw new RumiloError("Missing repository URI. Usage: rumilo repo -u ", "CLI_ERROR"); } if (!query) { - throw new RumiloError("Missing query", "CLI_ERROR"); + throw new RumiloError("Missing query. Usage: rumilo repo -u ", "CLI_ERROR"); } await runRepoCommand({ diff --git a/test/git-log-validation.test.ts b/test/git-log-validation.test.ts index d57862deaf7c2eabfdaeac6bc2927acc83235807..cfc97af3ad843bfcdfbb54853a69b604b1f8461f 100644 --- a/test/git-log-validation.test.ts +++ b/test/git-log-validation.test.ts @@ -15,6 +15,7 @@ beforeAll(async () => { await git.init(); await git.addConfig("user.name", "Test"); await git.addConfig("user.email", "test@test.com"); + await git.addConfig("commit.gpgsign", "false"); writeFileSync(join(workDir, "file.txt"), "hello"); await git.add("file.txt"); await git.commit("initial commit"); diff --git a/test/git-tools.test.ts b/test/git-tools.test.ts index d59a5206aa5adec48404af8d2833a1ec24be6a2e..0d21799c94b3bf55bd4aa78d0ebfcf0b7017651d 100644 --- a/test/git-tools.test.ts +++ b/test/git-tools.test.ts @@ -22,6 +22,7 @@ beforeAll(async () => { await git.init(); await git.addConfig("user.name", "Test"); await git.addConfig("user.email", "test@test.com"); + await git.addConfig("commit.gpgsign", "false"); // Create a large file for truncation tests const largeLine = "x".repeat(100); diff --git a/test/workspace-cleanup.test.ts b/test/workspace-cleanup.test.ts index 954c0f70f144224c44d3755ab09574a83a5738da..a69c14210b274ebbb55bdee3cfefc3d3e3020fc8 100644 --- a/test/workspace-cleanup.test.ts +++ b/test/workspace-cleanup.test.ts @@ -78,7 +78,7 @@ describe("repo command – workspace cleanup on early failure", () => { const workClone = mkdtempSync(join(tmpdir(), "rumilo-test-work-")); execSync(`git clone ${localRepo} work`, { cwd: workClone, stdio: "ignore" }); const workDir = join(workClone, "work"); - execSync("git config user.email test@test.com && git config user.name Test", { cwd: workDir, stdio: "ignore" }); + execSync("git config user.email test@test.com && git config user.name Test && git config commit.gpgsign false", { cwd: workDir, stdio: "ignore" }); execSync("echo hello > README.md && git add . && git commit -m init", { cwd: workDir, stdio: "ignore" }); execSync("git push", { cwd: workDir, stdio: "ignore" }); // Clean up work clone