diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f54a94cd..c35fd883 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,5 +1,6 @@ { "name": "cortex", + "version": "1.7.0", "description": "Syslog management via MCP", "author": { "name": "jmagar" diff --git a/CHANGELOG.md b/CHANGELOG.md index 667f29c0..4d41f22f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.7.0] - 2026-06-02 + +### Added + +- **CLI help now supports nested subcommand help.** `cortex ai search --help`, `cortex db status --help`, `cortex compose logs --help`, and other nested command paths now render focused usage instead of the whole namespace. The top-level `cortex --help` is regrouped into an axon-style sectioned layout with cyan headers. +- **CLI parser recovery now suggests close command and subcommand matches.** Mistyped top-level and nested commands now include `Did you mean ...` hints while preserving strict parsing. +- **AI investigation output now has compact/default controls.** `cortex ai investigate` and `cortex ai blocks` support `--detail compact|full`, with `--include-transcript`, `--max-bytes`, and `--limit` controls for bounded agent-friendly output. + +### Fixed + +- **Cortex CLI diagnostics no longer point operators at stale `syslog` binary invocations.** Backup guidance, help text, rollout notes, and parser comments now use the `cortex` binary where they refer to CLI commands. +- **`host-state` now fails with actionable usage when no host selector is supplied.** The parser now reports the required `--host-id` or `--hostname` selector before dispatching. +- **`--max-bytes` truncation is now byte-accurate on multibyte input** and yields a leading prefix for budgets smaller than the ellipsis instead of an empty string. + ## [1.6.1] - 2026-06-02 ### Added diff --git a/Cargo.lock b/Cargo.lock index fabc7baf..68759a0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -418,7 +418,7 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cortex" -version = "1.6.1" +version = "1.7.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index be977620..8a016487 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cortex" -version = "1.6.1" +version = "1.7.0" edition = "2021" rust-version = "1.86" description = "Homelab intelligence platform — syslog/OTLP/Docker log aggregation, fleet awareness, and AI agent coordination over MCP, CLI, and HTTP" diff --git a/docs/rollout.md b/docs/rollout.md index 1a91ffe9..c92dc1aa 100644 --- a/docs/rollout.md +++ b/docs/rollout.md @@ -31,7 +31,7 @@ grep CORTEX_API_TOKEN ~/.cortex/.env # 3. Parity check: query the same data via local + HTTP and assert # the JSON shapes agree. Empty diff = safe to cut over. cortex --json hosts | jq -S . > /tmp/syslog-local.json -CORTEX_USE_HTTP=1 syslog --json hosts | jq -S . > /tmp/syslog-http.json +CORTEX_USE_HTTP=1 cortex --json hosts | jq -S . > /tmp/syslog-http.json diff /tmp/syslog-local.json /tmp/syslog-http.json && echo "parity OK" # 4. ai-watch daemon must be active + binary SHA recorded so we know @@ -55,7 +55,7 @@ Order matters. Each step's failure mode is documented inline. `CORTEX_USE_HTTP=false`). Run BEFORE pulling the new image so the container has a token to start with. -2. **`cortex compose pull && syslog compose up`** — pull the v0.26 +2. **`cortex compose pull && cortex compose up`** — pull the v0.26 image and recreate the container. The container fails fast if `CORTEX_API_TOKEN` is missing; step 1 prevents that. Wait until `cortex compose ps` reports `healthy` before proceeding. @@ -107,7 +107,7 @@ docker compose logs cortex --since 5m | grep -E "500|ERROR|panic" | wc -l # ex # Total log count grew from the +0 baseline captured in pre-deploy step 1. cortex stats # CLI-to-API latency on a representative read. -time syslog tail -n 100 --json > /dev/null # expect: < 0.2s on a warm cache +time cortex tail -n 100 --json > /dev/null # expect: < 0.2s on a warm cache ``` ### +24 hours @@ -169,7 +169,7 @@ is required for a same-day revert. - **VACUUM on large databases**: `db vacuum --full` on a database larger than ~10 GB may exceed the 10-minute HTTP request timeout. - Workaround: `CORTEX_USE_HTTP=false syslog db vacuum --full --force` + Workaround: `CORTEX_USE_HTTP=false cortex db vacuum --full --force` to bypass the API and run VACUUM directly against the SQLite file. This is a known limitation tracked for the v0.27 successor. diff --git a/docs/runbooks/deploy.md b/docs/runbooks/deploy.md index 85c491e4..8ac65e25 100644 --- a/docs/runbooks/deploy.md +++ b/docs/runbooks/deploy.md @@ -37,7 +37,7 @@ cortex compose up # Option 2: Revert to previous commit git log --oneline -5 # find the good commit git revert HEAD # or git reset --hard -cortex compose pull && syslog compose up +cortex compose pull && cortex compose up ``` ## Health Check diff --git a/mcpb/manifest.json b/mcpb/manifest.json index 858ac26b..e47ddfed 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -3,7 +3,7 @@ "manifest_version": "0.4", "name": "cortex", "display_name": "Cortex", - "version": "1.6.1", + "version": "1.7.0", "description": "Query local cortex SQLite logs through a bundled stdio MCP server.", "long_description": "cortex packages the existing cortex stdio entrypoint as a local MCP Bundle. It is query-only: it reads the configured SQLite database and does not start syslog listeners, HTTP servers, Docker Compose, REST, or deploy flows.", "author": { diff --git a/server.json b/server.json index cb428821..bef0be57 100644 --- a/server.json +++ b/server.json @@ -7,11 +7,11 @@ "url": "https://github.com/jmagar/cortex", "source": "github" }, - "version": "1.6.1", + "version": "1.7.0", "packages": [ { "registryType": "oci", - "identifier": "ghcr.io/jmagar/cortex:v1.6.1", + "identifier": "ghcr.io/jmagar/cortex:v1.7.0", "transport": { "type": "stdio" }, diff --git a/src/cli.rs b/src/cli.rs index d9eda401..d9917af0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -10,15 +10,16 @@ pub(crate) use args::{ AgentCommandCommand, AgentCommandIngestSpoolArgs, AgentCommandWrapArgs, AiAbuseArgs, AiAddArgs, AiAskHistoryArgs, AiAssessArgs, AiBlocksArgs, AiCheckpointsArgs, AiCommand, AiContextArgs, AiCorrelateArgs, AiDoctorArgs, AiErrorsArgs, AiIncidentContextArgs, AiIncidentsArgs, - AiIndexArgs, AiInvestigateArgs, AiListArgs, AiPruneCheckpointsArgs, AiSearchArgs, - AiSimilarArgs, AiWatchArgs, CliCommand, ComposeArgs, ComposeCommand, ComposeLogsArgs, - ComposeMutationArgs, CorrelateArgs, DbBackupArgs, DbCheckpointArgs, DbCommand, DbIntegrityArgs, - DbIntegrityStatusArgs, DbStatusArgs, DbVacuumArgs, EntityArgs, FilterArgs, GraphAroundArgs, - GraphCommand, GraphExplainArgs, GraphRebuildArgs, GraphStatusArgs, HeartbeatAgentArgs, - HeartbeatCommand, IncidentArgs, IngestRateArgs, NotifyRecentArgs, NotifyTestArgs, OutputArgs, - PatternsArgs, PluginHookArgs, SearchArgs, ServiceCommand, ServiceLogsArgs, SessionsArgs, - SetupArgs, SetupCommand, ShellAtuinIndexArgs, ShellCommand, ShellIndexArgs, SigAckArgs, - SigListArgs, SigUnackArgs, SourceIpsArgs, TailArgs, TimeRangeArgs, TimelineArgs, + AiIndexArgs, AiInvestigateArgs, AiListArgs, AiOutputDetail, AiPruneCheckpointsArgs, + AiSearchArgs, AiSimilarArgs, AiWatchArgs, CliCommand, ComposeArgs, ComposeCommand, + ComposeLogsArgs, ComposeMutationArgs, CorrelateArgs, DbBackupArgs, DbCheckpointArgs, DbCommand, + DbIntegrityArgs, DbIntegrityStatusArgs, DbStatusArgs, DbVacuumArgs, EntityArgs, FilterArgs, + GraphAroundArgs, GraphCommand, GraphExplainArgs, GraphRebuildArgs, GraphStatusArgs, + HeartbeatAgentArgs, HeartbeatCommand, IncidentArgs, IngestRateArgs, NotifyRecentArgs, + NotifyTestArgs, OutputArgs, PatternsArgs, PluginHookArgs, SearchArgs, ServiceCommand, + ServiceLogsArgs, SessionsArgs, SetupArgs, SetupCommand, ShellAtuinIndexArgs, ShellCommand, + ShellIndexArgs, SigAckArgs, SigListArgs, SigUnackArgs, SourceIpsArgs, TailArgs, TimeRangeArgs, + TimelineArgs, }; pub(crate) use args_config::{ ConfigCommand, ConfigGetArgs, ConfigListArgs, ConfigSetArgs, ConfigTarget, ConfigUnsetArgs, @@ -59,6 +60,7 @@ mod parse_config; mod parse_logs; mod setup; mod sparkline; +mod suggest; mod table; pub(crate) use config_cmd::run_config; diff --git a/src/cli/args.rs b/src/cli/args.rs index 7dba73a7..ffccd308 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -7,8 +7,8 @@ mod surface; pub(crate) use ai::{ AiAbuseArgs, AiAddArgs, AiAskHistoryArgs, AiAssessArgs, AiBlocksArgs, AiCheckpointsArgs, AiCommand, AiContextArgs, AiCorrelateArgs, AiDoctorArgs, AiErrorsArgs, AiIncidentContextArgs, - AiIncidentsArgs, AiIndexArgs, AiInvestigateArgs, AiListArgs, AiPruneCheckpointsArgs, - AiSearchArgs, AiSimilarArgs, AiWatchArgs, + AiIncidentsArgs, AiIndexArgs, AiInvestigateArgs, AiListArgs, AiOutputDetail, + AiPruneCheckpointsArgs, AiSearchArgs, AiSimilarArgs, AiWatchArgs, }; pub(crate) use surface::{ AnomaliesArgs, AppsArgs, ClockSkewArgs, CompareArgs, CorrelateStateArgs, FleetStateArgs, diff --git a/src/cli/args/ai.rs b/src/cli/args/ai.rs index 899806d9..51acf728 100644 --- a/src/cli/args/ai.rs +++ b/src/cli/args/ai.rs @@ -79,9 +79,32 @@ pub(crate) struct AiBlocksArgs { pub tool: Option, pub from: Option, pub to: Option, + pub limit: Option, + pub detail: AiOutputDetail, pub json: bool, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) enum AiOutputDetail { + #[default] + Compact, + Full, +} + +impl AiOutputDetail { + pub(crate) fn parse(value: &str, flag: &str) -> anyhow::Result { + match value { + "compact" => Ok(Self::Compact), + "full" => Ok(Self::Full), + _ => anyhow::bail!("{flag} must be compact or full"), + } + } + + pub(crate) fn is_compact(self) -> bool { + matches!(self, Self::Compact) + } +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct AiContextArgs { pub project: String, @@ -217,6 +240,9 @@ pub(crate) struct AiInvestigateArgs { pub window_minutes: Option, pub correlation_window_minutes: Option, pub terms: Vec, + pub detail: AiOutputDetail, + pub include_transcript: bool, + pub max_bytes: Option, pub json: bool, } diff --git a/src/cli/commands/anomalies.rs b/src/cli/commands/anomalies.rs index 542b77b2..2e9d3b63 100644 --- a/src/cli/commands/anomalies.rs +++ b/src/cli/commands/anomalies.rs @@ -1,4 +1,4 @@ -//! Parse function for `syslog anomalies`. +//! Parse function for `cortex anomalies`. //! //! Surface parity (2026-05-22): exposes the `anomalies` MCP action and //! `GET /api/anomalies` REST route as a top-level CLI subcommand. diff --git a/src/cli/commands/apps.rs b/src/cli/commands/apps.rs index 3579cd6e..586139e3 100644 --- a/src/cli/commands/apps.rs +++ b/src/cli/commands/apps.rs @@ -1,4 +1,4 @@ -//! Parse function for `syslog apps`. +//! Parse function for `cortex apps`. //! //! Surface parity (2026-05-22): exposes the `apps` MCP action and //! `GET /api/apps` REST route as a top-level CLI subcommand. diff --git a/src/cli/commands/clock_skew.rs b/src/cli/commands/clock_skew.rs index 32e624dd..42e5aeae 100644 --- a/src/cli/commands/clock_skew.rs +++ b/src/cli/commands/clock_skew.rs @@ -1,4 +1,4 @@ -//! Parse function for `syslog clock-skew`. +//! Parse function for `cortex clock-skew`. //! //! Surface parity (2026-05-22): exposes the `clock_skew` MCP action and //! `GET /api/clock-skew` REST route as a top-level CLI subcommand. diff --git a/src/cli/commands/compare.rs b/src/cli/commands/compare.rs index 4b8d7230..d3affb2e 100644 --- a/src/cli/commands/compare.rs +++ b/src/cli/commands/compare.rs @@ -1,4 +1,4 @@ -//! Parse function for `syslog compare`. +//! Parse function for `cortex compare`. //! //! Surface parity (2026-05-22): exposes the `compare` MCP action and //! `GET /api/compare` REST route as a top-level CLI subcommand. All four diff --git a/src/cli/commands/host_state.rs b/src/cli/commands/host_state.rs index 2ac885a1..e25355bc 100644 --- a/src/cli/commands/host_state.rs +++ b/src/cli/commands/host_state.rs @@ -23,8 +23,20 @@ pub(crate) fn parse_host_state(args: &[String]) -> Result { } else if let Some(v) = flags.match_value(&arg, "--limit")? { parsed.limit = Some(parse_u32_flag("--limit", v)?); } else { - bail!("unknown host-state option: {arg}"); + bail!( + "{}", + super::super::suggest::unknown_option( + "host-state", + &arg, + &["--json", "--host-id", "--hostname", "--since", "--limit"], + ) + ); } } + if parsed.host_id.is_none() && parsed.hostname.is_none() { + bail!( + "host-state requires --host-id ID or --hostname HOST\n\nUsage: cortex host-state [--host-id ID] [--hostname HOST] [--since TIME] [--limit N] [--json]" + ); + } Ok(CliCommand::HostState(parsed)) } diff --git a/src/cli/commands/notify.rs b/src/cli/commands/notify.rs index 3c46234e..5890d4d0 100644 --- a/src/cli/commands/notify.rs +++ b/src/cli/commands/notify.rs @@ -15,7 +15,14 @@ pub(crate) fn parse_notify(args: &[String]) -> Result { match subcommand.as_str() { "recent" => parse_notify_recent(rest), "test" => parse_notify_test(rest), - _ => bail!("unknown notify subcommand: {subcommand}"), + _ => bail!( + "{}", + super::super::suggest::unknown_command( + "notify subcommand", + subcommand, + &["recent", "test"], + ) + ), } } diff --git a/src/cli/commands/sig.rs b/src/cli/commands/sig.rs index c90c11ff..d6d9c9fd 100644 --- a/src/cli/commands/sig.rs +++ b/src/cli/commands/sig.rs @@ -16,7 +16,14 @@ pub(crate) fn parse_sig(args: &[String]) -> Result { "list" => parse_sig_list(rest), "ack" => parse_sig_ack(rest), "unack" => parse_sig_unack(rest), - _ => bail!("unknown sig subcommand: {subcommand}"), + _ => bail!( + "{}", + super::super::suggest::unknown_command( + "sig subcommand", + subcommand, + &["list", "ack", "unack"], + ) + ), } } diff --git a/src/cli/commands/silent_hosts.rs b/src/cli/commands/silent_hosts.rs index 3525b839..1d6e02be 100644 --- a/src/cli/commands/silent_hosts.rs +++ b/src/cli/commands/silent_hosts.rs @@ -1,4 +1,4 @@ -//! Parse function for `syslog silent-hosts`. +//! Parse function for `cortex silent-hosts`. //! //! Surface parity (2026-05-22): exposes the `silent_hosts` MCP action and //! `GET /api/silent-hosts` REST route as a top-level CLI subcommand. diff --git a/src/cli/dispatch_ai.rs b/src/cli/dispatch_ai.rs index d6e687e2..af36d337 100644 --- a/src/cli/dispatch_ai.rs +++ b/src/cli/dispatch_ai.rs @@ -16,13 +16,14 @@ use super::output_ai::{ print_checkpoints_response, print_index_response, print_prune_checkpoints_response, }; use super::output_ai_more::{ - print_ai_incidents_response, print_ai_investigate_response, print_ask_history_response, - print_incident_context_response, print_similar_incidents_response, + print_ai_incidents_response, print_ai_investigate_response_with_options, + print_ask_history_response, print_incident_context_response, print_similar_incidents_response, + AiInvestigatePrintOptions, }; use super::output_logs::{ print_abuse_search_response, print_ai_correlate_response, print_ai_projects_response, print_ai_tools_response, print_project_context_response, print_search_sessions_response, - print_usage_blocks_response, + print_usage_blocks_response_with_options, UsageBlocksPrintOptions, }; use super::{ AiAbuseArgs, AiAddArgs, AiAskHistoryArgs, AiAssessArgs, AiBlocksArgs, AiCheckpointsArgs, @@ -229,12 +230,18 @@ pub(crate) async fn run_ai_correlate(mode: &CliMode, args: AiCorrelateArgs) -> R pub(crate) async fn run_ai_blocks(mode: &CliMode, args: AiBlocksArgs) -> Result<()> { let json = args.json; + let detail = args.detail; + let limit = args.limit; let req = args.into_request(); let response = match mode { CliMode::Local(service) => service.usage_blocks(req).await?, CliMode::Http(client) => http_or_cancel(client.ai_blocks(&req)).await?, }; - print_usage_blocks_response(&response, json) + print_usage_blocks_response_with_options( + &response, + json, + UsageBlocksPrintOptions { detail, limit }, + ) } pub(crate) async fn run_ai_context(mode: &CliMode, args: AiContextArgs) -> Result<()> { @@ -461,12 +468,17 @@ pub(crate) async fn run_ai_incidents(mode: &CliMode, args: AiIncidentsArgs) -> R pub(crate) async fn run_ai_investigate(mode: &CliMode, args: AiInvestigateArgs) -> Result<()> { let json = args.json; + let print_options = AiInvestigatePrintOptions { + detail: args.detail, + include_transcript: args.include_transcript, + max_bytes: args.max_bytes.unwrap_or(240), + }; let req = args.into_request(); let response = match mode { CliMode::Local(service) => service.investigate_ai_incidents(req).await?, CliMode::Http(client) => http_or_cancel(client.ai_investigate(&req)).await?, }; - print_ai_investigate_response(&response, json) + print_ai_investigate_response_with_options(&response, json, print_options) } pub(crate) async fn run_ai_assess(mode: &CliMode, args: AiAssessArgs) -> Result<()> { diff --git a/src/cli/dispatch_db.rs b/src/cli/dispatch_db.rs index 569637d5..74fd4da0 100644 --- a/src/cli/dispatch_db.rs +++ b/src/cli/dispatch_db.rs @@ -258,7 +258,7 @@ pub(crate) async fn run_db_backup(mode: &CliMode, args: DbBackupArgs) -> Result< The container is likely running and holds the SQLite write lock.\n\ To backup through the running server (recommended):\n\ \n\ - \tsyslog --http db backup --output /data/backup-$(date +%Y%m%d).db\n\ + \tcortex --http db backup --output /data/backup-$(date +%Y%m%d).db\n\ \n\ Or backup inside the container directly:\n\ \n\ diff --git a/src/cli/dispatch_tests.rs b/src/cli/dispatch_tests.rs index 07a321e2..a46ab7d0 100644 --- a/src/cli/dispatch_tests.rs +++ b/src/cli/dispatch_tests.rs @@ -590,6 +590,7 @@ fn ai_blocks_args_into_request_snapshot() { from: None, to: None, json: false, + ..Default::default() }; let req = args.into_request(); assert_eq!( diff --git a/src/cli/help.rs b/src/cli/help.rs index 2fea4e3f..334f9493 100644 --- a/src/cli/help.rs +++ b/src/cli/help.rs @@ -28,6 +28,13 @@ struct CommandDoc { usage: &'static [&'static str], } +struct NestedCommandDoc { + /// Full path after `cortex`, e.g. `ai search`. + path: &'static str, + summary: &'static str, + usage: &'static [&'static str], +} + /// Section title → ordered command names. Drives the grouped `Commands` block; /// every catalog entry must appear in exactly one section. const SECTIONS: &[(&str, &[&str])] = &[ @@ -171,7 +178,6 @@ const CATALOG: &[CommandDoc] = &[ summary: "Correlate events around a reference time", usage: &[ "cortex correlate --reference-time TIME [--window-minutes N] [--severity-min LEVEL] [--hostname HOST] [--source-ip SOURCE] [--query FTS] [--limit N] [--json]", - "syslog correlate --reference-time TIME [--window-minutes N] [--severity-min LEVEL] [--hostname HOST] [--source-ip SOURCE] [--query FTS] [--limit N] [--json]", ], }, CommandDoc { @@ -217,10 +223,10 @@ const CATALOG: &[CommandDoc] = &[ "cortex ai search QUERY [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--json]", "cortex ai abuse [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--before N] [--after N] [--term WORD] [--json]", "cortex ai incidents [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--window-minutes N] [--term WORD] [--json]", - "cortex ai investigate [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--window-minutes N] [--correlation-window-minutes N] [--term WORD] [--json]", + "cortex ai investigate [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--window-minutes N] [--correlation-window-minutes N] [--term WORD] [--detail compact|full] [--include-transcript] [--max-bytes N] [--json]", "cortex ai assess INCIDENT_ID [--model MODEL] [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--window-minutes N] [--correlation-window-minutes N] [--term WORD] [--json]", "cortex ai correlate [--project PATH] [--tool TOOL] [--session-id ID] [--ai-query FTS] [--log-query FTS] [--hostname HOST] [--source-ip SOURCE] [--app-name APP] [--from TIME] [--to TIME] [--window-minutes N] [--severity-min LEVEL] [--limit N] [--events-per-anchor N] [--json]", - "cortex ai blocks [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--json]", + "cortex ai blocks [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--detail compact|full] [--json]", "cortex ai context --project PATH [--tool TOOL] [--limit N] [--json]", "cortex ai tools [--project PATH] [--from TIME] [--to TIME] [--json]", "cortex ai projects [--tool TOOL] [--from TIME] [--to TIME] [--json]", @@ -354,6 +360,260 @@ const CATALOG: &[CommandDoc] = &[ }, ]; +const NESTED_CATALOG: &[NestedCommandDoc] = &[ + NestedCommandDoc { + path: "ai search", + summary: "Full-text search over indexed AI transcript sessions", + usage: &["cortex ai search QUERY [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--json]"], + }, + NestedCommandDoc { + path: "ai abuse", + summary: "Find risky or failure-related transcript messages", + usage: &["cortex ai abuse [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--before N] [--after N] [--term WORD] [--json]"], + }, + NestedCommandDoc { + path: "ai incidents", + summary: "Cluster AI transcript abuse matches into incidents", + usage: &["cortex ai incidents [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--window-minutes N] [--term WORD] [--json]"], + }, + NestedCommandDoc { + path: "ai investigate", + summary: "Expand AI incidents into evidence bundles", + usage: &[ + "cortex ai investigate [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--window-minutes N] [--correlation-window-minutes N] [--term WORD] [--detail compact|full] [--include-transcript] [--max-bytes N] [--json]", + "Default output is compact; use --detail full for complete evidence.", + ], + }, + NestedCommandDoc { + path: "ai assess", + summary: "Assess one AI incident with optional model context", + usage: &["cortex ai assess INCIDENT_ID [--model MODEL] [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--window-minutes N] [--correlation-window-minutes N] [--term WORD] [--json]"], + }, + NestedCommandDoc { + path: "ai correlate", + summary: "Correlate AI transcript anchors with non-AI logs", + usage: &["cortex ai correlate [--project PATH] [--tool TOOL] [--session-id ID] [--ai-query FTS] [--log-query FTS] [--hostname HOST] [--source-ip SOURCE] [--app-name APP] [--from TIME] [--to TIME] [--window-minutes N] [--severity-min LEVEL] [--limit N] [--events-per-anchor N] [--json]"], + }, + NestedCommandDoc { + path: "ai blocks", + summary: "AI transcript activity grouped into 5-hour UTC blocks", + usage: &[ + "cortex ai blocks [--project PATH] [--tool TOOL] [--from TIME] [--to TIME] [--limit N] [--detail compact|full] [--json]", + "Default output is capped for interactive use; use --detail full for every block.", + ], + }, + NestedCommandDoc { + path: "ai context", + summary: "Recent AI transcript context for one project", + usage: &["cortex ai context --project PATH [--tool TOOL] [--limit N] [--json]"], + }, + NestedCommandDoc { + path: "ai tools", + summary: "List AI tools present in transcript metadata", + usage: &["cortex ai tools [--project PATH] [--from TIME] [--to TIME] [--json]"], + }, + NestedCommandDoc { + path: "ai projects", + summary: "List AI projects present in transcript metadata", + usage: &["cortex ai projects [--tool TOOL] [--from TIME] [--to TIME] [--json]"], + }, + NestedCommandDoc { + path: "ai index", + summary: "Index local AI transcript roots", + usage: &["cortex ai index [--path PATH] [--since TIME] [--force] [--json]"], + }, + NestedCommandDoc { + path: "ai add", + summary: "Index one AI transcript file", + usage: &["cortex ai add --file FILE [--force] [--json]"], + }, + NestedCommandDoc { + path: "ai watch", + summary: "Run the local transcript watch daemon", + usage: &["cortex ai watch [--path PATH] [--debounce-ms N] [--settle-ms N] [--max-retries N] [--no-initial-scan] [--json]"], + }, + NestedCommandDoc { + path: "ai checkpoints", + summary: "List AI transcript indexing checkpoints", + usage: &["cortex ai checkpoints [--errors] [--missing] [--limit N] [--json]"], + }, + NestedCommandDoc { + path: "ai errors", + summary: "List AI transcript parse errors", + usage: &["cortex ai errors [--limit N] [--json]"], + }, + NestedCommandDoc { + path: "ai prune-checkpoints", + summary: "Prune stale AI indexing checkpoints", + usage: &["cortex ai prune-checkpoints --missing [--dry-run] [--limit N] [--json]"], + }, + NestedCommandDoc { + path: "ai doctor", + summary: "Check local AI transcript indexing prerequisites", + usage: &["cortex ai doctor [--strict-permissions] [--json]"], + }, + NestedCommandDoc { + path: "ai watch-status", + summary: "Inspect the local AI transcript watch service", + usage: &["cortex ai watch-status [--json]"], + }, + NestedCommandDoc { + path: "ai smoke-watch", + summary: "Run a local AI transcript watch smoke test", + usage: &["cortex ai smoke-watch [--json]"], + }, + NestedCommandDoc { + path: "ai similar", + summary: "Find incidents similar to a free-text query", + usage: &["cortex ai similar QUERY [--hostname HOST] [--app-name APP] [--severity-min LEVEL] [--from TIME] [--to TIME] [--window-minutes N] [--limit N] [--json]"], + }, + NestedCommandDoc { + path: "ai ask-history", + summary: "Search historical AI sessions and nearby system logs", + usage: &["cortex ai ask-history QUERY [--hostname HOST] [--app-name APP] [--from TIME] [--to TIME] [--limit N] [--json]"], + }, + NestedCommandDoc { + path: "ai incident-context", + summary: "Build incident context from an explicit time window", + usage: &["cortex ai incident-context --from TIME --to TIME [--hostname HOST] [--app-name APP] [--query FTS] [--severity-min LEVEL] [--limit N] [--json]"], + }, + NestedCommandDoc { + path: "db backup", + summary: "Create a WAL-safe SQLite backup", + usage: &["cortex db backup [--output PATH] [--json]"], + }, + NestedCommandDoc { + path: "db status", + summary: "Inspect SQLite maintenance state", + usage: &["cortex db status [--check-coord] [--json]"], + }, + NestedCommandDoc { + path: "db integrity", + summary: "Run SQLite integrity checks", + usage: &["cortex db integrity [--quick] [--json]"], + }, + NestedCommandDoc { + path: "db checkpoint", + summary: "Run a SQLite WAL checkpoint", + usage: &["cortex db checkpoint [--mode passive|full|restart|truncate] [--json]"], + }, + NestedCommandDoc { + path: "db vacuum", + summary: "Run SQLite incremental or full vacuum", + usage: &["cortex db vacuum [--pages N] [--full] [--force] [--json]"], + }, + NestedCommandDoc { + path: "compose status", + summary: "Inspect the resolved Docker Compose runtime", + usage: &["cortex compose status [--compose-file PATH] [--project-dir DIR] [--project-name NAME] [--service NAME] [--container NAME] [--json]"], + }, + NestedCommandDoc { + path: "compose doctor", + summary: "Diagnose Docker Compose/listener ownership", + usage: &["cortex compose doctor [--compose-file PATH] [--project-dir DIR] [--project-name NAME] [--service NAME] [--container NAME] [--json]"], + }, + NestedCommandDoc { + path: "compose pull", + summary: "Pull the resolved Docker Compose image", + usage: &["cortex compose pull [--dry-run] [--allow-cwd-target] [--json]"], + }, + NestedCommandDoc { + path: "compose up", + summary: "Recreate the resolved Docker Compose service", + usage: &["cortex compose up [--dry-run] [--allow-cwd-target] [--json]"], + }, + NestedCommandDoc { + path: "compose restart", + summary: "Restart the resolved Docker Compose service", + usage: &["cortex compose restart [--dry-run] [--allow-cwd-target] [--json]"], + }, + NestedCommandDoc { + path: "compose down", + summary: "Stop the resolved Docker Compose service", + usage: &["cortex compose down --yes [--dry-run] [--allow-cwd-target] [--json]"], + }, + NestedCommandDoc { + path: "compose logs", + summary: "Show bounded Docker Compose logs", + usage: &["cortex compose logs [--tail N] [--json]"], + }, + NestedCommandDoc { + path: "setup check", + summary: "Audit plugin setup without changing files", + usage: &["cortex setup check [--json]"], + }, + NestedCommandDoc { + path: "setup repair", + summary: "Repair plugin setup idempotently", + usage: &["cortex setup repair [--json]"], + }, + NestedCommandDoc { + path: "setup install", + summary: "Install plugin setup artifacts", + usage: &["cortex setup install [--json]"], + }, + NestedCommandDoc { + path: "setup plugin-hook", + summary: "Run plugin setup hook repair or audit mode", + usage: &["cortex setup plugin-hook [--no-repair] [--json]"], + }, + NestedCommandDoc { + path: "setup doctor", + summary: "Run setup diagnostics across all phases", + usage: &["cortex setup doctor [--json]"], + }, + NestedCommandDoc { + path: "sig list", + summary: "List error signatures", + usage: &["cortex sig list [--include-acknowledged] [--limit N] [--json]"], + }, + NestedCommandDoc { + path: "sig ack", + summary: "Acknowledge an error signature", + usage: &["cortex sig ack HASH [--notes TEXT] [--json]"], + }, + NestedCommandDoc { + path: "sig unack", + summary: "Unacknowledge an error signature", + usage: &["cortex sig unack HASH [--reason TEXT] [--json]"], + }, + NestedCommandDoc { + path: "notify recent", + summary: "List recent notification firings", + usage: &["cortex notify recent [--rule-id ID] [--since TIME] [--limit N] [--json]"], + }, + NestedCommandDoc { + path: "notify test", + summary: "Send a test notification", + usage: &["cortex notify test [--body TEXT] [--json] (requires --http)"], + }, + NestedCommandDoc { + path: "shell index", + summary: "Index shell history", + usage: &["cortex shell index --path PATH [--shell zsh] [--json]"], + }, + NestedCommandDoc { + path: "shell atuin-index", + summary: "Index Atuin shell history", + usage: &["cortex shell atuin-index --path PATH [--json]"], + }, + NestedCommandDoc { + path: "agent-command ingest-spool", + summary: "Ingest agent command spool files", + usage: &["cortex agent-command ingest-spool --path PATH [--json]"], + }, + NestedCommandDoc { + path: "agent-command wrap", + summary: "Wrap a command and spool execution metadata", + usage: &["cortex agent-command wrap --spool PATH -- COMMAND..."], + }, + NestedCommandDoc { + path: "heartbeat agent", + summary: "Run the host heartbeat agent", + usage: &["cortex heartbeat agent [--target URL] [--token TOKEN] [--interval-secs N] [--probe-deadline-ms N] [--collection-deadline-ms N] [--retry-buffer N] [--host-id-path PATH] [--once|--emit] [--json]"], + }, +]; + const GLOBAL_OPTIONS: &[(&str, &str)] = &[ ("-h, --help", "Display help (top-level or per-command)"), ("--version", "Print version and exit"), @@ -521,6 +781,19 @@ pub(crate) fn render_top_level(color: bool) -> String { /// Render per-command help, or `None` if the command is unknown. pub(crate) fn render_command(name: &str, color: bool) -> Option { + if let Some(doc) = nested_lookup(name) { + let mut out = String::with_capacity(512); + out.push_str(&format!( + " {} {}\n\n", + heading(color, doc.path), + paint(color, MUTED_ANSI, doc.summary) + )); + out.push_str(&format!(" {}\n", heading(color, "Usage"))); + for line in doc.usage { + out.push_str(&format!(" {}\n", paint(color, CYAN_ANSI, line))); + } + return Some(out); + } let doc = lookup(name)?; let mut out = String::with_capacity(512); out.push_str(&format!( @@ -539,6 +812,10 @@ fn lookup(name: &str) -> Option<&'static CommandDoc> { CATALOG.iter().find(|d| d.name == name) } +fn nested_lookup(path: &str) -> Option<&'static NestedCommandDoc> { + NESTED_CATALOG.iter().find(|d| d.path == path) +} + fn is_known(name: &str) -> bool { lookup(name).is_some() } @@ -576,12 +853,35 @@ pub(crate) fn classify_help(args: &[String]) -> HelpRequest { if !has_help { return HelpRequest::None; } - // First positional token that isn't a flag or the leading `help` word. - let first = scan - .iter() - .find(|a| !a.starts_with('-') && **a != "help") - .copied(); - match first { + // Build the command path, skipping the value consumed by a value-bearing + // global option (`--server URL`, `--token TOK`). Otherwise + // `cortex --server http://127.0.0.1:3100 db status --help` would treat the + // URL as the command and fall back to the top-level banner instead of + // resolving `db status`. (The `--flag=value` form is one `-`-prefixed token + // and is already excluded.) + const VALUE_FLAGS: [&str; 2] = ["--server", "--token"]; + let mut positionals = Vec::new(); + let mut skip_value = false; + for &a in &scan { + if skip_value { + skip_value = false; + continue; + } + if VALUE_FLAGS.contains(&a) { + skip_value = true; + continue; + } + if !a.starts_with('-') && a != "help" { + positionals.push(a); + } + } + if positionals.len() >= 2 { + let nested = format!("{} {}", positionals[0], positionals[1]); + if nested_lookup(&nested).is_some() { + return HelpRequest::Command(nested); + } + } + match positionals.first().copied() { Some(cmd) if is_known(cmd) => HelpRequest::Command(cmd.to_string()), _ => HelpRequest::TopLevel, } diff --git a/src/cli/help_tests.rs b/src/cli/help_tests.rs index 8664b166..e9b732a1 100644 --- a/src/cli/help_tests.rs +++ b/src/cli/help_tests.rs @@ -111,6 +111,51 @@ fn command_help_shows_detailed_flags() { assert!(render_command("definitely-not-a-command", false).is_none()); } +#[test] +fn nested_help_shows_subcommand_specific_usage() { + let out = render_command("ai search", false).expect("ai search is known"); + assert!(out.contains("cortex ai search QUERY"), "got: {out}"); + assert!(!out.contains("cortex ai investigate"), "got: {out}"); + + let out = render_command("ai investigate", false).expect("ai investigate is known"); + assert!(out.contains("--detail compact|full"), "got: {out}"); + assert!(out.contains("--include-transcript"), "got: {out}"); +} + +#[test] +fn setup_doctor_has_nested_help() { + // `cortex setup doctor --help` must show doctor-specific help, not fall back + // to the generic setup help (it is advertised in the setup CATALOG entry). + let out = render_command("setup doctor", false).expect("setup doctor is known"); + assert!(out.contains("cortex setup doctor"), "got: {out}"); + let v = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::>(); + assert_eq!( + classify_help(&v(&["setup", "doctor", "--help"])), + HelpRequest::Command("setup doctor".to_string()) + ); +} + +#[test] +fn classify_help_skips_global_option_values() { + // A value-bearing global option's value must not be mistaken for the command + // path: `cortex --server URL db status --help` resolves to `db status`. + let v = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::>(); + assert_eq!( + classify_help(&v(&[ + "--server", + "http://127.0.0.1:3100", + "db", + "status", + "--help" + ])), + HelpRequest::Command("db status".to_string()) + ); + assert_eq!( + classify_help(&v(&["--token", "secret", "search", "--help"])), + HelpRequest::Command("search".to_string()) + ); +} + #[test] fn classify_help_distinguishes_top_level_command_and_none() { let v = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::>(); @@ -120,7 +165,11 @@ fn classify_help_distinguishes_top_level_command_and_none() { assert_eq!(classify_help(&v(&["-h"])), HelpRequest::TopLevel); assert_eq!( classify_help(&v(&["db", "status", "--help"])), - HelpRequest::Command("db".to_string()) + HelpRequest::Command("db status".to_string()) + ); + assert_eq!( + classify_help(&v(&["ai", "search", "--help"])), + HelpRequest::Command("ai search".to_string()) ); assert_eq!( classify_help(&v(&["search", "--help"])), diff --git a/src/cli/output_ai_more.rs b/src/cli/output_ai_more.rs index 334e6f81..8bd053c8 100644 --- a/src/cli/output_ai_more.rs +++ b/src/cli/output_ai_more.rs @@ -1,11 +1,13 @@ use anyhow::Result; use cortex::app::{ AiIncidentResponse, AiInvestigateResponse, AskHistoryResponse, IncidentContextResponse, - SimilarIncidentsResponse, + LogEntry, SimilarIncidentsResponse, }; +use serde_json::{json, Value}; use super::color::{cyan, muted, primary, severity, violet, warn}; -use super::output_common::{local_ts, print_json, truncate}; +use super::output_common::{local_ts, print_json, truncate, truncate_bytes}; +use super::AiOutputDetail; pub(crate) fn print_similar_incidents_response( response: &SimilarIncidentsResponse, @@ -211,13 +213,34 @@ pub(crate) fn print_ai_incidents_response(response: &AiIncidentResponse, json: b Ok(()) } -pub(crate) fn print_ai_investigate_response( +#[derive(Debug, Clone, Copy)] +pub(crate) struct AiInvestigatePrintOptions { + pub detail: AiOutputDetail, + pub include_transcript: bool, + pub max_bytes: usize, +} + +impl Default for AiInvestigatePrintOptions { + fn default() -> Self { + Self { + detail: AiOutputDetail::Compact, + include_transcript: false, + max_bytes: 240, + } + } +} + +pub(crate) fn print_ai_investigate_response_with_options( response: &AiInvestigateResponse, json: bool, + options: AiInvestigatePrintOptions, ) -> Result<()> { - if json { + if json && matches!(options.detail, AiOutputDetail::Full) { return print_json(response); } + if json { + return print_json(&compact_ai_investigate_json(response, options)); + } println!( "{} evidence bundle(s) of {} total incident(s){}", cyan(&response.evidence.len().to_string()), @@ -251,7 +274,47 @@ pub(crate) fn print_ai_investigate_response( ); println!(" {}:", muted("anchor messages")); for a in &ev.anchors { - println!(" [{}] {}", muted(&local_ts(&a.timestamp)), a.message); + println!( + " [{}] {}", + muted(&local_ts(&a.timestamp)), + truncate_bytes(&a.message, options.max_bytes) + ); + } + // Honor --detail full / --include-transcript in terminal output too (not + // just JSON): surface the transcript window and non-error nearby logs so + // full evidence is available without --json. + let show_transcript = + options.include_transcript || matches!(options.detail, AiOutputDetail::Full); + if show_transcript && !ev.transcript_before.is_empty() { + println!(" {}:", muted("transcript before")); + for l in &ev.transcript_before { + println!( + " [{}] {}", + muted(&local_ts(&l.timestamp)), + truncate_bytes(&l.message, options.max_bytes) + ); + } + } + if show_transcript && !ev.transcript_after.is_empty() { + println!(" {}:", muted("transcript after")); + for l in &ev.transcript_after { + println!( + " [{}] {}", + muted(&local_ts(&l.timestamp)), + truncate_bytes(&l.message, options.max_bytes) + ); + } + } + if matches!(options.detail, AiOutputDetail::Full) && !ev.nearby_logs.is_empty() { + println!(" {}:", muted("nearby logs")); + for l in &ev.nearby_logs { + println!( + " [{}] ({}) {}", + muted(&local_ts(&l.timestamp)), + severity(&l.severity), + truncate_bytes(&l.message, options.max_bytes) + ); + } } if !ev.nearby_errors.is_empty() { println!(" {}:", muted("nearby errors")); @@ -260,7 +323,7 @@ pub(crate) fn print_ai_investigate_response( " [{}] ({}) {}", muted(&local_ts(&e.timestamp)), severity(&e.severity), - e.message + truncate_bytes(&e.message, options.max_bytes) ); } } @@ -297,6 +360,66 @@ pub(crate) fn print_ai_investigate_response( Ok(()) } +fn compact_ai_investigate_json( + response: &AiInvestigateResponse, + options: AiInvestigatePrintOptions, +) -> Value { + let evidence: Vec = response + .evidence + .iter() + .map(|ev| { + let mut item = json!({ + "incident": ev.incident, + "counts": { + "anchors": ev.anchors.len(), + "transcript_before": ev.transcript_before.len(), + "transcript_after": ev.transcript_after.len(), + "nearby_logs": ev.nearby_logs.len(), + "nearby_errors": ev.nearby_errors.len(), + }, + "truncated": { + "transcript_before": ev.transcript_before_truncated, + "transcript_after": ev.transcript_after_truncated, + "nearby_logs": ev.nearby_logs_truncated, + }, + "anchors": compact_logs(&ev.anchors, options.max_bytes), + "nearby_errors": compact_logs(&ev.nearby_errors, options.max_bytes), + "findings": ev.findings, + }); + if options.include_transcript { + item["transcript_before"] = + Value::Array(compact_logs(&ev.transcript_before, options.max_bytes)); + item["transcript_after"] = + Value::Array(compact_logs(&ev.transcript_after, options.max_bytes)); + } + item + }) + .collect(); + + json!({ + "total_incidents": response.total_incidents, + "truncated": response.truncated, + "detail": "compact", + "include_transcript": options.include_transcript, + "evidence": evidence, + }) +} + +fn compact_logs(logs: &[LogEntry], max_bytes: usize) -> Vec { + logs.iter() + .map(|log| { + json!({ + "id": log.id, + "timestamp": log.timestamp, + "hostname": log.hostname, + "severity": log.severity, + "app_name": log.app_name, + "message": truncate_bytes(&log.message, max_bytes), + }) + }) + .collect() +} + #[cfg(test)] #[path = "output_ai_more_tests.rs"] mod tests; diff --git a/src/cli/output_common.rs b/src/cli/output_common.rs index 7859c72e..1d7d006c 100644 --- a/src/cli/output_common.rs +++ b/src/cli/output_common.rs @@ -83,6 +83,35 @@ pub(crate) fn truncate(s: &str, max: usize) -> String { } } +/// Truncate `s` to at most `max_bytes` UTF-8 bytes (cutting on a char boundary), +/// appending `…` when truncated. Use this for `--max-bytes`-style budgets where +/// the limit is bytes, not characters — [`truncate`] counts characters and can +/// blow a byte budget on multibyte input. +pub(crate) fn truncate_bytes(s: &str, max_bytes: usize) -> String { + if s.len() <= max_bytes { + return s.to_string(); + } + const ELLIPSIS: &str = "…"; // 3 bytes + + // Budgets too small to fit the marker return a bare byte-bounded prefix. + // Compute the cut from `max_bytes` directly (not the ellipsis-reserved + // budget), so a 1–2 byte budget still yields its prefix, not an empty string. + if max_bytes < ELLIPSIS.len() { + let mut cut = max_bytes.min(s.len()); + while cut > 0 && !s.is_char_boundary(cut) { + cut -= 1; + } + return s[..cut].to_string(); + } + // Reserve room for the ellipsis so the result stays within `max_bytes`. + let budget = max_bytes - ELLIPSIS.len(); + let mut cut = budget.min(s.len()); + while cut > 0 && !s.is_char_boundary(cut) { + cut -= 1; + } + format!("{}{ELLIPSIS}", &s[..cut]) +} + #[cfg(test)] #[path = "output_common_tests.rs"] mod tests; diff --git a/src/cli/output_common_tests.rs b/src/cli/output_common_tests.rs index 7fe3c9fc..24c8ab82 100644 --- a/src/cli/output_common_tests.rs +++ b/src/cli/output_common_tests.rs @@ -7,6 +7,33 @@ fn truncate_is_utf8_safe_and_preserves_short_strings() { assert_eq!(truncate("anything", 0), ""); } +#[test] +fn truncate_bytes_respects_byte_budget_on_char_boundaries() { + // Short input untouched. + assert_eq!(truncate_bytes("short", 10), "short"); + // Multibyte: each "é" is 2 bytes. The result (prefix + 3-byte ellipsis) must + // never exceed the byte budget, and must cut on a char boundary. + let s = "ééééééé"; // 7 × 2 = 14 bytes + let out = truncate_bytes(s, 10); + assert!(out.len() <= 10, "byte budget exceeded: {} bytes", out.len()); + assert!(out.ends_with('…')); + assert!(out.is_char_boundary(out.len() - '…'.len_utf8())); + // A char-based truncate(s, 10) would keep 9 chars = 18 bytes — far over budget. + assert!(truncate_bytes(s, 14).len() <= 14); +} + +#[test] +fn truncate_bytes_tiny_budget_returns_prefix_not_empty() { + // Budgets smaller than the 3-byte ellipsis must still yield a byte-bounded + // prefix, never an empty string (regression: budget was zeroed first). + assert_eq!(truncate_bytes("hello", 2), "he"); + assert_eq!(truncate_bytes("hello", 1), "h"); + // Multibyte: a 1-byte budget can't fit a 2-byte 'é', so empty is correct; + // a 2-byte budget yields exactly one 'é'. + assert_eq!(truncate_bytes("ééé", 1), ""); + assert_eq!(truncate_bytes("ééé", 2), "é"); +} + #[test] fn transcript_detection_accepts_source_ip_and_app_suffix() { let mut log = cortex::app::LogEntry { diff --git a/src/cli/output_logs.rs b/src/cli/output_logs.rs index 4387d23b..b4c7aa55 100644 --- a/src/cli/output_logs.rs +++ b/src/cli/output_logs.rs @@ -7,6 +7,7 @@ use cortex::app::{ use super::color::{cyan, muted, primary, severity, violet}; use super::output_common::{local_ts, print_json, print_log, truncate}; +use super::AiOutputDetail; pub(crate) fn print_search_response(response: &SearchLogsResponse, json: bool) -> Result<()> { if json { @@ -218,24 +219,53 @@ pub(crate) fn print_ai_correlate_response( Ok(()) } -pub(crate) fn print_usage_blocks_response( +#[derive(Debug, Clone, Copy)] +pub(crate) struct UsageBlocksPrintOptions { + pub detail: AiOutputDetail, + pub limit: Option, +} + +impl Default for UsageBlocksPrintOptions { + fn default() -> Self { + // Compact matches the CLI default for `ai blocks` (AiBlocksArgs). Leaving + // this `Full` was a footgun: any call site using `..Default::default()` + // would silently disable the intended capping/truncation. + Self { + detail: AiOutputDetail::Compact, + limit: None, + } + } +} + +pub(crate) fn print_usage_blocks_response_with_options( response: &UsageBlocksResponse, json: bool, + options: UsageBlocksPrintOptions, ) -> Result<()> { + let mut projected = response.clone(); + if let Some(limit) = options.limit { + if projected.blocks.len() > limit { + projected.blocks.truncate(limit); + projected.truncated = true; + } + } else if options.detail.is_compact() && projected.blocks.len() > 25 { + projected.blocks.truncate(25); + projected.truncated = true; + } if json { - return print_json(response); + return print_json(&projected); } println!( "{} usage block(s) shown of {}{}", - cyan(&response.blocks.len().to_string()), - cyan(&response.total_blocks.to_string()), - if response.truncated { + cyan(&projected.blocks.len().to_string()), + cyan(&projected.total_blocks.to_string()), + if projected.truncated { " (truncated)" } else { "" } ); - for block in &response.blocks { + for block in &projected.blocks { println!( "{} {} {} {} events={} sessions={}", muted(&block.bucket_start), diff --git a/src/cli/parse.rs b/src/cli/parse.rs index b7a4857a..6223b4ce 100644 --- a/src/cli/parse.rs +++ b/src/cli/parse.rs @@ -7,7 +7,42 @@ use super::parse_logs::{ parse_correlate, parse_errors, parse_filter, parse_hosts, parse_incident, parse_ingest_rate, parse_patterns, parse_search, parse_sessions, parse_source_ips, parse_tail, parse_timeline, }; -use super::{commands, parse_config, CliCommand}; +use super::{commands, parse_config, suggest, CliCommand}; + +const TOP_LEVEL_COMMANDS: &[&str] = &[ + "search", + "filter", + "tail", + "errors", + "hosts", + "sessions", + "incident", + "ai", + "shell", + "agent-command", + "heartbeat", + "correlate", + "stats", + "compose", + "service", + "setup", + "db", + "config", + "source-ips", + "timeline", + "patterns", + "ingest-rate", + "sig", + "notify", + "silent-hosts", + "clock-skew", + "anomalies", + "compare", + "apps", + "host-state", + "fleet-state", + "correlate-state", +]; pub(crate) fn parse_command(args: Vec) -> Result { let (command, rest) = args @@ -50,7 +85,10 @@ pub(crate) fn parse_command(args: Vec) -> Result { "host-state" => commands::host_state::parse_host_state(rest), "fleet-state" => commands::fleet_state::parse_fleet_state(rest), "correlate-state" => commands::correlate_state::parse_correlate_state(rest), - _ => bail!("unknown CLI command: {command}"), + _ => bail!( + "{}", + suggest::unknown_command("CLI command", command, TOP_LEVEL_COMMANDS) + ), } } @@ -60,7 +98,10 @@ fn parse_heartbeat(args: &[String]) -> Result { .ok_or_else(|| anyhow!("heartbeat subcommand is required"))?; match command.as_str() { "agent" => parse_heartbeat_agent(rest), - _ => bail!("unknown heartbeat subcommand: {command}"), + _ => bail!( + "{}", + suggest::unknown_command("heartbeat subcommand", command, &["agent"]) + ), } } @@ -111,7 +152,25 @@ fn parse_heartbeat_agent(args: &[String]) -> Result { "--once" => out.once = true, "--emit" => out.emit = true, "--json" => out.json = true, - other => bail!("unknown heartbeat agent argument: {other}"), + other => bail!( + "{}", + suggest::unknown_option( + "heartbeat agent", + other, + &[ + "--target", + "--token", + "--interval-secs", + "--probe-deadline-ms", + "--collection-deadline-ms", + "--retry-buffer", + "--host-id-path", + "--once", + "--emit", + "--json", + ], + ) + ), } i += 1; } diff --git a/src/cli/parse_admin.rs b/src/cli/parse_admin.rs index 56512594..073fa9cf 100644 --- a/src/cli/parse_admin.rs +++ b/src/cli/parse_admin.rs @@ -17,7 +17,10 @@ pub(crate) fn parse_service(args: &[String]) -> Result { .ok_or_else(|| anyhow!("service requires a subcommand"))?; match subcommand.as_str() { "logs" => parse_service_logs(rest), - _ => bail!("unknown service subcommand: {subcommand}"), + _ => bail!( + "{}", + super::suggest::unknown_command("service subcommand", subcommand, &["logs"]) + ), } } @@ -69,7 +72,14 @@ pub(crate) fn parse_db(args: &[String]) -> Result { "checkpoint" => parse_db_checkpoint(rest), "vacuum" => parse_db_vacuum(rest), "backup" => parse_db_backup(rest), - _ => bail!("unknown db subcommand: {subcommand}"), + _ => bail!( + "{}", + super::suggest::unknown_command( + "db subcommand", + subcommand, + &["status", "integrity", "checkpoint", "vacuum", "backup"], + ) + ), } } @@ -213,7 +223,17 @@ pub(crate) fn parse_compose(args: &[String]) -> Result { "upgrade" => bail!( "cortex compose upgrade is deferred; run `cortex compose pull` then `cortex compose up`" ), - other => bail!("unknown compose subcommand: {other}"), + other => bail!( + "{}", + super::suggest::unknown_command( + "compose subcommand", + other, + &[ + "status", "doctor", "up", "down", "restart", "pull", "logs", "config", + "upgrade", + ], + ) + ), } } @@ -234,7 +254,14 @@ pub(crate) fn parse_setup(args: &[String]) -> Result { "plugin-hook" | "hook" => Ok(CliCommand::Setup(SetupCommand::PluginHook( parse_plugin_hook_args(rest)?, ))), - other => bail!("unknown setup subcommand: {other}"), + other => bail!( + "{}", + super::suggest::unknown_command( + "setup subcommand", + other, + &["check", "repair", "install", "plugin-hook"], + ) + ), } } diff --git a/src/cli/parse_ai.rs b/src/cli/parse_ai.rs index 2c5f3362..7727c38d 100644 --- a/src/cli/parse_ai.rs +++ b/src/cli/parse_ai.rs @@ -9,9 +9,35 @@ use super::parse_common::{ }; use super::{ AiAbuseArgs, AiAddArgs, AiBlocksArgs, AiCheckpointsArgs, AiCommand, AiContextArgs, - AiCorrelateArgs, AiDoctorArgs, AiErrorsArgs, AiIndexArgs, AiListArgs, AiPruneCheckpointsArgs, - AiSearchArgs, AiWatchArgs, CliCommand, + AiCorrelateArgs, AiDoctorArgs, AiErrorsArgs, AiIndexArgs, AiListArgs, AiOutputDetail, + AiPruneCheckpointsArgs, AiSearchArgs, AiWatchArgs, CliCommand, }; + +const AI_SUBCOMMANDS: &[&str] = &[ + "search", + "abuse", + "correlate", + "blocks", + "context", + "tools", + "projects", + "index", + "add", + "watch", + "checkpoints", + "errors", + "prune-checkpoints", + "doctor", + "watch-status", + "smoke-watch", + "similar", + "ask-history", + "incident-context", + "incidents", + "investigate", + "assess", +]; + pub(crate) fn parse_ai(args: &[String]) -> Result { let (subcommand, rest) = args .split_first() @@ -45,7 +71,10 @@ pub(crate) fn parse_ai(args: &[String]) -> Result { "incidents" => parse_ai_incidents(rest), "investigate" => parse_ai_investigate(rest), "assess" => parse_ai_assess(rest), - _ => bail!("unknown ai subcommand: {subcommand}"), + _ => bail!( + "{}", + super::suggest::unknown_command("ai subcommand", subcommand, AI_SUBCOMMANDS) + ), } } @@ -238,6 +267,12 @@ pub(crate) fn parse_ai_blocks(args: &[String]) -> Result { "--tool" => parsed.tool = Some(flags.value("--tool")?), "--from" => parsed.from = Some(flags.value("--from")?), "--to" => parsed.to = Some(flags.value("--to")?), + "--limit" => { + parsed.limit = Some(parse_u32_flag("--limit", flags.value("--limit")?)? as usize) + } + "--detail" => { + parsed.detail = AiOutputDetail::parse(&flags.value("--detail")?, "--detail")? + } _ if arg.starts_with("--project=") => { parsed.project = Some(value_after_equals(arg, "--project")?) } @@ -248,7 +283,31 @@ pub(crate) fn parse_ai_blocks(args: &[String]) -> Result { parsed.from = Some(value_after_equals(arg, "--from")?) } _ if arg.starts_with("--to=") => parsed.to = Some(value_after_equals(arg, "--to")?), - _ => bail!("unknown ai blocks option: {arg}"), + _ if arg.starts_with("--limit=") => { + parsed.limit = + Some(parse_u32_flag("--limit", value_after_equals(arg, "--limit")?)? as usize) + } + _ if arg.starts_with("--detail=") => { + parsed.detail = + AiOutputDetail::parse(&value_after_equals(arg, "--detail")?, "--detail")? + } + _ if arg.starts_with('-') => bail!( + "{}", + super::suggest::unknown_option( + "ai blocks", + &arg, + &[ + "--json", + "--project", + "--tool", + "--from", + "--to", + "--limit", + "--detail", + ], + ) + ), + _ => bail!("unexpected ai blocks argument: {arg}"), } } Ok(CliCommand::Ai(AiCommand::Blocks(parsed))) diff --git a/src/cli/parse_ai_more.rs b/src/cli/parse_ai_more.rs index 5ee1d927..8dafcaa1 100644 --- a/src/cli/parse_ai_more.rs +++ b/src/cli/parse_ai_more.rs @@ -3,7 +3,7 @@ use anyhow::{anyhow, bail, Result}; use super::parse_common::{parse_u32_flag, value_after_equals, FlagCursor}; use super::{ AiAskHistoryArgs, AiAssessArgs, AiCommand, AiIncidentContextArgs, AiIncidentsArgs, - AiInvestigateArgs, AiSimilarArgs, CliCommand, + AiInvestigateArgs, AiOutputDetail, AiSimilarArgs, CliCommand, }; pub(crate) fn parse_ai_similar(args: &[String]) -> Result { let mut parsed = AiSimilarArgs::default(); @@ -219,6 +219,14 @@ pub(crate) fn parse_ai_investigate(args: &[String]) -> Result { )?) } "--term" => parsed.terms.push(flags.value("--term")?), + "--detail" => { + parsed.detail = AiOutputDetail::parse(&flags.value("--detail")?, "--detail")? + } + "--include-transcript" => parsed.include_transcript = true, + "--max-bytes" => { + parsed.max_bytes = + Some(parse_u32_flag("--max-bytes", flags.value("--max-bytes")?)? as usize) + } _ if arg.starts_with("--project=") => { parsed.project = Some(value_after_equals(arg, "--project")?) } @@ -250,7 +258,37 @@ pub(crate) fn parse_ai_investigate(args: &[String]) -> Result { _ if arg.starts_with("--term=") => { parsed.terms.push(value_after_equals(arg, "--term")?) } - _ if arg.starts_with('-') => bail!("unknown ai investigate option: {arg}"), + _ if arg.starts_with("--detail=") => { + parsed.detail = + AiOutputDetail::parse(&value_after_equals(arg, "--detail")?, "--detail")? + } + _ if arg.starts_with("--max-bytes=") => { + parsed.max_bytes = Some(parse_u32_flag( + "--max-bytes", + value_after_equals(arg, "--max-bytes")?, + )? as usize) + } + _ if arg.starts_with('-') => bail!( + "{}", + super::suggest::unknown_option( + "ai investigate", + &arg, + &[ + "--json", + "--project", + "--tool", + "--from", + "--to", + "--limit", + "--window-minutes", + "--correlation-window-minutes", + "--term", + "--detail", + "--include-transcript", + "--max-bytes", + ], + ) + ), _ => bail!("unexpected ai investigate argument: {arg}"), } } diff --git a/src/cli/parse_ai_more_tests.rs b/src/cli/parse_ai_more_tests.rs index 36bbc06f..d779f3ec 100644 --- a/src/cli/parse_ai_more_tests.rs +++ b/src/cli/parse_ai_more_tests.rs @@ -26,6 +26,29 @@ fn parse_ai_incident_context_requires_from_and_to() { assert!(err.contains("requires --to")); } +#[test] +fn parse_ai_investigate_accepts_compact_output_controls() { + let args = strings(&[ + "--detail=full", + "--include-transcript", + "--max-bytes", + "80", + "--json", + ]); + + let command = parse_ai_investigate(&args).unwrap(); + + match command { + crate::cli::CliCommand::Ai(crate::cli::AiCommand::Investigate(args)) => { + assert_eq!(args.detail, crate::cli::AiOutputDetail::Full); + assert!(args.include_transcript); + assert_eq!(args.max_bytes, Some(80)); + assert!(args.json); + } + other => panic!("unexpected command: {other:?}"), + } +} + fn strings(values: &[&str]) -> Vec { values.iter().map(|value| (*value).to_string()).collect() } diff --git a/src/cli/parse_ai_tests.rs b/src/cli/parse_ai_tests.rs index 045b5e36..3c6e05ca 100644 --- a/src/cli/parse_ai_tests.rs +++ b/src/cli/parse_ai_tests.rs @@ -18,6 +18,31 @@ fn parse_ai_watch_rejects_zero_debounce() { assert!(err.contains("expects a positive integer")); } +#[test] +fn parse_ai_blocks_accepts_limit_and_detail() { + let args = strings(&["--limit", "12", "--detail", "full", "--json"]); + + let command = parse_ai_blocks(&args).unwrap(); + + match command { + crate::cli::CliCommand::Ai(crate::cli::AiCommand::Blocks(args)) => { + assert_eq!(args.limit, Some(12)); + assert_eq!(args.detail, crate::cli::AiOutputDetail::Full); + assert!(args.json); + } + other => panic!("unexpected command: {other:?}"), + } +} + +#[test] +fn parse_ai_unknown_subcommand_suggests_close_match() { + let err = parse_ai(&strings(&["serach", "error"])) + .unwrap_err() + .to_string(); + + assert!(err.contains("Did you mean `search`?"), "got: {err}"); +} + fn strings(values: &[&str]) -> Vec { values.iter().map(|value| (*value).to_string()).collect() } diff --git a/src/cli/parse_command_log.rs b/src/cli/parse_command_log.rs index c5dfa190..3ce4cd57 100644 --- a/src/cli/parse_command_log.rs +++ b/src/cli/parse_command_log.rs @@ -12,7 +12,10 @@ pub(crate) fn parse_shell(args: &[String]) -> Result { match command.as_str() { "index" => parse_shell_index(rest), "atuin-index" => parse_shell_atuin_index(rest), - _ => bail!("unknown shell subcommand: {command}"), + _ => bail!( + "{}", + super::suggest::unknown_command("shell subcommand", command, &["index", "atuin-index"]) + ), } } @@ -23,7 +26,14 @@ pub(crate) fn parse_agent_command(args: &[String]) -> Result { match command.as_str() { "ingest-spool" => parse_agent_command_ingest_spool(rest), "wrap" => parse_agent_command_wrap(rest), - _ => bail!("unknown agent-command subcommand: {command}"), + _ => bail!( + "{}", + super::suggest::unknown_command( + "agent-command subcommand", + command, + &["ingest-spool", "wrap"], + ) + ), } } diff --git a/src/cli/parse_tests.rs b/src/cli/parse_tests.rs index 7f6e52c0..60e82233 100644 --- a/src/cli/parse_tests.rs +++ b/src/cli/parse_tests.rs @@ -83,6 +83,15 @@ fn parse_rejects_unknown_command() { assert!(err.contains("unknown CLI command: wat")); } +#[test] +fn parse_unknown_command_suggests_close_match() { + let err = parse_command(vec!["serach".to_string()]) + .unwrap_err() + .to_string(); + + assert!(err.contains("Did you mean `search`?"), "got: {err}"); +} + // ─── Heartbeat fleet state parity (cxih.4) ────────────────────────────────── #[test] @@ -99,6 +108,19 @@ fn parse_routes_host_state() { )); } +#[test] +fn parse_host_state_requires_host_selector_with_usage() { + let err = parse_command(vec!["host-state".to_string()]) + .unwrap_err() + .to_string(); + + assert!( + err.contains("requires --host-id ID or --hostname HOST"), + "got: {err}" + ); + assert!(err.contains("Usage: cortex host-state"), "got: {err}"); +} + #[test] fn parse_routes_fleet_state() { assert!(matches!( diff --git a/src/cli/suggest.rs b/src/cli/suggest.rs new file mode 100644 index 00000000..1952999c --- /dev/null +++ b/src/cli/suggest.rs @@ -0,0 +1,68 @@ +//! Small suggestion helpers for the hand-rolled CLI parser. + +pub(crate) fn did_you_mean<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'a str> { + // Match on the flag/command name only — drop any `=value` so equals-style + // options (`--projct=foo`) still suggest the right flag (`--project`). + let name = input.split('=').next().unwrap_or(input); + let normalized = name.trim_start_matches('-'); + let mut best: Option<(&str, usize)> = None; + for &candidate in candidates { + let candidate_cmp = candidate.trim_start_matches('-'); + let distance = levenshtein(normalized, candidate_cmp); + let threshold = if candidate_cmp.len() <= 4 { 1 } else { 3 }; + if distance <= threshold && best.is_none_or(|(_, best_distance)| distance < best_distance) { + best = Some((candidate, distance)); + } + } + best.map(|(candidate, _)| candidate) +} + +pub(crate) fn unknown_command(kind: &str, input: &str, candidates: &[&str]) -> String { + match did_you_mean(input, candidates) { + Some(candidate) => { + format!("unknown {kind}: {input}\n\nDid you mean `{candidate}`?") + } + None => format!("unknown {kind}: {input}"), + } +} + +pub(crate) fn unknown_option(command: &str, input: &str, candidates: &[&str]) -> String { + match did_you_mean(input, candidates) { + Some(candidate) => { + format!("unknown {command} option: {input}\n\nDid you mean `{candidate}`?") + } + None => format!("unknown {command} option: {input}"), + } +} + +fn levenshtein(a: &str, b: &str) -> usize { + if a == b { + return 0; + } + if a.is_empty() { + return b.chars().count(); + } + if b.is_empty() { + return a.chars().count(); + } + + let b_chars: Vec = b.chars().collect(); + let mut prev: Vec = (0..=b_chars.len()).collect(); + let mut curr = vec![0; b_chars.len() + 1]; + + for (i, ac) in a.chars().enumerate() { + curr[0] = i + 1; + for (j, bc) in b_chars.iter().enumerate() { + let substitution = prev[j] + usize::from(ac != *bc); + let insertion = curr[j] + 1; + let deletion = prev[j + 1] + 1; + curr[j + 1] = substitution.min(insertion).min(deletion); + } + std::mem::swap(&mut prev, &mut curr); + } + prev[b_chars.len()] +} + +#[cfg(test)] +#[path = "suggest_tests.rs"] +mod tests; diff --git a/src/cli/suggest_tests.rs b/src/cli/suggest_tests.rs new file mode 100644 index 00000000..f53633bd --- /dev/null +++ b/src/cli/suggest_tests.rs @@ -0,0 +1,29 @@ +use super::*; + +#[test] +fn suggests_close_command_tokens() { + assert_eq!(did_you_mean("serach", &["search", "tail"]), Some("search")); + assert_eq!( + did_you_mean("--jsoon", &["--json", "--from"]), + Some("--json") + ); +} + +#[test] +fn ignores_distant_tokens() { + assert_eq!(did_you_mean("xyz", &["search", "compose"]), None); +} + +#[test] +fn matches_flag_name_ignoring_equals_value() { + // Equals-style options must still suggest the right flag — the `=value` part + // must not pollute the edit distance. + assert_eq!( + did_you_mean("--projct=foo", &["--project", "--tool"]), + Some("--project") + ); + assert_eq!( + did_you_mean("--tol=claude", &["--project", "--tool"]), + Some("--tool") + ); +} diff --git a/src/command_log_tests.rs b/src/command_log_tests.rs index 61d34437..91ea8957 100644 --- a/src/command_log_tests.rs +++ b/src/command_log_tests.rs @@ -398,7 +398,13 @@ fn imports_agent_spool_as_agent_command_rows() { } #[test] +#[serial] fn wrapper_preserves_command_exit_when_spool_append_fails() { + // `["true"]` is a single token, so the wrapper runs it via `$SHELL -c true` + // (see `command_status`). This must be `#[serial]` to exclude + // `wrapper_executes_multi_arg_commands_without_shell_reparse`, which mutates + // the global `SHELL`/`CORTEX_TEST_ARG_OUT` env — overlapping would exec that + // test's fake shell here and corrupt its output buffer (both tests fail). let dir = tempfile::tempdir().unwrap(); let exit_code = diff --git a/src/main.rs b/src/main.rs index c088c2fa..371b79e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -567,10 +567,10 @@ impl Mode { args.join(" ") ); } - _ => { - print_usage(); - anyhow::bail!("unknown command: {}", args.join(" ")); - } + _ => match cli::CliCommand::parse(args.clone()) { + Ok(_) => unreachable!("known CLI commands are handled above"), + Err(err) => anyhow::bail!("{err}"), + }, } } diff --git a/src/main_tests.rs b/src/main_tests.rs index 38e03615..ff3361ea 100644 --- a/src/main_tests.rs +++ b/src/main_tests.rs @@ -25,7 +25,13 @@ fn mode_parse_accepts_heartbeat_state_commands() { // parse.rs + run.rs, but were missing from Mode::parse's top-level command // gate, so they fell through to print_usage()+exit 1 (bd syslog-mcp-8fww). assert!(matches!( - Mode::parse(vec!["host-state".into(), "--json".into()]).unwrap(), + Mode::parse(vec![ + "host-state".into(), + "--hostname".into(), + "tootie".into(), + "--json".into() + ]) + .unwrap(), Mode::Cli(_) )); assert!(matches!( @@ -47,13 +53,16 @@ fn mode_parse_accepts_heartbeat_state_commands() { #[test] fn mode_parse_rejects_unknown_commands() { let err = Mode::parse(vec!["serve".into(), "http".into()]).unwrap_err(); - assert!(err.to_string().contains("unknown command")); + assert!(err.to_string().contains("unknown CLI command")); + + let err = Mode::parse(vec!["serach".into()]).unwrap_err(); + assert!(err.to_string().contains("Did you mean `search`?")); } #[test] fn mode_parse_keeps_runtime_status_mcp_only() { let err = Mode::parse(vec!["status".into()]).unwrap_err(); - assert!(err.to_string().contains("unknown command")); + assert!(err.to_string().contains("unknown CLI command")); } #[test]