Add a new CLI command
The CLI is built with System.CommandLine. A subcommand is wired through three files in
Cli/ plus a Core runner that holds the logic, and is then documented in README.md.
Match the existing commands (export, batch-export, report, multi-report, validate,
summary, upgrade) — don't invent a new shape.
Paths below are relative to the repo root (the directory containing MemorySnapshotDataTools.sln).
When to use
- Adding a brand-new subcommand (e.g.
diff, prune, export-csv).
- Adding a new argument or
--option to an existing subcommand.
- Exposing an existing Core capability through the CLI.
For SQL-touching logic inside the new command, also follow CLAUDE.md and
docs/sql-safety.md — SQL safety is a first-class rule here.
The files you touch
| File |
What you add |
Cli/CliOptions.cs |
A CommandKind enum value + any new option/arg properties on CliOptions. |
Cli/CommandLineBuilder.cs |
The Command, its Argument<>/Option<>s, the SetAction handler, root.Add(...), and a new Func<CliOptions,int> parameter on Build. |
Cli/Program.cs |
A RunXxx(CliOptions) handler that calls the Core runner, plus threading it through the Build(...) call in Main. |
Core/... (a runner) |
The actual logic — a XxxRunner + a XxxRunOptions, mirroring Core/Report/SummaryReportRunner.cs, Core/Export/ExportRunner.cs, etc. |
Tests/ |
Tests for the Core runner / calculator (there are no CommandLineBuilder tests; test the logic, not the parser). |
README.md |
Required — a usage subsection + example for the new command. See Document in README.md. |
Step by step
1. Cli/CliOptions.cs
- Add a value to the
CommandKind enum.
- Add a property to
CliOptions for each new argument/option. Reuse existing ones where they fit
(Verbose, ReportDbPath, Destination, …) before adding new fields.
2. Cli/CommandLineBuilder.cs
Inside Build(...), following the existing blocks:
// ---- mycommand ----
var myCmd = new Command("mycommand", "One-line description shown in --help.");
var inputArg = new Argument<string>("input")
{
Description = "Path to ...",
Arity = ArgumentArity.ExactlyOne,
};
myCmd.Add(inputArg);
var someOpt = new Option<string>("--mode")
{
Description = "...: a, b, or c.",
DefaultValueFactory = _ => "a",
};
someOpt.AcceptOnlyFromAmong("a", "b", "c"); // validate enum-like options at parse time
myCmd.Add(someOpt);
myCmd.SetAction((ParseResult parseResult) =>
{
var inputPath = ExpandPath(parseResult.GetValue(inputArg)!); // ALWAYS ExpandPath path args
if (!File.Exists(inputPath)) // validate existence, return 1
{
Console.Error.WriteLine($"Input file not found: {inputPath}");
return 1;
}
var options = new CliOptions
{
Command = CommandKind.MyCommand,
// ... map parsed values onto CliOptions ...
};
return runMyCommand(options);
});
Then:
- Register it:
root.Add(myCmd); near the bottom of Build.
- Add a
Func<CliOptions, int> runMyCommand parameter to the Build(...) signature (the params are
positional — keep the order consistent with Program.Main).
3. Cli/Program.cs
- Add a
RunMyCommand(CliOptions options) static handler that delegates to your Core runner.
- If the command reads an exported database, call
SchemaGate.Check(path) first (see
RunReport/RunSummary).
- If it does cancellable work, use
CreateCancellationSource() and catch
OperationCanceledException → return 2 (see RunExport).
- Add
RunMyCommand to the CommandLineBuilder.Build(...) call in Main in the matching position.
4. Core runner (the logic)
Put real work in Core/..., not in Cli/. Mirror an existing runner
(SummaryReportRunner, ExportRunner): a XxxRunOptions record + a static int Run(...) that
returns an exit code and reports via IProgressReporter. If it builds SQL, parameterize values and
validate identifiers per docs/sql-safety.md; open the DB
read-only if it only reads (Mode=ReadOnly / ACCESS_MODE=READ_ONLY).
5. Tests
Add tests under Tests/ targeting the Core runner / calculator (e.g.
BatchExportRunnerTests.cs, SummaryMetricsCalculatorTests.cs). Helpers you assert on must be
public — InternalsVisibleTo is a no-op here, so Core internals are not visible to Tests.
6. Document in README.md
This step is required — a new command is not done until the README documents it. In
README.md:
Add a ### <verb the command does> subsection under How to use, after the existing
command subsections, with the invocation form and a worked example, matching the style of the
export and report sections:
### <What the command does>
```bash
dotnet run --project Cli/MemorySnapshotDataTools.Cli.csproj -- mycommand <args> [options]
```
- **`--option`:** what it does (default: …).
**Example:**
```bash
dotnet run --project Cli/MemorySnapshotDataTools.Cli.csproj -- mycommand ./in.duckdb --mode b
```
If the command changes the headline behavior, add a bullet to the What it does list at the
top, and update Output/Schema if it writes new tables/files.
Consider also updating, when relevant: the Direct CLI invocation list in
run-memory-snapshot-data-tool/SKILL.md, and
docs/intro.md / docs/runbook.md.
7. Build, test, and verify it runs
dotnet build MemorySnapshotDataTools.sln
dotnet test MemorySnapshotDataTools.sln
dotnet run --project Cli/MemorySnapshotDataTools.Cli.csproj -- --help # command listed?
dotnet run --project Cli/MemorySnapshotDataTools.Cli.csproj -- mycommand --help # args/options correct?
To exercise the full pipeline (build → run → screenshot), use the run-memory-snapshot-data-tool
skill's driver.
Conventions & gotchas
- Always
ExpandPath(...) path arguments/options before use — it expands ~, env vars, and
resolves to a full path. Every existing handler does this.
- Validate inputs in
SetAction and return 1 for bad args / missing files, before
constructing CliOptions.
- Exit codes are meaningful and asserted on:
0 success, 1 bad args / file not found,
2 cancelled (Ctrl-C), 3 validation/error. Keep yours consistent.
- Enum-like string options →
option.AcceptOnlyFromAmong("a","b","c") so bad values are
rejected at parse time (see --validate, --destination).
Build's handlers are positional Func<CliOptions,int> parameters. Add yours in the same
position in both the Build signature and the Main call site, or the wrong handler runs.
- Reading an exported DB? Call
SchemaGate.Check(path) first so a stale/newer schema fails
with a clear message instead of a confusing query error.
- SQL safety is non-negotiable — never interpolate external values into SQL; bind parameters and
validate identifiers (
CLAUDE.md, docs/sql-safety.md).
Checklist
1---2name: add-cli-command3description: Add a new subcommand to the Memory Snapshot Data Tool CLI (the .NET 10 `MemorySnapshotDataTools` exe), wiring it through CliOptions → CommandLineBuilder → Program → a Core runner, then documenting it in README.md. Use when asked to add, create, or expose a new CLI command/subcommand (alongside export, batch-export, report, multi-report, validate, summary, upgrade), or a new flag/argument on an existing one.4---56# Add a new CLI command78The CLI is built with `System.CommandLine`. A subcommand is wired through **three files in9`Cli/`** plus a **Core runner** that holds the logic, and is then **documented in `README.md`**.10Match the existing commands (`export`, `batch-export`, `report`, `multi-report`, `validate`,11`summary`, `upgrade`) — don't invent a new shape.1213**Paths below are relative to the repo root** (the directory containing `MemorySnapshotDataTools.sln`).1415## When to use1617- Adding a brand-new subcommand (e.g. `diff`, `prune`, `export-csv`).18- Adding a new argument or `--option` to an existing subcommand.19- Exposing an existing Core capability through the CLI.2021For SQL-touching logic inside the new command, also follow [`CLAUDE.md`](../../../CLAUDE.md) and22[`docs/sql-safety.md`](../../../docs/sql-safety.md) — SQL safety is a first-class rule here.2324## The files you touch2526| File | What you add |27|------|--------------|28| [`Cli/CliOptions.cs`](../../../Cli/CliOptions.cs) | A `CommandKind` enum value + any new option/arg properties on `CliOptions`. |29| [`Cli/CommandLineBuilder.cs`](../../../Cli/CommandLineBuilder.cs) | The `Command`, its `Argument<>`/`Option<>`s, the `SetAction` handler, `root.Add(...)`, and a new `Func<CliOptions,int>` parameter on `Build`. |30| [`Cli/Program.cs`](../../../Cli/Program.cs) | A `RunXxx(CliOptions)` handler that calls the Core runner, plus threading it through the `Build(...)` call in `Main`. |31| `Core/...` (a runner) | The actual logic — a `XxxRunner` + a `XxxRunOptions`, mirroring `Core/Report/SummaryReportRunner.cs`, `Core/Export/ExportRunner.cs`, etc. |32| [`Tests/`](../../../Tests) | Tests for the **Core runner / calculator** (there are no `CommandLineBuilder` tests; test the logic, not the parser). |33| [`README.md`](../../../README.md) | **Required** — a usage subsection + example for the new command. See [Document in README.md](#document-in-readmemd). |3435## Step by step3637### 1. `Cli/CliOptions.cs`3839- Add a value to the `CommandKind` enum.40- Add a property to `CliOptions` for each new argument/option. Reuse existing ones where they fit41 (`Verbose`, `ReportDbPath`, `Destination`, …) before adding new fields.4243### 2. `Cli/CommandLineBuilder.cs`4445Inside `Build(...)`, following the existing blocks:4647```csharp48// ---- mycommand ----49var myCmd = new Command("mycommand", "One-line description shown in --help.");50var inputArg = new Argument<string>("input")51{52 Description = "Path to ...",53 Arity = ArgumentArity.ExactlyOne,54};55myCmd.Add(inputArg);5657var someOpt = new Option<string>("--mode")58{59 Description = "...: a, b, or c.",60 DefaultValueFactory = _ => "a",61};62someOpt.AcceptOnlyFromAmong("a", "b", "c"); // validate enum-like options at parse time63myCmd.Add(someOpt);6465myCmd.SetAction((ParseResult parseResult) =>66{67 var inputPath = ExpandPath(parseResult.GetValue(inputArg)!); // ALWAYS ExpandPath path args68 if (!File.Exists(inputPath)) // validate existence, return 169 {70 Console.Error.WriteLine($"Input file not found: {inputPath}");71 return 1;72 }73 var options = new CliOptions74 {75 Command = CommandKind.MyCommand,76 // ... map parsed values onto CliOptions ...77 };78 return runMyCommand(options);79});80```8182Then:83- Register it: `root.Add(myCmd);` near the bottom of `Build`.84- Add a `Func<CliOptions, int> runMyCommand` parameter to the `Build(...)` signature (the params are85 positional — keep the order consistent with `Program.Main`).8687### 3. `Cli/Program.cs`8889- Add a `RunMyCommand(CliOptions options)` static handler that delegates to your Core runner.90 - If the command **reads an exported database**, call `SchemaGate.Check(path)` first (see91 `RunReport`/`RunSummary`).92 - If it does cancellable work, use `CreateCancellationSource()` and catch93 `OperationCanceledException` → return `2` (see `RunExport`).94- Add `RunMyCommand` to the `CommandLineBuilder.Build(...)` call in `Main` in the matching position.9596### 4. Core runner (the logic)9798Put real work in `Core/...`, not in `Cli/`. Mirror an existing runner99(`SummaryReportRunner`, `ExportRunner`): a `XxxRunOptions` record + a `static int Run(...)` that100returns an exit code and reports via `IProgressReporter`. If it builds SQL, parameterize values and101validate identifiers per [`docs/sql-safety.md`](../../../docs/sql-safety.md); open the DB102**read-only** if it only reads (`Mode=ReadOnly` / `ACCESS_MODE=READ_ONLY`).103104### 5. Tests105106Add tests under [`Tests/`](../../../Tests) targeting the Core runner / calculator (e.g.107`BatchExportRunnerTests.cs`, `SummaryMetricsCalculatorTests.cs`). Helpers you assert on must be108**`public`** — `InternalsVisibleTo` is a no-op here, so Core internals are not visible to Tests.109110### 6. Document in README.md111112**This step is required — a new command is not done until the README documents it.** In113[`README.md`](../../../README.md):114115- Add a `### <verb the command does>` subsection under **How to use**, after the existing116 command subsections, with the invocation form and a worked example, matching the style of the117 `export` and `report` sections:118119 ````markdown120 ### <What the command does>121122 ```bash123 dotnet run --project Cli/MemorySnapshotDataTools.Cli.csproj -- mycommand <args> [options]124 ```125126 - **`--option`:** what it does (default: …).127128 **Example:**129130 ```bash131 dotnet run --project Cli/MemorySnapshotDataTools.Cli.csproj -- mycommand ./in.duckdb --mode b132 ```133 ````134135- If the command changes the headline behavior, add a bullet to the **What it does** list at the136 top, and update **Output**/**Schema** if it writes new tables/files.137- Consider also updating, when relevant: the **Direct CLI invocation** list in138 [`run-memory-snapshot-data-tool/SKILL.md`](../run-memory-snapshot-data-tool/SKILL.md), and139 `docs/intro.md` / `docs/runbook.md`.140141### 7. Build, test, and verify it runs142143```bash144dotnet build MemorySnapshotDataTools.sln145dotnet test MemorySnapshotDataTools.sln146dotnet run --project Cli/MemorySnapshotDataTools.Cli.csproj -- --help # command listed?147dotnet run --project Cli/MemorySnapshotDataTools.Cli.csproj -- mycommand --help # args/options correct?148```149150To exercise the full pipeline (build → run → screenshot), use the `run-memory-snapshot-data-tool`151skill's driver.152153## Conventions & gotchas154155- **Always `ExpandPath(...)` path arguments/options** before use — it expands `~`, env vars, and156 resolves to a full path. Every existing handler does this.157- **Validate inputs in `SetAction` and return `1`** for bad args / missing files, before158 constructing `CliOptions`.159- **Exit codes are meaningful and asserted on:** `0` success, `1` bad args / file not found,160 `2` cancelled (Ctrl-C), `3` validation/error. Keep yours consistent.161- **Enum-like string options** → `option.AcceptOnlyFromAmong("a","b","c")` so bad values are162 rejected at parse time (see `--validate`, `--destination`).163- **`Build`'s handlers are positional `Func<CliOptions,int>` parameters.** Add yours in the same164 position in both the `Build` signature and the `Main` call site, or the wrong handler runs.165- **Reading an exported DB?** Call `SchemaGate.Check(path)` first so a stale/newer schema fails166 with a clear message instead of a confusing query error.167- **SQL safety is non-negotiable** — never interpolate external values into SQL; bind parameters and168 validate identifiers (`CLAUDE.md`, `docs/sql-safety.md`).169170## Checklist171172- [ ] `CommandKind` value + `CliOptions` properties added (`Cli/CliOptions.cs`).173- [ ] Command, args/options, `SetAction`, `root.Add`, and new `Build` param added (`Cli/CommandLineBuilder.cs`).174- [ ] `RunXxx` handler added and threaded through `Main` (`Cli/Program.cs`).175- [ ] Logic lives in a Core `XxxRunner` (+ `XxxRunOptions`); SQL is parameterized / read-only.176- [ ] Tests added for the Core logic (public helpers).177- [ ] **`README.md` documents the command** (usage + example), other docs/skill updated if relevant.178- [ ] `dotnet build` + `dotnet test` pass; `-- --help` and `-- mycommand --help` look right.