From 4a9fcd97d0d758c9ced85396ef973ca38e97cfc8 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Thu, 13 Aug 2026 11:43:17 +0200 Subject: [PATCH] feat(cli): add explicit quiet output mode Signed-off-by: Martin Vogel --- README.md | 4 +- src/cli/progress_sink.c | 65 ++++++++++++++++++++- src/cli/progress_sink.h | 21 ++++++- src/main.c | 121 ++++++++++++++++++++++----------------- tests/test_cli.c | 124 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 275 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 6acffb31d..cb9405fc0 100644 --- a/README.md +++ b/README.md @@ -629,7 +629,7 @@ Every MCP tool can be invoked as a local, one-shot command. CLI tools neither st Commands that mutate graph data use shared OS-backed, per-project locks. This serializes conflicting work from CLI and MCP sessions on the same project while allowing unrelated projects to proceed independently. -When stderr is an interactive terminal, the CLI automatically shows lifecycle and indexing progress. Pass `--progress` to force the same feedback when stderr is redirected or the command is run non-interactively. Routine informational logs are quiet by default; pass outer `cli --verbose` to include them. Progress and logs use stderr while stdout remains reserved for the command result. Read tools return a compact tree by default; pass a tool's `--format json` for machine-readable payload JSON, or outer `--json` for the full MCP envelope. +When stderr is an interactive terminal, the CLI automatically shows lifecycle and indexing progress. Pass `--progress` to force the same feedback when stderr is redirected or the command is run non-interactively. Pass `--quiet` to disable automatic terminal progress and ordinary diagnostics while retaining errors; it cannot be combined with `--progress` or outer `cli --verbose`. Routine informational logs are quiet by default; pass outer `cli --verbose` to include them. Progress and logs use stderr while stdout remains reserved for the command result. Read tools return a compact tree by default; pass a tool's `--format json` for machine-readable payload JSON, or outer `--json` for the full MCP envelope. Large compact-tree tables may start with a response-local `
_refs` directory and an explicit `
_ref_rule`. A cell such as `@0+handler.go` reconstructs to ref `0`'s prefix plus `handler.go`. References are local to that sibling `
` table and expansion is non-recursive: entries inside `
_refs` are always literal prefixes. This is limited to declared path and qualified-name columns and activates only when that table is at least 15% and 64 bytes smaller. Search and trace likewise render direct and prefix-grouped tree shapes and keep the smaller complete representation, so singleton or scattered answers do not pay directory overhead. Keys are declared once per table but never cryptically abbreviated, and `--format json` keeps stable literal strings for machine consumers. These are deterministic model-neutral byte savings that normally reduce tokens; exact token counts still depend on the caller's tokenizer. @@ -650,6 +650,8 @@ codebase-memory-mcp cli query_graph --project my-project --query 'MATCH (f:Funct # Force human-readable progress without contaminating stdout. codebase-memory-mcp cli --progress index_repository --repo-path /path/to/repo +# Suppress automatic terminal progress and non-error diagnostics. +codebase-memory-mcp cli --quiet list_projects --format json codebase-memory-mcp cli search_graph --project my-project --label Function --format json codebase-memory-mcp cli list_projects --format json --detail stats | jq '.projects[].name' ``` diff --git a/src/cli/progress_sink.c b/src/cli/progress_sink.c index f21c87f9f..d994c8a81 100644 --- a/src/cli/progress_sink.c +++ b/src/cli/progress_sink.c @@ -50,8 +50,69 @@ static void progress_sink_mutex_ensure(void) { while (atomic_load_explicit(&s_sink_mutex_state, memory_order_acquire) != LOCK_READY) {} } -bool cbm_cli_progress_enabled(bool explicitly_requested, bool stderr_is_tty) { - return explicitly_requested || stderr_is_tty; +bool cbm_cli_output_flags_parse(int *argc, char **argv, cbm_cli_output_flags_t *flags, char *error, + size_t error_size) { + if (!argc || !argv || !flags) { + return false; + } + memset(flags, 0, sizeof(*flags)); + if (error && error_size > 0) { + error[0] = '\0'; + } + + /* --quiet and --progress are reserved process controls wherever they + * appear, matching the existing --progress behavior. --verbose remains + * outer-only because tools such as index_status own a verbose input. */ + bool before_tool = true; + int output_index = 0; + for (int input_index = 0; input_index < *argc; input_index++) { + char *arg = argv[input_index]; + bool quiet = arg && strcmp(arg, "--quiet") == 0; + bool progress = arg && strcmp(arg, "--progress") == 0; + bool verbose = before_tool && arg && strcmp(arg, "--verbose") == 0; + if (quiet) { + flags->quiet_requested = true; + } else if (progress) { + flags->progress_requested = true; + } else if (verbose) { + flags->verbose_requested = true; + } else { + argv[output_index++] = arg; + if (before_tool && arg && arg[0] != '-') { + before_tool = false; + } + } + } + *argc = output_index; + + if (flags->quiet_requested && (flags->progress_requested || flags->verbose_requested)) { + if (error && error_size > 0) { + (void)snprintf(error, error_size, + "--quiet cannot be combined with --progress or outer --verbose; " + "use --quiet for errors-only stderr, --progress for lifecycle " + "feedback, or --verbose for informational diagnostics"); + } + return false; + } + return true; +} + +void cbm_cli_diagnostics_configure(bool quiet_requested, bool verbose_requested) { + if (quiet_requested) { + /* Keep ERROR records visible while suppressing DEBUG/INFO/WARN. An + * explicit CBM_LOG_LEVEL=none remains stronger than --quiet. */ + if (cbm_log_get_level() < CBM_LOG_ERROR) { + cbm_log_set_level(CBM_LOG_ERROR); + } + return; + } + if (verbose_requested && cbm_log_get_level() > CBM_LOG_INFO) { + cbm_log_set_level(CBM_LOG_INFO); + } +} + +bool cbm_cli_progress_enabled(bool explicitly_requested, bool quiet_requested, bool stderr_is_tty) { + return !quiet_requested && (explicitly_requested || stderr_is_tty); } static void progress_tool_name(const char *tool_name, char out[CBM_SZ_64]) { diff --git a/src/cli/progress_sink.h b/src/cli/progress_sink.h index 7301a3357..7a9058b8a 100644 --- a/src/cli/progress_sink.h +++ b/src/cli/progress_sink.h @@ -3,7 +3,7 @@ * * Installs a log sink that maps structured pipeline events to phase labels. * Interactive terminals enable it automatically; --progress forces it when - * stderr is redirected. + * stderr is redirected, and --quiet disables it. * Usage: * cbm_progress_sink_init(stderr); * // ... run pipeline ... @@ -13,12 +13,27 @@ #define CBM_PROGRESS_SINK_H #include +#include #include #include +typedef struct { + bool quiet_requested; + bool progress_requested; + bool verbose_requested; +} cbm_cli_output_flags_t; + +/* Parse process-level output controls without consuming a tool's own + * `--verbose` argument. The argv array is compacted in place. */ +bool cbm_cli_output_flags_parse(int *argc, char **argv, cbm_cli_output_flags_t *flags, char *error, + size_t error_size); + +/* Apply process-level diagnostic verbosity without changing stdout. */ +void cbm_cli_diagnostics_configure(bool quiet_requested, bool verbose_requested); + /* Interactive terminals get lifecycle feedback automatically. --progress - * forces the same behavior for redirected stderr without touching stdout. */ -bool cbm_cli_progress_enabled(bool explicitly_requested, bool stderr_is_tty); + * forces the same behavior for redirected stderr; --quiet disables both. */ +bool cbm_cli_progress_enabled(bool explicitly_requested, bool quiet_requested, bool stderr_is_tty); void cbm_cli_progress_start(FILE *out, const char *tool_name); void cbm_cli_progress_finish(FILE *out, const char *tool_name, bool success, uint64_t elapsed_ms); diff --git a/src/main.c b/src/main.c index 36f435a8c..db86a44a4 100644 --- a/src/main.c +++ b/src/main.c @@ -6,6 +6,7 @@ * cli Run a single tool call and print result * --version Print version and exit * --help Print usage and exit + * cli --quiet Show errors only; disable automatic terminal progress * cli --verbose Include informational logs for one-shot commands * --ui=true/false Enable/disable HTTP UI server (persisted) * --port=N Set HTTP UI port (persisted, default 9749) @@ -540,10 +541,12 @@ static bool client_start_parent_watchdog(pid_t initial_ppid) { /* ── CLI mode ───────────────────────────────────────────────────── */ -#define CLI_USAGE \ - "Usage: codebase-memory-mcp cli [--progress] [--verbose] [--json] [json_args]\n" \ - " --progress Show lifecycle progress even when stderr is redirected\n" \ - " --verbose Include informational logs (preserves CBM_LOG_LEVEL=debug)\n" \ +#define CLI_USAGE \ + "Usage: codebase-memory-mcp cli [--quiet] [--progress] [--verbose] [--json] " \ + "[json_args]\n" \ + " --quiet Show errors only; cannot combine with --progress or outer --verbose\n" \ + " --progress Show lifecycle progress even when stderr is redirected\n" \ + " --verbose Include informational logs (preserves CBM_LOG_LEVEL=debug)\n" \ " --json Print the raw MCP result envelope\n" /* Extract text content from MCP tool result envelope and print it. @@ -592,24 +595,6 @@ static bool cli_strip_flag(int *argc, char **argv, const char *flag) { return false; } -/* Outer CLI flags precede the tool name. Keep this distinct from - * cli_strip_flag(): several MCP tools have their own `verbose` input, so - * `cli index_status --verbose` must remain a tool argument while - * `cli --verbose index_status` configures process logging. */ -static bool cli_strip_outer_flag(int *argc, char **argv, const char *flag) { - for (int i = 0; i < *argc && argv[i] && argv[i][0] == '-'; i++) { - if (strcmp(argv[i], flag) != 0) { - continue; - } - for (int j = i; j < *argc - SKIP_ONE; j++) { - argv[j] = argv[j + SKIP_ONE]; - } - (*argc)--; - return true; - } - return false; -} - /* Strip a flag AND its following value from argv, returning the value (a pointer * into the original argv strings, valid for the process lifetime) or NULL if the * flag is absent. */ @@ -688,14 +673,21 @@ static bool cli_first_nonspace_is_brace(const char *s) { return *s == '{'; } -static char *main_local_cli_daemon_execute(const char *tool_name, const char *args_json); +static char *main_local_cli_daemon_execute(const char *tool_name, const char *args_json, + bool quiet_requested); static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_locks, main_local_maintenance_context_t *maintenance_context) { - bool verbose_requested = cli_strip_outer_flag(&argc, argv, "--verbose"); - if (verbose_requested && cbm_log_get_level() > CBM_LOG_INFO) { - cbm_log_set_level(CBM_LOG_INFO); + cbm_cli_output_flags_t output_flags; + char output_error[CBM_SZ_512] = {0}; + if (!cbm_cli_output_flags_parse(&argc, argv, &output_flags, output_error, + sizeof(output_error))) { + (void)fprintf(stderr, "error: %s\n", + output_error[0] ? output_error : "invalid output mode"); + (void)fprintf(stderr, CLI_USAGE); + return SKIP_ONE; } + cbm_cli_diagnostics_configure(output_flags.quiet_requested, output_flags.verbose_requested); if (argc == 1 && argv && (strcmp(argv[0], "--help") == 0 || strcmp(argv[0], "-h") == 0)) { (void)fputs(CLI_USAGE, stdout); return 0; @@ -705,7 +697,6 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo return SKIP_ONE; } - bool progress_requested = cli_strip_flag(&argc, argv, "--progress"); bool raw_json = cli_strip_flag(&argc, argv, "--json"); /* Supervisor worker role: when this process was spawned as a supervised index @@ -773,11 +764,13 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo } else if (rem_argc >= SKIP_ONE && cli_first_nonspace_is_brace(rem_argv[0])) { /* raw-JSON back-compat: cli '{"k":"v"}' (deprecated path). Warn on * STDERR only — stdout must stay clean JSON for piping. */ - (void)fprintf(stderr, - "warning: passing raw JSON to 'cli %s' is deprecated and " - "will be removed in a future release; use flags (run 'cli " - "%s --help'), --args-file , or piped stdin.\n", - tool_name, tool_name); + if (!output_flags.quiet_requested) { + (void)fprintf(stderr, + "warning: passing raw JSON to 'cli %s' is deprecated and " + "will be removed in a future release; use flags (run 'cli " + "%s --help'), --args-file , or piped stdin.\n", + tool_name, tool_name); + } args_json = rem_argv[0]; } else if (rem_argc >= SKIP_ONE && strncmp(rem_argv[0], "--", 2) == 0) { /* flag form: cli --flag value --bare-bool ... */ @@ -804,7 +797,8 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo } bool progress = - !index_worker && cbm_cli_progress_enabled(progress_requested, cli_isatty(2) != 0); + !index_worker && cbm_cli_progress_enabled(output_flags.progress_requested, + output_flags.quiet_requested, cli_isatty(2) != 0); uint64_t progress_started_ms = cbm_now_ms(); if (progress) { cbm_progress_sink_init(stderr); @@ -824,7 +818,7 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo bool maintenance_binding_failed = false; bool maintenance_cancelled = false; if (!index_worker) { - result = main_local_cli_daemon_execute(tool_name, args_json); + result = main_local_cli_daemon_execute(tool_name, args_json, output_flags.quiet_requested); } else { srv = cbm_mcp_server_new(NULL); if (srv) { @@ -925,7 +919,8 @@ static void print_help(void) { printf("codebase-memory-mcp %s\n\n", CBM_VERSION); printf("Usage:\n"); printf(" codebase-memory-mcp Run MCP server on stdio\n"); - printf(" codebase-memory-mcp cli [--progress] [--verbose] [--json] [args]\n"); + printf(" codebase-memory-mcp cli [--quiet] [--progress] [--verbose] [--json] " + "[args]\n"); printf(" Run one tool locally, then exit\n"); printf(" codebase-memory-mcp install [-y|-n] [--force] [--dry-run] " "[--dir=] [--skip-config]\n"); @@ -936,6 +931,12 @@ static void print_help(void) { printf(" codebase-memory-mcp config \n"); printf(" codebase-memory-mcp --version Print version\n"); printf(" codebase-memory-mcp --help Print this help\n"); + printf("\nCLI output options:\n"); + printf(" --quiet Show errors only; disable automatic terminal progress\n"); + printf(" --progress Show lifecycle progress even with redirected stderr\n"); + printf(" --verbose Include informational diagnostics\n"); + printf(" --json Print the raw MCP result envelope\n"); + printf(" --quiet cannot be combined with --progress or outer --verbose.\n"); printf("\nUI options:\n"); printf(" --ui=true Enable HTTP graph visualization (persisted)\n"); printf(" --ui=false Disable HTTP graph visualization (persisted)\n"); @@ -1312,15 +1313,23 @@ static cbm_daemon_ipc_endpoint_t *main_daemon_endpoint_new(void) { return cbm_daemon_bootstrap_endpoint_new(runtime_parent); } -static bool main_local_cli_feedback_enabled(int argc, char **argv) { - bool requested = false; +static bool main_cli_flag_present(int argc, char **argv, const char *flag) { + if (!argv || !flag) { + return false; + } for (int index = 1; index < argc; index++) { - if (argv[index] && strcmp(argv[index], "--progress") == 0) { - requested = true; - break; + if (argv[index] && strcmp(argv[index], flag) == 0) { + return true; } } - return cbm_cli_progress_enabled(requested, cli_isatty(2) != 0); + return false; +} + +static bool main_local_cli_feedback_enabled(int argc, char **argv) { + bool requested = false; + requested = main_cli_flag_present(argc, argv, "--progress"); + bool quiet = main_cli_flag_present(argc, argv, "--quiet"); + return cbm_cli_progress_enabled(requested, quiet, cli_isatty(2) != 0); } /* Acquire the exclusive startup transition, waiting out whatever currently holds it. @@ -1573,7 +1582,8 @@ static void main_report_client_bootstrap_failure(cbm_daemon_process_role_t role, * lifecycle used to, instead of deadlocking behind the pinned daemon. Same- * or newer-build daemons and dev builds are never auto-drained. */ static cbm_daemon_bootstrap_status_t main_client_bootstrap_with_upgrade( - const cbm_daemon_bootstrap_config_t *config, cbm_daemon_bootstrap_result_t *result) { + const cbm_daemon_bootstrap_config_t *config, cbm_daemon_bootstrap_result_t *result, + bool report_lifecycle) { cbm_daemon_bootstrap_status_t status = cbm_daemon_bootstrap_execute(config, result); if (status != CBM_DAEMON_BOOTSTRAP_CONFLICT) { return status; @@ -1585,11 +1595,13 @@ static cbm_daemon_bootstrap_status_t main_client_bootstrap_with_upgrade( !main_semver_newer(config->identity->semantic_version, active.semantic_version)) { return status; } - (void)fprintf(stderr, - "codebase-memory-mcp: retiring the active permanent daemon (%s, pid %lu) for " - "this newer build (%s)\n", - active.semantic_version, (unsigned long)active.daemon_pid, - config->identity->semantic_version); + if (report_lifecycle) { + (void)fprintf(stderr, + "codebase-memory-mcp: retiring the active permanent daemon (%s, pid %lu) " + "for this newer build (%s)\n", + active.semantic_version, (unsigned long)active.daemon_pid, + config->identity->semantic_version); + } cbm_daemon_runtime_activation_result_t drain; if (!cbm_daemon_runtime_request_activation_shutdown(config->endpoint, config->identity, CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE, @@ -1606,7 +1618,8 @@ static cbm_daemon_bootstrap_status_t main_client_bootstrap_with_upgrade( * sessions and hooks: an active daemon (any starter) is recycled, an absent * one is spawned for this command — with a hint that `daemon start` removes * that per-command cost. Only supervised index workers stay in-process. */ -static char *main_local_cli_daemon_execute(const char *tool_name, const char *args_json) { +static char *main_local_cli_daemon_execute(const char *tool_name, const char *args_json, + bool quiet_requested) { cbm_daemon_ipc_endpoint_t *endpoint = cbm_daemon_bootstrap_endpoint_new(NULL); char executable_path[MAIN_PATH_CAP] = {0}; cbm_daemon_build_identity_t identity; @@ -1628,7 +1641,8 @@ static char *main_local_cli_daemon_execute(const char *tool_name, const char *ar .startup_timeout_ms = MAIN_MCP_STARTUP_TIMEOUT_MS, }; cbm_daemon_bootstrap_result_t bootstrap; - cbm_daemon_bootstrap_status_t status = main_client_bootstrap_with_upgrade(&config, &bootstrap); + cbm_daemon_bootstrap_status_t status = + main_client_bootstrap_with_upgrade(&config, &bootstrap, !quiet_requested); cbm_daemon_ipc_endpoint_free(endpoint); if (status != CBM_DAEMON_BOOTSTRAP_CONNECTED || !bootstrap.client) { (void)fprintf(stderr, "error: %s\n", @@ -2428,7 +2442,7 @@ static int main_run_daemon_ctl(int argc, char **argv, const cbm_daemon_ipc_endpo }; cbm_daemon_bootstrap_result_t start_result; cbm_daemon_bootstrap_status_t start_status = - main_client_bootstrap_with_upgrade(&start_config, &start_result); + main_client_bootstrap_with_upgrade(&start_config, &start_result, true); if (start_status != CBM_DAEMON_BOOTSTRAP_CONNECTED || !start_result.client) { (void)fprintf(stderr, "error: %s\n", start_result.message[0] ? start_result.message @@ -2547,6 +2561,11 @@ int main(int argc, char **argv) { * are their supervisor's quiet-timeout heartbeat. */ bool quiet_log_default = role != CBM_DAEMON_PROCESS_DAEMON && role != CBM_DAEMON_PROCESS_WORKER; cbm_log_init_for_process(quiet_log_default, role == CBM_DAEMON_PROCESS_WORKER); + if (role == CBM_DAEMON_PROCESS_LOCAL_CLI && main_cli_flag_present(argc, argv, "--quiet")) { + /* Apply quiet before local coordination begins, not only once tool + * dispatch is reached, so pre-dispatch WARN chatter is suppressed. */ + cbm_cli_diagnostics_configure(true, false); + } cbm_mcp_tool_profile_t tool_profile = CBM_MCP_TOOL_PROFILE_ALL; if (role == CBM_DAEMON_PROCESS_MCP_CLIENT && @@ -3044,7 +3063,7 @@ int main(int argc, char **argv) { }; cbm_daemon_bootstrap_result_t bootstrap_result; cbm_daemon_bootstrap_status_t bootstrap_status = - main_client_bootstrap_with_upgrade(&bootstrap_config, &bootstrap_result); + main_client_bootstrap_with_upgrade(&bootstrap_config, &bootstrap_result, true); cbm_daemon_ipc_endpoint_free(endpoint); if (bootstrap_status != CBM_DAEMON_BOOTSTRAP_CONNECTED || !bootstrap_result.client) { main_report_client_bootstrap_failure(role, &bootstrap_result); diff --git a/tests/test_cli.c b/tests/test_cli.c index f4f53c6a5..02cff8b76 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -59,9 +59,123 @@ int cbm_cli_build_yaml_stdio_mcp_block_for_test(const char *binary_path, bool go bool cbm_cli_stdin_allowed_for_schema_for_test(const char *schema_str); TEST(cli_progress_visibility_policy) { - ASSERT_TRUE(cbm_cli_progress_enabled(true, false)); - ASSERT_TRUE(cbm_cli_progress_enabled(false, true)); - ASSERT_FALSE(cbm_cli_progress_enabled(false, false)); + ASSERT_TRUE(cbm_cli_progress_enabled(true, false, false)); + ASSERT_TRUE(cbm_cli_progress_enabled(false, false, true)); + ASSERT_FALSE(cbm_cli_progress_enabled(false, false, false)); + ASSERT_FALSE(cbm_cli_progress_enabled(false, true, true)); + ASSERT_FALSE(cbm_cli_progress_enabled(true, true, true)); + PASS(); +} + +TEST(cli_quiet_is_stripped_before_tool_argument_forwarding) { + char *argv[] = {"search_graph", "--project", "demo", "--quiet"}; + int argc = (int)(sizeof(argv) / sizeof(argv[0])); + cbm_cli_output_flags_t flags; + char error[256] = {0}; + + ASSERT_TRUE(cbm_cli_output_flags_parse(&argc, argv, &flags, error, sizeof(error))); + ASSERT_TRUE(flags.quiet_requested); + ASSERT_FALSE(flags.progress_requested); + ASSERT_FALSE(flags.verbose_requested); + ASSERT_EQ(argc, 3); + ASSERT_STR_EQ(argv[0], "search_graph"); + ASSERT_STR_EQ(argv[1], "--project"); + ASSERT_STR_EQ(argv[2], "demo"); + + char *build_error = NULL; + char *json = cbm_cli_build_args_json(argv[0], argc - 1, argv + 1, &build_error); + ASSERT_NOT_NULL(json); + ASSERT_NULL(build_error); + ASSERT_NOT_NULL(strstr(json, "\"project\":\"demo\"")); + ASSERT_NULL(strstr(json, "quiet")); + free(json); + PASS(); +} + +TEST(cli_quiet_rejects_other_outer_output_modes_with_guidance) { + char *progress_argv[] = {"--quiet", "--progress", "search_graph"}; + int progress_argc = (int)(sizeof(progress_argv) / sizeof(progress_argv[0])); + cbm_cli_output_flags_t progress_flags; + char progress_error[256] = {0}; + ASSERT_FALSE(cbm_cli_output_flags_parse(&progress_argc, progress_argv, &progress_flags, + progress_error, sizeof(progress_error))); + ASSERT_NOT_NULL(strstr(progress_error, "--quiet")); + ASSERT_NOT_NULL(strstr(progress_error, "--progress")); + ASSERT_NOT_NULL(strstr(progress_error, "errors-only")); + + char *verbose_argv[] = {"--quiet", "--verbose", "index_status"}; + int verbose_argc = (int)(sizeof(verbose_argv) / sizeof(verbose_argv[0])); + cbm_cli_output_flags_t verbose_flags; + char verbose_error[256] = {0}; + ASSERT_FALSE(cbm_cli_output_flags_parse(&verbose_argc, verbose_argv, &verbose_flags, + verbose_error, sizeof(verbose_error))); + ASSERT_NOT_NULL(strstr(verbose_error, "--verbose")); + ASSERT_NOT_NULL(strstr(verbose_error, "informational diagnostics")); + + /* Existing progress plus verbose behavior remains valid; only quiet is + * exclusive with the two output-expanding controls. */ + char *compatible_argv[] = {"--progress", "--verbose", "search_graph"}; + int compatible_argc = (int)(sizeof(compatible_argv) / sizeof(compatible_argv[0])); + cbm_cli_output_flags_t compatible_flags; + char compatible_error[256] = {0}; + ASSERT_TRUE(cbm_cli_output_flags_parse(&compatible_argc, compatible_argv, &compatible_flags, + compatible_error, sizeof(compatible_error))); + ASSERT_TRUE(compatible_flags.progress_requested); + ASSERT_TRUE(compatible_flags.verbose_requested); + ASSERT_FALSE(compatible_flags.quiet_requested); + ASSERT_EQ(compatible_argc, 1); + ASSERT_STR_EQ(compatible_argv[0], "search_graph"); + PASS(); +} + +TEST(cli_quiet_preserves_tool_level_verbose_argument) { + char *argv[] = {"--quiet", "index_status", "--verbose"}; + int argc = (int)(sizeof(argv) / sizeof(argv[0])); + cbm_cli_output_flags_t flags; + char error[256] = {0}; + + ASSERT_TRUE(cbm_cli_output_flags_parse(&argc, argv, &flags, error, sizeof(error))); + ASSERT_TRUE(flags.quiet_requested); + ASSERT_FALSE(flags.verbose_requested); + ASSERT_EQ(argc, 2); + ASSERT_STR_EQ(argv[0], "index_status"); + ASSERT_STR_EQ(argv[1], "--verbose"); + PASS(); +} + +static FILE *s_cli_quiet_log_capture; + +static void cli_quiet_log_capture(const char *line) { + if (s_cli_quiet_log_capture && line) { + (void)fprintf(s_cli_quiet_log_capture, "%s\n", line); + } +} + +TEST(cli_quiet_suppresses_ordinary_diagnostics_but_preserves_errors) { + FILE *out = tmpfile(); + ASSERT_NOT_NULL(out); + CBMLogLevel previous_level = cbm_log_get_level(); + CBMLogFormat previous_format = cbm_log_get_format(); + cbm_log_set_level(CBM_LOG_DEBUG); + cbm_log_set_format(CBM_LOG_FORMAT_TEXT); + s_cli_quiet_log_capture = out; + cbm_log_set_sink(cli_quiet_log_capture); + + cbm_cli_diagnostics_configure(true, false); + cbm_log_info("quiet.info", "detail", "drop"); + cbm_log_warn("quiet.warning", "detail", "drop"); + cbm_log_error("quiet.error", "detail", "keep"); + + cbm_log_set_sink(NULL); + s_cli_quiet_log_capture = NULL; + cbm_log_set_level(previous_level); + cbm_log_set_format(previous_format); + ASSERT_EQ(fseek(out, 0, SEEK_SET), 0); + char rendered[512] = {0}; + size_t rendered_size = fread(rendered, 1, sizeof(rendered) - 1, out); + (void)fclose(out); + ASSERT_TRUE(rendered_size > 0); + ASSERT_STR_EQ(rendered, "level=error msg=quiet.error detail=keep\n"); PASS(); } @@ -14185,6 +14299,10 @@ SUITE(cli) { #endif RUN_TEST(cli_index_restart_cap_honours_zero_and_refuses_junk); RUN_TEST(cli_progress_visibility_policy); + RUN_TEST(cli_quiet_is_stripped_before_tool_argument_forwarding); + RUN_TEST(cli_quiet_rejects_other_outer_output_modes_with_guidance); + RUN_TEST(cli_quiet_preserves_tool_level_verbose_argument); + RUN_TEST(cli_quiet_suppresses_ordinary_diagnostics_but_preserves_errors); RUN_TEST(cli_raw_mcp_result_preserves_tool_error_status); RUN_TEST(cli_maintenance_cancellation_forces_failure_status); RUN_TEST(cli_progress_sink_accepts_worker_json_logs);