feat(cli): agent-native output, stdin symbols, and exit-code classes

Phase 1 of docs/DESIGN.md:

- Output format resolves -o > IDX_OUTPUT > general.output > auto; auto
  is a table on a terminal and JSON when stdout is piped or captured.
  `config init` now writes output = "auto".
- JSON is pretty only on a terminal and compact otherwise. New `-o
  jsonl` prints one array item per line with the same key order.
- No ANSI color when stdout is not a terminal (direct owo-colors calls
  now go through output::style).
- `stocks quote -` and `stocks compare -` read symbols from stdin.
- Exit codes by class: 2 invalid input/config, 3 not found or cache
  miss, 4 upstream unavailable, 5 unsupported, 1 other.

Golden changes are limited to those documented kinds and were verified
mechanically: JSON goldens are semantically identical, tables differ
only by removed ANSI codes and the column padding comfy-table had
reserved for them (cell contents identical), exit codes match each
error's class, and help/completions only gain the new --output values.
17 new golden cases cover auto, jsonl, stdin, and the new errors.

tests/cli.rs now pins IDX_OUTPUT=table in its harness (its tests assert
table text and run piped) and parses discover JSON instead of matching
pretty-printed whitespace.

Live A/B against main: 16/16 stocks cases identical; ownership discover
got IDX 403 on both binaries, now exiting 4 instead of 1 as intended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hermes 2026-10-14 08:52:13 +00:00
commit 22b6dabaeb
119 changed files with 920 additions and 1896 deletions

View file

@ -1,4 +1,5 @@
use std::fs;
use std::io::IsTerminal;
use std::path::PathBuf;
use clap::ValueEnum;
@ -7,7 +8,7 @@ use serde::Deserialize;
use crate::cli::Cli;
use crate::error::IdxError;
use crate::output::OutputFormat;
use crate::output::{OutputFormat, OutputSetting};
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
@ -92,7 +93,7 @@ struct FileGeneral {
provider: Option<ProviderKind>,
history_provider: Option<HistoryProviderKind>,
exchange: Option<String>,
output: Option<OutputFormat>,
output: Option<OutputSetting>,
color: Option<bool>,
}
@ -108,7 +109,7 @@ impl Default for IdxConfig {
provider: ProviderKind::Msn,
history_provider: HistoryProviderKind::Auto,
exchange: "JK".to_string(),
output: OutputFormat::Table,
output: OutputSetting::Auto.resolve(stdout_is_terminal()),
no_color: false,
quote_ttl: 300,
fundamental_ttl: 3600,
@ -130,15 +131,12 @@ impl IdxConfig {
cfg.exchange = exchange;
}
if let Ok(output) = std::env::var("IDX_OUTPUT") {
cfg.output = if output.eq_ignore_ascii_case("json") {
OutputFormat::Json
} else if output.eq_ignore_ascii_case("table") {
OutputFormat::Table
} else {
return Err(IdxError::ConfigError(format!(
"invalid IDX_OUTPUT value: '{output}', expected 'json' or 'table'"
)));
};
let setting = OutputSetting::parse(&output).ok_or_else(|| {
IdxError::ConfigError(format!(
"invalid IDX_OUTPUT value: '{output}', expected 'auto', 'table', 'json', or 'jsonl'"
))
})?;
cfg.output = setting.resolve(stdout_is_terminal());
}
if let Ok(no_color) = std::env::var("IDX_NO_COLOR") {
cfg.no_color = no_color == "1" || no_color.eq_ignore_ascii_case("true");
@ -151,9 +149,9 @@ impl IdxConfig {
}
if let Some(output) = cli.output {
cfg.output = output;
cfg.output = output.resolve(stdout_is_terminal());
}
cfg.no_color = cfg.no_color || cli.no_color;
cfg.no_color = cfg.no_color || cli.no_color || !stdout_is_terminal();
Ok(cfg)
}
@ -176,7 +174,7 @@ impl IdxConfig {
cfg.exchange = exchange;
}
if let Some(output) = general.output {
cfg.output = output;
cfg.output = output.resolve(stdout_is_terminal());
}
if let Some(color) = general.color {
cfg.no_color = !color;
@ -216,8 +214,12 @@ fn parse_env_ttl(name: &str) -> Result<Option<u64>, IdxError> {
Ok(Some(parsed))
}
pub fn stdout_is_terminal() -> bool {
std::io::stdout().is_terminal()
}
pub fn default_config_toml() -> String {
"[general]\nprovider = \"msn\"\nhistory_provider = \"auto\"\nexchange = \"JK\"\noutput = \"table\"\ncolor = true\n\n[cache]\nquote_ttl = 300\nfundamental_ttl = 3600\n".to_string()
"[general]\nprovider = \"msn\"\nhistory_provider = \"auto\"\nexchange = \"JK\"\noutput = \"auto\"\ncolor = true\n\n[cache]\nquote_ttl = 300\nfundamental_ttl = 3600\n".to_string()
}
pub fn config_path() -> Result<PathBuf, IdxError> {
@ -292,9 +294,9 @@ fn normalize_config_value(key: &str, value: &str) -> Result<toml::Value, IdxErro
HistoryProviderKind::parse(value)?.as_str().to_string(),
)),
"general.output" => {
if !value.eq_ignore_ascii_case("table") && !value.eq_ignore_ascii_case("json") {
if OutputSetting::parse(value).is_none() {
return Err(IdxError::InvalidInput(format!(
"invalid output format '{value}': expected 'table' or 'json'"
"invalid output format '{value}': expected 'auto', 'table', 'json', or 'jsonl'"
)));
}
Ok(toml::Value::String(value.to_ascii_lowercase()))