diff --git a/README.md b/README.md index b13b455..cfb26c3 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,14 @@ xurl [OPTIONS] - stdin: `-d @-` - `-o, --output `: write command output to file. +## Error Output + +`xurl` writes actionable stderr errors for agents: + +- unsupported providers and unsupported capabilities include `requested_uri`, suggested `next_steps`, and the GitHub issue link for requesting support +- missing local data includes evidence such as `searched_roots` so the next recovery step is explicit +- provider CLI failures include the command, exit code, and concrete retry guidance + ## URI Reference ### Agents URI diff --git a/xurl-cli/src/main.rs b/xurl-cli/src/main.rs index bc33ba0..bae14ff 100644 --- a/xurl-cli/src/main.rs +++ b/xurl-cli/src/main.rs @@ -39,11 +39,12 @@ struct Cli { fn main() -> ExitCode { let cli = Cli::parse(); + let requested_uri = cli.uri.clone(); match run(cli) { Ok(()) => ExitCode::SUCCESS, Err(err) => { - eprintln!("error: {}", user_facing_error(&err)); + eprintln!("error: {}", user_facing_error(&requested_uri, &err)); ExitCode::from(1) } } @@ -374,56 +375,543 @@ impl WriteEventSink for CliWriteSink { } } -fn user_facing_error(err: &XurlError) -> String { - match err { - XurlError::CommandNotFound { command } if command.contains("amp") => format!( - "{err}\nhint: write mode needs Amp CLI; run `amp --version`, install Amp CLI if missing, then run `amp login`." - ), - XurlError::CommandNotFound { command } if command.contains("codex") => format!( - "{err}\nhint: write mode needs Codex CLI; run `codex --version`, install Codex CLI if missing, then run `codex login`." - ), - XurlError::CommandNotFound { command } if command.contains("copilot") => format!( - "{err}\nhint: write mode needs GitHub Copilot CLI; run `copilot --version`, install Copilot CLI if missing, then authenticate with `copilot login`." - ), - XurlError::CommandNotFound { command } if command.contains("claude") => format!( - "{err}\nhint: write mode needs Claude CLI; run `claude --version`, install Claude Code if missing, then authenticate." - ), - XurlError::CommandNotFound { command } if command.contains("cursor") => format!( - "{err}\nhint: write mode needs Cursor Agent CLI; run `cursor-agent --version`, install Cursor Agent if missing, then authenticate with `cursor-agent login`." - ), - XurlError::CommandNotFound { command } if command.contains("gemini") => format!( - "{err}\nhint: write mode needs Gemini CLI; run `gemini --version`, install Gemini CLI if missing, then authenticate." - ), - XurlError::CommandNotFound { command } if command.contains("pi") => format!( - "{err}\nhint: write mode needs pi CLI; run `pi --version`, install pi if missing, then configure provider credentials." - ), - XurlError::CommandNotFound { command } if command.contains("opencode") => format!( - "{err}\nhint: write mode needs OpenCode CLI; run `opencode --version`, install OpenCode if missing, then configure providers/models." - ), - XurlError::CommandFailed { command, .. } if command.contains("amp") => { - format!("{err}\nhint: verify authentication with `amp login` and retry.") +const ISSUE_CREATE_URL: &str = "https://github.com/Xuanwo/xurl/issues/new"; +const SUPPORTED_PROVIDERS: &[&str] = &[ + "amp", "copilot", "codex", "claude", "cursor", "gemini", "kimi", "pi", "opencode", +]; + +#[derive(Default)] +struct ErrorReport { + summary: String, + fields: Vec<(String, String)>, + lists: Vec<(String, Vec)>, + next_steps: Vec, +} + +impl ErrorReport { + fn new(summary: impl Into) -> Self { + Self { + summary: summary.into(), + ..Self::default() + } + } + + fn field(mut self, key: impl Into, value: impl Into) -> Self { + self.fields.push((key.into(), flatten_line(&value.into()))); + self + } + + fn list(mut self, key: impl Into, values: Vec) -> Self { + if !values.is_empty() { + self.lists.push((key.into(), values)); } - XurlError::CommandFailed { command, .. } if command.contains("codex") => { - format!("{err}\nhint: verify authentication with `codex login` and retry.") + self + } + + fn steps(mut self, values: Vec) -> Self { + self.next_steps.extend(values); + self + } + + fn render(self) -> String { + let mut output = self.summary; + for (key, value) in self.fields { + output.push('\n'); + output.push_str(&format!("{key}: {value}")); } - XurlError::CommandFailed { command, .. } if command.contains("copilot") => format!( - "{err}\nhint: verify authentication with `copilot login`, or retry the equivalent `copilot -p ... --output-format json` command directly once." - ), - XurlError::CommandFailed { command, .. } if command.contains("claude") => format!( - "{err}\nhint: verify authentication with `claude auth` (or your configured login flow) and retry." - ), - XurlError::CommandFailed { command, .. } if command.contains("cursor") => format!( - "{err}\nhint: verify authentication with `cursor-agent login`, confirm the workspace is trusted for headless mode, and retry." - ), - XurlError::CommandFailed { command, .. } if command.contains("gemini") => format!( - "{err}\nhint: verify Gemini authentication/configuration and retry the command directly once." - ), - XurlError::CommandFailed { command, .. } if command.contains("pi") => format!( - "{err}\nhint: verify pi provider/model credentials and retry with `pi -p \"hello\" --mode json`." + for (key, values) in self.lists { + output.push('\n'); + output.push_str(&format!("{key}:")); + for value in values { + output.push('\n'); + output.push_str(&format!(" - {}", flatten_line(&value))); + } + } + if !self.next_steps.is_empty() { + output.push('\n'); + output.push_str("next_steps:"); + for step in self.next_steps { + output.push('\n'); + output.push_str(&format!(" - {}", flatten_line(&step))); + } + } + output + } +} + +fn flatten_line(value: &str) -> String { + value + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join(" | ") +} + +fn requested_provider(input: &str) -> Option<&str> { + let target = if let Some(rest) = input.strip_prefix("agents://") { + rest + } else if let Some((scheme, _)) = input.split_once("://") { + return Some(scheme); + } else { + input + }; + + target + .split(['/', '?']) + .find(|segment| !segment.is_empty()) + .filter(|segment| *segment != "." && *segment != ".." && *segment != "~") +} + +fn expected_session_id_shape(provider: Option<&str>) -> Option<&'static str> { + match provider { + Some("amp") => Some("T-"), + Some("opencode") => Some("ses_"), + Some("codex") | Some("copilot") | Some("claude") | Some("cursor") | Some("gemini") + | Some("kimi") | Some("pi") => Some(""), + _ => None, + } +} + +fn provider_root_check(provider: &str) -> Option { + match provider { + "amp" => { + Some("verify XDG_DATA_HOME/amp or ~/.local/share/amp contains this thread".to_string()) + } + "copilot" => Some("verify COPILOT_HOME or ~/.copilot contains this thread".to_string()), + "codex" => Some("verify CODEX_HOME or ~/.codex contains this thread".to_string()), + "claude" => Some("verify CLAUDE_CONFIG_DIR or ~/.claude contains this thread".to_string()), + "cursor" => Some( + "verify CURSOR_DATA_DIR, CURSOR_CONFIG_DIR, or ~/.cursor contains this thread" + .to_string(), ), - XurlError::CommandFailed { command, .. } if command.contains("opencode") => format!( - "{err}\nhint: verify OpenCode provider/model configuration and retry with `opencode run \"hello\" --format json`." + "gemini" => { + Some("verify GEMINI_CLI_HOME/.gemini or ~/.gemini contains this thread".to_string()) + } + "kimi" => Some("verify KIMI_SHARE_DIR or ~/.kimi contains this thread".to_string()), + "pi" => Some("verify PI_CODING_AGENT_DIR or ~/.pi/agent contains this thread".to_string()), + "opencode" => Some( + "verify XDG_DATA_HOME/opencode or ~/.local/share/opencode contains this thread" + .to_string(), ), - _ => err.to_string(), + _ => None, + } +} + +fn provider_command_steps(command: &str, failed: bool) -> Vec { + let program = command.split_whitespace().next().unwrap_or(command); + match program { + "amp" => { + if failed { + vec![ + "verify authentication with `amp login`".to_string(), + "retry the provider command directly once to inspect its stderr".to_string(), + ] + } else { + vec![ + "run `amp --version`".to_string(), + "install Amp CLI if missing, then run `amp login`".to_string(), + ] + } + } + "codex" => { + if failed { + vec![ + "verify authentication with `codex login`".to_string(), + "retry the provider command directly once to inspect its stderr".to_string(), + ] + } else { + vec![ + "run `codex --version`".to_string(), + "install Codex CLI if missing, then run `codex login`".to_string(), + ] + } + } + "copilot" => { + if failed { + vec![ + "verify authentication with `copilot login`".to_string(), + "retry the equivalent `copilot -p ... --output-format json` command directly once".to_string(), + ] + } else { + vec![ + "run `copilot --version`".to_string(), + "install GitHub Copilot CLI if missing, then authenticate with `copilot login`" + .to_string(), + ] + } + } + "claude" => { + if failed { + vec![ + "verify authentication with `claude auth` or your configured login flow" + .to_string(), + "retry the provider command directly once to inspect its stderr".to_string(), + ] + } else { + vec![ + "run `claude --version`".to_string(), + "install Claude Code if missing, then authenticate".to_string(), + ] + } + } + "cursor-agent" => { + if failed { + vec![ + "verify authentication with `cursor-agent login`".to_string(), + "confirm the workspace is trusted for headless mode, then retry".to_string(), + ] + } else { + vec![ + "run `cursor-agent --version`".to_string(), + "install Cursor Agent if missing, then authenticate with `cursor-agent login`" + .to_string(), + ] + } + } + "gemini" => { + if failed { + vec![ + "verify Gemini authentication and configuration".to_string(), + "retry the provider command directly once to inspect its stderr".to_string(), + ] + } else { + vec![ + "run `gemini --version`".to_string(), + "install Gemini CLI if missing, then authenticate".to_string(), + ] + } + } + "pi" => { + if failed { + vec![ + "verify pi provider or model credentials".to_string(), + "retry with `pi -p \"hello\" --mode json` to inspect provider output" + .to_string(), + ] + } else { + vec![ + "run `pi --version`".to_string(), + "install pi if missing, then configure provider credentials".to_string(), + ] + } + } + "opencode" => { + if failed { + vec![ + "verify OpenCode provider and model configuration".to_string(), + "retry with `opencode run \"hello\" --format json` to inspect provider output" + .to_string(), + ] + } else { + vec![ + "run `opencode --version`".to_string(), + "install OpenCode CLI if missing, then configure providers and models" + .to_string(), + ] + } + } + _ => { + if failed { + vec!["retry the provider command directly once to inspect its stderr".to_string()] + } else { + vec!["install the required provider CLI and make sure it is on PATH".to_string()] + } + } + } +} + +fn invalid_mode_report(requested_uri: &str, detail: &str) -> ErrorReport { + let provider = requested_provider(requested_uri); + + if detail.contains("does not support role-based write URI") { + let provider_name = provider.unwrap_or("this provider"); + return ErrorReport::new(format!( + "provider `{provider_name}` does not support role-based create in write mode" + )) + .field("requested_uri", requested_uri) + .field("detail", detail) + .steps(vec![ + format!("create without a role: `xurl agents://{provider_name} -d \"...\"`"), + format!( + "if you need role-based create for `{provider_name}`, open an issue: {ISSUE_CREATE_URL}" + ), + ]); + } + + if detail.contains("read mode requires a thread URI") { + return ErrorReport::new("read mode requires a thread URI") + .field("requested_uri", requested_uri) + .steps(vec![ + "read one conversation with `xurl agents:///`".to_string(), + "query conversations with `xurl agents://`".to_string(), + ]); + } + + if detail.contains("head mode (-I/--head) cannot be combined with write mode") { + return ErrorReport::new("head mode cannot be combined with write mode") + .field("requested_uri", requested_uri) + .steps(vec![ + "remove `-I/--head` to write".to_string(), + "remove `-d/--data` to inspect frontmatter only".to_string(), + ]); + } + + if detail.contains("write mode does not support path-scoped query URIs") { + return ErrorReport::new("write mode does not support path-scoped query URIs") + .field("requested_uri", requested_uri) + .steps(vec![ + "write to a provider URI such as `xurl agents://codex -d \"...\"`".to_string(), + "use path-scoped URIs only for query mode".to_string(), + ]); + } + + if detail.contains("write mode only supports main thread URIs") { + return ErrorReport::new("write mode only supports provider or main thread URIs") + .field("requested_uri", requested_uri) + .steps(vec![ + "create with `xurl agents:// -d \"...\"`".to_string(), + "append with `xurl agents:/// -d \"...\"`".to_string(), + ]); + } + + if detail.contains("subagent index mode requires") { + return ErrorReport::new("subagent index mode requires a main thread URI") + .field("requested_uri", requested_uri) + .steps(vec![ + "use `xurl -I agents:///`".to_string(), + ]); + } + + if detail.contains("subagent drill-down requires") { + return ErrorReport::new("subagent drill-down requires a child URI") + .field("requested_uri", requested_uri) + .steps(vec![ + "discover child ids first with `xurl -I agents:///`" + .to_string(), + "then read one child with `xurl agents:////`" + .to_string(), + ]); + } + + ErrorReport::new("invalid mode") + .field("requested_uri", requested_uri) + .field("detail", detail) +} + +fn user_facing_error(requested_uri: &str, err: &XurlError) -> String { + match err { + XurlError::InvalidUri(detail) => ErrorReport::new("invalid URI") + .field("requested_uri", requested_uri) + .field("detail", detail) + .steps(vec![ + "use `xurl agents://` to query conversations".to_string(), + "use `xurl agents:///` to read one conversation".to_string(), + ]) + .render(), + XurlError::UnsupportedScheme(scheme) => { + let kind = if requested_uri.starts_with("agents://") || !requested_uri.contains("://") + { + "provider" + } else { + "scheme" + }; + ErrorReport::new(format!("unsupported {kind} `{scheme}`")) + .field("requested_uri", requested_uri) + .field("supported_providers", SUPPORTED_PROVIDERS.join(", ")) + .steps(vec![ + if kind == "provider" { + "use one of the supported providers above".to_string() + } else { + "use the `agents:///...` URI family".to_string() + }, + format!( + "if you need `{scheme}` support, open an issue: {ISSUE_CREATE_URL}" + ), + ]) + .render() + } + XurlError::InvalidSessionId(session_id) => { + let provider = requested_provider(requested_uri); + let report = ErrorReport::new(format!("invalid session id `{session_id}`")) + .field("requested_uri", requested_uri) + .field("provider", provider.unwrap_or("unknown")); + let report = if let Some(shape) = expected_session_id_shape(provider) { + report.field("expected_format", shape) + } else { + report + }; + report + .steps(vec![ + "verify that the session or child id matches the provider format above".to_string(), + "if this token is a role, use it in query mode or with `-d` instead of read mode".to_string(), + ]) + .render() + } + XurlError::InvalidMode(detail) => invalid_mode_report(requested_uri, detail).render(), + XurlError::UnsupportedSubagentProvider(provider) => ErrorReport::new(format!( + "provider `{provider}` does not support child/subagent drill-down" + )) + .field("requested_uri", requested_uri) + .steps(vec![ + format!("read the main conversation with `xurl agents://{provider}/`"), + format!( + "if you need subagent support for `{provider}`, open an issue: {ISSUE_CREATE_URL}" + ), + ]) + .render(), + XurlError::UnsupportedProviderWrite(provider) => ErrorReport::new(format!( + "provider `{provider}` does not support write mode" + )) + .field("requested_uri", requested_uri) + .steps(vec![ + format!("use `{provider}` for read, query, or discover only"), + format!( + "if you need write support for `{provider}`, open an issue: {ISSUE_CREATE_URL}" + ), + ]) + .render(), + XurlError::CommandNotFound { command } => ErrorReport::new(format!( + "required provider CLI `{command}` is not available" + )) + .field("requested_uri", requested_uri) + .field("command", command) + .steps(provider_command_steps(command, false)) + .render(), + XurlError::CommandFailed { + command, + code, + stderr, + } => { + let report = ErrorReport::new("provider CLI command failed") + .field("requested_uri", requested_uri) + .field("command", command) + .field( + "exit_code", + code.map_or_else(|| "unknown".to_string(), |value| value.to_string()), + ); + let report = if stderr.trim().is_empty() { + report + } else { + report.field("stderr", stderr) + }; + report.steps(provider_command_steps(command, true)).render() + } + XurlError::WriteProtocol(detail) => ErrorReport::new( + "provider CLI output did not match the xurl write protocol", + ) + .field("requested_uri", requested_uri) + .field("detail", detail) + .steps(vec![ + "retry the provider command directly once to confirm its current output format" + .to_string(), + format!( + "if the provider CLI output format changed, open an issue: {ISSUE_CREATE_URL}" + ), + ]) + .render(), + XurlError::Serialization(detail) => ErrorReport::new("failed to serialize xurl data") + .field("requested_uri", requested_uri) + .field("detail", detail) + .steps(vec![format!( + "open an issue with the failing URI and provider output: {ISSUE_CREATE_URL}" + )]) + .render(), + XurlError::HomeDirectoryNotFound => ErrorReport::new("cannot determine home directory") + .field("requested_uri", requested_uri) + .steps(vec![ + "set `HOME` before running xurl".to_string(), + "or set provider-specific root env vars such as `CODEX_HOME` or `CLAUDE_CONFIG_DIR`".to_string(), + ]) + .render(), + XurlError::ThreadNotFound { + provider, + session_id, + searched_roots, + } => { + let report = ErrorReport::new(format!( + "thread not found for provider `{provider}` session `{session_id}`" + )) + .field("requested_uri", requested_uri) + .list( + "searched_roots", + searched_roots + .iter() + .map(|path| path.display().to_string()) + .collect(), + ); + let mut steps = vec![ + format!("run `xurl agents://{provider}` to list local conversations"), + "verify that the session id is correct".to_string(), + ]; + if let Some(root_step) = provider_root_check(provider) { + steps.push(root_step); + } + report.steps(steps).render() + } + XurlError::EntryNotFound { + provider, + session_id, + entry_id, + } => ErrorReport::new(format!( + "entry not found for provider `{provider}` session `{session_id}` entry `{entry_id}`" + )) + .field("requested_uri", requested_uri) + .steps(vec![ + format!( + "run `xurl -I agents://{provider}/{session_id}` to discover valid entry or child ids" + ), + "verify that the entry id is correct".to_string(), + ]) + .render(), + XurlError::EmptyThreadFile { path } => ErrorReport::new("thread file is empty") + .field("requested_uri", requested_uri) + .field("path", path.display().to_string()) + .steps(vec![ + "inspect whether the provider wrote an incomplete local transcript".to_string(), + "regenerate or restore the thread file, then retry".to_string(), + ]) + .render(), + XurlError::NonUtf8ThreadFile { path } => { + ErrorReport::new("thread file is not valid UTF-8") + .field("requested_uri", requested_uri) + .field("path", path.display().to_string()) + .steps(vec![ + "inspect the local thread file encoding".to_string(), + "regenerate the provider transcript if the file is corrupted".to_string(), + ]) + .render() + } + XurlError::Io { path, source } => ErrorReport::new("i/o error") + .field("requested_uri", requested_uri) + .field("path", path.display().to_string()) + .field("detail", source.to_string()) + .steps(vec![ + "verify that the path exists and parent directories are writable".to_string(), + "check file permissions, then retry".to_string(), + ]) + .render(), + XurlError::Sqlite { path, source } => ErrorReport::new("sqlite error") + .field("requested_uri", requested_uri) + .field("path", path.display().to_string()) + .field("detail", source.to_string()) + .steps(vec![ + "inspect the sqlite file for corruption or schema mismatch".to_string(), + "retry after the provider finishes writing to the database".to_string(), + ]) + .render(), + XurlError::InvalidJsonLine { path, line, source } => { + ErrorReport::new("invalid JSON line in local thread data") + .field("requested_uri", requested_uri) + .field("path", path.display().to_string()) + .field("line", line.to_string()) + .field("detail", source.to_string()) + .steps(vec![ + "inspect the local thread file at the line above".to_string(), + "regenerate the provider transcript if the file is truncated or corrupted" + .to_string(), + ]) + .render() + } } } diff --git a/xurl-cli/tests/cli.rs b/xurl-cli/tests/cli.rs index b47d3bc..d0f04c9 100644 --- a/xurl-cli/tests/cli.rs +++ b/xurl-cli/tests/cli.rs @@ -771,7 +771,9 @@ fn output_flag_returns_error_when_parent_directory_missing() { .arg(&output_path) .assert() .failure() - .stderr(predicate::str::contains("error: i/o error on")); + .stderr(predicate::str::contains("error: i/o error")) + .stderr(predicate::str::contains("path:")) + .stderr(predicate::str::contains("next_steps:")); } #[test] @@ -818,7 +820,11 @@ fn skills_scheme_is_rejected() { .assert() .failure() .stderr(predicate::str::contains( - "error: unsupported scheme: skills", + "error: unsupported scheme `skills`", + )) + .stderr(predicate::str::contains("supported_providers:")) + .stderr(predicate::str::contains( + "https://github.com/Xuanwo/xurl/issues/new", )); } @@ -1149,7 +1155,8 @@ fn unsupported_global_query_form_returns_error() { .arg("agents://?q=hello") .assert() .failure() - .stderr(predicate::str::contains("invalid uri")); + .stderr(predicate::str::contains("invalid URI")) + .stderr(predicate::str::contains("requested_uri: agents://?q=hello")); } #[test] @@ -1402,7 +1409,11 @@ fn missing_thread_returns_non_zero() { .arg(codex_uri()) .assert() .failure() - .stderr(predicate::str::contains("thread not found")); + .stderr(predicate::str::contains( + "thread not found for provider `codex`", + )) + .stderr(predicate::str::contains("searched_roots:")) + .stderr(predicate::str::contains("xurl agents://codex")); } #[test] @@ -2029,7 +2040,10 @@ fn copilot_child_uri_is_rejected_until_subagent_support_exists() { .assert() .failure() .stderr(predicate::str::contains( - "provider does not support subagent queries: copilot", + "provider `copilot` does not support child/subagent drill-down", + )) + .stderr(predicate::str::contains( + "https://github.com/Xuanwo/xurl/issues/new", )); } @@ -2564,7 +2578,10 @@ exit 99 .assert() .failure() .stderr(predicate::str::contains( - "write mode only supports main thread URIs", + "write mode only supports provider or main thread URIs", + )) + .stderr(predicate::str::contains( + "append with `xurl agents:/// -d", )); } @@ -2579,7 +2596,11 @@ fn write_command_not_found_has_hint() { .arg("hello") .assert() .failure() - .stderr(predicate::str::contains("hint: write mode needs Codex CLI")); + .stderr(predicate::str::contains( + "required provider CLI `codex` is not available", + )) + .stderr(predicate::str::contains("run `codex --version`")) + .stderr(predicate::str::contains("install Codex CLI if missing")); } #[cfg(unix)] @@ -2631,8 +2652,9 @@ exit 99 .assert() .failure() .stderr(predicate::str::contains( - "does not support role-based write URI", - )); + "does not support role-based create in write mode", + )) + .stderr(predicate::str::contains("xurl agents://amp -d")); } #[cfg(unix)] @@ -2685,8 +2707,9 @@ exit 99 .assert() .failure() .stderr(predicate::str::contains( - "does not support role-based write URI", - )); + "does not support role-based create in write mode", + )) + .stderr(predicate::str::contains("xurl agents://gemini -d")); } #[cfg(unix)] @@ -2738,8 +2761,9 @@ exit 99 .assert() .failure() .stderr(predicate::str::contains( - "does not support role-based write URI", - )); + "does not support role-based create in write mode", + )) + .stderr(predicate::str::contains("xurl agents://pi -d")); } #[cfg(unix)] @@ -2955,8 +2979,9 @@ exit 99 .assert() .failure() .stderr(predicate::str::contains( - "cursor does not support role-based write URI", - )); + "provider `cursor` does not support role-based create in write mode", + )) + .stderr(predicate::str::contains("xurl agents://cursor -d")); } #[cfg(unix)]