refactor: improve UX messaging in CLI and tools

Amolith created

- 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)

Change summary

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(-)

Detailed changes

src/agent/model-resolver.ts πŸ”—

@@ -12,14 +12,14 @@ export function resolveModel(
 ): Model<any> {
   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<any> {
   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}`,
     );
   }
 

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;

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 || ".";

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()) {

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);

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);

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);

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()) {

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;

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);

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");

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 });

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 {

src/cli/commands/web.ts πŸ”—

@@ -45,10 +45,10 @@ export async function runWebCommand(options: WebCommandOptions): Promise<void> {
       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;

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 <query> [-u URL] [--model <provider:model>] [--verbose] [--no-cleanup]");
-    console.log("rumilo repo -u <uri> <query> [--ref <ref>] [--full] [--model <provider: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 <query> [-u <url>] [options]
+  rumilo repo -u <uri> <query> [options]
+
+Options:
+  -u <url>                 Seed URL to pre-fetch (web) or repository to clone (repo)
+  --model <provider:model> Override the default model
+  --ref <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 <query>", "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 <uri> <query>", "CLI_ERROR");
       }
       if (!query) {
-        throw new RumiloError("Missing query", "CLI_ERROR");
+        throw new RumiloError("Missing query. Usage: rumilo repo -u <uri> <query>", "CLI_ERROR");
       }
 
       await runRepoCommand({

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");

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);

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