Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ Terminals that request ASCII-only output with `--ascii`, `CDIDX_ASCII=1`,
spinner frames as `|` / `/` / `-` / `\` and progress bars with `#` / `-` instead
of Unicode glyphs. Very narrow Unicode-capable terminals show a percentage-only
progress line so the display does not wrap.
For script-friendly query pipelines, pass `--quiet`, `-q`, `--silent`, or set
`CDIDX_QUIET=1` to suppress informational stderr output such as zero-result
hints, summaries, warnings, notes, and verbose diagnostics while preserving
error lines. `--quiet` takes precedence over `--verbose` for stderr text.

Use `cdidx` when a repository will be searched repeatedly from terminals,
scripts, CI, or AI tools. Use `rg` when you only need a one-off text scan.
Expand Down Expand Up @@ -290,6 +294,10 @@ POSIX 環境では、persistent global tool stderr log は開くたびに所有
または非 UTF-8 locale により ASCII-only 出力が要求されている端末では、スピナーは
`|` / `/` / `-` / `\`、進捗バーは `#` / `-` で描画されます。Unicode を利用できる端末でも
幅が非常に狭い場合は、折り返しを避けるため percentage-only の進捗行を表示します。
スクリプト向けの query pipeline では、`--quiet`、`-q`、`--silent`、または
`CDIDX_QUIET=1` により、0件時のヒント、summary、warning、note、verbose 診断などの
informational stderr 出力を抑制し、error 行だけを残せます。stderr text については
`--quiet` が `--verbose` より優先されます。

ターミナル、スクリプト、CI、AI ツールから同じリポジトリを繰り返し検索する
場合は `cdidx` が向いています。1回限りのテキスト検索には `rg` が向いています。
Expand Down
20 changes: 20 additions & 0 deletions changelog.d/unreleased/1805.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
category: added
issues:
- 1805
affected:
- src/CodeIndex/Cli/ProgramRunner.cs
- src/CodeIndex/Cli/CliFlagSchema.cs
- src/CodeIndex/Cli/ConsoleUi.cs
- src/CodeIndex/Cli/QueryCommandRunner.cs
- README.md
- tests/CodeIndex.Tests/ProgramCliTests.cs
---

## English

- **Global quiet mode for script-friendly query pipelines (#1805)** — `--quiet`, `-q`, `--silent`, and `CDIDX_QUIET=1` now suppress informational stderr text while preserving error lines.

## 日本語

- **スクリプト向け query pipeline 用の global quiet mode を追加しました (#1805)** — `--quiet`、`-q`、`--silent`、`CDIDX_QUIET=1` により、error 行を残したまま informational stderr text を抑制できます。
2 changes: 2 additions & 0 deletions src/CodeIndex/Cli/CliFlagSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@ private static IReadOnlyList<CliFlag> BuildAll()
new() { Name = "--db", ValuePlaceholder = "<path>", Description = "Database path", Commands = Set(DbPathCommands) },
new() { Name = "--data-dir", ValuePlaceholder = "<dir>", Description = "Directory containing codeindex.db; overrides CDIDX_DATA_DIR/XDG/workspace defaults", Commands = Set(DataDirCommands) },
new() { Name = "--json", Description = "JSON output; search also accepts --json=array for a single JSON array", Commands = Set(JsonCommands) },
new() { Name = "--quiet", ShortName = "-q", Description = "Suppress informational stderr output; errors still print", Commands = Set(AllCommands.ToArray()) },
new() { Name = "--silent", Description = "Alias for --quiet", Commands = Set(AllCommands.ToArray()) },
new() { Name = "--profile", Description = "Emit SQL timing and EXPLAIN QUERY PLAN profile JSON after the normal result", Commands = Set(ProfileCommands) },
new() { Name = "--verbose", Description = "Emit query debug diagnostics to stderr, or _debug JSON when combined with --json", Commands = Set(VerboseQueryCommands.Concat(new[] { "index" }).ToArray()) },
new() { Name = "--slow-query-ms", ValuePlaceholder = "<n>", Description = "Log profiled SQL statements at or above this millisecond threshold", Commands = Set(ProfileCommands) },
Expand Down
2 changes: 2 additions & 0 deletions src/CodeIndex/Cli/ConsoleUi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,7 @@ public static void PrintUsage(bool showBanner = true)
Console.WriteLine(" --dry-run Scan files without writing to the database");
Console.WriteLine(" --force Bypass the per-database index lock; only use when no other cdidx index is active");
Console.WriteLine(" --json Output results as JSON (for AI/machine use)");
Console.WriteLine(" --quiet, -q, --silent Suppress informational stderr output; errors still print (also honors CDIDX_QUIET=1)");
Console.WriteLine(" --duration-format <format> Index elapsed time format: `auto` (default), `seconds`, or `hms`; JSON keeps raw elapsed_ms");
Console.WriteLine(" --max-file-bytes <bytes> Index only files up to this size (default: 4MiB; also honors CDIDX_MAX_FILE_BYTES; accepts K/M/G suffixes)");
Console.WriteLine(" --parallelism <n> Full-scan extraction workers (default: CPU count capped at 16; also honors CDIDX_INDEX_PARALLELISM)");
Expand Down Expand Up @@ -687,6 +688,7 @@ public static void PrintUsage(bool showBanner = true)
Console.WriteLine(" --db <path> Database file path (default: .cdidx/codeindex.db in current directory)");
Console.WriteLine(" --json Output as JSON (search streams ndjson by default; use search --json=array for one array)");
Console.WriteLine(" --verbose Query commands: emit debug diagnostics to stderr; with --json, append an _debug JSON object");
Console.WriteLine(" --quiet, -q, --silent Query commands: suppress informational stderr output, including zero-result hints and summaries; errors still print. Overrides --verbose stderr text.");
Console.WriteLine(" --profile Read commands: append SQL timing, row-count, and EXPLAIN QUERY PLAN JSON after the normal result");
Console.WriteLine(" --slow-query-ms <n> Read commands: log profiled SQL statements that take at least <n> ms (use 0 to log every statement)");
Console.WriteLine(" --limit <n>, --top <n> Max results to return (default: 20)");
Expand Down
130 changes: 130 additions & 0 deletions src/CodeIndex/Cli/ProgramRunner.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Diagnostics;
using System.Globalization;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using CodeIndex.Database;
Expand All @@ -10,6 +11,8 @@ namespace CodeIndex.Cli;

internal static class ProgramRunner
{
internal const string QuietEnvironmentVariable = "CDIDX_QUIET";

internal static int Run(
string[] args,
JsonSerializerOptions? jsonOptions = null,
Expand Down Expand Up @@ -38,6 +41,9 @@ internal static int Run(
GlobalToolLog.Info($"config_file_loaded path={configResult.Path}");
jsonOptions ??= CreateDefaultJsonOptions();

var quiet = TryConsumeQuietFlag(ref args) || IsTruthyEnvironmentVariable(QuietEnvironmentVariable);
using var quietScope = quiet ? QuietStderrScope.Start() : null;

if (!TryConsumeColorFlag(ref args, out var colorError))
{
CommandErrorWriter.Write(StripErrorPrefix(colorError), "use one of `auto`, `always`, `never`.");
Expand Down Expand Up @@ -241,6 +247,51 @@ _ when IsProjectPathArg(commandName)
internal static bool IsProjectPathArg(string arg) =>
!arg.StartsWith('-') && (Directory.Exists(arg) || arg.Contains('/') || arg.Contains('\\') || arg == ".");

internal static bool TryConsumeQuietFlag(ref string[] args)
{
if (args.Length == 0)
return false;

var kept = new List<string>(args.Length);
var quiet = false;
var passthrough = false;
for (var i = 0; i < args.Length; i++)
{
var arg = args[i];
if (passthrough)
{
kept.Add(arg);
continue;
}
if (arg == "--")
{
passthrough = true;
kept.Add(arg);
continue;
}
if (arg is "--quiet" or "-q" or "--silent")
{
quiet = true;
continue;
}

kept.Add(arg);
}

args = kept.ToArray();
return quiet;
}

private static bool IsTruthyEnvironmentVariable(string name)
{
var value = Environment.GetEnvironmentVariable(name);
return value != null
&& !string.Equals(value, "0", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(value, "no", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(value, "off", StringComparison.OrdinalIgnoreCase);
}

internal static int MapCodeIndexExceptionExitCode(string code) => code switch
{
CommandErrorCodes.DbNotFound => CommandExitCodes.NotFound,
Expand All @@ -257,6 +308,85 @@ internal static bool IsProjectPathArg(string arg) =>
_ => CommandExitCodes.DatabaseError,
};

private sealed class QuietStderrScope : IDisposable
{
private readonly TextWriter _originalError;

private QuietStderrScope(TextWriter originalError)
{
_originalError = originalError;
}

public static QuietStderrScope Start()
{
var originalError = Console.Error;
Console.SetError(new ErrorOnlyTextWriter(originalError));
return new QuietStderrScope(originalError);
}

public void Dispose()
{
Console.Error.Flush();
Console.SetError(_originalError);
}
}

private sealed class ErrorOnlyTextWriter(TextWriter inner) : TextWriter
{
private readonly StringBuilder _lineBuffer = new();

public override Encoding Encoding => inner.Encoding;

public override void Write(char value)
{
if (value == '\r')
return;

if (value == '\n')
{
FlushBufferedLine();
return;
}

_lineBuffer.Append(value);
}

public override void Write(string? value)
{
if (value == null)
return;

foreach (var ch in value)
Write(ch);
}

public override void WriteLine(string? value)
{
Write(value);
FlushBufferedLine();
}

public override void Flush()
{
FlushBufferedLine();
inner.Flush();
}

private void FlushBufferedLine()
{
if (_lineBuffer.Length == 0)
return;

var line = _lineBuffer.ToString();
_lineBuffer.Clear();
if (IsErrorLine(line))
inner.WriteLine(line);
}

private static bool IsErrorLine(string line)
=> line.StartsWith("Error", StringComparison.Ordinal);
}

internal static bool TryConsumeColorFlag(ref string[] args, out string error)
{
error = string.Empty;
Expand Down
3 changes: 3 additions & 0 deletions src/CodeIndex/Cli/QueryCommandRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ private sealed record StatusReadinessField(
"--version",
"-V",
"--verbose",
"--quiet",
"-q",
"--silent",
"--by-bucket",
"--group-by-name",
"--with-paths",
Expand Down
2 changes: 1 addition & 1 deletion tests/CodeIndex.Tests/ConsoleUiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,7 @@ public void PrintCompletions_ReportFlagSetsMatchAcrossShells()

var expected = new SortedSet<string>(StringComparer.Ordinal)
{
"db", "json", "output", "log-lines", "no-log", "include-args",
"db", "json", "quiet", "silent", "output", "log-lines", "no-log", "include-args",
};
Assert.Equal(expected, bashReport);
Assert.Equal(expected, zshReport);
Expand Down
72 changes: 71 additions & 1 deletion tests/CodeIndex.Tests/ProgramCliTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,66 @@ public void Symbols_NameHelpLikeValueReturnsUsageError()
Assert.DoesNotContain("██████╗", stderr);
}

[Theory]
[InlineData("--quiet")]
[InlineData("-q")]
[InlineData("--silent")]
public void QueryQuietFlag_SuppressesInformationalStderrOnZeroResults(string quietFlag)
{
var projectRoot = TestProjectHelper.CreateTempProject("cdidx_program_quiet_zero");
try
{
var dbPath = TestProjectHelper.CreateProjectDb(projectRoot);
TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n");

var (exitCode, stdout, stderr) = RunCliInSubprocess([quietFlag, "search", "definitely_missing_query", "--db", dbPath]);

Assert.Equal(CommandExitCodes.NotFound, exitCode);
Assert.Equal(string.Empty, stdout);
Assert.Equal(string.Empty, stderr);
}
finally
{
TestProjectHelper.DeleteDirectory(projectRoot);
}
}

[Fact]
public void QueryQuietEnvironment_SuppressesVerboseStderr()
{
var projectRoot = TestProjectHelper.CreateTempProject("cdidx_program_quiet_env");
try
{
var dbPath = TestProjectHelper.CreateProjectDb(projectRoot);
TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n");

var (exitCode, stdout, stderr) = RunCliInSubprocess(
["search", "definitely_missing_query", "--verbose", "--db", dbPath],
new Dictionary<string, string?> { [ProgramRunner.QuietEnvironmentVariable] = "1" });

Assert.Equal(CommandExitCodes.NotFound, exitCode);
Assert.Equal(string.Empty, stdout);
Assert.Equal(string.Empty, stderr);
}
finally
{
TestProjectHelper.DeleteDirectory(projectRoot);
}
}

[Fact]
public void QueryQuietFlag_PreservesErrorLines()
{
var missingDbPath = Path.Combine(Path.GetTempPath(), $"cdidx_missing_{Guid.NewGuid():N}.db");

var (exitCode, stdout, stderr) = RunCliInSubprocess(["--quiet", "search", "Run", "--db", missingDbPath]);

Assert.NotEqual(CommandExitCodes.Success, exitCode);
Assert.Equal(string.Empty, stdout);
Assert.Contains($"Error [{CommandErrorCodes.DbNotFound}]:", stderr);
Assert.DoesNotContain("Hint:", stderr);
}

[Fact]
public void Completions_HelpLikeValueReturnsCompletionsError()
{
Expand Down Expand Up @@ -293,7 +353,7 @@ public void Suggestions_ExportMarkdownIncludesFilteredSuggestions()
Assert.DoesNotContain("Add parser support", stdout);
}

private static (int ExitCode, string StdOut, string StdErr) RunCliInSubprocess(string[] args)
private static (int ExitCode, string StdOut, string StdErr) RunCliInSubprocess(string[] args, IReadOnlyDictionary<string, string?>? environment = null)
{
var psi = new System.Diagnostics.ProcessStartInfo
{
Expand All @@ -308,6 +368,16 @@ private static (int ExitCode, string StdOut, string StdErr) RunCliInSubprocess(s
psi.ArgumentList.Add(GetBuiltCliDllPath());
foreach (var arg in args)
psi.ArgumentList.Add(arg);
if (environment != null)
{
foreach (var (key, value) in environment)
{
if (value == null)
psi.Environment.Remove(key);
else
psi.Environment[key] = value;
}
}

using var process = System.Diagnostics.Process.Start(psi)
?? throw new InvalidOperationException("Failed to start cdidx subprocess / cdidx サブプロセスの起動に失敗");
Expand Down
Loading